From 9759080134ff91d14034bd053b4a6ec3bccc2e41 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Thu, 21 May 2026 23:25:35 +0100 Subject: [PATCH] phase D milestones 1+2: referral system + paid-access gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=; 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). --- alembic/versions/0013_referrals.py | 77 ++++++++++++ alembic/versions/0014_user_credit_until.py | 36 ++++++ app/auth.py | 19 ++- app/cli.py | 136 +++++++++++++++++++++ app/models.py | 42 ++++++- app/routers/auth.py | 68 +++++++++-- app/routers/pages.py | 54 +++++++- app/routers/universe.py | 8 +- app/services/access.py | 95 ++++++++++++++ app/services/referral_service.py | 119 ++++++++++++++++++ app/static/css/cassandra.css | 134 ++++++++++++++++++++ app/templates/base.html | 9 +- app/templates/login.html | 9 ++ app/templates/settings.html | 102 ++++++++++++++++ tests/test_access.py | 133 ++++++++++++++++++++ tests/test_cli.py | 49 ++++++++ tests/test_pending_cookie.py | 10 +- tests/test_referral.py | 80 ++++++++++++ 18 files changed, 1159 insertions(+), 21 deletions(-) create mode 100644 alembic/versions/0013_referrals.py create mode 100644 alembic/versions/0014_user_credit_until.py create mode 100644 app/cli.py create mode 100644 app/services/access.py create mode 100644 app/services/referral_service.py create mode 100644 app/templates/settings.html create mode 100644 tests/test_access.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_referral.py diff --git a/alembic/versions/0013_referrals.py b/alembic/versions/0013_referrals.py new file mode 100644 index 0000000..6eeae26 --- /dev/null +++ b/alembic/versions/0013_referrals.py @@ -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=` 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") diff --git a/alembic/versions/0014_user_credit_until.py b/alembic/versions/0014_user_credit_until.py new file mode 100644 index 0000000..f567e1e --- /dev/null +++ b/alembic/versions/0014_user_credit_until.py @@ -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 `) — 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") diff --git a/app/auth.py b/app/auth.py index ba19ee9..06a31a4 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..47152fb --- /dev/null +++ b/app/cli.py @@ -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 + docker compose exec app python -m app.cli revoke-credit + docker compose exec app python -m app.cli show-status + +`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()) diff --git a/app/models.py b/app/models.py index 8ee33d1..efa5a03 100644 --- a/app/models.py +++ b/app/models.py @@ -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=`. 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): diff --git a/app/routers/auth.py b/app/routers/auth.py index d475a54..59733a9 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 diff --git a/app/routers/pages.py b/app/routers/pages.py index 156ebb6..1214586 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -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), + }, + ) diff --git a/app/routers/universe.py b/app/routers/universe.py index 98f6144..163e99d 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -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() diff --git a/app/services/access.py b/app/services/access.py new file mode 100644 index 0000000..9066f1d --- /dev/null +++ b/app/services/access.py @@ -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.", + }, + ) diff --git a/app/services/referral_service.py b/app/services/referral_service.py new file mode 100644 index 0000000..91e7b7c --- /dev/null +++ b/app/services/referral_service.py @@ -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=`. +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=` 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 diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index a029a0c..2cbeddf 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -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); diff --git a/app/templates/base.html b/app/templates/base.html index d6cd975..70aa7af 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -139,10 +139,11 @@
Cassandra
Cassandra
sign in with email
+ {% if referrer_present %} +
+ You've been invited. + When you subscribe, you and your friend both get + 50% off for 3 months. Sign up below to lock it in. +
+ {% endif %} +

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 @@

+ {% if ref %}{% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..6188b58 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}Cassandra · Settings{% endblock %} + +{% block main %} +
+
+ Settings + your account · client-only data unchanged +
+
+ + {% if not user %} +
no per-user settings (admin bearer-token session)
+ {% else %} + +
+
Email
+
{{ user.email }}
+
+ +
+
Tier
+
+ + {% if paid and paid.active %} + {% if paid.source == "credit" %} + + Paid features active via credit · {{ paid.days_remaining }} day(s) remaining + (expires {{ paid.expires_at.strftime("%Y-%m-%d") }}). + + {% else %} + Paid subscription active. + {% endif %} + {% else %} + Paid features unlock with Paddle (D.3) or invite credits. + {% endif %} +
+
+ + {# --- Referral block ---------------------------------------------- #} +
+
Invite a friend
+

+ Share your invite link. When your friend subscribes, you and + they each get 50% off for 3 months. +

+ +
+ +
{{ user.referral_code }}
+ + + +
+ +
+
+
Pending signups
+
{{ pending_count }}
+
+
+
Converted (paid)
+
{{ converted_count }}
+
+
+
Active credits
+
— (D.3)
+
+
+
+ + {# Future: Paddle subscription block, AI-spend ledger summary, etc. #} + + {% endif %} + +
+
+ + +{% endblock %} diff --git a/tests/test_access.py b/tests/test_access.py new file mode 100644 index 0000000..b6d255e --- /dev/null +++ b/tests/test_access.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..616bed9 --- /dev/null +++ b/tests/test_cli.py @@ -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([]) diff --git a/tests/test_pending_cookie.py b/tests/test_pending_cookie.py index 4704038..893585a 100644 --- a/tests/test_pending_cookie.py +++ b/tests/test_pending_cookie.py @@ -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(): diff --git a/tests/test_referral.py b/tests/test_referral.py new file mode 100644 index 0000000..e2a9f4d --- /dev/null +++ b/tests/test_referral.py @@ -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