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:
parent
62960d5bea
commit
a07fd144ea
10 changed files with 390 additions and 31 deletions
31
alembic/versions/0020_trial_end.py
Normal file
31
alembic/versions/0020_trial_end.py
Normal 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")
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">£7<span class="tier-card__price-unit"> / month</span></div>
|
<div class="tier-card__price">£7<span class="tier-card__price-unit"> / month</span></div>
|
||||||
<div class="tier-card__price-hint">
|
<div class="tier-card__price-hint">
|
||||||
Or <strong>£70 / year</strong> — two months free.
|
Or <strong>£70 / year</strong> — 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 — see
|
||||||
|
<a href="/terms" target="_blank" rel="noopener">Terms §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 — £7/month</button>
|
data-stripe-checkout="monthly" disabled>Subscribe — £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 £70/year (two months free)</button>
|
style="margin-top:10px;">or £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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,65 @@
|
||||||
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
|
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="settings-row__hint">Paid subscription active.</span>
|
{% if trial_days_remaining %}
|
||||||
|
<span class="settings-row__hint">
|
||||||
|
<strong>Free trial</strong> — {{ trial_days_remaining }}
|
||||||
|
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
|
||||||
|
Cancel before the trial ends and you won’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 ↔ annual, or cancel any time. Opens the Stripe-hosted billing portal.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% 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 — <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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue