diff --git a/alembic/versions/0020_trial_end.py b/alembic/versions/0020_trial_end.py new file mode 100644 index 0000000..c845673 --- /dev/null +++ b/alembic/versions/0020_trial_end.py @@ -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") diff --git a/app/models.py b/app/models.py index 2140f6b..32b04ad 100644 --- a/app/models.py +++ b/app/models.py @@ -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"), diff --git a/app/routers/pages.py b/app/routers/pages.py index 993ec0f..228b5c5 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -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, }, ) diff --git a/app/routers/polar_webhook.py b/app/routers/polar_webhook.py index 220ce48..a60acd4 100644 --- a/app/routers/polar_webhook.py +++ b/app/routers/polar_webhook.py @@ -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) diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index e896f74..b47aa85 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -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) diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 2e1765a..128fcad 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -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; diff --git a/app/templates/pricing.html b/app/templates/pricing.html index 6bb4411..a224455 100644 --- a/app/templates/pricing.html +++ b/app/templates/pricing.html @@ -50,8 +50,10 @@
Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.
£7 / month
- Or £70 / year — two months free. - Prices in GBP, VAT where applicable. + Or £70 / year — two months free, and + starts with a 14-day free trial (cancel during + the trial and you are not charged). Monthly plans start + immediately. Prices in GBP, VAT where applicable.
Everything in Free, plus
@@ -74,11 +76,23 @@ {% if paid %} Manage subscription {% elif cu and cu.user %} + + data-stripe-checkout="monthly" disabled>Subscribe — £7/month + data-stripe-checkout="annual" disabled + style="margin-top:10px;">or £70/year (with 14-day free trial) {% else %} Sign in to subscribe {% endif %} @@ -89,10 +103,28 @@ + {% endif %} + {# --- Import portfolio --------------------------------------------- #}
Import portfolio (Trading 212 CSV)
diff --git a/app/templates/terms.html b/app/templates/terms.html index cb0dd1d..cd3530b 100644 --- a/app/templates/terms.html +++ b/app/templates/terms.html @@ -98,13 +98,32 @@

6. Refunds

- 14-day cooling-off (UK / EU consumers). 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. + 14-day cooling-off period (UK / EU consumers). + 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. +

+

+ 1a. Annual subscriptions. 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. +

+

+ 1b. Monthly subscriptions. 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.

Cancellation after the cooling-off window. You can diff --git a/tests/test_stripe_billing.py b/tests/test_stripe_billing.py index 61bf410..f00e72d 100644 --- a/tests/test_stripe_billing.py +++ b/tests/test_stripe_billing.py @@ -190,6 +190,110 @@ def test_subscription_deleted_drops_tier_to_free(tmp_path): 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): """customer.subscription.updated with status=active should also 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) ------------------------ -def test_checkout_endpoint_creates_session_and_returns_url(tmp_path): - client, _, session_cookie = _build_app(tmp_path) - # Mock the Stripe SDK call so no real HTTP goes out. +def _fake_checkout_client(asserter): + """Build a fake Stripe client whose checkout.sessions.create calls + the supplied asserter on the params dict and returns a stub URL.""" fake_session = SimpleNamespace( 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: @staticmethod def create(params): # noqa: ANN001 - assert params["mode"] == "subscription" - assert params["line_items"][0]["price"] == _PRICE_MONTHLY - assert params["client_reference_id"] == "1" - assert params["customer_email"] == "buyer@x" + asserter(params) return fake_session class _FakeCheckout: @@ -304,8 +405,28 @@ def test_checkout_endpoint_creates_session_and_returns_url(tmp_path): class _FakeClient: 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", - return_value=_FakeClient()): + return_value=_fake_checkout_client(asserter)): r = client.post( "/api/stripe/checkout", 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" +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): client, _, _ = _build_app(tmp_path) r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})