539 lines
26 KiB
Python
539 lines
26 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,
|
|
LargeBinary,
|
|
SmallInteger,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db import Base, utcnow
|
|
|
|
|
|
# Portable autoincrement primary-key type. SQLite only treats `INTEGER
|
|
# PRIMARY KEY` as a ROWID alias (the bit that auto-fills); plain BIGINT
|
|
# requires explicit values, which breaks our async tests. `with_variant`
|
|
# emits INTEGER on SQLite and keeps BIGINT everywhere else.
|
|
_PK = BigInteger().with_variant(Integer(), "sqlite")
|
|
|
|
|
|
class Quote(Base):
|
|
__tablename__ = "quotes"
|
|
id: Mapped[int] = mapped_column(_PK, 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(128), 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(_PK, 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(_PK, 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 StrategicLogTranslation(Base):
|
|
"""Cached translation of a single StrategicLog row.
|
|
|
|
Populated by ai_log_job after the English row is committed: one
|
|
row per (log_id, lang) combination. The /log endpoint serves the
|
|
matching row when available and falls back to the English source
|
|
when no row exists yet (e.g. translation failed or the language
|
|
was added after the log was generated).
|
|
|
|
No user attribution — the cache is shared. Setting `lang` on a
|
|
user just selects which (already-translated) variant they see.
|
|
"""
|
|
__tablename__ = "strategic_log_translations"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
log_id: Mapped[int] = mapped_column(
|
|
BigInteger().with_variant(Integer(), "sqlite"),
|
|
ForeignKey("strategic_logs.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
content_md: Mapped[str] = mapped_column(Text, nullable=False)
|
|
generated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
llm_model: Mapped[str | None] = mapped_column(String(64))
|
|
llm_cost_usd: Mapped[float | None] = mapped_column(Float)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("log_id", "lang", name="uq_slt_log_lang"),
|
|
)
|
|
|
|
|
|
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(_PK, 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 IndicatorSummaryTranslation(Base):
|
|
"""Cached translation of a single IndicatorSummary row.
|
|
|
|
Same pattern as StrategicLogTranslation: one row per
|
|
(summary_id, lang). Populated by indicator_summary_job after the
|
|
English row is committed. The dashboard / indicators endpoints
|
|
swap in the matching translation when a user with a non-en
|
|
lang preference loads them, falling back silently to the English
|
|
source when no row exists yet.
|
|
"""
|
|
__tablename__ = "indicator_summary_translations"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
summary_id: Mapped[int] = mapped_column(
|
|
BigInteger().with_variant(Integer(), "sqlite"),
|
|
ForeignKey("indicator_summaries.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
content_md: Mapped[str] = mapped_column(Text, nullable=False)
|
|
generated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
llm_model: Mapped[str | None] = mapped_column(String(64))
|
|
llm_cost_usd: Mapped[float | None] = mapped_column(Float)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("summary_id", "lang", name="uq_ist_summary_lang"),
|
|
)
|
|
|
|
|
|
class AICall(Base):
|
|
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
|
|
__tablename__ = "ai_calls"
|
|
id: Mapped[int] = mapped_column(_PK, 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))
|
|
# Referral 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. Null = no credit. When set and > now(),
|
|
# the user gets paid-tier features regardless of `tier`. Populated
|
|
# by admin CLI (manual grants) and by referral conversion (45 days
|
|
# per converted referral, both parties).
|
|
credit_until: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True,
|
|
)
|
|
email_digest_opt_in: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=True, server_default=text("1"),
|
|
)
|
|
# NULL = use INTERMEDIATE at render time. Server-side mirror of the
|
|
# dashboard tone, decoupled because the dashboard pref is localStorage.
|
|
digest_tone: Mapped[str | None] = mapped_column(String(16))
|
|
# Preferred language for AI-generated content (strategic log,
|
|
# digest emails, portfolio commentary). Default 'en'. The settings
|
|
# PATCH endpoint validates against ACTIVE_LANGUAGES in
|
|
# app/services/i18n.py before writing.
|
|
lang: Mapped[str] = mapped_column(
|
|
String(8), nullable=False, default="en", server_default="en",
|
|
index=True,
|
|
)
|
|
# Polar (MoR) linkage — populated by the polar_webhook handler the
|
|
# first time we see a subscription/order event for the user. The
|
|
# customer id is the stable join key; the subscription id is what
|
|
# we cancel against from /settings.
|
|
polar_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
polar_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# Stripe (merchant-on-record for read.markets). Populated on the
|
|
# first checkout.session.completed event via client_reference_id;
|
|
# used thereafter to match incoming subscription/invoice events
|
|
# back to this row.
|
|
stripe_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
stripe_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# Set when a subscription is in `trialing` state — drives the
|
|
# "Free trial — N days remaining" hint on /settings. Cleared on
|
|
# subscription.revoked or when status transitions out of trialing.
|
|
stripe_trial_end_at: 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"),
|
|
UniqueConstraint("polar_customer_id", name="uq_users_polar_customer"),
|
|
UniqueConstraint("stripe_customer_id", name="uq_users_stripe_customer"),
|
|
)
|
|
|
|
|
|
class PortfolioSync(Base):
|
|
"""Opt-in encrypted backup of a user's pie. Stored as opaque bytes:
|
|
the client encrypts the pie with a PIN-derived key (AES-GCM), and the
|
|
server wraps that ciphertext again with a per-user key derived from
|
|
PORTFOLIO_SYNC_PEPPER + user_id (also AES-GCM). A DB-only leak yields
|
|
nothing usable without the env-only pepper; a pepper-only leak still
|
|
leaves the attacker brute-forcing the PIN through PBKDF2(600k).
|
|
|
|
One row per user. Absent row = sync disabled for that user. The
|
|
fetch_window_* fields drive a sliding-window rate limit on GET so the
|
|
pepper-leak threat model can't degenerate into an unthrottled brute
|
|
force against the inner PBKDF2."""
|
|
__tablename__ = "portfolio_sync"
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True,
|
|
)
|
|
outer_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
outer_nonce: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
fetch_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
fetch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# 8-byte HKDF fingerprint of the pepper that wrapped this row. A
|
|
# mismatch against the current pepper means the row is orphaned
|
|
# (pepper was rotated) — distinct from genuine GCM corruption.
|
|
pepper_fp: Mapped[bytes | None] = mapped_column(LargeBinary(length=8))
|
|
|
|
|
|
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 — the Stripe webhook calls
|
|
``referral_service.convert_referral`` to fill them in and extend
|
|
both parties' ``credit_until``."""
|
|
__tablename__ = "referrals"
|
|
id: Mapped[int] = mapped_column(_PK, 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(_PK, 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(_PK, 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(_PK, 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"),)
|
|
|
|
|
|
class EmailSend(Base):
|
|
"""Audit row per digest email send. Used for idempotency (don't send
|
|
twice on the same UTC day) and for surfacing 'last delivery' on the
|
|
Settings page."""
|
|
__tablename__ = "email_sends"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly"
|
|
sent_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False,
|
|
)
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error"
|
|
error: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
__table_args__ = (
|
|
Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"),
|
|
)
|
|
|
|
|
|
class PolarEvent(Base):
|
|
"""Audit + idempotency table for inbound Polar (MoR) webhook deliveries.
|
|
|
|
Polar uses the Standard Webhooks spec, which guarantees each delivery
|
|
carries a unique `webhook-id` header. We store that ID under a UNIQUE
|
|
constraint so a replay of the same event is a no-op (the INSERT fails
|
|
and the handler returns the prior result).
|
|
|
|
`processed_at` distinguishes "delivered and handled" from "delivered
|
|
but the handler crashed mid-flight" — the latter rows are what an
|
|
operator looks at when investigating a stuck subscription."""
|
|
__tablename__ = "polar_events"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
event_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
received_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False,
|
|
)
|
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
# Raw JSON body, kept for forensics. Truncated to 16 KiB to keep
|
|
# one bad request from blowing up the row.
|
|
payload: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("event_id", name="uq_polar_events_event_id"),
|
|
Index("ix_polar_events_type_received", "event_type", "received_at"),
|
|
)
|
|
|
|
|
|
class StripeEvent(Base):
|
|
"""Audit + idempotency table for inbound Stripe webhook deliveries.
|
|
|
|
Same shape and purpose as PolarEvent — Stripe's `event.id` plays the
|
|
same role as Standard Webhooks' `webhook-id`. We keep the tables
|
|
distinct (rather than a single 'webhook_events' table) so an
|
|
operator can look at the audit trail per processor without filtering
|
|
on a `source` column."""
|
|
__tablename__ = "stripe_events"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
event_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
received_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, nullable=False,
|
|
)
|
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
payload: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("event_id", name="uq_stripe_events_event_id"),
|
|
Index("ix_stripe_events_type_received", "event_type", "received_at"),
|
|
)
|
|
|
|
|
|
class CsvFormatTemplate(Base):
|
|
"""Cached column-mapping for a single broker CSV format.
|
|
|
|
Populated on the first upload of a previously-unseen format via the
|
|
LLM-fallback parser. Subsequent uploads of the same format
|
|
(identified by ``fingerprint``, a sha256 of the normalised header
|
|
row) replay ``mapping`` deterministically with no LLM call.
|
|
|
|
The table holds the actual ``headers`` and one anonymous ``sample_row``
|
|
from the originating upload — there is no ``user_id`` column, no link
|
|
back to the uploader. The sample exists so the operator has concrete
|
|
material to look at when hand-writing future native parsers; the
|
|
system never auto-generates or modifies parser code from this data.
|
|
"""
|
|
__tablename__ = "csv_format_templates"
|
|
|
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
|
fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
headers: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
|
sample_row: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
|
mapping: Mapped[dict[str, str | None]] = mapped_column(JSON, nullable=False)
|
|
preamble_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
delimiter: Mapped[str] = mapped_column(String(1), nullable=False, default=",")
|
|
broker_label: Mapped[str | None] = mapped_column(String(128))
|
|
first_seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
# use_count and last_used_at are application-managed: parse_with_llm
|
|
# increments use_count and sets last_used_at = utcnow() on every cache hit.
|
|
# No onupdate hook — we don't want unrelated writes (e.g. broker_label edits)
|
|
# to re-stamp last_used_at.
|
|
use_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
last_used_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow,
|
|
)
|
|
llm_model: Mapped[str | None] = mapped_column(String(64))
|
|
llm_cost_usd: Mapped[float | None] = mapped_column(Float)
|