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

View file

@ -87,15 +87,26 @@ def _pending_serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(secret, salt="cassandra-pending-v1") return URLSafeTimedSerializer(secret, salt="cassandra-pending-v1")
def sign_pending(email: str, user_id: int) -> str: def sign_pending(email: str, user_id: int, ref: str | None = None) -> str:
return _pending_serializer().dumps({"email": email, "uid": int(user_id)}) """Signed payload for the pending-verify cookie. Carries the email
+ user_id under verification, and optionally a referral code captured
at signup (so it survives the GET POST /verify hop)."""
payload: dict = {"email": email, "uid": int(user_id)}
if ref:
payload["ref"] = ref
return _pending_serializer().dumps(payload)
def verify_pending(cookie: str) -> dict | None: def verify_pending(cookie: str) -> dict | None:
"""Returns {"email": str, "uid": int} or None if signature/expiry bad.""" """Returns {"email": str, "uid": int, "ref": str|None} or None if
signature/expiry bad."""
try: try:
data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS) data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS)
return {"email": str(data["email"]), "uid": int(data["uid"])} return {
"email": str(data["email"]),
"uid": int(data["uid"]),
"ref": data.get("ref"),
}
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError): except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return None return None

136
app/cli.py Normal file
View file

@ -0,0 +1,136 @@
"""Admin CLI — runs inside the `app` container.
Usage from the host::
docker compose exec app python -m app.cli grant-credit <email> <months>
docker compose exec app python -m app.cli revoke-credit <email>
docker compose exec app python -m app.cli show-status <email>
`grant-credit` is idempotent: it extends `users.credit_until` from
``max(now, current_credit_until)``, so granting "1 month" twice gives
two months, not one (avoids accidental erosion of an existing grant
when re-running the command).
This is the manual lever for Phase D.2. In D.3 the Paddle webhook will
call the same helper for both sides of a referral conversion.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db import get_engine, get_session_factory
from app.models import User
from app.services.access import _aware, paid_status
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
async def _get_user_by_email(session, email: str) -> User | None:
return (await session.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
async def grant_credit(email: str, months: float) -> int:
if months <= 0:
print(f"error: months must be positive (got {months})", file=sys.stderr)
return 2
factory = get_session_factory()
async with factory() as session:
user = await _get_user_by_email(session, email)
if user is None:
print(f"error: no user with email {email!r}", file=sys.stderr)
return 1
anchor = max(_utcnow(), _aware(user.credit_until) or _utcnow())
# 30-day months — simple, predictable, no calendar arithmetic.
days = int(round(months * 30))
new_expiry = anchor + timedelta(days=days)
user.credit_until = new_expiry
await session.commit()
# Refresh status snapshot from the just-committed value.
st = paid_status(user)
print(
f"granted {months} month(s) to {email}: "
f"credit_until={new_expiry.isoformat()} "
f"(~{st.days_remaining} days remaining)"
)
return 0
async def revoke_credit(email: str) -> int:
factory = get_session_factory()
async with factory() as session:
user = await _get_user_by_email(session, email)
if user is None:
print(f"error: no user with email {email!r}", file=sys.stderr)
return 1
user.credit_until = None
await session.commit()
print(f"revoked: credit_until cleared for {email}")
return 0
async def show_status(email: str) -> int:
factory = get_session_factory()
async with factory() as session:
user = await _get_user_by_email(session, email)
if user is None:
print(f"error: no user with email {email!r}", file=sys.stderr)
return 1
st = paid_status(user)
print(f"email: {user.email}")
print(f"tier: {user.tier}")
print(f"credit_until: {user.credit_until or ''}")
print(f"paid active: {st.active} (source={st.source or ''})")
if st.expires_at:
print(f"expires in: {st.days_remaining} days")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI")
sub = p.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("grant-credit", help="Extend a user's paid-credit window")
g.add_argument("email")
g.add_argument("months", type=float)
r = sub.add_parser("revoke-credit", help="Clear a user's credit_until")
r.add_argument("email")
s = sub.add_parser("show-status", help="Print paid-tier status for a user")
s.add_argument("email")
return p
async def _dispatch(args) -> int:
"""Run the chosen sub-command, then dispose the async engine cleanly
so aiomysql's __del__ doesn't squawk at interpreter shutdown about a
closed event loop."""
try:
if args.cmd == "grant-credit":
return await grant_credit(args.email, args.months)
if args.cmd == "revoke-credit":
return await revoke_credit(args.email)
if args.cmd == "show-status":
return await show_status(args.email)
return 2
finally:
await get_engine().dispose()
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return asyncio.run(_dispatch(args))
if __name__ == "__main__":
sys.exit(main())

View file

@ -159,8 +159,48 @@ class User(Base):
settings_json: Mapped[dict | None] = mapped_column(JSON) settings_json: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Referrals (Phase D.1). The code is unique + URL-safe; generated on
# first need rather than at row creation so existing accounts get one
# the next time they hit /settings.
referral_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
referred_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
# Paid-tier credit window (Phase D.2). Null = no credit. When set and
# > now(), the user gets paid-tier features regardless of `tier`.
# Populated by admin CLI (manual grants) or Paddle webhook (D.3).
credit_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),) __table_args__ = (
UniqueConstraint("email", name="uq_users_email"),
UniqueConstraint("referral_code", name="uq_users_referral_code"),
)
class Referral(Base):
"""One row per captured (referrer, referred) pair. Created at signup
when the new user supplied a valid `?ref=<code>`. The conversion
fields (`converted_at`, `credited_at`) stay null until the referred
user makes their first paid subscription Phase D.3 fills them in
via the Paddle webhook."""
__tablename__ = "referrals"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
referrer_user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
referred_user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
converted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
credited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("referred_user_id", name="uq_referrals_referred"),
Index("ix_referrals_referrer", "referrer_user_id"),
)
class EmailOTP(Base): class EmailOTP(Base):

View file

@ -36,7 +36,7 @@ from app.config import get_settings
from app.db import get_session, utcnow from app.db import get_session, utcnow
from app.logging import get_logger from app.logging import get_logger
from app.services.auth_service import AuthError, get_or_create_user, get_user from app.services.auth_service import AuthError, get_or_create_user, get_user
from app.services import otp_service from app.services import otp_service, referral_service
from app.services.email_service import EmailSendError, send_otp from app.services.email_service import EmailSendError, send_otp
from app.templates_env import templates from app.templates_env import templates
@ -67,10 +67,15 @@ def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
) )
def _set_pending_cookie(response: RedirectResponse, email: str, user_id: int) -> None: def _set_pending_cookie(
response: RedirectResponse,
email: str,
user_id: int,
ref: str | None = None,
) -> None:
response.set_cookie( response.set_cookie(
key=PENDING_COOKIE_NAME, key=PENDING_COOKIE_NAME,
value=sign_pending(email, user_id), value=sign_pending(email, user_id, ref=ref),
max_age=PENDING_TTL_SECONDS, max_age=PENDING_TTL_SECONDS,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
@ -101,10 +106,29 @@ async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str | None = None, error: str | None = None): async def login_page(
request: Request,
next: str | None = None,
error: str | None = None,
ref: str | None = None,
session: AsyncSession = Depends(get_session),
):
# If a valid referral code is supplied, surface a small "invited"
# banner. We resolve it server-side so the banner can show the
# referrer's actual greeting (and a bad code silently degrades).
ref_norm = referral_service.normalise_code(ref) if ref else None
referrer = (
await referral_service.lookup_referrer(session, ref_norm)
if ref_norm else None
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "login.html", request, "login.html",
{"next_path": _safe_next(next), "error": error}, {
"next_path": _safe_next(next),
"error": error,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None,
},
) )
@ -113,9 +137,24 @@ async def login_submit(
request: Request, request: Request,
email: str = Form(...), email: str = Form(...),
next: str | None = Form(default=None), next: str | None = Form(default=None),
ref: str | None = Form(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
s = get_settings() s = get_settings()
# Look up the referrer up front so a bad code doesn't pollute the
# rest of the flow. Self-referral protection lives in
# referral_service.link_new_user.
ref_norm = referral_service.normalise_code(ref) if ref else None
referrer = (
await referral_service.lookup_referrer(session, ref_norm)
if ref_norm else None
)
# Track whether THIS request creates the user row (i.e. a referral
# capture window). Cleanest way: probe for existence first.
from app.services.auth_service import get_user_by_email
was_new = (await get_user_by_email(session, email)) is None
try: try:
user = await get_or_create_user( user = await get_or_create_user(
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED, session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
@ -123,10 +162,19 @@ async def login_submit(
except AuthError as e: except AuthError as e:
return templates.TemplateResponse( return templates.TemplateResponse(
request, "login.html", request, "login.html",
{"next_path": _safe_next(next), "error": str(e), "email": email}, {"next_path": _safe_next(next), "error": str(e), "email": email,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None},
status_code=400, status_code=400,
) )
# First-time signup with a valid referrer → persist the linkage now.
# We do this BEFORE OTP-verify because the row is already created;
# if the user abandons OTP we'll have an orphan link but that's
# harmless audit data.
if was_new and referrer is not None:
await referral_service.link_new_user(session, user, referrer)
# Issue OTP only if cooldown allows; if a fresh one was sent in the # Issue OTP only if cooldown allows; if a fresh one was sent in the
# last 60s we just reuse the existing one (silently) to avoid # last 60s we just reuse the existing one (silently) to avoid
# spamming the user's inbox on a refreshed form submit. # spamming the user's inbox on a refreshed form submit.
@ -135,7 +183,13 @@ async def login_submit(
await _issue_and_send_otp(session, user.email) await _issue_and_send_otp(session, user.email)
resp = RedirectResponse(url="/verify", status_code=303) resp = RedirectResponse(url="/verify", status_code=303)
_set_pending_cookie(resp, user.email, user.id) # Stash the referral code on the pending cookie too — handy for
# showing the "invited" badge on the /verify page so the friend
# knows the discount is still tracking.
_set_pending_cookie(
resp, user.email, user.id,
ref=ref_norm if referrer is not None else None,
)
return resp return resp

View file

@ -8,10 +8,12 @@ from fastapi.responses import HTMLResponse
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_token from app.auth import CurrentUser, require_auth, require_token
from app.config import get_settings, load_groups from app.config import get_settings, load_groups
from app.db import get_session from app.db import get_session
from app.models import StrategicLog from app.models import Referral, StrategicLog, User
from app.services.access import paid_status
from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates from app.templates_env import templates
router = APIRouter(dependencies=[Depends(require_token)]) router = APIRouter(dependencies=[Depends(require_token)])
@ -84,3 +86,51 @@ async def log_page_day(
): ):
target = await _resolve_log_date(session, day) target = await _resolve_log_date(session, day)
return templates.TemplateResponse(request, "log.html", _log_page_context(target)) return templates.TemplateResponse(request, "log.html", _log_page_context(target))
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(
request: Request,
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_auth),
):
"""Per-user settings. Currently shows email, tier, and the referral
block (own code + invite link + counts of pending/converted
referrals). The Credit / Paddle pieces land in D.3."""
user = principal.user
if user is None:
# Bearer-token admin path — no per-user settings to show.
return templates.TemplateResponse(
request, "settings.html",
{"user": None, "invite_url": None,
"pending_count": 0, "converted_count": 0},
)
# 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`.
pending_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_(None))
)).scalar() or 0
converted_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_not(None))
)).scalar() or 0
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
return templates.TemplateResponse(
request, "settings.html",
{
"user": user,
"invite_url": invite_url,
"pending_count": int(pending_count),
"converted_count": int(converted_count),
"paid": paid_status(user),
},
)

View file

@ -42,6 +42,7 @@ from app.db import get_session, utcnow
from app.logging import get_logger from app.logging import get_logger
from app.models import Quote, QuoteDaily from app.models import Quote, QuoteDaily
from app.services import fx, portfolio_analysis, ticker_universe from app.services import fx, portfolio_analysis, ticker_universe
from app.services.access import require_paid
from app.services.csv_import import CSVImportError, parse_t212_csv from app.services.csv_import import CSVImportError, parse_t212_csv
from app.services.instrument_map import resolve_slice from app.services.instrument_map import resolve_slice
from app.services.market import fetch as market_fetch from app.services.market import fetch as market_fetch
@ -310,7 +311,7 @@ async def parse_portfolio(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/analyze") @router.post("/analyze", dependencies=[Depends(require_paid)])
async def analyze_portfolio( async def analyze_portfolio(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -318,7 +319,10 @@ async def analyze_portfolio(
"""Generate AI commentary for the supplied pie. The pie is held in """Generate AI commentary for the supplied pie. The pie is held in
memory only for the duration of the LLM call; nothing about holdings memory only for the duration of the LLM call; nothing about holdings
is persisted. The ai_calls ledger row records tokens + cost, never is persisted. The ai_calls ledger row records tokens + cost, never
holdings.""" holdings.
Gated behind ``require_paid`` (Phase D.2): free-tier users get 402.
Admin bearer-token bypasses the gate for testing."""
# Read JSON body manually so we can enforce a hard size cap. FastAPI's # Read JSON body manually so we can enforce a hard size cap. FastAPI's
# default body limit is generous; we want tighter control here. # default body limit is generous; we want tighter control here.
body = await request.body() body = await request.body()

95
app/services/access.py Normal file
View file

@ -0,0 +1,95 @@
"""Paid-tier access checks.
Two sources can grant paid access:
1. ``user.tier in {"paid", "enterprise"}`` set by Paddle webhook in
Phase D.3 once a subscription is active.
2. ``user.credit_until > now()`` non-subscription credit. Currently
populated by the admin CLI (`python -m app.cli grant-credit`) and, in
D.3, by the referral-conversion path (3 months at 50% off).
Either is sufficient. We use a single ``paid_status`` function so the
Settings page can show *why* a user has paid access ("paid subscription"
vs "credit, 47 days left") without duplicating the rules.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from app.auth import CurrentUser, require_auth
from app.models import User
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
@dataclass(frozen=True)
class PaidStatus:
"""Snapshot of paid-tier status for one user."""
active: bool
source: str | None # "tier" | "credit" | None
expires_at: datetime | None # only meaningful when source == "credit"
days_remaining: int | None # only meaningful when source == "credit"
def _aware(dt: datetime | None) -> datetime | None:
"""MariaDB round-trips DateTime(timezone=True) as a naive UTC value
via aiomysql. Normalise to tz-aware so comparisons against utcnow()
never raise."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def paid_status(user: User | None) -> PaidStatus:
"""Compute paid-tier status for a User row. ``user=None`` (anonymous
or admin bearer-token) returns inactive callers should special-case
admin separately via ``is_paid_active``."""
if user is None:
return PaidStatus(False, None, None, None)
if user.tier in ("paid", "enterprise"):
return PaidStatus(True, "tier", None, None)
cu = _aware(getattr(user, "credit_until", None))
if cu is not None and cu > _utcnow():
days = max(0, (cu - _utcnow()).days)
return PaidStatus(True, "credit", cu, days)
return PaidStatus(False, None, None, None)
def is_paid_active(principal: CurrentUser | User | None) -> bool:
"""True if the principal has paid-tier access right now. Admin
bearer-token (``CurrentUser.is_admin=True``) always passes."""
if principal is None:
return False
if isinstance(principal, CurrentUser):
if principal.is_admin:
return True
return paid_status(principal.user).active
return paid_status(principal).active
async def require_paid(
principal: CurrentUser = Depends(require_auth),
) -> CurrentUser:
"""FastAPI dependency for paid-only endpoints. Returns the principal
on success; raises 402 Payment Required otherwise.
402 is the semantically-correct code for "auth succeeded but plan
insufficient" — distinct from 401 (not authenticated) and 403
(authenticated but forbidden by ACL). Frontends key off it to show
the upgrade prompt rather than redirecting to /login."""
if is_paid_active(principal):
return principal
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "paid_required",
"message": "This feature requires an active paid plan or credit.",
},
)

View file

@ -0,0 +1,119 @@
"""Referral-code generation, lookup, and signup-time linkage.
D.1 lays down the bookkeeping only actual credit application happens
in D.3 when the Paddle webhook fires. 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.
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.
"""
from __future__ import annotations
import secrets
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.logging import get_logger
from app.models import Referral, User
log = get_logger("referral")
# Unambiguous alphabet — no 0/O, no 1/I/L. 32 chars → 8 positions ≈ 1e12
# combinations, plenty for our scale, and a unique-constraint catches
# collisions if we ever generate the same one twice.
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
_CODE_LEN = 8
def generate_code() -> str:
"""Cryptographically random 8-char code from the unambiguous alphabet."""
return "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LEN))
def normalise_code(raw: str | None) -> str | None:
"""Trim, uppercase, strip non-alphabet characters. Used on inbound
`?ref=<code>` params so users can paste with spaces / lowercase.
Returns None if the result isn't a plausible code."""
if not raw:
return None
cleaned = "".join(c for c in raw.upper() if c in _ALPHABET)
if len(cleaned) != _CODE_LEN:
return None
return cleaned
async def assign_code_if_missing(session: AsyncSession, user: User) -> User:
"""Generate + persist a referral code on `user` if they don't have
one yet. Retries on the (very rare) collision."""
if user.referral_code:
return user
for _ in range(8):
code = generate_code()
existing = (await session.execute(
select(User.id).where(User.referral_code == code)
)).scalar_one_or_none()
if existing is None:
user.referral_code = code
await session.commit()
await session.refresh(user)
log.info("referral.code_assigned", user_id=user.id, code=code)
return user
# 8 collisions in a row would be a statistical event we'd want to
# know about.
raise RuntimeError("referral_service: exhausted code-collision retries")
async def lookup_referrer(session: AsyncSession, code: str | None) -> User | None:
"""Return the User whose `referral_code` matches, or None. Normalises
the input via `normalise_code` so URL-paste variations all resolve."""
code = normalise_code(code)
if not code:
return None
return (await session.execute(
select(User).where(User.referral_code == code)
)).scalar_one_or_none()
async def link_new_user(
session: AsyncSession,
new_user: User,
referrer: User | None,
) -> Referral | None:
"""Record a referral if the supplied referrer is valid. Idempotent
(safe to call multiple times for the same new user the unique
constraint on `referred_user_id` makes duplicate inserts a no-op).
Self-referral is silently rejected.
"""
if referrer is None or new_user.id is None or referrer.id == new_user.id:
return None
if new_user.referred_by_user_id is not None:
# Already linked; this user can't be referred twice.
return None
new_user.referred_by_user_id = referrer.id
ref = Referral(
referrer_user_id=referrer.id,
referred_user_id=new_user.id,
created_at=utcnow(),
)
session.add(ref)
await session.commit()
await session.refresh(new_user)
await session.refresh(ref)
log.info(
"referral.linked",
referrer_id=referrer.id, referred_id=new_user.id,
)
return ref

View file

@ -774,6 +774,7 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
.badge--analysis-speculative { color: var(--accent); } .badge--analysis-speculative { color: var(--accent); }
.badge--ver { color: var(--dim); } .badge--ver { color: var(--dim); }
.badge--ok { color: var(--positive); border-color: var(--positive); }
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; } .meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
@ -882,6 +883,139 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin-bottom: 14px; margin-bottom: 14px;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.auth-info--invited {
/* Slightly warmer / friendlier shading for the referral banner. */
border-left-color: var(--positive);
background: color-mix(in srgb, var(--positive) 7%, transparent);
color: var(--text);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
}
.auth-info--invited strong { color: var(--positive); font-weight: 600; }
/* --- Settings page --------------------------------------------------- */
.settings-row {
display: flex;
align-items: baseline;
gap: 14px;
padding: 8px 0;
border-bottom: 1px solid var(--surface-2);
font-size: 13px;
}
.settings-row__label {
width: 110px;
flex-shrink: 0;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10.5px;
font-family: var(--font-mono);
}
.settings-row__value { color: var(--text); }
.settings-row__hint {
color: var(--dim);
font-size: 11px;
margin-left: 8px;
}
.settings-section { margin-top: 22px; }
.settings-section__head {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
}
.settings-section__head::before { content: "▸ "; color: var(--accent); }
.settings-section__lede {
color: var(--muted);
font-size: 12.5px;
line-height: 1.55;
margin: 0 0 14px;
}
.settings-section__lede strong { color: var(--positive); font-weight: 600; }
.invite-block {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 14px 16px;
}
.invite-block__label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 4px;
}
.invite-block__label:not(:first-child) { margin-top: 12px; }
.invite-block__code {
font-family: var(--font-mono);
font-size: 22px;
letter-spacing: 0.32em;
color: var(--accent);
background: var(--surface);
padding: 10px 14px;
border: 1px solid var(--accent);
text-align: center;
user-select: all;
}
.invite-block__link {
display: flex;
gap: 6px;
}
.invite-block__link input {
flex: 1;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 7px 10px;
font-family: var(--font-mono);
font-size: 12px;
}
.invite-block__link button {
background: var(--accent);
color: var(--bg);
border: 0;
padding: 0 14px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.invite-block__link button:hover { opacity: 0.85; }
.invite-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
margin-top: 16px;
}
.invite-stats > div {
background: var(--surface);
padding: 10px 14px;
}
.invite-stats__label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.invite-stats__value {
font-family: var(--font-mono);
font-size: 18px;
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: 4px;
}
.auth-card__lede { .auth-card__lede {
font-size: 12.5px; font-size: 12.5px;
color: var(--muted); color: var(--muted);

View file

@ -139,10 +139,11 @@
<header class="app-header"> <header class="app-header">
<div class="brand">Cassandra</div> <div class="brand">Cassandra</div>
<nav> <nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a> <a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a> <a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a> <a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a> <a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE" <div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"

View file

@ -18,6 +18,14 @@
<div class="auth-card__brand">Cassandra</div> <div class="auth-card__brand">Cassandra</div>
<div class="auth-card__hint">sign in with email</div> <div class="auth-card__hint">sign in with email</div>
{% if referrer_present %}
<div class="auth-info auth-info--invited">
<strong>You've been invited.</strong>
When you subscribe, you and your friend both get
<strong>50% off for 3 months</strong>. Sign up below to lock it in.
</div>
{% endif %}
<p class="auth-card__lede"> <p class="auth-card__lede">
Enter your email and we'll send you a 6-digit code. No password. Enter your email and we'll send you a 6-digit code. No password.
First-time visitors get an account; returning visitors get a sign-in. First-time visitors get an account; returning visitors get a sign-in.
@ -27,6 +35,7 @@
<form method="post" action="/login" autocomplete="on"> <form method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next_path or '/' }}"> <input type="hidden" name="next" value="{{ next_path or '/' }}">
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
<label>Email <label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus> <input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label> </label>

102
app/templates/settings.html Normal file
View file

@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Cassandra · Settings{% endblock %}
{% block main %}
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Settings</span>
<span class="meta">your account &middot; client-only data unchanged</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
{% if not user %}
<div class="empty">no per-user settings (admin bearer-token session)</div>
{% else %}
<div class="settings-row">
<div class="settings-row__label">Email</div>
<div class="settings-row__value">{{ user.email }}</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<div class="settings-row__value">
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
</span>
{% if paid and paid.active %}
{% if paid.source == "credit" %}
<span class="settings-row__hint">
Paid features active via credit · {{ paid.days_remaining }} day(s) remaining
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% else %}
<span class="settings-row__hint">Paid features unlock with Paddle (D.3) or invite credits.</span>
{% endif %}
</div>
</div>
{# --- Referral block ---------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Invite a friend</div>
<p class="settings-section__lede">
Share your invite link. When your friend subscribes, you and
they each get <strong>50% off for 3 months</strong>.
</p>
<div class="invite-block">
<label class="invite-block__label">Your code</label>
<div class="invite-block__code">{{ user.referral_code }}</div>
<label class="invite-block__label">Invite link</label>
<div class="invite-block__link">
<input type="text" id="invite-url" readonly value="{{ invite_url }}">
<button type="button" id="invite-copy">Copy</button>
</div>
</div>
<div class="invite-stats">
<div>
<div class="invite-stats__label">Pending signups</div>
<div class="invite-stats__value">{{ pending_count }}</div>
</div>
<div>
<div class="invite-stats__label">Converted (paid)</div>
<div class="invite-stats__value">{{ converted_count }}</div>
</div>
<div>
<div class="invite-stats__label">Active credits</div>
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
</div>
</div>
</div>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
{% endif %}
</div>
</section>
<script>
(function () {
var btn = document.getElementById('invite-copy');
var fld = document.getElementById('invite-url');
if (!btn || !fld) return;
btn.addEventListener('click', async function () {
try {
await navigator.clipboard.writeText(fld.value);
var orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function () { btn.textContent = orig; }, 1500);
} catch (e) {
// Fallback for older browsers: select the input.
fld.select();
}
});
})();
</script>
{% endblock %}

133
tests/test_access.py Normal file
View file

@ -0,0 +1,133 @@
"""Unit tests for app.services.access — the paid-tier gate.
No DB; we hand-construct ``User`` rows and ``CurrentUser`` principals
directly. The point is to nail down the truth table:
tier | credit_until | active | source
-------------|-------------------|--------|--------
free | None | False | None
free | past | False | None
free | future | True | credit
paid | None | True | tier
paid | future | True | tier (tier wins)
enterprise | None | True | tier
admin bearer | n/a | True | (bypass)
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from app.auth import CurrentUser
from app.services.access import is_paid_active, paid_status
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _make_user(*, tier: str = "free", credit_until: datetime | None = None):
"""Build something User-shaped without touching SQLAlchemy."""
return SimpleNamespace(tier=tier, credit_until=credit_until)
# ---------------------------------------------------------------------------
# paid_status — the truth table
# ---------------------------------------------------------------------------
def test_paid_status_free_no_credit():
st = paid_status(_make_user(tier="free"))
assert st.active is False
assert st.source is None
assert st.expires_at is None
assert st.days_remaining is None
def test_paid_status_free_expired_credit():
st = paid_status(_make_user(tier="free", credit_until=_utcnow() - timedelta(days=1)))
assert st.active is False
assert st.source is None
def test_paid_status_free_future_credit():
expiry = _utcnow() + timedelta(days=45)
st = paid_status(_make_user(tier="free", credit_until=expiry))
assert st.active is True
assert st.source == "credit"
assert st.expires_at == expiry
# Allow ±1 day slack for clock drift; integer-days floors.
assert 44 <= st.days_remaining <= 45
def test_paid_status_paid_tier_no_credit():
st = paid_status(_make_user(tier="paid"))
assert st.active is True
assert st.source == "tier"
assert st.expires_at is None
def test_paid_status_paid_tier_wins_over_credit():
"""A paid subscription dominates — we surface 'tier' even if a
credit row also exists. Avoids confusing the user with 'X days
remaining' when they're actually on a rolling subscription."""
st = paid_status(_make_user(tier="paid", credit_until=_utcnow() + timedelta(days=10)))
assert st.source == "tier"
assert st.days_remaining is None
def test_paid_status_enterprise_tier():
st = paid_status(_make_user(tier="enterprise"))
assert st.active is True
assert st.source == "tier"
def test_paid_status_none_user():
"""No DB row → no paid status. Admin bearer-token hits this path."""
st = paid_status(None)
assert st.active is False
assert st.source is None
def test_paid_status_handles_naive_datetime():
"""MariaDB+aiomysql sometimes returns DateTime(timezone=True) as a
naive datetime. The helper must normalise rather than raising
'can't compare offset-naive and offset-aware'."""
naive_future = (_utcnow() + timedelta(days=5)).replace(tzinfo=None)
st = paid_status(_make_user(credit_until=naive_future))
assert st.active is True
assert st.source == "credit"
# ---------------------------------------------------------------------------
# is_paid_active — sugar + admin bypass
# ---------------------------------------------------------------------------
def test_is_paid_active_admin_bearer_bypass():
"""Admin bearer-token (is_admin=True, user=None) always passes — the
dev/CLI path must not be artificially gated."""
principal = CurrentUser(is_admin=True, user=None)
assert is_paid_active(principal) is True
def test_is_paid_active_free_user_principal():
principal = CurrentUser(is_admin=False, user=_make_user(tier="free"))
assert is_paid_active(principal) is False
def test_is_paid_active_paid_user_principal():
principal = CurrentUser(is_admin=False, user=_make_user(tier="paid"))
assert is_paid_active(principal) is True
def test_is_paid_active_accepts_bare_user():
"""Sugar: accepts a User row directly, not just a CurrentUser."""
assert is_paid_active(_make_user(tier="paid")) is True
assert is_paid_active(_make_user(tier="free")) is False
def test_is_paid_active_none():
assert is_paid_active(None) is False

49
tests/test_cli.py Normal file
View file

@ -0,0 +1,49 @@
"""Unit tests for app.cli.
Sub-command parsing only the DB-touching paths (`grant_credit`,
`revoke_credit`, `show_status`) are exercised manually inside the dev
container. The parser-level tests are enough to catch the common
shapes: bad args, missing args, unknown sub-command."""
from __future__ import annotations
import pytest
from app.cli import build_parser
def test_grant_credit_parses():
args = build_parser().parse_args(["grant-credit", "user@example.com", "3"])
assert args.cmd == "grant-credit"
assert args.email == "user@example.com"
assert args.months == 3.0
def test_grant_credit_accepts_fractional_months():
args = build_parser().parse_args(["grant-credit", "user@x.com", "0.5"])
assert args.months == 0.5
def test_revoke_credit_parses():
args = build_parser().parse_args(["revoke-credit", "user@example.com"])
assert args.cmd == "revoke-credit"
assert args.email == "user@example.com"
def test_show_status_parses():
args = build_parser().parse_args(["show-status", "user@example.com"])
assert args.cmd == "show-status"
def test_grant_credit_requires_months():
with pytest.raises(SystemExit):
build_parser().parse_args(["grant-credit", "user@example.com"])
def test_unknown_command_rejected():
with pytest.raises(SystemExit):
build_parser().parse_args(["bogus-cmd"])
def test_no_command_rejected():
with pytest.raises(SystemExit):
build_parser().parse_args([])

View file

@ -13,7 +13,15 @@ from app import auth
def test_pending_cookie_roundtrip(): def test_pending_cookie_roundtrip():
cookie = auth.sign_pending("user@example.com", 42) cookie = auth.sign_pending("user@example.com", 42)
out = auth.verify_pending(cookie) out = auth.verify_pending(cookie)
assert out == {"email": "user@example.com", "uid": 42} assert out == {"email": "user@example.com", "uid": 42, "ref": None}
def test_pending_cookie_roundtrip_with_ref():
"""Referral code captured at signup (Phase D.1) rides on the
pending cookie so it survives the POST /login /verify hop."""
cookie = auth.sign_pending("user@example.com", 42, ref="ABCD1234")
out = auth.verify_pending(cookie)
assert out == {"email": "user@example.com", "uid": 42, "ref": "ABCD1234"}
def test_pending_cookie_rejects_garbage(): def test_pending_cookie_rejects_garbage():

80
tests/test_referral.py Normal file
View file

@ -0,0 +1,80 @@
"""Unit tests for the deterministic half of referral_service: code
generation, normalisation, and lookup helpers. DB-backed linkage logic
is exercised manually via the dev container."""
from __future__ import annotations
import pytest
from app.services.referral_service import (
_ALPHABET,
_CODE_LEN,
generate_code,
normalise_code,
)
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
def test_generate_code_length():
code = generate_code()
assert len(code) == _CODE_LEN
def test_generate_code_alphabet():
"""Every character must come from the unambiguous alphabet."""
for _ in range(50):
code = generate_code()
for ch in code:
assert ch in _ALPHABET, f"unexpected char {ch!r} in {code!r}"
def test_generate_code_no_ambiguous_chars():
"""0, O, 1, I, L are excluded to avoid dictation errors."""
for _ in range(200):
code = generate_code()
assert not (set(code) & set("01IOL"))
def test_generate_code_diversity():
"""Two consecutive generations should almost never collide
(sanity check on the RNG)."""
a, b = generate_code(), generate_code()
assert a != b
# ---------------------------------------------------------------------------
# normalise_code
# ---------------------------------------------------------------------------
def test_normalise_uppercases():
assert normalise_code("abcdefgh") == "ABCDEFGH"
def test_normalise_strips_disallowed_chars():
"""Users may paste with spaces / dashes / quotes — strip those."""
assert normalise_code(" ABCD-EFGH ") == "ABCDEFGH"
assert normalise_code('"ABCDEFGH"') == "ABCDEFGH"
def test_normalise_rejects_wrong_length():
"""If too short / too long after cleaning, return None — bogus."""
assert normalise_code("ABC") is None
assert normalise_code("ABCDEFGHX") is None
# Long enough but ambiguous chars stripped → still wrong length:
assert normalise_code("ABCDEFG0") is None # 0 stripped → 7 chars
def test_normalise_rejects_none_and_empty():
assert normalise_code(None) is None
assert normalise_code("") is None
assert normalise_code(" ") is None
def test_normalise_preserves_valid_code():
"""A code that's already canonical should pass through unchanged."""
code = generate_code()
assert normalise_code(code) == code