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,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> — {{ 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 %}
|
||||
{% 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 %}
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue