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")
|
||||
|
||||
|
||||
def sign_pending(email: str, user_id: int) -> str:
|
||||
return _pending_serializer().dumps({"email": email, "uid": int(user_id)})
|
||||
def sign_pending(email: str, user_id: int, ref: str | None = None) -> str:
|
||||
"""Signed payload for the pending-verify cookie. Carries the email
|
||||
+ user_id under verification, and optionally a referral code captured
|
||||
at signup (so it survives the GET → POST → /verify hop)."""
|
||||
payload: dict = {"email": email, "uid": int(user_id)}
|
||||
if ref:
|
||||
payload["ref"] = ref
|
||||
return _pending_serializer().dumps(payload)
|
||||
|
||||
|
||||
def verify_pending(cookie: str) -> dict | None:
|
||||
"""Returns {"email": str, "uid": int} or None if signature/expiry bad."""
|
||||
"""Returns {"email": str, "uid": int, "ref": str|None} or None if
|
||||
signature/expiry bad."""
|
||||
try:
|
||||
data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS)
|
||||
return {"email": str(data["email"]), "uid": int(data["uid"])}
|
||||
return {
|
||||
"email": str(data["email"]),
|
||||
"uid": int(data["uid"]),
|
||||
"ref": data.get("ref"),
|
||||
}
|
||||
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
|
|
|||
136
app/cli.py
Normal file
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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Referrals (Phase D.1). The code is unique + URL-safe; generated on
|
||||
# first need rather than at row creation so existing accounts get one
|
||||
# the next time they hit /settings.
|
||||
referral_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
referred_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
# Paid-tier credit window (Phase D.2). Null = no credit. When set and
|
||||
# > now(), the user gets paid-tier features regardless of `tier`.
|
||||
# Populated by admin CLI (manual grants) or Paddle webhook (D.3).
|
||||
credit_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("email", name="uq_users_email"),
|
||||
UniqueConstraint("referral_code", name="uq_users_referral_code"),
|
||||
)
|
||||
|
||||
|
||||
class Referral(Base):
|
||||
"""One row per captured (referrer, referred) pair. Created at signup
|
||||
when the new user supplied a valid `?ref=<code>`. The conversion
|
||||
fields (`converted_at`, `credited_at`) stay null until the referred
|
||||
user makes their first paid subscription — Phase D.3 fills them in
|
||||
via the Paddle webhook."""
|
||||
__tablename__ = "referrals"
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
referrer_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
referred_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
converted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
credited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("referred_user_id", name="uq_referrals_referred"),
|
||||
Index("ix_referrals_referrer", "referrer_user_id"),
|
||||
)
|
||||
|
||||
|
||||
class EmailOTP(Base):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from app.db import get_session, utcnow
|
|||
from app.logging import get_logger
|
||||
from app.models import Quote, QuoteDaily
|
||||
from app.services import fx, portfolio_analysis, ticker_universe
|
||||
from app.services.access import require_paid
|
||||
from app.services.csv_import import CSVImportError, parse_t212_csv
|
||||
from app.services.instrument_map import resolve_slice
|
||||
from app.services.market import fetch as market_fetch
|
||||
|
|
@ -310,7 +311,7 @@ async def parse_portfolio(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
@router.post("/analyze", dependencies=[Depends(require_paid)])
|
||||
async def analyze_portfolio(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
|
@ -318,7 +319,10 @@ async def analyze_portfolio(
|
|||
"""Generate AI commentary for the supplied pie. The pie is held in
|
||||
memory only for the duration of the LLM call; nothing about holdings
|
||||
is persisted. The ai_calls ledger row records tokens + cost, never
|
||||
holdings."""
|
||||
holdings.
|
||||
|
||||
Gated behind ``require_paid`` (Phase D.2): free-tier users get 402.
|
||||
Admin bearer-token bypasses the gate for testing."""
|
||||
# Read JSON body manually so we can enforce a hard size cap. FastAPI's
|
||||
# default body limit is generous; we want tighter control here.
|
||||
body = await request.body()
|
||||
|
|
|
|||
95
app/services/access.py
Normal file
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--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);
|
||||
|
|
|
|||
|
|
@ -139,10 +139,11 @@
|
|||
<header class="app-header">
|
||||
<div class="brand">Cassandra</div>
|
||||
<nav>
|
||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
||||
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
||||
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
||||
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
||||
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
||||
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@
|
|||
<div class="auth-card__brand">Cassandra</div>
|
||||
<div class="auth-card__hint">sign in with email</div>
|
||||
|
||||
{% if referrer_present %}
|
||||
<div class="auth-info auth-info--invited">
|
||||
<strong>You've been invited.</strong>
|
||||
When you subscribe, you and your friend both get
|
||||
<strong>50% off for 3 months</strong>. Sign up below to lock it in.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="auth-card__lede">
|
||||
Enter your email and we'll send you a 6-digit code. No password.
|
||||
First-time visitors get an account; returning visitors get a sign-in.
|
||||
|
|
@ -27,6 +35,7 @@
|
|||
|
||||
<form method="post" action="/login" autocomplete="on">
|
||||
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
||||
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||
</label>
|
||||
|
|
|
|||
102
app/templates/settings.html
Normal file
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():
|
||||
cookie = auth.sign_pending("user@example.com", 42)
|
||||
out = auth.verify_pending(cookie)
|
||||
assert out == {"email": "user@example.com", "uid": 42}
|
||||
assert out == {"email": "user@example.com", "uid": 42, "ref": None}
|
||||
|
||||
|
||||
def test_pending_cookie_roundtrip_with_ref():
|
||||
"""Referral code captured at signup (Phase D.1) rides on the
|
||||
pending cookie so it survives the POST /login → /verify hop."""
|
||||
cookie = auth.sign_pending("user@example.com", 42, ref="ABCD1234")
|
||||
out = auth.verify_pending(cookie)
|
||||
assert out == {"email": "user@example.com", "uid": 42, "ref": "ABCD1234"}
|
||||
|
||||
|
||||
def test_pending_cookie_rejects_garbage():
|
||||
|
|
|
|||
80
tests/test_referral.py
Normal file
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