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")
def sign_pending(email: str, user_id: int) -> str:
return _pending_serializer().dumps({"email": email, "uid": int(user_id)})
def sign_pending(email: str, user_id: int, ref: str | None = None) -> str:
"""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:
"""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:
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):
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
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):

View file

@ -36,7 +36,7 @@ from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
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.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(
key=PENDING_COOKIE_NAME,
value=sign_pending(email, user_id),
value=sign_pending(email, user_id, ref=ref),
max_age=PENDING_TTL_SECONDS,
httponly=True,
samesite="lax",
@ -101,10 +106,29 @@ async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
@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(
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,
email: str = Form(...),
next: str | None = Form(default=None),
ref: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
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:
user = await get_or_create_user(
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
@ -123,10 +162,19 @@ async def login_submit(
except AuthError as e:
return templates.TemplateResponse(
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,
)
# 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
# last 60s we just reuse the existing one (silently) to avoid
# 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)
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

View file

@ -8,10 +8,12 @@ from fastapi.responses import HTMLResponse
from sqlalchemy import desc, func, select
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.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
router = APIRouter(dependencies=[Depends(require_token)])
@ -84,3 +86,51 @@ async def log_page_day(
):
target = await _resolve_log_date(session, day)
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.models import Quote, QuoteDaily
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.instrument_map import resolve_slice
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(
request: Request,
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
memory only for the duration of the LLM call; nothing about holdings
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
# default body limit is generous; we want tighter control here.
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--ver { color: var(--dim); }
.badge--ok { color: var(--positive); border-color: var(--positive); }
.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;
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 {
font-size: 12.5px;
color: var(--muted);

View file

@ -139,10 +139,11 @@
<header class="app-header">
<div class="brand">Cassandra</div>
<nav>
<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="/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="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</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="/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>
<div class="header-right">
<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__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">
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.
@ -27,6 +35,7 @@
<form method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next_path or '/' }}">
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</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():
cookie = auth.sign_pending("user@example.com", 42)
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():

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