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
|
|
@ -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">£7<span class="tier-card__price-unit"> / month</span></div>
|
||||
<div class="tier-card__price-hint">
|
||||
Or <strong>£70 / year</strong> — two months free.
|
||||
Prices in GBP, VAT where applicable.
|
||||
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>
|
||||
|
|
@ -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 — 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">Subscribe — £7/month</button>
|
||||
data-stripe-checkout="monthly" disabled>Subscribe — £7/month</button>
|
||||
<button class="btn-secondary btn-block" type="button"
|
||||
data-stripe-checkout="annual"
|
||||
style="margin-top:10px;">or £70/year (two months free)</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 %}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue