referrals: close D.3 — both parties get 45 days credit on conversion

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 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-26 23:05:29 +02:00
parent 00211fec02
commit ce36ce36fd
7 changed files with 556 additions and 15 deletions

View file

@ -132,8 +132,9 @@ async def settings_page(
# Lazily assign a referral code on first visit. # Lazily assign a referral code on first visit.
user = await assign_code_if_missing(session, user) user = await assign_code_if_missing(session, user)
# Stats: how many people have signed up with their code so far, and # Stats: how many people have signed up with their code so far, how
# how many of those converted (paid). D.3 will fill `converted_at`. # 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( pending_count = (await session.execute(
select(func.count(Referral.id)) select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.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.referrer_user_id == user.id)
.where(Referral.converted_at.is_not(None)) .where(Referral.converted_at.is_not(None))
)).scalar() or 0 )).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}" 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, "invite_url": invite_url,
"pending_count": int(pending_count), "pending_count": int(pending_count),
"converted_count": int(converted_count), "converted_count": int(converted_count),
"active_credit_count": int(active_credit_count),
"own_credit_days": own_credit_days,
"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, "trial_days_remaining": trial_days_remaining,

View file

@ -232,6 +232,7 @@ async def _find_user(
async def _grant_paid( async def _grant_paid(
session: AsyncSession,
user: User, user: User,
*, *,
customer_id: str | None, customer_id: str | None,
@ -239,6 +240,11 @@ async def _grant_paid(
trial_end: int | None = None, trial_end: int | None = None,
status: str | None = None, status: str | None = 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" 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
@ -253,6 +259,13 @@ async def _grant_paid(
elif status == "active": elif status == "active":
user.stripe_trial_end_at = None 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: async def _revoke_paid(user: User) -> None:
user.tier = "free" user.tier = "free"
@ -277,6 +290,7 @@ async def _handle_checkout_completed(
# after will carry it. We grant paid here without trial info and # after will carry it. We grant paid here without trial info and
# let the subscription event fill in trial_end_at moments later. # let the subscription event fill in trial_end_at moments later.
await _grant_paid( await _grant_paid(
session,
user, user,
customer_id=obj.get("customer"), customer_id=obj.get("customer"),
subscription_id=obj.get("subscription"), subscription_id=obj.get("subscription"),
@ -301,6 +315,7 @@ async def _handle_subscription_event(
# subscription.deleted (which fires after the final state lands). # subscription.deleted (which fires after the final state lands).
if status in ("trialing", "active"): if status in ("trialing", "active"):
await _grant_paid( await _grant_paid(
session,
user, user,
customer_id=obj.get("customer"), customer_id=obj.get("customer"),
subscription_id=obj.get("id"), subscription_id=obj.get("id"),

View file

@ -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 The flow:
in D.3 when the Paddle webhook fires. The flow:
1. /login renders an "invited" banner when the URL carries `?ref=<code>`. 1. /login renders an "invited" banner when the URL carries `?ref=<code>`.
2. The code travels through the email-OTP flow inside the pending cookie 2. The code travels through the email-OTP flow inside the pending cookie
so it survives the GET /login POST /login /verify hops. 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 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. 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 4. On the referred user's first paid subscription, `convert_referral`
`Referral` row to apply discounts to both parties. 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 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. 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 from __future__ import annotations
import secrets import secrets
from datetime import timedelta
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -24,6 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow from app.db import utcnow
from app.logging import get_logger from app.logging import get_logger
from app.models import Referral, User from app.models import Referral, User
from app.services.access import _aware
log = get_logger("referral") log = get_logger("referral")
@ -35,6 +40,12 @@ log = get_logger("referral")
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" _ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
_CODE_LEN = 8 _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: def generate_code() -> str:
"""Cryptographically random 8-char code from the unambiguous alphabet.""" """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, referrer_id=referrer.id, referred_id=new_user.id,
) )
return ref 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

View file

@ -225,7 +225,7 @@
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' + '<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
'autocomplete="off" placeholder="PIN" ' + 'autocomplete="off" placeholder="PIN" ' +
'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' + 'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
'<button type="submit">Restore</button>' + '<button type="submit" class="settings-btn">Restore</button>' +
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' + '<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
'or import a new CSV →</a>' + 'or import a new CSV →</a>' +
'</form>' + '</form>' +

View file

@ -212,9 +212,9 @@
<div class="invite-callout__icon" aria-hidden="true">&#x1F381;</div> <div class="invite-callout__icon" aria-hidden="true">&#x1F381;</div>
<div class="invite-callout__body"> <div class="invite-callout__body">
<div class="invite-callout__eyebrow">Invite a friend</div> <div class="invite-callout__eyebrow">Invite a friend</div>
<div class="invite-callout__headline">Both of you get <strong>50% off for 3 months</strong></div> <div class="invite-callout__headline">Both of you get <strong>45 days of paid access</strong></div>
<div class="invite-callout__sub"> <div class="invite-callout__sub">
Share your personal invite link from <a href="/settings">Settings</a>. The discount applies when they start a paid plan. Share your personal invite link from <a href="/settings">Settings</a>. The credit applies when they start a paid plan.
</div> </div>
</div> </div>
<button type="button" class="btn-secondary" id="invite-more">How it works</button> <button type="button" class="btn-secondary" id="invite-more">How it works</button>
@ -227,20 +227,21 @@
Every account gets an 8-character referral code and matching invite Every account gets an 8-character referral code and matching invite
link, both shown on your <a href="/settings">Settings</a> page. When link, both shown on your <a href="/settings">Settings</a> page. When
someone signs up through your link and starts a paid plan, someone signs up through your link and starts a paid plan,
<strong>both of you get 50% off for the next three months</strong>. <strong>both of you get 45 days of paid access</strong> credited
to your account.
</p> </p>
<h3 class="text-modal__head">How it works</h3> <h3 class="text-modal__head">How it works</h3>
<ol class="text-modal__list"> <ol class="text-modal__list">
<li><strong>Sign up.</strong> Your code and link go live in Settings.</li> <li><strong>Sign up.</strong> Your code and link go live in Settings.</li>
<li><strong>Share.</strong> Send the link, or read the code &mdash; the alphabet drops <code>0/O</code> and <code>1/I/L</code> so it dictates cleanly.</li> <li><strong>Share.</strong> Send the link, or read the code &mdash; the alphabet drops <code>0/O</code> and <code>1/I/L</code> so it dictates cleanly.</li>
<li><strong>They sign up.</strong> The referral is recorded against your account when they verify their email.</li> <li><strong>They sign up.</strong> The referral is recorded against your account when they verify their email.</li>
<li><strong>They subscribe.</strong> The discount applies to their next bill and credits against yours.</li> <li><strong>They subscribe.</strong> 45 days of paid access lands on both accounts &mdash; usable any time over the next month and a half.</li>
</ol> </ol>
<h3 class="text-modal__head">The fine print</h3> <h3 class="text-modal__head">The fine print</h3>
<ul class="text-modal__list"> <ul class="text-modal__list">
<li>One referral per new account &mdash; whichever link they used first.</li> <li>One referral per new account &mdash; whichever link they used first.</li>
<li>No self-referral.</li> <li>No self-referral.</li>
<li>The credit ledger is live today; the cash value kicks in when paid checkout opens. Referrals logged in the meantime are honoured.</li> <li>Credits stack: if you already have a credit window running, the new 45 days extend from its end, not from today.</li>
<li>Credits aren&rsquo;t refundable for cash &mdash; see <a href="/terms">Terms &amp; Conditions &sect; 6</a>.</li> <li>Credits aren&rsquo;t refundable for cash &mdash; see <a href="/terms">Terms &amp; Conditions &sect; 6</a>.</li>
<li>Pending signups, conversions, and active credits are visible on the Settings page.</li> <li>Pending signups, conversions, and active credits are visible on the Settings page.</li>
</ul> </ul>

View file

@ -121,7 +121,8 @@
<summary class="settings-section__head">Invite a friend</summary> <summary class="settings-section__head">Invite a friend</summary>
<p class="settings-section__lede"> <p class="settings-section__lede">
Share your invite link. When your friend subscribes, you and Share your invite link. When your friend subscribes, you and
they each get <strong>50% off for 3 months</strong>. they each get <strong>45 days of paid access</strong> credited
to your account.
</p> </p>
<div class="invite-block"> <div class="invite-block">
@ -146,7 +147,12 @@
</div> </div>
<div> <div>
<div class="invite-stats__label">Active credits</div> <div class="invite-stats__label">Active credits</div>
<div class="invite-stats__value settings-row__hint">— (D.3)</div> <div class="invite-stats__value">{{ active_credit_count }}</div>
{% if own_credit_days %}
<div class="settings-row__hint" style="margin-left:0;">
+{{ own_credit_days }} day{{ '' if own_credit_days == 1 else 's' }} on your account
</div>
{% endif %}
</div> </div>
</div> </div>
</details> </details>

View file

@ -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}"
)