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

@ -0,0 +1,31 @@
"""stripe trial: users.stripe_trial_end_at.
Revision ID: 0020
Revises: 0019
Create Date: 2026-05-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0020"
down_revision: Union[str, None] = "0019"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"stripe_trial_end_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("users", "stripe_trial_end_at")

View file

@ -200,6 +200,12 @@ class User(Base):
# back to this row. # back to this row.
stripe_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True) stripe_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
stripe_subscription_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__ = ( __table_args__ = (
UniqueConstraint("email", name="uq_users_email"), UniqueConstraint("email", name="uq_users_email"),

View file

@ -154,6 +154,21 @@ async def settings_page(
.limit(1) .limit(1)
)).scalar_one_or_none() )).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( return templates.TemplateResponse(
request, "settings.html", request, "settings.html",
{ {
@ -163,5 +178,6 @@ async def settings_page(
"converted_count": int(converted_count), "converted_count": int(converted_count),
"paid": paid_status(user), "paid": paid_status(user),
"last_email_send": last_email_send, "last_email_send": last_email_send,
"trial_days_remaining": trial_days_remaining,
}, },
) )

View file

@ -177,7 +177,7 @@ async def _handle_subscription_active(
) -> None: ) -> None:
user = await _find_user(session, data) user = await _find_user(session, data)
if user is None: 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)) customer_id=_customer_id_from_payload(data))
return return
await _grant_paid(session, user, data) await _grant_paid(session, user, data)
@ -188,7 +188,7 @@ async def _handle_subscription_revoked(
) -> None: ) -> None:
user = await _find_user(session, data) user = await _find_user(session, data)
if user is None: 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)) customer_id=_customer_id_from_payload(data))
return return
await _revoke_paid(session, user) await _revoke_paid(session, user)

View file

@ -120,6 +120,22 @@ async def create_checkout(
# referral redemption flow ships. # referral redemption flow ships.
"allow_promotion_codes": True, "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: if user.stripe_customer_id:
create_kwargs["customer"] = user.stripe_customer_id create_kwargs["customer"] = user.stripe_customer_id
else: else:
@ -220,17 +236,28 @@ async def _grant_paid(
*, *,
customer_id: str | None, customer_id: str | None,
subscription_id: str | None, subscription_id: str | None,
trial_end: int | None = None,
status: str | None = None,
) -> None: ) -> None:
user.tier = "paid" user.tier = "paid"
if customer_id and user.stripe_customer_id != customer_id: if customer_id and user.stripe_customer_id != customer_id:
user.stripe_customer_id = customer_id user.stripe_customer_id = customer_id
if subscription_id and user.stripe_subscription_id != subscription_id: if subscription_id and user.stripe_subscription_id != subscription_id:
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: async def _revoke_paid(user: User) -> None:
user.tier = "free" user.tier = "free"
user.stripe_subscription_id = None user.stripe_subscription_id = None
user.stripe_trial_end_at = None
# Keep stripe_customer_id so a re-subscription matches this row. # 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"), customer_id=obj.get("customer"),
) )
if user is None: if user is None:
log.warning("stripe.user_not_found", event=event_type) log.warning("stripe.user_not_found", event_type=event_type)
return 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( await _grant_paid(
user, user,
customer_id=obj.get("customer"), customer_id=obj.get("customer"),
@ -260,7 +291,7 @@ async def _handle_subscription_event(
to free if it's an end-state.""" to free if it's an end-state."""
user = await _find_user(session, customer_id=obj.get("customer")) user = await _find_user(session, customer_id=obj.get("customer"))
if user is None: 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")) customer_id=obj.get("customer"))
return return
status = obj.get("status") status = obj.get("status")
@ -273,6 +304,8 @@ async def _handle_subscription_event(
user, user,
customer_id=obj.get("customer"), customer_id=obj.get("customer"),
subscription_id=obj.get("id"), subscription_id=obj.get("id"),
trial_end=obj.get("trial_end"),
status=status,
) )
@ -281,7 +314,7 @@ async def _handle_subscription_deleted(
) -> None: ) -> None:
user = await _find_user(session, customer_id=obj.get("customer")) user = await _find_user(session, customer_id=obj.get("customer"))
if user is None: 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")) customer_id=obj.get("customer"))
return return
await _revoke_paid(user) await _revoke_paid(user)

View file

@ -1802,6 +1802,34 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
font-weight: 700; font-weight: 700;
} }
.tier-card__cta { margin-top: 18px; } .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 { .tier-card__more {
margin-top: 14px; 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__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">&pound;7<span class="tier-card__price-unit"> / month</span></div>
<div class="tier-card__price-hint"> <div class="tier-card__price-hint">
Or <strong>&pound;70 / year</strong> &mdash; two months free. Or <strong>&pound;70 / year</strong> &mdash; two months free, and
Prices in GBP, VAT where applicable. 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>
<div class="tier-card__divider"></div> <div class="tier-card__divider"></div>
<div class="tier-card__list-head">Everything in Free, plus</div> <div class="tier-card__list-head">Everything in Free, plus</div>
@ -74,11 +76,23 @@
{% if paid %} {% if paid %}
<a class="btn-secondary btn-block" href="/settings">Manage subscription</a> <a class="btn-secondary btn-block" href="/settings">Manage subscription</a>
{% elif cu and cu.user %} {% 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" <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" <button class="btn-secondary btn-block" type="button"
data-stripe-checkout="annual" data-stripe-checkout="annual" disabled
style="margin-top:10px;">or &pound;70/year (two months free)</button> style="margin-top:10px;">or &pound;70/year (with 14-day free trial)</button>
{% else %} {% else %}
<a class="btn-primary btn-block" href="/login?next=/pricing">Sign in to subscribe</a> <a class="btn-primary btn-block" href="/login?next=/pricing">Sign in to subscribe</a>
{% endif %} {% endif %}
@ -89,10 +103,28 @@
<script> <script>
(function () { (function () {
// Wire the two upgrade buttons to /api/stripe/checkout. Stripe returns // Gate the Subscribe buttons on the ToS / Reg-36 consent checkbox.
// a hosted-checkout URL; we just redirect there. No Stripe.js needed. // The buttons render with `disabled` set; toggling the checkbox is
document.querySelectorAll('[data-stripe-checkout]').forEach(function (btn) { // 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 () { 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'); var cadence = btn.getAttribute('data-stripe-checkout');
btn.disabled = true; btn.disabled = true;
var prev = btn.textContent; var prev = btn.textContent;
@ -113,7 +145,7 @@
window.location.href = data.url; window.location.href = data.url;
} catch (e) { } catch (e) {
alert(e.message || 'Could not start checkout. Please try again.'); alert(e.message || 'Could not start checkout. Please try again.');
btn.disabled = false; btn.disabled = (consent && !consent.checked);
btn.textContent = prev; btn.textContent = prev;
} }
}); });

View file

@ -30,15 +30,66 @@
Paid features active via credit · {{ paid.days_remaining }} day(s) remaining Paid features active via credit · {{ paid.days_remaining }} day(s) remaining
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}). (expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
</span> </span>
{% else %}
{% 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 %} {% else %}
<span class="settings-row__hint">Paid subscription active.</span> <span class="settings-row__hint">Paid subscription active.</span>
{% endif %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
</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 --------------------------------------------- #} {# --- Import portfolio --------------------------------------------- #}
<div class="settings-section" id="import"> <div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div> <div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>

View file

@ -98,13 +98,32 @@
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">6. Refunds</h2> <h2 class="public-section__head">6. Refunds</h2>
<p> <p>
<strong>14-day cooling-off (UK / EU consumers).</strong> If you buy a <strong>14-day cooling-off period (UK / EU consumers).</strong>
paid subscription as a consumer, you have 14 days from the day of The Consumer Contracts (Information, Cancellation and Additional
purchase to cancel and receive a full refund of that purchase, under Charges) Regulations 2013 give consumers a 14-day right to cancel
the Consumer Contracts (Information, Cancellation and Additional digital service contracts. We honour this right in different ways
Charges) Regulations 2013. As noted in clause 5, if you start using depending on your billing cadence.
a paid feature inside the cancellation window you lose the right to </p>
cancel in respect of digital content already delivered. <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>
<p> <p>
<strong>Cancellation after the cooling-off window.</strong> You can <strong>Cancellation after the cooling-off window.</strong> You can

View file

@ -190,6 +190,110 @@ def test_subscription_deleted_drops_tier_to_free(tmp_path):
assert sid is None assert sid is None
def test_subscription_trialing_stores_trial_end(tmp_path):
"""customer.subscription.created with status=trialing + trial_end
should grant paid AND persist the trial_end timestamp so the
settings page can show 'N days remaining'.
Realistic flow: checkout.session.completed fires first (linking
customer_id to user.id via client_reference_id), then
subscription.created fires moments later carrying trial_end."""
import datetime as _dt
client, factory, _ = _build_app(tmp_path)
# First: link the user to the Stripe customer via checkout.
_post_webhook(client, body={
"id": "evt_link",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_trial",
"subscription": "sub_trial",
}},
})
# Then: the subscription event carrying trial_end (12 days out).
trial_end_ts = int((_dt.datetime.now(_dt.timezone.utc)
+ _dt.timedelta(days=12)).timestamp())
r = _post_webhook(client, body={
"id": "evt_trial",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_trial",
"customer": "cus_trial",
"status": "trialing",
"trial_end": trial_end_ts,
}},
})
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_trial_end_at
tier, end = asyncio.run(_check())
assert tier == "paid", "trial users must have paid features"
assert end is not None
# Stored value should match the trial_end we sent (within a second).
expected = _dt.datetime.fromtimestamp(trial_end_ts, tz=_dt.timezone.utc)
if end.tzinfo is None:
end = end.replace(tzinfo=_dt.timezone.utc)
assert abs((end - expected).total_seconds()) < 2
def test_subscription_active_clears_trial_end(tmp_path):
"""When the subscription transitions trialing -> active (day 15),
the trial_end marker should be cleared so settings stops showing
'trial — N days remaining'."""
import datetime as _dt
client, factory, _ = _build_app(tmp_path)
# Link the customer first via checkout, then plant a trial state.
_post_webhook(client, body={
"id": "evt_link2",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_t",
"subscription": "sub_t",
}},
})
trial_end_ts = int((_dt.datetime.now(_dt.timezone.utc)
+ _dt.timedelta(days=12)).timestamp())
_post_webhook(client, body={
"id": "evt_t1",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_t", "customer": "cus_t",
"status": "trialing", "trial_end": trial_end_ts,
}},
})
# Now transition to active.
_post_webhook(client, body={
"id": "evt_t2",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_t", "customer": "cus_t",
"status": "active",
}},
})
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_trial_end_at
tier, end = asyncio.run(_check())
assert tier == "paid"
assert end is None, "trial_end_at must be cleared once active"
def test_subscription_active_grants_paid(tmp_path): def test_subscription_active_grants_paid(tmp_path):
"""customer.subscription.updated with status=active should also """customer.subscription.updated with status=active should also
grant paid covers the case where checkout.session.completed grant paid covers the case where checkout.session.completed
@ -282,9 +386,9 @@ def test_unknown_event_is_acked(tmp_path):
# --- /api/stripe/checkout (with Stripe SDK mocked) ------------------------ # --- /api/stripe/checkout (with Stripe SDK mocked) ------------------------
def test_checkout_endpoint_creates_session_and_returns_url(tmp_path): def _fake_checkout_client(asserter):
client, _, session_cookie = _build_app(tmp_path) """Build a fake Stripe client whose checkout.sessions.create calls
# Mock the Stripe SDK call so no real HTTP goes out. the supplied asserter on the params dict and returns a stub URL."""
fake_session = SimpleNamespace( fake_session = SimpleNamespace(
id="cs_test_123", url="https://checkout.stripe.com/test", id="cs_test_123", url="https://checkout.stripe.com/test",
) )
@ -292,10 +396,7 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
class _FakeSessions: class _FakeSessions:
@staticmethod @staticmethod
def create(params): # noqa: ANN001 def create(params): # noqa: ANN001
assert params["mode"] == "subscription" asserter(params)
assert params["line_items"][0]["price"] == _PRICE_MONTHLY
assert params["client_reference_id"] == "1"
assert params["customer_email"] == "buyer@x"
return fake_session return fake_session
class _FakeCheckout: class _FakeCheckout:
@ -304,8 +405,28 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
class _FakeClient: class _FakeClient:
checkout = _FakeCheckout() checkout = _FakeCheckout()
return _FakeClient()
def test_checkout_monthly_has_no_trial_and_no_stripe_consent(tmp_path):
"""Monthly checkout must NOT carry a free trial (£7 × 14 days would
halve cycle-1 revenue) AND must NOT use Stripe's account-wide
consent_collection the Reg-36 waiver is collected on /pricing
so each product can use its own Terms URL."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["mode"] == "subscription"
assert params["line_items"][0]["price"] == _PRICE_MONTHLY
assert params["client_reference_id"] == "1"
assert params["customer_email"] == "buyer@x"
assert "subscription_data" not in params, "no trial on monthly"
assert "consent_collection" not in params, (
"consent is collected on /pricing, not via Stripe's account-wide setting"
)
with patch("app.routers.stripe_billing._stripe_client", with patch("app.routers.stripe_billing._stripe_client",
return_value=_FakeClient()): return_value=_fake_checkout_client(asserter)):
r = client.post( r = client.post(
"/api/stripe/checkout", "/api/stripe/checkout",
json={"cadence": "monthly"}, json={"cadence": "monthly"},
@ -315,6 +436,28 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path):
assert r.json()["url"] == "https://checkout.stripe.com/test" assert r.json()["url"] == "https://checkout.stripe.com/test"
def test_checkout_annual_uses_trial_not_consent_collection(tmp_path):
"""Annual checkout gets the 14-day free trial (substitutes for the
statutory cooling-off right; no money moves during the trial)."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["mode"] == "subscription"
assert params["line_items"][0]["price"] == _PRICE_ANNUAL
assert params["subscription_data"]["trial_period_days"] == 14
assert "consent_collection" not in params, "annual relies on trial, not consent"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "annual"},
cookies={"cassandra_session": session_cookie},
)
assert r.status_code == 200, r.text
assert r.json()["url"] == "https://checkout.stripe.com/test"
def test_checkout_endpoint_requires_login(tmp_path): def test_checkout_endpoint_requires_login(tmp_path):
client, _, _ = _build_app(tmp_path) client, _, _ = _build_app(tmp_path)
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"}) r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})