Bundles three related pieces that came out of the operator's first
end-to-end test of the paid flow:
1. Manage subscription button on /settings (paid users with a real
Stripe sub — i.e. not credit-granted access). POSTs to the existing
/api/stripe/portal endpoint; Stripe-hosted customer portal handles
card updates, cancellation, monthly↔annual switch, invoice history.
Replaces the stale "Paid features unlock with Paddle (D.3) or
invite credits" hint for free users with a live link to /pricing.
2. Per-cadence cooling-off treatment:
- **Annual £70**: 14-day free trial via
subscription_data.trial_period_days=14. No money moves during
the trial, so the CCR 2013 14-day refund question doesn't arise
(nothing paid = nothing to refund). Card is still required at
checkout so Stripe can charge on day 15.
- **Monthly £7**: bills immediately. A 14-day trial there would
give away ~50% of cycle one. Instead, /pricing now carries a
required tick-box above the Subscribe buttons (subscribe stays
disabled until checked) — by ticking, the user expressly
consents to begin performance immediately and acknowledges that
this extinguishes their statutory 14-day right under Reg 36
CCR 2013. Consent collected on our own page (not via Stripe's
account-wide consent_collection.terms_of_service) so each
product can keep its own Terms URL as we add more.
3. T&C §6 clause 1 split into 1a (annual / trial substitute) +
1b (monthly / Reg 36 waiver via on-page tick-box). Clause 2
(post-cooling-off cancellation) unchanged.
Settings page shows "Free trial — N days remaining" while the
sub is in `trialing` status, falling back to "Paid subscription
active." once it transitions to active. Countdown is computed
server-side from User.stripe_trial_end_at (new column, migration
0020) populated by the subscription.created/updated webhook from
the Stripe trial_end timestamp; cleared on the trialing→active
transition and on revoke.
Drive-by: fixed a structlog kwarg-name collision on
`log.warning(..., event=event_type, ...)` in both polar_webhook.py
and stripe_billing.py — `event` is structlog's positional event
name and "got multiple values" crashed the user-not-found log
path. Renamed to `event_type=` everywhere it appeared. Caught by
the new trialing-stores-trial-end test.
Tests
- 4 new in test_stripe_billing.py covering monthly (no trial, no
consent_collection), annual (trial, no consent), trialing stores
trial_end, trialing→active clears trial_end.
- 1 existing test renamed + reworked for the consent split.
- Full suite: 224 passed, 5 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
291 lines
13 KiB
HTML
291 lines
13 KiB
HTML
{% extends "public_base.html" %}
|
|
{% block title %}{{ BRAND_NAME }} · Pricing{% endblock %}
|
|
|
|
{% block main %}
|
|
|
|
<section class="public-section">
|
|
<h1 class="public-section__head">Pricing</h1>
|
|
<p>
|
|
Two tiers. The core editorial is free today — 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 — news, indicators, and a strategic log every 6 hours.</div>
|
|
<div class="tier-card__price">£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 — <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, …) with a one-paragraph AI read on each tab</li>
|
|
<li>Strategic log — 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 — 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> →
|
|
</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">£7<span class="tier-card__price-unit"> / month</span></div>
|
|
<div class="tier-card__price-hint">
|
|
Or <strong>£70 / year</strong> — 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 — a whole session in view, nothing rolls off</li>
|
|
<li><strong>Strategic log refreshed every hour</strong> instead of every six — track intraday moves as they unfold</li>
|
|
<li><strong>Follow-up chat on any past log</strong> — ask the model a question against the day’s full context</li>
|
|
<li><strong>Daily email digest</strong> (Mon–Sat) — ~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> — diversification, sector and currency concentration, macro-regime fit on your holdings</li>
|
|
<li><strong>Optional encrypted cloud sync</strong> — 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 — see
|
|
<a href="/terms" target="_blank" rel="noopener">Terms §6</a>.)
|
|
</span>
|
|
</label>
|
|
<button class="btn-primary btn-block" type="button"
|
|
data-stripe-checkout="monthly" disabled>Subscribe — £7/month</button>
|
|
<button class="btn-secondary btn-block" type="button"
|
|
data-stripe-checkout="annual" disabled
|
|
style="margin-top:10px;">or £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 — headlines from the last…</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">✓</td>
|
|
<td class="compare-table__paid">✓</td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row">Follow-up chat on past logs</th>
|
|
<td class="compare-table__none">—</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–Sat</strong></td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row">Portfolio import (broker CSV)</th>
|
|
<td class="compare-table__none">—</td>
|
|
<td class="compare-table__paid"><strong>Included</strong></td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row">AI portfolio read</th>
|
|
<td class="compare-table__none">—</td>
|
|
<td class="compare-table__paid"><strong>Included</strong></td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row">Encrypted cloud sync</th>
|
|
<td class="compare-table__none">—</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">🎁</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>50% off for 3 months</strong></div>
|
|
<div class="invite-callout__sub">
|
|
Share your personal invite link from <a href="/settings">Settings</a>. The discount 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">×</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 50% off for the next three months</strong>.
|
|
</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 — 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> The discount applies to their next bill and credits against yours.</li>
|
|
</ol>
|
|
<h3 class="text-modal__head">The fine print</h3>
|
|
<ul class="text-modal__list">
|
|
<li>One referral per new account — whichever link they used first.</li>
|
|
<li>No self-referral.</li>
|
|
<li>The credit ledger is live today; the cash value kicks in when paid checkout opens. Referrals logged in the meantime are honoured.</li>
|
|
<li>Credits aren’t refundable for cash — see <a href="/terms">Terms & Conditions § 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’s local storage by
|
|
default. The server only learns which Yahoo tickers appear across the
|
|
user base — 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’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 — 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 %}
|