stripe: per-cadence cooling-off + manage-subscription button

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>
This commit is contained in:
Giorgio Gilestro 2026-05-26 20:06:19 +02:00
parent 62960d5bea
commit a07fd144ea
10 changed files with 390 additions and 31 deletions

View file

@ -200,6 +200,12 @@ class User(Base):
# back to this row.
stripe_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
stripe_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Set when a subscription is in `trialing` state — drives the
# "Free trial — N days remaining" hint on /settings. Cleared on
# subscription.revoked or when status transitions out of trialing.
stripe_trial_end_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
__table_args__ = (
UniqueConstraint("email", name="uq_users_email"),

View file

@ -154,6 +154,21 @@ async def settings_page(
.limit(1)
)).scalar_one_or_none()
# Trial countdown — when the Stripe subscription is in its 14-day
# trial, show "N days remaining" on the tier row. Computed here
# rather than in the template because Jinja's date arithmetic is
# painful, and we already have to handle MariaDB's tz-naive
# round-trip via _aware-style normalisation.
trial_days_remaining: int | None = None
if user.stripe_trial_end_at is not None:
end = user.stripe_trial_end_at
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
delta = end - datetime.now(timezone.utc)
if delta.total_seconds() > 0:
# Round up so the last hours of the trial still read "1 day".
trial_days_remaining = max(1, -(-int(delta.total_seconds()) // 86400))
return templates.TemplateResponse(
request, "settings.html",
{
@ -163,5 +178,6 @@ async def settings_page(
"converted_count": int(converted_count),
"paid": paid_status(user),
"last_email_send": last_email_send,
"trial_days_remaining": trial_days_remaining,
},
)

View file

@ -177,7 +177,7 @@ async def _handle_subscription_active(
) -> None:
user = await _find_user(session, data)
if user is None:
log.warning("polar.user_not_found", event=event_type,
log.warning("polar.user_not_found", event_type=event_type,
customer_id=_customer_id_from_payload(data))
return
await _grant_paid(session, user, data)
@ -188,7 +188,7 @@ async def _handle_subscription_revoked(
) -> None:
user = await _find_user(session, data)
if user is None:
log.warning("polar.user_not_found", event=event_type,
log.warning("polar.user_not_found", event_type=event_type,
customer_id=_customer_id_from_payload(data))
return
await _revoke_paid(session, user)

View file

@ -120,6 +120,22 @@ async def create_checkout(
# referral redemption flow ships.
"allow_promotion_codes": True,
}
# Per-cadence cooling-off treatment:
#
# - Annual gets a 14-day free trial. No money moves during the
# trial, so the Consumer Contracts Regulations 14-day refund
# question is moot (nothing paid = nothing to refund). Card is
# still required at checkout so Stripe can charge on day 15.
#
# - Monthly bills immediately (a 14-day trial on a £7/month plan
# would give away ~50% of cycle one). The Reg-36 waiver lives
# on our own /pricing page as a required tick-box (see
# pricing.html); we deliberately do NOT use Stripe's
# consent_collection.terms_of_service here because that's an
# account-wide setting and we want per-product control (and
# per-product Terms URLs) as we grow.
if body.cadence == "annual":
create_kwargs["subscription_data"] = {"trial_period_days": 14}
if user.stripe_customer_id:
create_kwargs["customer"] = user.stripe_customer_id
else:
@ -220,17 +236,28 @@ async def _grant_paid(
*,
customer_id: str | None,
subscription_id: str | None,
trial_end: int | None = None,
status: str | None = None,
) -> None:
user.tier = "paid"
if customer_id and user.stripe_customer_id != customer_id:
user.stripe_customer_id = customer_id
if subscription_id and user.stripe_subscription_id != subscription_id:
user.stripe_subscription_id = subscription_id
# Track trial_end so the settings page can show "N days remaining".
# Only populated when Stripe reports the sub as trialing — once the
# status flips to active (paid for real), we clear the trial marker.
if status == "trialing" and trial_end:
from datetime import datetime, timezone
user.stripe_trial_end_at = datetime.fromtimestamp(trial_end, tz=timezone.utc)
elif status == "active":
user.stripe_trial_end_at = None
async def _revoke_paid(user: User) -> None:
user.tier = "free"
user.stripe_subscription_id = None
user.stripe_trial_end_at = None
# Keep stripe_customer_id so a re-subscription matches this row.
@ -243,8 +270,12 @@ async def _handle_checkout_completed(
customer_id=obj.get("customer"),
)
if user is None:
log.warning("stripe.user_not_found", event=event_type)
log.warning("stripe.user_not_found", event_type=event_type)
return
# checkout.session.completed doesn't carry trial_end on the session
# object itself — the subscription.created event that fires right
# after will carry it. We grant paid here without trial info and
# let the subscription event fill in trial_end_at moments later.
await _grant_paid(
user,
customer_id=obj.get("customer"),
@ -260,7 +291,7 @@ async def _handle_subscription_event(
to free if it's an end-state."""
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event=event_type,
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
status = obj.get("status")
@ -273,6 +304,8 @@ async def _handle_subscription_event(
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("id"),
trial_end=obj.get("trial_end"),
status=status,
)
@ -281,7 +314,7 @@ async def _handle_subscription_deleted(
) -> None:
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event=event_type,
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
await _revoke_paid(user)

View file

@ -1802,6 +1802,34 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
font-weight: 700;
}
.tier-card__cta { margin-top: 18px; }
/* Consent block above the Subscribe buttons (paid card, logged-in
free user). The Subscribe buttons render disabled; ticking the box
is what enables them. Wording covers ToS agreement (both cadences)
+ the Reg 36 CCR 2013 waiver (monthly only). */
.tier-card__consent {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 14px;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
cursor: pointer;
}
.tier-card__consent input[type="checkbox"] {
flex-shrink: 0;
margin-top: 2px;
cursor: pointer;
}
.tier-card__consent a {
color: var(--accent);
text-decoration: underline;
}
.tier-card__consent strong { color: var(--text); }
.tier-card__more {
margin-top: 14px;

View file

@ -50,8 +50,10 @@
<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.
Prices in GBP, VAT where applicable.
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>
@ -74,11 +76,23 @@
{% 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">Subscribe &mdash; &pound;7/month</button>
data-stripe-checkout="monthly" disabled>Subscribe &mdash; &pound;7/month</button>
<button class="btn-secondary btn-block" type="button"
data-stripe-checkout="annual"
style="margin-top:10px;">or &pound;70/year (two months free)</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 %}
@ -89,10 +103,28 @@
<script>
(function () {
// Wire the two upgrade buttons to /api/stripe/checkout. Stripe returns
// a hosted-checkout URL; we just redirect there. No Stripe.js needed.
document.querySelectorAll('[data-stripe-checkout]').forEach(function (btn) {
// 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;
@ -113,7 +145,7 @@
window.location.href = data.url;
} catch (e) {
alert(e.message || 'Could not start checkout. Please try again.');
btn.disabled = false;
btn.disabled = (consent && !consent.checked);
btn.textContent = prev;
}
});

View file

@ -31,14 +31,65 @@
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% if trial_days_remaining %}
<span class="settings-row__hint">
<strong>Free trial</strong> &mdash; {{ trial_days_remaining }}
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
Cancel before the trial ends and you won&rsquo;t be charged.
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% if user.stripe_customer_id %}
<button type="button" id="stripe-portal-btn"
class="btn-secondary"
style="margin-left:10px; padding:6px 14px; font-size:12px;">
Manage subscription
</button>
<div class="settings-row__hint" style="margin-top:6px;">
Update payment method, view invoices, switch monthly &harr; annual, or cancel any time. Opens the Stripe-hosted billing portal.
</div>
{% endif %}
{% endif %}
{% else %}
<span class="settings-row__hint">Paid features unlock with Paddle (D.3) or invite credits.</span>
<span class="settings-row__hint">
Free tier &mdash; <a href="/pricing">upgrade for £7/month or £70/year</a>.
</span>
{% endif %}
</div>
</div>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<script>
(function () {
var btn = document.getElementById('stripe-portal-btn');
if (!btn) return;
btn.addEventListener('click', async function () {
btn.disabled = true;
var prev = btn.textContent;
btn.textContent = 'Opening portal…';
try {
var r = await fetch('/api/stripe/portal', {
method: 'POST',
credentials: 'same-origin',
});
if (!r.ok) {
var detail = '';
try { detail = (await r.json()).detail || ''; } catch (e) {}
throw new Error('Could not open portal: ' + (detail || r.status));
}
var data = await r.json();
window.location.href = data.url;
} catch (e) {
alert(e.message || 'Could not open portal. Please try again.');
btn.disabled = false;
btn.textContent = prev;
}
});
})();
</script>
{% endif %}
{# --- Import portfolio --------------------------------------------- #}
<div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>

View file

@ -98,13 +98,32 @@
<section class="public-section">
<h2 class="public-section__head">6. Refunds</h2>
<p>
<strong>14-day cooling-off (UK / EU consumers).</strong> If you buy a
paid subscription as a consumer, you have 14 days from the day of
purchase to cancel and receive a full refund of that purchase, under
the Consumer Contracts (Information, Cancellation and Additional
Charges) Regulations 2013. As noted in clause 5, if you start using
a paid feature inside the cancellation window you lose the right to
cancel in respect of digital content already delivered.
<strong>14-day cooling-off period (UK / EU consumers).</strong>
The Consumer Contracts (Information, Cancellation and Additional
Charges) Regulations 2013 give consumers a 14-day right to cancel
digital service contracts. We honour this right in different ways
depending on your billing cadence.
</p>
<p>
<strong>1a. Annual subscriptions.</strong> Every new annual
subscription begins with a 14-day free trial. No payment is taken
during the trial; cancel any time before the trial ends and you
are not charged. This is offered as a substitute for, and is at
least as generous as, the statutory 14-day refund right. After
the trial ends and the first payment is taken, the rules in
paragraph 2 below apply.
</p>
<p>
<strong>1b. Monthly subscriptions.</strong> Monthly subscribers
receive immediate access to paid features at checkout and are
billed on day one. Before you can start a monthly subscription
you must tick a required consent box on the pricing page in
which you (i) agree to these Terms, (ii) give express consent to
begin performance immediately, and (iii) acknowledge that, under
Regulation 36 of the Consumer Contracts Regulations 2013, doing
so extinguishes your statutory 14-day right to cancel in respect
of digital content already delivered. The rules in paragraph 2
below apply from the first day.
</p>
<p>
<strong>Cancellation after the cooling-off window.</strong> You can