phase D milestones 1+2: referral system + paid-access gate

Lays the billing-prep spine before Paddle lands in D.3.

D.1 — referrals
- users.referral_code: unique 8-char URL-safe code (alphabet excludes the
  ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing
  accounts pick one up without a backfill migration.
- users.referred_by_user_id + new referrals audit table (referrer,
  referred, created_at, converted_at, credited_at). converted_at /
  credited_at stay null until D.3 fills them via the Paddle webhook.
- POST /login accepts ?ref=<code>; the code rides on the signed
  pending-verify cookie so it survives the GET → POST → /verify hop.
- /settings page: email, tier badge, referral code chip + invite link
  with one-click copy, pending/converted/active-credits stats grid.
  Settings nav link added to the top bar.

Reward shape: when the referred user makes their first paid Paddle
subscription, both they and the referrer get 50% off for 3 months.
(D.3 wires the actual credit application via the Paddle webhook.)

D.2 — paid-access gate
- users.credit_until: timestamp until which a free-tier account has
  paid-tier access. Null = no credit. Populated by admin CLI now and the
  D.3 webhook later.
- app.services.access exposes paid_status(user) → PaidStatus dataclass
  (active / source / expires_at / days_remaining), is_paid_active() with
  admin-bearer-token bypass, and a require_paid FastAPI dependency that
  raises 402 Payment Required for free-tier callers.
- POST /api/analyze (portfolio AI commentary) gated behind require_paid.
- Settings page surfaces credit window when active ("free · credit · N
  day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not.
- Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}.
  grant-credit is idempotent — extends from max(now, current expiry) so
  re-running the command never erodes an existing grant.

Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the
paid-status truth table, code generation + normalisation, CLI argument
parsing, and the pending-cookie ref roundtrip (29 new tests).
This commit is contained in:
Giorgio Gilestro 2026-05-21 23:25:35 +01:00
parent 2013bfa8cc
commit 9759080134
18 changed files with 1159 additions and 21 deletions

View file

@ -0,0 +1,77 @@
"""referrals: user.referral_code + user.referred_by_user_id + referrals table
Phase D.1 of the multi-user billing work. Adds:
- `users.referral_code` unique 8-char URL-safe code per user, generated
lazily on first visit to /settings (or signup).
- `users.referred_by_user_id` FK to the user who referred this account,
set at signup if `?ref=<code>` was supplied. Null otherwise.
- `referrals` audit trail. One row per (referrer, referred) pair when the
link is captured. `converted_at` / `credited_at` filled in D.3 by the
Paddle webhook when the referred user makes their first paid subscription.
The Credit table that holds actual discount records is deferred to D.3
no point creating it until Paddle is wired and we know what to write.
Revision ID: 0013
Revises: 0012
Create Date: 2026-05-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0013"
down_revision: Union[str, None] = "0012"
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("referral_code", sa.String(16), nullable=True),
)
op.create_unique_constraint(
"uq_users_referral_code", "users", ["referral_code"],
)
op.add_column(
"users",
sa.Column("referred_by_user_id", sa.Integer, nullable=True),
)
op.create_foreign_key(
"fk_users_referred_by",
"users", "users",
["referred_by_user_id"], ["id"],
ondelete="SET NULL",
)
op.create_table(
"referrals",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("referrer_user_id", sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
# UNIQUE — a single user can only be referred once, ever.
sa.Column("referred_user_id", sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
# converted_at = referred user made their first paid sub. credited_at =
# we successfully applied the discount via Paddle. Both filled in D.3.
sa.Column("converted_at", sa.DateTime(timezone=True)),
sa.Column("credited_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint("referred_user_id", name="uq_referrals_referred"),
)
op.create_index(
"ix_referrals_referrer", "referrals", ["referrer_user_id"],
)
def downgrade() -> None:
op.drop_index("ix_referrals_referrer", table_name="referrals")
op.drop_table("referrals")
op.drop_constraint("fk_users_referred_by", "users", type_="foreignkey")
op.drop_column("users", "referred_by_user_id")
op.drop_constraint("uq_users_referral_code", "users", type_="unique")
op.drop_column("users", "referral_code")

View file

@ -0,0 +1,36 @@
"""users.credit_until: timestamp until which a free-tier user has paid-tier
access. Set by:
- Admin CLI (`python -m app.cli grant-credit <email> <months>`) manual
grants for testing & goodwill, in lieu of Paddle in Phase D.2.
- Paddle webhook (Phase D.3) referral conversion bumps both parties'
credit forward by 3 months at 50% off.
Null means "no credit". The `is_paid_active` helper in app/services/access.py
treats `credit_until > now()` as paid-equivalent.
Revision ID: 0014
Revises: 0013
Create Date: 2026-05-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0014"
down_revision: Union[str, None] = "0013"
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("credit_until", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "credit_until")