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:
parent
2013bfa8cc
commit
9759080134
18 changed files with 1159 additions and 21 deletions
77
alembic/versions/0013_referrals.py
Normal file
77
alembic/versions/0013_referrals.py
Normal 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")
|
||||||
36
alembic/versions/0014_user_credit_until.py
Normal file
36
alembic/versions/0014_user_credit_until.py
Normal 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")
|
||||||
19
app/auth.py
19
app/auth.py
|
|
@ -87,15 +87,26 @@ def _pending_serializer() -> URLSafeTimedSerializer:
|
||||||
return URLSafeTimedSerializer(secret, salt="cassandra-pending-v1")
|
return URLSafeTimedSerializer(secret, salt="cassandra-pending-v1")
|
||||||
|
|
||||||
|
|
||||||
def sign_pending(email: str, user_id: int) -> str:
|
def sign_pending(email: str, user_id: int, ref: str | None = None) -> str:
|
||||||
return _pending_serializer().dumps({"email": email, "uid": int(user_id)})
|
"""Signed payload for the pending-verify cookie. Carries the email
|
||||||
|
+ user_id under verification, and optionally a referral code captured
|
||||||
|
at signup (so it survives the GET → POST → /verify hop)."""
|
||||||
|
payload: dict = {"email": email, "uid": int(user_id)}
|
||||||
|
if ref:
|
||||||
|
payload["ref"] = ref
|
||||||
|
return _pending_serializer().dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
def verify_pending(cookie: str) -> dict | None:
|
def verify_pending(cookie: str) -> dict | None:
|
||||||
"""Returns {"email": str, "uid": int} or None if signature/expiry bad."""
|
"""Returns {"email": str, "uid": int, "ref": str|None} or None if
|
||||||
|
signature/expiry bad."""
|
||||||
try:
|
try:
|
||||||
data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS)
|
data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS)
|
||||||
return {"email": str(data["email"]), "uid": int(data["uid"])}
|
return {
|
||||||
|
"email": str(data["email"]),
|
||||||
|
"uid": int(data["uid"]),
|
||||||
|
"ref": data.get("ref"),
|
||||||
|
}
|
||||||
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
136
app/cli.py
Normal file
136
app/cli.py
Normal 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())
|
||||||
|
|
@ -159,8 +159,48 @@ class User(Base):
|
||||||
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Referrals (Phase D.1). The code is unique + URL-safe; generated on
|
||||||
|
# first need rather than at row creation so existing accounts get one
|
||||||
|
# the next time they hit /settings.
|
||||||
|
referral_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
referred_by_user_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
)
|
||||||
|
# Paid-tier credit window (Phase D.2). Null = no credit. When set and
|
||||||
|
# > now(), the user gets paid-tier features regardless of `tier`.
|
||||||
|
# Populated by admin CLI (manual grants) or Paddle webhook (D.3).
|
||||||
|
credit_until: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("email", name="uq_users_email"),
|
||||||
|
UniqueConstraint("referral_code", name="uq_users_referral_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Referral(Base):
|
||||||
|
"""One row per captured (referrer, referred) pair. Created at signup
|
||||||
|
when the new user supplied a valid `?ref=<code>`. The conversion
|
||||||
|
fields (`converted_at`, `credited_at`) stay null until the referred
|
||||||
|
user makes their first paid subscription — Phase D.3 fills them in
|
||||||
|
via the Paddle webhook."""
|
||||||
|
__tablename__ = "referrals"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
referrer_user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
referred_user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
converted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
credited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("referred_user_id", name="uq_referrals_referred"),
|
||||||
|
Index("ix_referrals_referrer", "referrer_user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EmailOTP(Base):
|
class EmailOTP(Base):
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ from app.config import get_settings
|
||||||
from app.db import get_session, utcnow
|
from app.db import get_session, utcnow
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
||||||
from app.services import otp_service
|
from app.services import otp_service, referral_service
|
||||||
from app.services.email_service import EmailSendError, send_otp
|
from app.services.email_service import EmailSendError, send_otp
|
||||||
from app.templates_env import templates
|
from app.templates_env import templates
|
||||||
|
|
||||||
|
|
@ -67,10 +67,15 @@ def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _set_pending_cookie(response: RedirectResponse, email: str, user_id: int) -> None:
|
def _set_pending_cookie(
|
||||||
|
response: RedirectResponse,
|
||||||
|
email: str,
|
||||||
|
user_id: int,
|
||||||
|
ref: str | None = None,
|
||||||
|
) -> None:
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=PENDING_COOKIE_NAME,
|
key=PENDING_COOKIE_NAME,
|
||||||
value=sign_pending(email, user_id),
|
value=sign_pending(email, user_id, ref=ref),
|
||||||
max_age=PENDING_TTL_SECONDS,
|
max_age=PENDING_TTL_SECONDS,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
|
@ -101,10 +106,29 @@ async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
async def login_page(request: Request, next: str | None = None, error: str | None = None):
|
async def login_page(
|
||||||
|
request: Request,
|
||||||
|
next: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
ref: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
# If a valid referral code is supplied, surface a small "invited"
|
||||||
|
# banner. We resolve it server-side so the banner can show the
|
||||||
|
# referrer's actual greeting (and a bad code silently degrades).
|
||||||
|
ref_norm = referral_service.normalise_code(ref) if ref else None
|
||||||
|
referrer = (
|
||||||
|
await referral_service.lookup_referrer(session, ref_norm)
|
||||||
|
if ref_norm else None
|
||||||
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "login.html",
|
request, "login.html",
|
||||||
{"next_path": _safe_next(next), "error": error},
|
{
|
||||||
|
"next_path": _safe_next(next),
|
||||||
|
"error": error,
|
||||||
|
"ref": ref_norm if referrer else None,
|
||||||
|
"referrer_present": referrer is not None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,9 +137,24 @@ async def login_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
email: str = Form(...),
|
email: str = Form(...),
|
||||||
next: str | None = Form(default=None),
|
next: str | None = Form(default=None),
|
||||||
|
ref: str | None = Form(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
# Look up the referrer up front so a bad code doesn't pollute the
|
||||||
|
# rest of the flow. Self-referral protection lives in
|
||||||
|
# referral_service.link_new_user.
|
||||||
|
ref_norm = referral_service.normalise_code(ref) if ref else None
|
||||||
|
referrer = (
|
||||||
|
await referral_service.lookup_referrer(session, ref_norm)
|
||||||
|
if ref_norm else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track whether THIS request creates the user row (i.e. a referral
|
||||||
|
# capture window). Cleanest way: probe for existence first.
|
||||||
|
from app.services.auth_service import get_user_by_email
|
||||||
|
was_new = (await get_user_by_email(session, email)) is None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await get_or_create_user(
|
user = await get_or_create_user(
|
||||||
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
|
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
|
||||||
|
|
@ -123,10 +162,19 @@ async def login_submit(
|
||||||
except AuthError as e:
|
except AuthError as e:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "login.html",
|
request, "login.html",
|
||||||
{"next_path": _safe_next(next), "error": str(e), "email": email},
|
{"next_path": _safe_next(next), "error": str(e), "email": email,
|
||||||
|
"ref": ref_norm if referrer else None,
|
||||||
|
"referrer_present": referrer is not None},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# First-time signup with a valid referrer → persist the linkage now.
|
||||||
|
# We do this BEFORE OTP-verify because the row is already created;
|
||||||
|
# if the user abandons OTP we'll have an orphan link but that's
|
||||||
|
# harmless audit data.
|
||||||
|
if was_new and referrer is not None:
|
||||||
|
await referral_service.link_new_user(session, user, referrer)
|
||||||
|
|
||||||
# Issue OTP only if cooldown allows; if a fresh one was sent in the
|
# Issue OTP only if cooldown allows; if a fresh one was sent in the
|
||||||
# last 60s we just reuse the existing one (silently) to avoid
|
# last 60s we just reuse the existing one (silently) to avoid
|
||||||
# spamming the user's inbox on a refreshed form submit.
|
# spamming the user's inbox on a refreshed form submit.
|
||||||
|
|
@ -135,7 +183,13 @@ async def login_submit(
|
||||||
await _issue_and_send_otp(session, user.email)
|
await _issue_and_send_otp(session, user.email)
|
||||||
|
|
||||||
resp = RedirectResponse(url="/verify", status_code=303)
|
resp = RedirectResponse(url="/verify", status_code=303)
|
||||||
_set_pending_cookie(resp, user.email, user.id)
|
# Stash the referral code on the pending cookie too — handy for
|
||||||
|
# showing the "invited" badge on the /verify page so the friend
|
||||||
|
# knows the discount is still tracking.
|
||||||
|
_set_pending_cookie(
|
||||||
|
resp, user.email, user.id,
|
||||||
|
ref=ref_norm if referrer is not None else None,
|
||||||
|
)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import require_token
|
from app.auth import CurrentUser, require_auth, require_token
|
||||||
from app.config import get_settings, load_groups
|
from app.config import get_settings, load_groups
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import StrategicLog
|
from app.models import Referral, StrategicLog, User
|
||||||
|
from app.services.access import paid_status
|
||||||
|
from app.services.referral_service import assign_code_if_missing
|
||||||
from app.templates_env import templates
|
from app.templates_env import templates
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_token)])
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
@ -84,3 +86,51 @@ async def log_page_day(
|
||||||
):
|
):
|
||||||
target = await _resolve_log_date(session, day)
|
target = await _resolve_log_date(session, day)
|
||||||
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
|
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
principal: CurrentUser = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Per-user settings. Currently shows email, tier, and the referral
|
||||||
|
block (own code + invite link + counts of pending/converted
|
||||||
|
referrals). The Credit / Paddle pieces land in D.3."""
|
||||||
|
user = principal.user
|
||||||
|
if user is None:
|
||||||
|
# Bearer-token admin path — no per-user settings to show.
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "settings.html",
|
||||||
|
{"user": None, "invite_url": None,
|
||||||
|
"pending_count": 0, "converted_count": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lazily assign a referral code on first visit.
|
||||||
|
user = await assign_code_if_missing(session, user)
|
||||||
|
|
||||||
|
# Stats: how many people have signed up with their code so far, and
|
||||||
|
# how many of those converted (paid). D.3 will fill `converted_at`.
|
||||||
|
pending_count = (await session.execute(
|
||||||
|
select(func.count(Referral.id))
|
||||||
|
.where(Referral.referrer_user_id == user.id)
|
||||||
|
.where(Referral.converted_at.is_(None))
|
||||||
|
)).scalar() or 0
|
||||||
|
converted_count = (await session.execute(
|
||||||
|
select(func.count(Referral.id))
|
||||||
|
.where(Referral.referrer_user_id == user.id)
|
||||||
|
.where(Referral.converted_at.is_not(None))
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "settings.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"invite_url": invite_url,
|
||||||
|
"pending_count": int(pending_count),
|
||||||
|
"converted_count": int(converted_count),
|
||||||
|
"paid": paid_status(user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from app.db import get_session, utcnow
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
from app.models import Quote, QuoteDaily
|
from app.models import Quote, QuoteDaily
|
||||||
from app.services import fx, portfolio_analysis, ticker_universe
|
from app.services import fx, portfolio_analysis, ticker_universe
|
||||||
|
from app.services.access import require_paid
|
||||||
from app.services.csv_import import CSVImportError, parse_t212_csv
|
from app.services.csv_import import CSVImportError, parse_t212_csv
|
||||||
from app.services.instrument_map import resolve_slice
|
from app.services.instrument_map import resolve_slice
|
||||||
from app.services.market import fetch as market_fetch
|
from app.services.market import fetch as market_fetch
|
||||||
|
|
@ -310,7 +311,7 @@ async def parse_portfolio(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze")
|
@router.post("/analyze", dependencies=[Depends(require_paid)])
|
||||||
async def analyze_portfolio(
|
async def analyze_portfolio(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -318,7 +319,10 @@ async def analyze_portfolio(
|
||||||
"""Generate AI commentary for the supplied pie. The pie is held in
|
"""Generate AI commentary for the supplied pie. The pie is held in
|
||||||
memory only for the duration of the LLM call; nothing about holdings
|
memory only for the duration of the LLM call; nothing about holdings
|
||||||
is persisted. The ai_calls ledger row records tokens + cost, never
|
is persisted. The ai_calls ledger row records tokens + cost, never
|
||||||
holdings."""
|
holdings.
|
||||||
|
|
||||||
|
Gated behind ``require_paid`` (Phase D.2): free-tier users get 402.
|
||||||
|
Admin bearer-token bypasses the gate for testing."""
|
||||||
# Read JSON body manually so we can enforce a hard size cap. FastAPI's
|
# Read JSON body manually so we can enforce a hard size cap. FastAPI's
|
||||||
# default body limit is generous; we want tighter control here.
|
# default body limit is generous; we want tighter control here.
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
|
||||||
95
app/services/access.py
Normal file
95
app/services/access.py
Normal 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.",
|
||||||
|
},
|
||||||
|
)
|
||||||
119
app/services/referral_service.py
Normal file
119
app/services/referral_service.py
Normal 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
|
||||||
|
|
@ -774,6 +774,7 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
||||||
.badge--analysis-speculative { color: var(--accent); }
|
.badge--analysis-speculative { color: var(--accent); }
|
||||||
|
|
||||||
.badge--ver { color: var(--dim); }
|
.badge--ver { color: var(--dim); }
|
||||||
|
.badge--ok { color: var(--positive); border-color: var(--positive); }
|
||||||
|
|
||||||
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
|
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
|
||||||
|
|
||||||
|
|
@ -882,6 +883,139 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
.auth-info--invited {
|
||||||
|
/* Slightly warmer / friendlier shading for the referral banner. */
|
||||||
|
border-left-color: var(--positive);
|
||||||
|
background: color-mix(in srgb, var(--positive) 7%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.auth-info--invited strong { color: var(--positive); font-weight: 600; }
|
||||||
|
|
||||||
|
/* --- Settings page --------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.settings-row__label {
|
||||||
|
width: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.settings-row__value { color: var(--text); }
|
||||||
|
.settings-row__hint {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section { margin-top: 22px; }
|
||||||
|
.settings-section__head {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.settings-section__head::before { content: "▸ "; color: var(--accent); }
|
||||||
|
.settings-section__lede {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.settings-section__lede strong { color: var(--positive); font-weight: 600; }
|
||||||
|
|
||||||
|
.invite-block {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.invite-block__label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.invite-block__label:not(:first-child) { margin-top: 12px; }
|
||||||
|
.invite-block__code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.invite-block__link {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.invite-block__link input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.invite-block__link button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 0;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.invite-block__link button:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.invite-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.invite-stats > div {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.invite-stats__label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.invite-stats__value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
.auth-card__lede {
|
.auth-card__lede {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,11 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="brand">Cassandra</div>
|
<div class="brand">Cassandra</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||||
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
||||||
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
||||||
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
||||||
|
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@
|
||||||
<div class="auth-card__brand">Cassandra</div>
|
<div class="auth-card__brand">Cassandra</div>
|
||||||
<div class="auth-card__hint">sign in with email</div>
|
<div class="auth-card__hint">sign in with email</div>
|
||||||
|
|
||||||
|
{% if referrer_present %}
|
||||||
|
<div class="auth-info auth-info--invited">
|
||||||
|
<strong>You've been invited.</strong>
|
||||||
|
When you subscribe, you and your friend both get
|
||||||
|
<strong>50% off for 3 months</strong>. Sign up below to lock it in.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p class="auth-card__lede">
|
<p class="auth-card__lede">
|
||||||
Enter your email and we'll send you a 6-digit code. No password.
|
Enter your email and we'll send you a 6-digit code. No password.
|
||||||
First-time visitors get an account; returning visitors get a sign-in.
|
First-time visitors get an account; returning visitors get a sign-in.
|
||||||
|
|
@ -27,6 +35,7 @@
|
||||||
|
|
||||||
<form method="post" action="/login" autocomplete="on">
|
<form method="post" action="/login" autocomplete="on">
|
||||||
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
||||||
|
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
|
||||||
<label>Email
|
<label>Email
|
||||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
102
app/templates/settings.html
Normal file
102
app/templates/settings.html
Normal 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 · 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
133
tests/test_access.py
Normal 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
49
tests/test_cli.py
Normal 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([])
|
||||||
|
|
@ -13,7 +13,15 @@ from app import auth
|
||||||
def test_pending_cookie_roundtrip():
|
def test_pending_cookie_roundtrip():
|
||||||
cookie = auth.sign_pending("user@example.com", 42)
|
cookie = auth.sign_pending("user@example.com", 42)
|
||||||
out = auth.verify_pending(cookie)
|
out = auth.verify_pending(cookie)
|
||||||
assert out == {"email": "user@example.com", "uid": 42}
|
assert out == {"email": "user@example.com", "uid": 42, "ref": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_cookie_roundtrip_with_ref():
|
||||||
|
"""Referral code captured at signup (Phase D.1) rides on the
|
||||||
|
pending cookie so it survives the POST /login → /verify hop."""
|
||||||
|
cookie = auth.sign_pending("user@example.com", 42, ref="ABCD1234")
|
||||||
|
out = auth.verify_pending(cookie)
|
||||||
|
assert out == {"email": "user@example.com", "uid": 42, "ref": "ABCD1234"}
|
||||||
|
|
||||||
|
|
||||||
def test_pending_cookie_rejects_garbage():
|
def test_pending_cookie_rejects_garbage():
|
||||||
|
|
|
||||||
80
tests/test_referral.py
Normal file
80
tests/test_referral.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue