From ce36ce36fdc10efc2218daee2b2655381bc9cddb Mon Sep 17 00:00:00 2001
From: Giorgio Gilestro
Date: Tue, 26 May 2026 23:05:29 +0200
Subject: [PATCH] =?UTF-8?q?referrals:=20close=20D.3=20=E2=80=94=20both=20p?=
=?UTF-8?q?arties=20get=2045=20days=20credit=20on=20conversion?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.
Closing the loop:
- New `convert_referral(session, user)` in referral_service.py looks
up the user's Referral row, stamps `converted_at` + `credited_at`,
and extends `credit_until` by 45 days on BOTH the buyer and the
referrer. Idempotent — replayed webhooks and renewals are no-ops.
Stacks correctly when the user already has a credit window running
(anchors at max(now, current_credit_until) like cli.grant_credit).
- Stripe webhook wires this into `_grant_paid`. A captured
`first_paid_transition = user.tier != "paid"` gate avoids the DB
lookup on every renewal event; convert_referral's own idempotency
is the second line of defence.
- `_grant_paid` now takes `session` as its first positional arg so
the conversion runs inside the same transaction as the tier flip
and audit-row write. A mid-flight failure rolls everything back
together — no partial state.
- Settings page replaces the "— (D.3)" placeholder with the live
count of conversions still inside their 45-day credit window, plus
a "+N days on your account" hint when the user has any credit of
their own (referrer bonus, admin grant, or future refund-as-credit).
- Marketing copy on pricing.html + settings.html switches from "50%
off for 3 months" to "45 days of paid access" — same economic value,
honest about the actual mechanism (full free access rather than
discounted billing).
Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.
7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.
Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).
Co-Authored-By: Claude Opus 4.7
---
app/routers/pages.py | 33 ++-
app/routers/stripe_billing.py | 15 ++
app/services/referral_service.py | 83 +++++-
app/static/js/portfolio.js | 2 +-
app/templates/pricing.html | 11 +-
app/templates/settings.html | 10 +-
tests/test_referral_conversion.py | 417 ++++++++++++++++++++++++++++++
7 files changed, 556 insertions(+), 15 deletions(-)
create mode 100644 tests/test_referral_conversion.py
diff --git a/app/routers/pages.py b/app/routers/pages.py
index 228b5c5..db6f5a3 100644
--- a/app/routers/pages.py
+++ b/app/routers/pages.py
@@ -132,8 +132,9 @@ async def settings_page(
# Lazily assign a referral code on first visit.
user = await assign_code_if_missing(session, user)
- # Stats: how many people have signed up with their code so far, and
- # how many of those converted (paid). D.3 will fill `converted_at`.
+ # Stats: how many people have signed up with their code so far, how
+ # many converted (paid), and how many of those credit grants are
+ # still live (referrer-side bonus runway not yet expired).
pending_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
@@ -144,6 +145,32 @@ async def settings_page(
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_not(None))
)).scalar() or 0
+ # An "active credit" is a conversion whose credit window hasn't yet
+ # expired for the REFERRED user. We approximate by counting
+ # conversions in the last REFERRAL_CREDIT_DAYS days — simpler than
+ # joining against the referred user's credit_until, and matches the
+ # marketing copy ("45 days of paid access each").
+ from datetime import timedelta
+ from app.services.referral_service import REFERRAL_CREDIT_DAYS
+ credit_horizon = datetime.now(timezone.utc) - timedelta(days=REFERRAL_CREDIT_DAYS)
+ active_credit_count = (await session.execute(
+ select(func.count(Referral.id))
+ .where(Referral.referrer_user_id == user.id)
+ .where(Referral.credited_at.is_not(None))
+ .where(Referral.credited_at >= credit_horizon)
+ )).scalar() or 0
+
+ # Days of credit the user themselves has on their own account (from
+ # any source: referrer bonus, admin grant, refund-as-credit). None
+ # if no credit or it has already expired.
+ own_credit_days: int | None = None
+ if user.credit_until is not None:
+ cu = user.credit_until
+ if cu.tzinfo is None:
+ cu = cu.replace(tzinfo=timezone.utc)
+ delta = cu - datetime.now(timezone.utc)
+ if delta.total_seconds() > 0:
+ own_credit_days = max(1, -(-int(delta.total_seconds()) // 86400))
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
@@ -176,6 +203,8 @@ async def settings_page(
"invite_url": invite_url,
"pending_count": int(pending_count),
"converted_count": int(converted_count),
+ "active_credit_count": int(active_credit_count),
+ "own_credit_days": own_credit_days,
"paid": paid_status(user),
"last_email_send": last_email_send,
"trial_days_remaining": trial_days_remaining,
diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py
index b47aa85..60bc7f7 100644
--- a/app/routers/stripe_billing.py
+++ b/app/routers/stripe_billing.py
@@ -232,6 +232,7 @@ async def _find_user(
async def _grant_paid(
+ session: AsyncSession,
user: User,
*,
customer_id: str | None,
@@ -239,6 +240,11 @@ async def _grant_paid(
trial_end: int | None = None,
status: str | None = None,
) -> None:
+ # Capture "first paid transition" before mutating — drives the
+ # referral-conversion call below. Skipping the convert lookup on
+ # every renewal event saves a DB roundtrip per webhook.
+ first_paid_transition = user.tier != "paid"
+
user.tier = "paid"
if customer_id and user.stripe_customer_id != customer_id:
user.stripe_customer_id = customer_id
@@ -253,6 +259,13 @@ async def _grant_paid(
elif status == "active":
user.stripe_trial_end_at = None
+ # Apply referral credit on the FIRST paid transition only.
+ # convert_referral is itself idempotent (no-op on missing or
+ # already-converted rows), so this guard is purely a perf hint.
+ if first_paid_transition:
+ from app.services.referral_service import convert_referral
+ await convert_referral(session, user)
+
async def _revoke_paid(user: User) -> None:
user.tier = "free"
@@ -277,6 +290,7 @@ async def _handle_checkout_completed(
# 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(
+ session,
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("subscription"),
@@ -301,6 +315,7 @@ async def _handle_subscription_event(
# subscription.deleted (which fires after the final state lands).
if status in ("trialing", "active"):
await _grant_paid(
+ session,
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("id"),
diff --git a/app/services/referral_service.py b/app/services/referral_service.py
index 5f663e6..05ee579 100644
--- a/app/services/referral_service.py
+++ b/app/services/referral_service.py
@@ -1,15 +1,18 @@
-"""Referral-code generation, lookup, and signup-time linkage.
+"""Referral-code generation, lookup, signup-time linkage, and
+conversion-time credit grants.
-D.1 lays down the bookkeeping only — actual credit application happens
-in D.3 when the Paddle webhook fires. The flow:
+The flow:
1. /login renders an "invited" banner when the URL carries `?ref=`.
2. The code travels through the email-OTP flow inside the pending cookie
so it survives the GET /login → POST /login → /verify hops.
3. When the new user's row is first created (POST /login on an unknown
email), `referred_by_user_id` is set and a `Referral` row is written.
-4. On the new user's first paid subscription (D.3), we read the
- `Referral` row to apply discounts to both parties.
+4. On the referred user's first paid subscription, `convert_referral`
+ is called from the Stripe webhook: both parties get a credit-window
+ extension worth the promised "50% off for 3 months" (= 45 days of
+ full paid access via `users.credit_until`), and the Referral row's
+ `converted_at` + `credited_at` are stamped.
The code itself is 8 characters from an unambiguous alphabet so users
can read it off a phone screen or dictate it over the phone.
@@ -17,6 +20,7 @@ can read it off a phone screen or dictate it over the phone.
from __future__ import annotations
import secrets
+from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,6 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.logging import get_logger
from app.models import Referral, User
+from app.services.access import _aware
log = get_logger("referral")
@@ -35,6 +40,12 @@ log = get_logger("referral")
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
_CODE_LEN = 8
+# Value-equivalent of the public-facing "50% off for 3 months" promise,
+# delivered as a credit-window extension. 50% × 3 months ≈ 1.5 months
+# of free service ≈ 45 days. Pure-credit delivery means the mechanism
+# is processor-agnostic and stacks cleanly when both parties refer.
+REFERRAL_CREDIT_DAYS = 45
+
def generate_code() -> str:
"""Cryptographically random 8-char code from the unambiguous alphabet."""
@@ -128,3 +139,65 @@ async def link_new_user(
referrer_id=referrer.id, referred_id=new_user.id,
)
return ref
+
+
+def _extend_credit(user: User, days: int) -> None:
+ """Stack `days` of paid-tier credit onto `user.credit_until`. Anchors
+ at max(now, current credit_until) so granting twice gives twice the
+ runway — never shortens the window. Mirrors the cli.grant_credit
+ anchoring rule so manual + automatic grants compose."""
+ now = utcnow()
+ anchor = max(now, _aware(user.credit_until) or now)
+ user.credit_until = anchor + timedelta(days=days)
+
+
+async def convert_referral(
+ session: AsyncSession, referred_user: User,
+) -> Referral | None:
+ """Stamp the Referral row for `referred_user` as converted and grant
+ both parties their credit. Idempotent — safe to call from every
+ subscription event:
+
+ - Returns None if no Referral row exists for this user (direct
+ signup, no inviter).
+ - Returns the existing Referral (unchanged) if `converted_at` is
+ already set — this is a renewal or duplicate webhook delivery.
+ - Otherwise: extends both users' `credit_until` by
+ REFERRAL_CREDIT_DAYS and sets `converted_at` + `credited_at`.
+
+ The caller is responsible for committing the session — this lets
+ the Stripe webhook compose the conversion inside its outer
+ audit-row transaction, so a mid-flight failure rolls back the
+ tier flip AND the conversion together.
+
+ Self-referral cannot happen here in practice (link_new_user blocks
+ it at signup) but we guard anyway: if the row somehow names the
+ same user on both sides, we stamp the timestamps but only credit
+ once."""
+ row = (await session.execute(
+ select(Referral).where(Referral.referred_user_id == referred_user.id)
+ )).scalar_one_or_none()
+ if row is None:
+ return None
+ if row.converted_at is not None:
+ return row
+
+ referrer = await session.get(User, row.referrer_user_id)
+ now = utcnow()
+
+ # Always credit the buyer; credit the referrer too unless they're
+ # the same row (defence-in-depth) or have been deleted.
+ _extend_credit(referred_user, REFERRAL_CREDIT_DAYS)
+ if referrer is not None and referrer.id != referred_user.id:
+ _extend_credit(referrer, REFERRAL_CREDIT_DAYS)
+
+ row.converted_at = now
+ row.credited_at = now
+ log.info(
+ "referral.converted",
+ referral_id=row.id,
+ referrer_id=row.referrer_user_id,
+ referred_id=row.referred_user_id,
+ credit_days=REFERRAL_CREDIT_DAYS,
+ )
+ return row
diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js
index 742d748..6314512 100644
--- a/app/static/js/portfolio.js
+++ b/app/static/js/portfolio.js
@@ -225,7 +225,7 @@
'' +
- '' +
+ '' +
'' +
'or import a new CSV →' +
'' +
diff --git a/app/templates/pricing.html b/app/templates/pricing.html
index a224455..93f1562 100644
--- a/app/templates/pricing.html
+++ b/app/templates/pricing.html
@@ -212,9 +212,9 @@
🎁
Invite a friend
-
Both of you get 50% off for 3 months
+
Both of you get 45 days of paid access
- Share your personal invite link from Settings. The discount applies when they start a paid plan.
+ Share your personal invite link from Settings. The credit applies when they start a paid plan.
@@ -227,20 +227,21 @@
Every account gets an 8-character referral code and matching invite
link, both shown on your Settings page. When
someone signs up through your link and starts a paid plan,
- both of you get 50% off for the next three months.
+ both of you get 45 days of paid access credited
+ to your account.
How it works
Sign up. Your code and link go live in Settings.
Share. Send the link, or read the code — the alphabet drops 0/O and 1/I/L so it dictates cleanly.
They sign up. The referral is recorded against your account when they verify their email.
-
They subscribe. The discount applies to their next bill and credits against yours.
+
They subscribe. 45 days of paid access lands on both accounts — usable any time over the next month and a half.
The fine print
One referral per new account — whichever link they used first.
No self-referral.
-
The credit ledger is live today; the cash value kicks in when paid checkout opens. Referrals logged in the meantime are honoured.
+
Credits stack: if you already have a credit window running, the new 45 days extend from its end, not from today.
Pending signups, conversions, and active credits are visible on the Settings page.
diff --git a/app/templates/settings.html b/app/templates/settings.html
index 1ada212..66d5e3d 100644
--- a/app/templates/settings.html
+++ b/app/templates/settings.html
@@ -121,7 +121,8 @@
Invite a friend
Share your invite link. When your friend subscribes, you and
- they each get 50% off for 3 months.
+ they each get 45 days of paid access credited
+ to your account.
@@ -146,7 +147,12 @@
Active credits
-
— (D.3)
+
{{ active_credit_count }}
+ {% if own_credit_days %}
+
+ +{{ own_credit_days }} day{{ '' if own_credit_days == 1 else 's' }} on your account
+
+ {% endif %}
diff --git a/tests/test_referral_conversion.py b/tests/test_referral_conversion.py
new file mode 100644
index 0000000..9ca5858
--- /dev/null
+++ b/tests/test_referral_conversion.py
@@ -0,0 +1,417 @@
+"""DB-backed tests for the referral-conversion path: when a referred
+user becomes paid, both parties should get REFERRAL_CREDIT_DAYS of
+extra credit, the Referral row should be stamped, and the operation
+should be idempotent so renewal events don't double-credit.
+
+Also exercises the Stripe-webhook integration to confirm the credit
+actually lands when a subscription.created event fires for a referred
+user."""
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import json
+import time
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Shared scaffolding
+# ---------------------------------------------------------------------------
+
+
+def _build_session_factory(tmp_path):
+ """Spin up a fresh in-memory schema and return (engine, factory).
+ Mirrors test_stripe_billing._build_app's seeding strategy but
+ skips the FastAPI app — most conversion tests only need the
+ session factory."""
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+ from app import db as db_mod
+ from app.db import Base
+
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv.db")
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ db_mod._engine = engine
+ db_mod._session_factory = factory
+
+ async def _seed():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ asyncio.run(_seed())
+ return factory
+
+
+async def _add_pair(factory, *, referrer_id=1, referred_id=2):
+ """Insert a referrer + referred user pair and a linking Referral row.
+ Returns nothing — tests re-fetch via the factory."""
+ from app.db import utcnow
+ from app.models import Referral, User
+
+ async with factory() as s:
+ s.add(User(id=referrer_id, email=f"r{referrer_id}@x",
+ tier="paid", referral_code="REFREFXX"))
+ s.add(User(id=referred_id, email=f"u{referred_id}@x",
+ tier="free", referred_by_user_id=referrer_id))
+ s.add(Referral(referrer_user_id=referrer_id,
+ referred_user_id=referred_id,
+ created_at=utcnow()))
+ await s.commit()
+
+
+# ---------------------------------------------------------------------------
+# convert_referral happy path
+# ---------------------------------------------------------------------------
+
+
+def test_first_conversion_credits_both_parties(tmp_path):
+ """Calling convert_referral on a freshly-paid referred user should
+ extend credit_until by REFERRAL_CREDIT_DAYS for BOTH the buyer and
+ the referrer, and stamp converted_at + credited_at."""
+ from app.models import Referral, User
+ from app.services.referral_service import (
+ REFERRAL_CREDIT_DAYS, convert_referral,
+ )
+
+ factory = _build_session_factory(tmp_path)
+ asyncio.run(_add_pair(factory))
+
+ async def _run():
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ ref = await convert_referral(s, referred)
+ assert ref is not None
+ assert ref.converted_at is not None
+ assert ref.credited_at is not None
+ await s.commit()
+
+ # Re-open a fresh session so we read committed state, not the
+ # session-cached version.
+ async with factory() as s:
+ referrer = await s.get(User, 1)
+ referred = await s.get(User, 2)
+ now = datetime.now(timezone.utc)
+ # Both windows should sit ~REFERRAL_CREDIT_DAYS in the
+ # future (allow 1 day slack for clock + rounding).
+ for u in (referrer, referred):
+ assert u.credit_until is not None
+ cu = u.credit_until
+ if cu.tzinfo is None:
+ cu = cu.replace(tzinfo=timezone.utc)
+ delta_days = (cu - now).total_seconds() / 86400
+ assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
+
+ asyncio.run(_run())
+
+
+def test_idempotent_on_repeat_call(tmp_path):
+ """A second convert_referral call (e.g. from a duplicate webhook or
+ renewal event) must NOT extend credit a second time. The Referral
+ row is already stamped, so we should early-return unchanged."""
+ from app.models import User
+ from app.services.referral_service import convert_referral
+
+ factory = _build_session_factory(tmp_path)
+ asyncio.run(_add_pair(factory))
+
+ async def _run():
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ await convert_referral(s, referred)
+ await s.commit()
+ # Snapshot credit_until after first conversion.
+ async with factory() as s:
+ referrer = await s.get(User, 1)
+ referred = await s.get(User, 2)
+ first_referrer_credit = referrer.credit_until
+ first_referred_credit = referred.credit_until
+
+ # Second call — should no-op.
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ ref2 = await convert_referral(s, referred)
+ assert ref2 is not None # we still return the row
+ await s.commit()
+ async with factory() as s:
+ referrer = await s.get(User, 1)
+ referred = await s.get(User, 2)
+ assert referrer.credit_until == first_referrer_credit
+ assert referred.credit_until == first_referred_credit
+
+ asyncio.run(_run())
+
+
+def test_no_referral_row_returns_none(tmp_path):
+ """A user signing up directly (no inviter) has no Referral row.
+ convert_referral must return None and touch nothing."""
+ from app.models import User
+ from app.services.referral_service import convert_referral
+
+ factory = _build_session_factory(tmp_path)
+
+ async def _seed_orphan():
+ async with factory() as s:
+ s.add(User(id=9, email="lone@x", tier="free"))
+ await s.commit()
+
+ asyncio.run(_seed_orphan())
+
+ async def _run():
+ async with factory() as s:
+ user = await s.get(User, 9)
+ result = await convert_referral(s, user)
+ assert result is None
+ assert user.credit_until is None
+
+ asyncio.run(_run())
+
+
+def test_credit_stacks_from_existing_window(tmp_path):
+ """If the user already has a future credit_until (admin grant, prior
+ referral), the new credit should extend from THAT anchor — not from
+ now. Mirrors cli.grant_credit's stacking semantics."""
+ from app.models import User
+ from app.services.referral_service import (
+ REFERRAL_CREDIT_DAYS, convert_referral,
+ )
+
+ factory = _build_session_factory(tmp_path)
+ asyncio.run(_add_pair(factory))
+
+ # Pre-load 30 days of credit on the referred user.
+ existing = datetime.now(timezone.utc) + timedelta(days=30)
+
+ async def _preload():
+ async with factory() as s:
+ u = await s.get(User, 2)
+ u.credit_until = existing
+ await s.commit()
+
+ asyncio.run(_preload())
+
+ async def _run():
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ await convert_referral(s, referred)
+ await s.commit()
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ cu = referred.credit_until
+ if cu.tzinfo is None:
+ cu = cu.replace(tzinfo=timezone.utc)
+ # Expected: existing + REFERRAL_CREDIT_DAYS days, not now + days.
+ expected = existing + timedelta(days=REFERRAL_CREDIT_DAYS)
+ delta_seconds = abs((cu - expected).total_seconds())
+ assert delta_seconds < 60, (
+ f"new credit anchored at now, not existing window: "
+ f"got {cu}, expected ~{expected}"
+ )
+
+ asyncio.run(_run())
+
+
+def test_deleted_referrer_does_not_crash(tmp_path):
+ """If the referrer's User row has been deleted, the referred user
+ should still be credited and the Referral still stamped — we just
+ skip the missing referrer."""
+ from app.models import Referral, User
+ from app.services.referral_service import convert_referral
+
+ factory = _build_session_factory(tmp_path)
+
+ async def _seed():
+ from app.db import utcnow
+ async with factory() as s:
+ # Referrer with FK SET NULL — we don't delete the row, we
+ # instead create a Referral pointing at a non-existent id
+ # to simulate a deleted referrer.
+ s.add(User(id=2, email="u2@x", tier="free"))
+ s.add(Referral(referrer_user_id=999, # nonexistent
+ referred_user_id=2,
+ created_at=utcnow()))
+ await s.commit()
+
+ asyncio.run(_seed())
+
+ async def _run():
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ ref = await convert_referral(s, referred)
+ await s.commit()
+ assert ref is not None
+ assert ref.converted_at is not None
+ # Referred still got their credit even though referrer is gone.
+ assert referred.credit_until is not None
+
+ asyncio.run(_run())
+
+
+# ---------------------------------------------------------------------------
+# Stripe-webhook integration
+# ---------------------------------------------------------------------------
+
+
+_WEBHOOK_SECRET = "whsec_dummy_test_secret_for_unit_tests"
+
+
+def _stripe_sig(body: bytes, secret: str, ts: int | None = None) -> str:
+ ts = ts if ts is not None else int(time.time())
+ signed = f"{ts}.{body.decode('utf-8')}"
+ mac = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"),
+ hashlib.sha256).hexdigest()
+ return f"t={ts},v1={mac}"
+
+
+def _build_webhook_app(tmp_path):
+ """Same as the helper in test_stripe_billing but pre-seeds a
+ referrer+referred pair so the webhook can drive a full conversion."""
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+ from app import db as db_mod
+ from app.config import get_settings
+ from app.db import Base, utcnow
+ from app.models import Referral, User
+ from app.routers import stripe_billing as stripe_router
+
+ s = get_settings()
+ s.STRIPE_API_KEY = "sk_test_dummy" # type: ignore[misc]
+ s.STRIPE_WEBHOOK_SECRET = _WEBHOOK_SECRET # type: ignore[misc]
+ s.STRIPE_PRICE_MONTHLY = "price_test_monthly_xxxxxxxxxxxxxxxxxxxx" # type: ignore[misc]
+ s.STRIPE_PRICE_ANNUAL = "price_test_annual_xxxxxxxxxxxxxxxxxxxxx" # type: ignore[misc]
+
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv-int.db")
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ db_mod._engine = engine
+ db_mod._session_factory = factory
+
+ async def _seed():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ async with factory() as session:
+ session.add(User(id=1, email="referrer@x", tier="paid",
+ referral_code="REFCODE1"))
+ session.add(User(id=2, email="buyer@x", tier="free",
+ stripe_customer_id="cus_test_referred",
+ referred_by_user_id=1))
+ session.add(Referral(referrer_user_id=1, referred_user_id=2,
+ created_at=utcnow()))
+ await session.commit()
+
+ asyncio.run(_seed())
+
+ app = FastAPI()
+ app.include_router(stripe_router.router)
+ return TestClient(app), factory
+
+
+def _post_webhook(client, body: dict):
+ body.setdefault("object", "event")
+ raw = json.dumps(body).encode("utf-8")
+ sig = _stripe_sig(raw, _WEBHOOK_SECRET)
+ return client.post(
+ "/api/stripe/webhook",
+ content=raw,
+ headers={"stripe-signature": sig, "content-type": "application/json"},
+ )
+
+
+def test_subscription_active_event_triggers_referral_conversion(tmp_path):
+ """End-to-end: fire a customer.subscription.created event for the
+ referred user. The webhook should flip their tier AND extend
+ credit_until on both parties via convert_referral."""
+ from app.models import Referral, User
+ from app.services.referral_service import REFERRAL_CREDIT_DAYS
+
+ client, factory = _build_webhook_app(tmp_path)
+
+ body = {
+ "id": "evt_conv_1",
+ "type": "customer.subscription.created",
+ "data": {"object": {
+ "id": "sub_test_1",
+ "customer": "cus_test_referred",
+ "status": "active",
+ "trial_end": None,
+ }},
+ }
+ r = _post_webhook(client, body)
+ assert r.status_code == 200, r.text
+ assert r.json()["status"] == "ok"
+
+ async def _check():
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ referrer = await s.get(User, 1)
+ assert referred.tier == "paid"
+ now = datetime.now(timezone.utc)
+ for u in (referred, referrer):
+ assert u.credit_until is not None, (
+ f"user {u.id} got no credit"
+ )
+ cu = u.credit_until
+ if cu.tzinfo is None:
+ cu = cu.replace(tzinfo=timezone.utc)
+ delta_days = (cu - now).total_seconds() / 86400
+ assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1, (
+ f"user {u.id} credit days={delta_days} not ~{REFERRAL_CREDIT_DAYS}"
+ )
+ # Referral row stamped.
+ from sqlalchemy import select
+ ref = (await s.execute(
+ select(Referral).where(Referral.referred_user_id == 2)
+ )).scalar_one()
+ assert ref.converted_at is not None
+ assert ref.credited_at is not None
+
+ asyncio.run(_check())
+
+
+def test_renewal_event_does_not_double_credit(tmp_path):
+ """A subscription.updated event firing later (renewal, status change)
+ on an already-paid user must NOT re-trigger conversion. The
+ first_paid_transition guard inside _grant_paid should skip the
+ convert_referral lookup; convert_referral itself is also
+ idempotent as a second line of defence."""
+ from app.models import User
+
+ client, factory = _build_webhook_app(tmp_path)
+
+ # First event: created → conversion fires.
+ _post_webhook(client, {
+ "id": "evt_first",
+ "type": "customer.subscription.created",
+ "data": {"object": {
+ "id": "sub_test_1", "customer": "cus_test_referred",
+ "status": "active",
+ }},
+ })
+
+ async def _snapshot():
+ async with factory() as s:
+ referred = await s.get(User, 2)
+ referrer = await s.get(User, 1)
+ return referred.credit_until, referrer.credit_until
+
+ before = asyncio.run(_snapshot())
+
+ # Second event: updated → must not extend credit further.
+ r = _post_webhook(client, {
+ "id": "evt_second",
+ "type": "customer.subscription.updated",
+ "data": {"object": {
+ "id": "sub_test_1", "customer": "cus_test_referred",
+ "status": "active",
+ }},
+ })
+ assert r.status_code == 200
+
+ after = asyncio.run(_snapshot())
+ assert before == after, (
+ f"renewal event extended credit: before={before}, after={after}"
+ )