read.markets/app/models.py
Giorgio Gilestro 9759080134 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).
2026-05-21 23:25:35 +01:00

282 lines
14 KiB
Python

"""SQLAlchemy models for Cassandra.
Schema rationale lives in /home/gg/.claude/plans/ok-i-think-this-tidy-lake.md.
All datetimes are tz-aware UTC (see app.db.utcnow).
"""
from __future__ import annotations
from datetime import datetime, date
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base, utcnow
class Quote(Base):
__tablename__ = "quotes"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(128), nullable=False)
source: Mapped[str] = mapped_column(String(32), nullable=False)
label: Mapped[str] = mapped_column(String(128), default="")
group_name: Mapped[str] = mapped_column(String(64), nullable=False)
price: Mapped[float | None] = mapped_column(Float)
currency: Mapped[str | None] = mapped_column(String(8))
as_of: Mapped[str | None] = mapped_column(String(16)) # provider date string
changes: Mapped[dict | None] = mapped_column(JSON) # {"1d": x, "1m": y, ...}
error: Mapped[str | None] = mapped_column(String(255))
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
__table_args__ = (
Index("ix_quotes_symbol_fetched", "symbol", "fetched_at"),
Index("ix_quotes_group", "group_name"),
)
class QuoteDaily(Base):
"""Daily rollup — sparkline source. PK on (symbol, date)."""
__tablename__ = "quotes_daily"
symbol: Mapped[str] = mapped_column(String(64), primary_key=True)
date: Mapped[date] = mapped_column(Date, primary_key=True)
close: Mapped[float | None] = mapped_column(Float)
high: Mapped[float | None] = mapped_column(Float)
low: Mapped[float | None] = mapped_column(Float)
source: Mapped[str] = mapped_column(String(32))
class Headline(Base):
__tablename__ = "headlines"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
source: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str] = mapped_column(String(512), nullable=False)
url: Mapped[str] = mapped_column(String(1024), nullable=False)
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
fingerprint: Mapped[str] = mapped_column(String(40), nullable=False) # sha1 of normalised title
# Semantic content tags from app.services.news_tagging. NULL = not yet
# tagged; the next news_job run picks it up. Each entry is one of the
# values in news_tagging.TAG_VOCABULARY.
tags: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"),
Index("ix_headlines_published", "published_at"),
Index("ix_headlines_category_published", "category", "published_at"),
)
class Feed(Base):
"""Persisted feed state; bootstrapped from default.toml on first startup."""
__tablename__ = "feeds"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
url: Mapped[str] = mapped_column(String(1024), nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("category", "name", name="uq_feeds_cat_name"),
)
class StrategicLog(Base):
__tablename__ = "strategic_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
model: Mapped[str] = mapped_column(String(64), nullable=False)
anchor_date: Mapped[str | None] = mapped_column(String(16))
prompt_version: Mapped[int] = mapped_column(Integer, default=1)
tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE|INTERMEDIATE|PRO
analysis: Mapped[str | None] = mapped_column(String(16)) # DRY|SPECULATIVE
content: Mapped[str] = mapped_column(Text, nullable=False)
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float)
class IndicatorSummary(Base):
"""Short AI-generated read for one indicator group, regenerated hourly.
The latest row per group_name is what the dashboard renders."""
__tablename__ = "indicator_summaries"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
group_name: Mapped[str] = mapped_column(String(64), nullable=False)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
model: Mapped[str] = mapped_column(String(64), nullable=False)
tone: Mapped[str | None] = mapped_column(String(16))
analysis: Mapped[str | None] = mapped_column(String(16))
prompt_version: Mapped[int] = mapped_column(Integer, default=1)
content: Mapped[str] = mapped_column(Text, nullable=False)
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float)
__table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),)
class AICall(Base):
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
__tablename__ = "ai_calls"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
called_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
model: Mapped[str] = mapped_column(String(64), nullable=False)
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
cost_usd: Mapped[float | None] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(16), default="ok")
error: Mapped[str | None] = mapped_column(String(512))
# Portfolio / PortfolioSnapshot / Position removed in Phase G —
# holdings live in the browser, the server stores only the anonymous
# ticker universe + public market data.
class User(Base):
"""A user account. Authentication is e-mail-only via one-time codes
(see EmailOTP) — no passwords. Possessing an active session cookie
means the user proved control of `email` at session creation time, so
a separate `email_verified` flag would be redundant."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
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"),
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):
"""One-time codes for email verification. The plaintext 6-digit code is
sent in the email; we store an argon2 hash, expiry, attempt count, and
a used_at timestamp so a single code can't be reused or brute-forced."""
__tablename__ = "email_otps"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, default=0)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
purpose: Mapped[str] = mapped_column(String(16), default="signup")
__table_args__ = (Index("ix_otps_email_created", "email", "created_at"),)
class InstrumentMap(Base):
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
refresh prices via Yahoo after a user uploads a T212 pie CSV.
Synced periodically from T212's /equity/metadata/instruments endpoint
via the admin's read-only API key. Each row is one T212 listing.
Multiple rows can share a shortName (e.g. SHEL on LSE in GBX vs
SHEL on NYSE in USD); the resolver picks the right one per user."""
__tablename__ = "instrument_map"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
t212_ticker: Mapped[str] = mapped_column(String(64), nullable=False)
t212_shortname: Mapped[str] = mapped_column(String(32), nullable=False)
yahoo_ticker: Mapped[str | None] = mapped_column(String(32))
name: Mapped[str] = mapped_column(String(128), nullable=False)
currency: Mapped[str | None] = mapped_column(String(8))
isin: Mapped[str | None] = mapped_column(String(16))
instrument_type: Mapped[str | None] = mapped_column(String(16))
manual: Mapped[bool] = mapped_column(Boolean, default=False)
last_verified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
__table_args__ = (
UniqueConstraint("t212_ticker", name="uq_imap_t212_ticker"),
Index("ix_imap_shortname", "t212_shortname"),
Index("ix_imap_isin", "isin"),
)
class TickerUniverse(Base):
"""The set of public tickers Cassandra is currently tracking. Populated
as the union of all users' holdings, *without user attribution* — once
a ticker is in the universe, the row carries no signal as to who put
it there. The /api/universe endpoint returns the entire set (gzipped)
to every authenticated client, so the request body itself doesn't leak
which tickers belong to which user.
Eviction policy: passive aging. last_referenced_at is bumped whenever
the ticker appears in /api/portfolio/parse or /api/analyze. A nightly
cron prunes rows older than UNIVERSE_EVICTION_TTL (60 days).
"""
__tablename__ = "ticker_universe"
yahoo_ticker: Mapped[str] = mapped_column(String(32), primary_key=True)
currency: Mapped[str | None] = mapped_column(String(8))
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_referenced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
__table_args__ = (Index("ix_universe_last_ref", "last_referenced_at"),)
class JobRun(Base):
"""One row per scheduled-job invocation; powers /api/health + the ops footer."""
__tablename__ = "job_runs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(16), default="running") # running|success|failed
error: Mapped[str | None] = mapped_column(Text)
items_written: Mapped[int | None] = mapped_column(Integer)
__table_args__ = (Index("ix_jobruns_name_started", "name", "started_at"),)