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

@ -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=<code>`.
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