read.markets/app/templates/pricing.html
Giorgio Gilestro ce36ce36fd referrals: close D.3 — both parties get 45 days credit on conversion
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.

Closing the loop:

- New `convert_referral(session, user)` in referral_service.py looks
  up the user's Referral row, stamps `converted_at` + `credited_at`,
  and extends `credit_until` by 45 days on BOTH the buyer and the
  referrer. Idempotent — replayed webhooks and renewals are no-ops.
  Stacks correctly when the user already has a credit window running
  (anchors at max(now, current_credit_until) like cli.grant_credit).

- Stripe webhook wires this into `_grant_paid`. A captured
  `first_paid_transition = user.tier != "paid"` gate avoids the DB
  lookup on every renewal event; convert_referral's own idempotency
  is the second line of defence.

- `_grant_paid` now takes `session` as its first positional arg so
  the conversion runs inside the same transaction as the tier flip
  and audit-row write. A mid-flight failure rolls everything back
  together — no partial state.

- Settings page replaces the "— (D.3)" placeholder with the live
  count of conversions still inside their 45-day credit window, plus
  a "+N days on your account" hint when the user has any credit of
  their own (referrer bonus, admin grant, or future refund-as-credit).

- Marketing copy on pricing.html + settings.html switches from "50%
  off for 3 months" to "45 days of paid access" — same economic value,
  honest about the actual mechanism (full free access rather than
  discounted billing).

Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.

7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.

Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:05:29 +02:00

292 lines
13 KiB
HTML

{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Pricing{% endblock %}
{% block main %}
<section class="public-section">
<h1 class="public-section__head">Pricing</h1>
<p>
Two tiers. The core editorial is free today &mdash; a rolling
6-hour news feed, the cross-asset indicator panels, and a strategic
log refreshed every six hours. Paid stretches the news feed to a
full 24 hours, runs the strategic log hourly, unlocks the follow-up
chat against past logs, adds portfolio import with AI analysis, and
turns on the daily email digest on top of the Sunday recap everyone
gets.
</p>
</section>
<section class="tier-grid">
<div class="tier-card">
<h2 class="tier-card__name">Free</h2>
<div class="tier-card__tagline">The core editorial &mdash; news, indicators, and a strategic log every 6 hours.</div>
<div class="tier-card__price">&pound;0</div>
<div class="tier-card__price-hint">No card needed.</div>
<div class="tier-card__divider"></div>
<div class="tier-card__list-head">What you get</div>
<ul>
<li>News feed &mdash; <strong>headlines from the last 6 hours</strong>, auto-tagged by theme, click-to-filter</li>
<li>Cross-asset indicator panels (equities, rates, FX, commodities, credit, &hellip;) with a one-paragraph AI read on each tab</li>
<li>Strategic log &mdash; a single editorial interpretation of the day, <strong>refreshed every 6 hours</strong></li>
<li>Two reading levels: <em>Novice</em> (defines jargon) or <em>Intermediate</em> (terse, for fluent readers)</li>
<li><strong>Sunday weekly digest</strong> by email &mdash; week behind + week ahead, one-click unsubscribe</li>
</ul>
<div class="tier-card__more">
Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See <strong>Paid</strong> &rarr;
</div>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary btn-block" href="/">Open dashboard</a>
{% else %}
<a class="btn-primary btn-block" href="/login">Sign up free</a>
{% endif %}
</div>
</div>
<div class="tier-card tier-card--featured">
<div class="tier-card__badge">Best value</div>
<h2 class="tier-card__name">Paid</h2>
<div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.</div>
<div class="tier-card__price">&pound;7<span class="tier-card__price-unit"> / month</span></div>
<div class="tier-card__price-hint">
Or <strong>&pound;70 / year</strong> &mdash; two months free, and
starts with a <strong>14-day free trial</strong> (cancel during
the trial and you are not charged). Monthly plans start
immediately. Prices in GBP, VAT where applicable.
</div>
<div class="tier-card__divider"></div>
<div class="tier-card__list-head">Everything in Free, plus</div>
<ul>
<li><strong>News feed: headlines from the last 24 hours</strong> instead of 6 &mdash; a whole session in view, nothing rolls off</li>
<li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li>
<li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li>
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li>
<li><strong>Portfolio import</strong> from a broker CSV (Trading 212 supported today; more brokers planned)</li>
<li><strong>AI portfolio read</strong> &mdash; diversification, sector and currency concentration, macro-regime fit on your holdings</li>
<li><strong>Optional encrypted cloud sync</strong> &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
</ul>
<p class="tier-card__more" style="font-style: italic;">
The portfolio feature does not produce buy, sell or hold
recommendations and does not consider your wider finances, debts,
tax position or objectives. It is not regulated investment advice
or a personal recommendation under FSMA / FCA COBS.
</p>
<div class="tier-card__cta">
{% if paid %}
<a class="btn-secondary btn-block" href="/settings">Manage subscription</a>
{% elif cu and cu.user %}
<label class="tier-card__consent">
<input type="checkbox" id="tos-consent">
<span>
I agree to the <a href="/terms" target="_blank" rel="noopener">Terms of Service</a>.
For <strong>monthly plans</strong>, I expressly consent to begin
immediate access and acknowledge this waives my 14-day cancellation
right under Regulation 36 of the Consumer Contracts Regulations 2013.
(<strong>Annual plans</strong> include a 14-day free trial, so the
cancellation right is preserved differently &mdash; see
<a href="/terms" target="_blank" rel="noopener">Terms &sect;6</a>.)
</span>
</label>
<button class="btn-primary btn-block" type="button"
data-stripe-checkout="monthly" disabled>Subscribe &mdash; &pound;7/month</button>
<button class="btn-secondary btn-block" type="button"
data-stripe-checkout="annual" disabled
style="margin-top:10px;">or &pound;70/year (with 14-day free trial)</button>
{% else %}
<a class="btn-primary btn-block" href="/login?next=/pricing">Sign in to subscribe</a>
{% endif %}
</div>
</div>
</section>
<script>
(function () {
// Gate the Subscribe buttons on the ToS / Reg-36 consent checkbox.
// The buttons render with `disabled` set; toggling the checkbox is
// what enables them. Server-side, the Reg-36 waiver applies to
// monthly only — but the single checkbox covers both ToS agreement
// (always needed) and the monthly waiver, so it's required for any
// subscribe path.
var consent = document.getElementById('tos-consent');
var subscribeBtns = document.querySelectorAll('[data-stripe-checkout]');
if (consent) {
consent.addEventListener('change', function () {
subscribeBtns.forEach(function (b) { b.disabled = !consent.checked; });
});
}
// Wire the buttons to /api/stripe/checkout. Stripe returns a
// hosted-checkout URL; we just redirect there. No Stripe.js needed.
subscribeBtns.forEach(function (btn) {
btn.addEventListener('click', async function () {
if (consent && !consent.checked) {
alert('Please agree to the Terms of Service before subscribing.');
return;
}
var cadence = btn.getAttribute('data-stripe-checkout');
btn.disabled = true;
var prev = btn.textContent;
btn.textContent = 'Opening checkout…';
try {
var r = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({cadence: cadence}),
credentials: 'same-origin',
});
if (!r.ok) {
var detail = '';
try { detail = (await r.json()).detail || ''; } catch (e) {}
throw new Error('Checkout failed: ' + (detail || r.status));
}
var data = await r.json();
window.location.href = data.url;
} catch (e) {
alert(e.message || 'Could not start checkout. Please try again.');
btn.disabled = (consent && !consent.checked);
btn.textContent = prev;
}
});
});
})();
</script>
<section class="public-section">
<h2 class="public-section__head">Free vs Paid at a glance</h2>
<table class="compare-table">
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Free</th>
<th scope="col">Paid</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">News feed &mdash; headlines from the last&hellip;</th>
<td class="compare-table__free">6 hours</td>
<td class="compare-table__paid"><strong>24 hours</strong></td>
</tr>
<tr>
<th scope="row">Strategic log refresh</th>
<td class="compare-table__free">Every 6 hours</td>
<td class="compare-table__paid"><strong>Every hour</strong></td>
</tr>
<tr>
<th scope="row">Cross-asset indicator panels</th>
<td class="compare-table__free">&check;</td>
<td class="compare-table__paid">&check;</td>
</tr>
<tr>
<th scope="row">Follow-up chat on past logs</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Email digest</th>
<td class="compare-table__free">Sunday only</td>
<td class="compare-table__paid"><strong>Sunday + daily Mon&ndash;Sat</strong></td>
</tr>
<tr>
<th scope="row">Portfolio import (broker CSV)</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">AI portfolio read</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Encrypted cloud sync</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
</tbody>
</table>
</section>
<section class="invite-callout">
<div class="invite-callout__icon" aria-hidden="true">&#x1F381;</div>
<div class="invite-callout__body">
<div class="invite-callout__eyebrow">Invite a friend</div>
<div class="invite-callout__headline">Both of you get <strong>45 days of paid access</strong></div>
<div class="invite-callout__sub">
Share your personal invite link from <a href="/settings">Settings</a>. The credit applies when they start a paid plan.
</div>
</div>
<button type="button" class="btn-secondary" id="invite-more">How it works</button>
</section>
<dialog id="invite-modal" class="text-modal" aria-label="How the referral works">
<button type="button" class="text-modal__close" aria-label="Close">&times;</button>
<h2 class="text-modal__title">Invite a friend</h2>
<p>
Every account gets an 8-character referral code and matching invite
link, both shown on your <a href="/settings">Settings</a> page. When
someone signs up through your link and starts a paid plan,
<strong>both of you get 45 days of paid access</strong> credited
to your account.
</p>
<h3 class="text-modal__head">How it works</h3>
<ol class="text-modal__list">
<li><strong>Sign up.</strong> Your code and link go live in Settings.</li>
<li><strong>Share.</strong> Send the link, or read the code &mdash; the alphabet drops <code>0/O</code> and <code>1/I/L</code> so it dictates cleanly.</li>
<li><strong>They sign up.</strong> The referral is recorded against your account when they verify their email.</li>
<li><strong>They subscribe.</strong> 45 days of paid access lands on both accounts &mdash; usable any time over the next month and a half.</li>
</ol>
<h3 class="text-modal__head">The fine print</h3>
<ul class="text-modal__list">
<li>One referral per new account &mdash; whichever link they used first.</li>
<li>No self-referral.</li>
<li>Credits stack: if you already have a credit window running, the new 45 days extend from its end, not from today.</li>
<li>Credits aren&rsquo;t refundable for cash &mdash; see <a href="/terms">Terms &amp; Conditions &sect; 6</a>.</li>
<li>Pending signups, conversions, and active credits are visible on the Settings page.</li>
</ul>
</dialog>
<script>
(function () {
var dlg = document.getElementById('invite-modal');
var open = document.getElementById('invite-more');
if (!dlg || !dlg.showModal || !open) return;
open.addEventListener('click', function () { dlg.showModal(); });
dlg.addEventListener('click', function (e) {
if (e.target === dlg) dlg.close();
});
dlg.querySelector('.text-modal__close').addEventListener('click', function () {
dlg.close();
});
})();
</script>
<section class="public-section">
<h2 class="public-section__head">How the data is handled</h2>
<p>
Your portfolio holdings live in your browser&rsquo;s local storage by
default. The server only learns which Yahoo tickers appear across the
user base &mdash; an anonymous union, with no link back to any specific
user.
</p>
<p>
If you opt in to <strong>encrypted cloud sync</strong>, your pie is
encrypted in your browser with a PIN you choose, then sent to the
server. We add a second layer of encryption with a key only the
server holds. We never see your holdings as plaintext, and forgetting
the PIN means we can&rsquo;t recover it for you. Full details on the
<a href="/privacy">privacy page</a>.
</p>
</section>
<section class="public-section public-section--callout">
<p style="margin:0;">
<strong>Not investment advice.</strong> Every output here is an
interpretation of public data &mdash; not personalised advice, not a
recommendation, and not produced by a regulated entity. Read the full
<a href="/disclaimer">disclaimer</a> before relying on anything you see.
</p>
</section>
{% endblock %}