Compare commits

..

No commits in common. "11662c0ea8ba0e20947b016c8d7c2f6d2b8251a8" and "f1903e1e6130c269083b8661a67801e27c6045f3" have entirely different histories.

79 changed files with 317 additions and 14132 deletions

View file

@ -32,31 +32,3 @@ COPY alembic.ini ./
# Default command is the web app; scheduler container overrides via `command:`.
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
# ---------------------------------------------------------------------------
# Test stage — same Python, same prod deps, plus dev extras (pytest +
# aiosqlite). Built and run only via docker-compose.test.yml; never shipped.
# ---------------------------------------------------------------------------
FROM python:3.13-slim AS test
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
TZ=UTC \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
# tests/ is excluded by .dockerignore (prod-correct: never bake tests into
# a shipped image). docker-compose.test.yml bind-mounts ./tests:/app/tests
# at run time, so the suite is always available without baking it in.
RUN /opt/venv/bin/pip install ".[dev]"
CMD ["pytest", "tests/", "-v"]

View file

@ -1,39 +0,0 @@
"""portfolio_sync: add pepper_fp for orphan-blob detection.
When PORTFOLIO_SYNC_PEPPER rotates (intentional or otherwise), any
existing wrapped blob becomes permanently unreadable. Today that
manifests as a GCM InvalidTag 500 on the GET endpoint. We add a
short HKDF-derived fingerprint of the pepper so we can detect the
rotation case explicitly and surface it to the client as a clean
"stale" state (410), distinct from genuine corruption (500).
Existing rows get pepper_fp=NULL on upgrade; the service treats NULL
as "orphaned" (always true: those rows were written before this
column existed, so we can't prove the pepper matches). The next
successful upsert refreshes the fingerprint.
Revision ID: 0016
Revises: 0015
Create Date: 2026-05-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"portfolio_sync",
sa.Column("pepper_fp", sa.LargeBinary(length=8), nullable=True),
)
def downgrade() -> None:
op.drop_column("portfolio_sync", "pepper_fp")

View file

@ -1,55 +0,0 @@
"""email digests: User.email_digest_opt_in, User.digest_tone, email_sends table.
Revision ID: 0017
Revises: 0016
Create Date: 2026-05-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0017"
down_revision: Union[str, None] = "0016"
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(
"email_digest_opt_in", sa.Boolean(), nullable=False,
server_default=sa.text("1"),
),
)
op.add_column(
"users",
sa.Column("digest_tone", sa.String(length=16), nullable=True),
)
op.create_table(
"email_sends",
sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("error", sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], ondelete="CASCADE",
),
)
op.create_index(
"ix_email_sends_user_kind_sent",
"email_sends",
["user_id", "kind", "sent_at"],
)
def downgrade() -> None:
op.drop_index("ix_email_sends_user_kind_sent", table_name="email_sends")
op.drop_table("email_sends")
op.drop_column("users", "digest_tone")
op.drop_column("users", "email_digest_opt_in")

View file

@ -1,55 +0,0 @@
"""polar webhook: User.polar_customer_id/subscription_id, polar_events table.
Revision ID: 0018
Revises: 0017
Create Date: 2026-05-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018"
down_revision: Union[str, None] = "0017"
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("polar_customer_id", sa.String(length=64), nullable=True),
)
op.add_column(
"users",
sa.Column("polar_subscription_id", sa.String(length=64), nullable=True),
)
op.create_unique_constraint(
"uq_users_polar_customer", "users", ["polar_customer_id"],
)
op.create_table(
"polar_events",
sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True),
sa.Column("event_id", sa.String(length=128), nullable=False),
sa.Column("event_type", sa.String(length=64), nullable=False),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("payload", sa.Text(), nullable=False),
sa.UniqueConstraint("event_id", name="uq_polar_events_event_id"),
)
op.create_index(
"ix_polar_events_type_received",
"polar_events",
["event_type", "received_at"],
)
def downgrade() -> None:
op.drop_index("ix_polar_events_type_received", table_name="polar_events")
op.drop_table("polar_events")
op.drop_constraint("uq_users_polar_customer", "users", type_="unique")
op.drop_column("users", "polar_subscription_id")
op.drop_column("users", "polar_customer_id")

View file

@ -1,56 +0,0 @@
"""stripe integration: users.stripe_customer_id / stripe_subscription_id,
stripe_events table.
Revision ID: 0019
Revises: 0018
Create Date: 2026-05-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0019"
down_revision: Union[str, None] = "0018"
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("stripe_customer_id", sa.String(length=64), nullable=True),
)
op.add_column(
"users",
sa.Column("stripe_subscription_id", sa.String(length=64), nullable=True),
)
op.create_unique_constraint(
"uq_users_stripe_customer", "users", ["stripe_customer_id"],
)
op.create_table(
"stripe_events",
sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True),
sa.Column("event_id", sa.String(length=128), nullable=False),
sa.Column("event_type", sa.String(length=64), nullable=False),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("payload", sa.Text(), nullable=False),
sa.UniqueConstraint("event_id", name="uq_stripe_events_event_id"),
)
op.create_index(
"ix_stripe_events_type_received",
"stripe_events",
["event_type", "received_at"],
)
def downgrade() -> None:
op.drop_index("ix_stripe_events_type_received", table_name="stripe_events")
op.drop_table("stripe_events")
op.drop_constraint("uq_users_stripe_customer", "users", type_="unique")
op.drop_column("users", "stripe_subscription_id")
op.drop_column("users", "stripe_customer_id")

View file

@ -1,31 +0,0 @@
"""stripe trial: users.stripe_trial_end_at.
Revision ID: 0020
Revises: 0019
Create Date: 2026-05-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0020"
down_revision: Union[str, None] = "0019"
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(
"stripe_trial_end_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("users", "stripe_trial_end_at")

View file

@ -1,40 +0,0 @@
"""csv format templates table — LLM-fallback parser cache.
Revision ID: 0021
Revises: 0020
Create Date: 2026-05-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0021"
down_revision: Union[str, None] = "0020"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"csv_format_templates",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("fingerprint", sa.String(length=64), nullable=False),
sa.Column("headers", sa.JSON(), nullable=False),
sa.Column("sample_row", sa.JSON(), nullable=False),
sa.Column("mapping", sa.JSON(), nullable=False),
sa.Column("preamble_rows", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("delimiter", sa.String(length=1), nullable=False, server_default=","),
sa.Column("broker_label", sa.String(length=128), nullable=True),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("use_count", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("llm_model", sa.String(length=64), nullable=True),
sa.Column("llm_cost_usd", sa.Float(), nullable=True),
sa.UniqueConstraint("fingerprint", name="uq_csv_format_templates_fingerprint"),
)
def downgrade() -> None:
op.drop_table("csv_format_templates")

View file

@ -11,9 +11,8 @@ Usage from the host::
two months, not one (avoids accidental erosion of an existing grant
when re-running the command).
This is the manual lever for admin grants. The Stripe webhook applies
the same stacking rule via ``referral_service.convert_referral`` for
both sides of a referral conversion.
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
@ -95,57 +94,6 @@ async def show_status(email: str) -> int:
return 0
async def send_test_digest(email: str, kind: str) -> int:
"""Generate a digest and send it to the named user immediately, ignoring
opt-in state and idempotency. Useful for previewing copy in your own
inbox before a real run lands."""
import httpx
from app.jobs._market_context import (
REFERENCE_LINE,
latest_quotes_by_group,
recent_headlines_by_bucket,
)
from app.jobs.email_digest_job import _generate_variants, _send_one
from app.services.openrouter import llm_configured
if kind not in ("daily", "weekly"):
print(f"error: kind must be 'daily' or 'weekly' (got {kind!r})",
file=sys.stderr)
return 2
if not llm_configured():
print("error: LLM provider not configured (set OPENROUTER_API_KEY)",
file=sys.stderr)
return 1
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
today = _utcnow()
quotes = await latest_quotes_by_group(session)
news = await recent_headlines_by_bucket(
session, hours=(168 if kind == "weekly" else 24),
)
ctx = dict(today=today, quotes_by_group=quotes,
headlines_by_bucket=news, reference_line=REFERENCE_LINE)
async with httpx.AsyncClient(follow_redirects=True) as client:
variants = await _generate_variants(session, client, kind, ctx)
tone = (user.digest_tone or "INTERMEDIATE").upper()
content = (variants.get(tone)
or variants.get("INTERMEDIATE")
or next(iter(variants.values()), None))
if content is None:
print("error: all LLM variants failed", file=sys.stderr)
return 1
date_str = today.strftime("%Y-%m-%d")
await _send_one(user, kind, content, date_str, session)
print(f"sent {kind} digest to {email} (tone={tone})")
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)
@ -160,11 +108,6 @@ def build_parser() -> argparse.ArgumentParser:
s = sub.add_parser("show-status", help="Print paid-tier status for a user")
s.add_argument("email")
t = sub.add_parser("send-test-digest",
help="Send one digest immediately (bypasses opt-in/idempotency)")
t.add_argument("email")
t.add_argument("kind", choices=("daily", "weekly"))
return p
@ -179,8 +122,6 @@ async def _dispatch(args) -> int:
return await revoke_credit(args.email)
if args.cmd == "show-status":
return await show_status(args.email)
if args.cmd == "send-test-digest":
return await send_test_digest(args.email, args.kind)
return 2
finally:
await get_engine().dispose()

View file

@ -90,24 +90,6 @@ class Settings(BaseSettings):
# by app.services.openrouter._resolve_tone.
CASSANDRA_TONE: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE
CASSANDRA_ANALYSIS: str = "SPECULATIVE" # DRY | SPECULATIVE
BETA_MODE: bool = True # Shows a "BETA" pill in the app header. Flip to False at GA.
# Polar (merchant-of-record). Webhook secret is base64-encoded with a
# `whsec_` prefix in the Polar dashboard; paste it verbatim into the
# env var. Empty = webhook endpoint refuses with 503 (so a misconfig
# is loud rather than silently accepting unsigned events).
POLAR_WEBHOOK_SECRET: str = ""
POLAR_API_KEY: str = ""
# Stripe (merchant-on-record for read.markets after Polar/Paddle
# both declined the financial-media category). Test-mode keys are
# `sk_test_*` / `whsec_*`; live-mode keys are `sk_live_*` — swap at
# GA cutover. Empty values make the corresponding endpoints 503 so
# a misconfig is loud rather than silently accepting unsigned events.
STRIPE_API_KEY: str = ""
STRIPE_WEBHOOK_SECRET: str = ""
STRIPE_PRICE_MONTHLY: str = "" # price_xxx for £7/month subscription
STRIPE_PRICE_ANNUAL: str = "" # price_xxx for £70/year subscription
# Config file locations (overridable for tests)
BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml")

View file

@ -23,21 +23,17 @@ async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]]
handles the bookkeeping.
A MariaDB GET_LOCK(name, 0) is acquired to prevent concurrent runs of the
same job across processes. If the lock is busy, we skip the run.
The lock dance is MariaDB-specific; on SQLite (used in tests) it's a
no-op, since the single-process test runner can't race itself."""
same job across processes. If the lock is busy, we skip the run."""
factory = get_session_factory()
async with factory() as session:
bind = session.get_bind()
use_lock = bind is not None and bind.dialect.name == "mysql"
if use_lock:
got = (await session.execute(
text("SELECT GET_LOCK(:n, 0)"), {"n": f"cassandra_{name}"}
)).scalar()
if not got:
log.warning("job.skipped_locked", name=name)
yield session, JobRun(name=name, started_at=utcnow(), status="skipped")
return
# Try lock; skip if held.
got = (await session.execute(
text("SELECT GET_LOCK(:n, 0)"), {"n": f"cassandra_{name}"}
)).scalar()
if not got:
log.warning("job.skipped_locked", name=name)
yield session, JobRun(name=name, started_at=utcnow(), status="skipped")
return
run = JobRun(name=name, started_at=utcnow(), status="running")
session.add(run)
await session.commit()
@ -57,7 +53,6 @@ async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]]
log.error("job.failed", name=name, error=str(e))
raise
finally:
if use_lock:
await session.execute(text("SELECT RELEASE_LOCK(:n)"),
{"n": f"cassandra_{name}"})
await session.commit()
await session.execute(text("SELECT RELEASE_LOCK(:n)"),
{"n": f"cassandra_{name}"})
await session.commit()

View file

@ -1,86 +0,0 @@
"""Shared market-context helpers consumed by LLM-driven jobs.
Both ai_log_job and email_digest_job pull "the latest tape" the same
way most-recent quote per (group, symbol), last N hours of headlines
bucketed by category, and the running month's LLM spend. Moved here so
neither job depends on the other's internals.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import timedelta
from sqlalchemy import desc, func, select
from app.db import utcnow
from app.models import AICall, Headline, Quote
from app.services.openrouter import month_start
REFERENCE_LINE = (
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
)
async def latest_quotes_by_group(session) -> dict[str, list[dict]]:
"""Latest quote per (group, symbol). Skips error rows where price is null."""
sub = (
select(
Quote.group_name,
Quote.symbol,
func.max(Quote.fetched_at).label("mx"),
)
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
stmt = (
select(Quote)
.join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
)
.order_by(Quote.group_name, Quote.symbol)
)
rows = (await session.execute(stmt)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append(dict(
symbol=q.symbol, source=q.source, label=q.label,
note="", price=q.price, currency=q.currency,
as_of=q.as_of, changes=q.changes,
))
return by_group
async def recent_headlines_by_bucket(session, hours: float = 24) -> dict[str, list[dict]]:
"""Last N hours of headlines, bucketed by category. Hard cap per
bucket to keep the prompt under ~40KB."""
cutoff = utcnow() - timedelta(hours=hours)
stmt = (
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(400)
)
rows = (await session.execute(stmt)).scalars().all()
by_bucket: dict[str, list[dict]] = defaultdict(list)
for h in rows:
if len(by_bucket[h.category]) >= 40:
continue
by_bucket[h.category].append(dict(
when=h.published_at.isoformat(),
source=h.source, title=h.title,
))
return by_bucket
async def month_spend(session) -> float:
start = month_start()
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= start)
)).scalar()
return float(total or 0.0)

View file

@ -4,6 +4,8 @@ and a row in the cost ledger."""
from __future__ import annotations
import asyncio
from collections import defaultdict
from datetime import timedelta
import httpx
from sqlalchemy import desc, func, select
@ -11,13 +13,7 @@ from sqlalchemy import desc, func, select
from app.config import get_settings
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.jobs._market_context import (
REFERENCE_LINE,
latest_quotes_by_group,
month_spend,
recent_headlines_by_bucket,
)
from app.models import AICall, JobRun, StrategicLog
from app.models import AICall, Headline, JobRun, Quote, StrategicLog
from app.services.cadence import DEFAULT_POLICY
from app.services.openrouter import (
PROMPT_VERSION,
@ -26,9 +22,79 @@ from app.services.openrouter import (
build_user_prompt,
call_llm,
llm_configured,
month_start,
)
REFERENCE_LINE = (
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
)
async def _latest_quotes_by_group(session) -> dict[str, list[dict]]:
"""Latest quote per (group, symbol). Skips error rows where price is null."""
sub = (
select(
Quote.group_name,
Quote.symbol,
func.max(Quote.fetched_at).label("mx"),
)
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
stmt = (
select(Quote)
.join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
)
.order_by(Quote.group_name, Quote.symbol)
)
rows = (await session.execute(stmt)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append(dict(
symbol=q.symbol, source=q.source, label=q.label,
note="", price=q.price, currency=q.currency,
as_of=q.as_of, changes=q.changes,
))
return by_group
async def _recent_headlines_by_bucket(session, hours: float = 24) -> dict[str, list[dict]]:
"""Last N hours of headlines, bucketed by category. Hard cap per bucket
to keep the prompt under ~40KB."""
cutoff = utcnow() - timedelta(hours=hours)
stmt = (
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(400)
)
rows = (await session.execute(stmt)).scalars().all()
by_bucket: dict[str, list[dict]] = defaultdict(list)
for h in rows:
if len(by_bucket[h.category]) >= 40:
continue
by_bucket[h.category].append(dict(
when=h.published_at.isoformat(),
source=h.source, title=h.title,
))
return by_bucket
async def _month_spend(session) -> float:
start = month_start()
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= start)
)).scalar()
return float(total or 0.0)
async def run() -> None:
async with job_lifecycle("ai_log_job") as (session, jr):
if jr.status == "skipped":
@ -53,7 +119,7 @@ async def run() -> None:
jr.error = reason
return
spent = await month_spend(session)
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
log.warning("ai_log.cap_reached", spent=spent,
cap=s.OPENROUTER_MONTHLY_CAP_USD)
@ -61,8 +127,8 @@ async def run() -> None:
jr.error = f"monthly cost cap reached (${spent:.2f})"
return
quotes = await latest_quotes_by_group(session)
news = await recent_headlines_by_bucket(session)
quotes = await _latest_quotes_by_group(session)
news = await _recent_headlines_by_bucket(session)
if not quotes and not news:
log.warning("ai_log.no_data_yet")
jr.status = "skipped"
@ -103,7 +169,7 @@ async def run() -> None:
for tone, analysis in variants:
# Re-check cost cap between variants so a runaway run is
# bounded.
spent = await month_spend(session)
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
log.warning("ai_log.cap_reached_midrun",
spent=spent, completed=written)

View file

@ -1,224 +0,0 @@
"""Daily/weekly editorial email digest.
Runs once a day at 06:30 UTC via the scheduler. On Sundays sends the
weekly recap to every opt-in user (free + paid). On other days sends
the daily digest to opt-in paid users only.
Generates LLM content once per tone (NOVICE + INTERMEDIATE), then fans
out by SMTP. EmailSend audit rows guard against double-delivery if the
job is re-run within the same UTC day.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import httpx
from sqlalchemy import select
from app import branding
from app.config import get_settings
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.jobs._market_context import (
REFERENCE_LINE,
latest_quotes_by_group,
month_spend,
recent_headlines_by_bucket,
)
from app.models import EmailSend, User
from app.routers.email import sign_unsubscribe_token
from app.services.access import paid_status
from app.services.email_service import render_digest_email, send_email
from app.services.openrouter import (
PROMPT_VERSION,
build_daily_digest_prompt,
build_weekly_digest_prompt,
call_llm,
llm_configured,
)
def _now() -> datetime:
"""Indirection so tests can monkeypatch the "current time" without
touching the system clock."""
return utcnow()
async def _opt_in_recipients(session, *, paid_only: bool) -> list[User]:
stmt = select(User).where(User.email_digest_opt_in.is_(True))
rows = (await session.execute(stmt)).scalars().all()
if paid_only:
rows = [u for u in rows if paid_status(u).active]
return rows
async def _already_sent_today(session, user_id: int, kind: str, today: datetime) -> bool:
"""True if an EmailSend row exists for this user+kind on the same UTC
day, with status in ('sent','error'). 'error' counts because we don't
want to keep retrying a bad address inside the same daily slot."""
day_start = today.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
stmt = select(EmailSend.id).where(
EmailSend.user_id == user_id,
EmailSend.kind == kind,
EmailSend.sent_at >= day_start,
EmailSend.sent_at < day_end,
EmailSend.status.in_(("sent", "error")),
)
return (await session.execute(stmt)).first() is not None
async def _generate_variants(session, client, kind: str, ctx: dict) -> dict[str, str]:
"""Returns {tone: html_content}. Missing tone means generation failed
for that variant skip recipients on that tone.
Persists an AICall row per attempt so digest LLM spend counts toward
the monthly cost cap on subsequent runs."""
from app.models import AICall
from app.services.openrouter import active_model
builder = build_weekly_digest_prompt if kind == "weekly" else build_daily_digest_prompt
out: dict[str, str] = {}
for tone in ("NOVICE", "INTERMEDIATE"):
sys_, usr = builder(tone=tone, **ctx)
try:
result = await call_llm(
client,
[{"role": "system", "content": sys_},
{"role": "user", "content": usr}],
)
out[tone] = result.content
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
log.info("digest.variant_ok", kind=kind, tone=tone,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens)
except Exception as e:
session.add(AICall(
model=active_model(), status="error",
error=f"{kind}/{tone}: {str(e)[:480]}",
))
await session.commit()
log.error("digest.variant_failed", kind=kind, tone=tone,
error=str(e)[:200])
return out
def _kind_for_today(today: datetime) -> str:
"""Sunday → weekly. MonSat → daily."""
return "weekly" if today.weekday() == 6 else "daily"
async def _send_one(user: User, kind: str, content_html: str, date_str: str,
session) -> None:
settings_url = f"{branding.SITE_URL}/settings"
unsubscribe_url = (
f"{branding.SITE_URL}/email/unsubscribe"
f"?token={sign_unsubscribe_token(user.id)}"
)
subject, text_body, html_body = render_digest_email(
kind=kind, date_str=date_str,
content_html=content_html,
unsubscribe_url=unsubscribe_url,
settings_url=settings_url,
)
try:
await send_email(to=user.email, subject=subject,
text_body=text_body, html_body=html_body)
status_ = "sent"
err = None
except Exception as e:
status_ = "error"
err = str(e)[:255]
log.error("digest.send_failed", user_id=user.id, error=err)
session.add(EmailSend(
user_id=user.id, kind=kind, sent_at=_now(),
status=status_, error=err,
))
await session.commit()
async def run() -> None:
async with job_lifecycle("email_digest_job") as (session, jr):
if jr.status == "skipped":
return
s = get_settings()
if not llm_configured():
log.warning("digest.skipped_no_key", provider=s.LLM_PROVIDER)
jr.status = "skipped"
return
today = _now()
kind = _kind_for_today(today)
date_str = today.strftime("%Y-%m-%d")
recipients = await _opt_in_recipients(
session, paid_only=(kind == "daily"),
)
fresh: list[User] = []
for u in recipients:
if not await _already_sent_today(session, u.id, kind, today):
fresh.append(u)
if not fresh:
log.info("digest.no_fresh_recipients", kind=kind,
total=len(recipients))
jr.status = "skipped"
return
spent = await month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
log.warning("digest.cap_reached", spent=spent,
cap=s.OPENROUTER_MONTHLY_CAP_USD)
jr.status = "skipped"
jr.error = f"monthly cost cap reached (${spent:.2f})"
return
quotes = await latest_quotes_by_group(session)
news = await recent_headlines_by_bucket(
session, hours=(168 if kind == "weekly" else 24),
)
ctx = dict(
today=today,
quotes_by_group=quotes,
headlines_by_bucket=news,
reference_line=REFERENCE_LINE,
)
async with httpx.AsyncClient(follow_redirects=True) as client:
variants = await _generate_variants(session, client, kind, ctx)
if not variants:
log.warning("digest.all_variants_failed", kind=kind)
jr.status = "failed"
jr.error = "all variants failed"
return
written = 0
for u in fresh:
tone = (u.digest_tone or "INTERMEDIATE").upper()
# Fall back to INTERMEDIATE first (the more common tone) and then
# to whatever variant succeeded, so an asymmetric LLM failure
# doesn't silently skip the user.
content = (variants.get(tone)
or variants.get("INTERMEDIATE")
or next(iter(variants.values()), None))
if content is None:
continue
await _send_one(u, kind, content, date_str, session)
await asyncio.sleep(0.1)
written += 1
jr.items_written = written
log.info("digest.done", kind=kind, written=written,
prompt_version=PROMPT_VERSION)
if __name__ == "__main__":
asyncio.run(run())

View file

@ -19,13 +19,9 @@ from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import email as email_router
from app.routers import pages as pages_router
from app.routers import polar_webhook as polar_webhook_router
from app.routers import public as public_router
from app.routers import stripe_billing as stripe_router
from app.routers import sync as sync_router
from app.routers import ticker_validate as ticker_validate_router
from app.routers import universe as universe_router
from app.services.feeds_bootstrap import bootstrap_feeds
@ -87,18 +83,9 @@ app.mount(
)
app.include_router(auth_router.router, tags=["auth"])
app.include_router(email_router.router, tags=["email"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"])
app.include_router(sync_router.router, tags=["portfolio-sync"])
# Polar webhook (no bearer-token auth — authenticity via HMAC). Path
# `/api/polar/webhook` is set on the route itself so the URL Polar
# stores remains stable even if api_router's prefix ever moves.
app.include_router(polar_webhook_router.router, tags=["polar-webhook"])
# Stripe billing (checkout, portal, webhook). Auth lives per-route:
# checkout + portal require_auth, webhook is signature-gated.
app.include_router(stripe_router.router, tags=["stripe-billing"])
# Public router (no auth dep) before pages_router so the marketing/legal
# paths can never collide with future authenticated routes.
app.include_router(public_router.router)

View file

@ -22,23 +22,15 @@ from sqlalchemy import (
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)
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="")
@ -69,7 +61,7 @@ class QuoteDaily(Base):
class Headline(Base):
__tablename__ = "headlines"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
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)
@ -107,7 +99,7 @@ class Feed(Base):
class StrategicLog(Base):
__tablename__ = "strategic_logs"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
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))
@ -124,7 +116,7 @@ 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)
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)
@ -142,7 +134,7 @@ class IndicatorSummary(Base):
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)
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)
@ -169,50 +161,23 @@ 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))
# 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.
# 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. 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).
# 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,
)
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))
# 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"),
)
@ -239,21 +204,16 @@ class PortfolioSync(Base):
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``."""
user makes their first paid subscription Phase D.3 fills them in
via the Paddle webhook."""
__tablename__ = "referrals"
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
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,
)
@ -275,7 +235,7 @@ class EmailOTP(Base):
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)
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)
@ -296,7 +256,7 @@ class InstrumentMap(Base):
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)
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))
@ -338,7 +298,7 @@ class TickerUniverse(Base):
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)
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))
@ -347,121 +307,3 @@ class JobRun(Base):
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)

View file

@ -8,7 +8,6 @@ from __future__ import annotations
import calendar as _cal
import re
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
@ -20,7 +19,7 @@ from collections import defaultdict
import httpx
from pydantic import BaseModel, Field
from app.auth import require_token, maybe_current_user, CurrentUser
from app.auth import require_token
from app.config import get_settings
from app.db import get_session, utcnow
from app.services.openrouter import (
@ -37,7 +36,6 @@ from app.models import (
JobRun,
Quote,
StrategicLog,
User,
)
from app.schemas import (
HealthOut,
@ -51,8 +49,7 @@ from app.schemas import (
router = APIRouter(dependencies=[Depends(require_token)])
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
"indicator_summary_job", "universe_flush_job",
"email_digest_job")
"indicator_summary_job", "universe_flush_job")
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
# Per-group expected freshness — bonds and intraday tape want daily data,
@ -231,18 +228,11 @@ async def news_list(
limit: int = Query(50, ge=1, le=500),
tags: str | None = Query(None, description="comma-separated include list"),
exclude_tags: str | None = Query(None, description="comma-separated exclude list"),
principal: CurrentUser | None = Depends(maybe_current_user),
as_: str | None = Query(default=None, alias="as"),
):
from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY
from app.services.access import FREE_NEWS_WINDOW_HOURS, is_paid_active
effective_hours = since_hours
capped = not is_paid_active(principal)
if capped:
effective_hours = min(since_hours, FREE_NEWS_WINDOW_HOURS)
cutoff = utcnow() - timedelta(hours=effective_hours)
cutoff = utcnow() - timedelta(hours=since_hours)
stmt = select(Headline).where(Headline.published_at >= cutoff)
if category:
stmt = stmt.where(Headline.category == category)
@ -285,9 +275,7 @@ async def news_list(
"tag_vocabulary": TAG_VOCABULARY,
"tag_labels": TAG_LABELS,
"active_include": sorted(include),
"active_exclude": sorted(exclude),
"capped": capped,
"window_hours": effective_hours},
"active_exclude": sorted(exclude)},
)
return [HeadlineOut.model_validate(r, from_attributes=True) for r in filtered]
@ -322,54 +310,31 @@ def _resolve_tone_param(tone: str | None) -> str:
return "INTERMEDIATE"
def _free_tier_hour_filter():
"""Free-tier cadence filter for the strategic log: restrict matches to
logs generated at one of the 6-hour boundary hours (00, 06, 12, 18
UTC). The job itself runs at :20 every hour, so this effectively gives
free users a fresh log roughly every six hours."""
from app.services.access import FREE_LOG_HOURS_UTC
# `func.extract` works on both MariaDB and SQLite.
return func.extract("hour", StrategicLog.generated_at).in_(FREE_LOG_HOURS_UTC)
@router.get("/log/latest")
async def log_latest(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
principal: CurrentUser | None = Depends(maybe_current_user),
):
from app.services.access import is_paid_active
free_only = not is_paid_active(principal)
wanted_tone = _resolve_tone_param(tone)
stmt = (
row = (await session.execute(
select(StrategicLog)
.where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)
if free_only:
stmt = stmt.where(_free_tier_hour_filter())
row = (await session.execute(stmt)).scalar_one_or_none()
)).scalar_one_or_none()
# Fallback during rollout: if the requested tone isn't produced yet,
# serve whatever is latest rather than 404 the panel.
if row is None:
fallback = (
select(StrategicLog)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)
if free_only:
fallback = fallback.where(_free_tier_hour_filter())
row = (await session.execute(fallback)).scalar_one_or_none()
row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html",
{"log": _log_partial_payload(row), "tone": wanted_tone,
"paid": not free_only},
{"log": _log_partial_payload(row), "tone": wanted_tone},
)
if row is None:
@ -384,46 +349,34 @@ async def log_by_date(
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
principal: CurrentUser | None = Depends(maybe_current_user),
):
"""Canonical log for a given day = MAX(generated_at) within that day,
filtered by tone (NOVICE | INTERMEDIATE; default from settings).
Free-tier users only see logs generated at the 6-hour boundary slots."""
filtered by tone (NOVICE | INTERMEDIATE; default from settings)."""
try:
target = datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
from app.services.access import is_paid_active
free_only = not is_paid_active(principal)
wanted_tone = _resolve_tone_param(tone)
stmt = (
row = (await session.execute(
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)
if free_only:
stmt = stmt.where(_free_tier_hour_filter())
row = (await session.execute(stmt)).scalar_one_or_none()
)).scalar_one_or_none()
if row is None:
# Fallback: any tone for that day (still tier-filtered).
fallback = (
# Fallback: any tone for that day.
row = (await session.execute(
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)
if free_only:
fallback = fallback.where(_free_tier_hour_filter())
row = (await session.execute(fallback)).scalar_one_or_none()
)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html",
{"log": _log_partial_payload(row), "tone": wanted_tone,
"paid": not free_only},
{"log": _log_partial_payload(row), "tone": wanted_tone},
)
if row is None:
raise HTTPException(status_code=404, detail="No log on this date")
@ -779,22 +732,11 @@ async def _month_spend(session: AsyncSession) -> float:
async def chat(
body: ChatRequest,
session: AsyncSession = Depends(get_session),
principal: CurrentUser | None = Depends(maybe_current_user),
):
"""Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines.
Ephemeral the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`."""
# Paid-only feature. Free users get the static log but not the
# interactive chat (see /pricing).
from app.services.access import is_paid_active
if not is_paid_active(principal):
raise HTTPException(
status_code=402,
detail={"code": "paid_required",
"message": "Follow-up chat is a paid-tier feature."},
)
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
@ -858,40 +800,3 @@ async def chat(
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}
# ---------------------------------------------------------------------------
# Settings — digest preferences
# ---------------------------------------------------------------------------
class DigestPrefsIn(BaseModel):
opt_in: bool
tone: Literal["NOVICE", "INTERMEDIATE"]
class DigestPrefsOut(BaseModel):
opt_in: bool
tone: str
@router.patch("/settings/digest", response_model=DigestPrefsOut)
async def patch_digest_prefs(
payload: DigestPrefsIn,
principal: CurrentUser = Depends(require_token),
session: AsyncSession = Depends(get_session),
) -> DigestPrefsOut:
if principal.user is None:
# Admin bearer-token path — no per-user row to persist to.
raise HTTPException(status_code=400, detail="no_user_context")
# require_token loads `principal.user` in its own short-lived session.
# By the time this handler runs, that session is closed; mutating the
# detached object and committing via `session` would persist nothing.
# Re-fetch in the active session before writing.
user = await session.get(User, principal.user.id)
if user is None:
raise HTTPException(status_code=404, detail="user_not_found")
user.email_digest_opt_in = payload.opt_in
user.digest_tone = payload.tone
await session.commit()
return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone)

View file

@ -37,7 +37,7 @@ 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, referral_service
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
from app.services.email_service import EmailSendError, send_otp
from app.templates_env import templates
@ -239,26 +239,10 @@ async def verify_submit(
if user is None:
# User row vanished between cookie issue and verify. Restart flow.
return RedirectResponse(url="/login", status_code=303)
is_first_login = user.last_login_at is None
user.last_login_at = utcnow()
# Default opt-in is set on User row creation; we don't touch it here.
# The one-time welcome email below explains the digest and the Settings
# opt-out path — re-applying a checkbox state on every login would
# silently re-subscribe users who explicitly opted out later.
await session.commit()
log.info("user.login", user_id=user.id, email=email)
# First-login welcome email — best effort. SMTP failure must not block
# the login itself; we log and continue. Idempotent because we commit
# last_login_at above before this point, so a retried verify won't
# re-trigger send.
if is_first_login:
try:
await send_welcome_email(email)
except Exception as e: # noqa: BLE001
log.warning("welcome_email.send_failed",
user_id=user.id, error=str(e)[:200])
resp = RedirectResponse(url="/", status_code=303)
_set_session_cookie(resp, user.id)
_clear_pending_cookie(resp)
@ -292,32 +276,9 @@ async def verify_resend(
# ---------------------------------------------------------------------------
_LOGOUT_HTML = """<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<title>Signing out</title>
<meta http-equiv="refresh" content="0;url=/login">
<script>
// Wipe per-user browser state before the redirect. Keeps `cassandra.theme`
// (cosmetic, no privacy concern) so the next user's first paint isn't a
// white-flash. The meta-refresh above is the no-JS fallback for the redirect;
// without JS, localStorage isn't cleared, but base.html's user-mismatch
// guard catches the next authenticated page load.
(function() {
try {
var theme = localStorage.getItem('cassandra.theme');
localStorage.clear();
if (theme) localStorage.setItem('cassandra.theme', theme);
sessionStorage.clear();
} catch (e) {}
window.location.replace('/login');
})();
</script>
</head><body>Signing out&hellip;</body></html>"""
@router.post("/logout")
async def logout(request: Request):
resp = HTMLResponse(content=_LOGOUT_HTML)
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
_clear_pending_cookie(resp)
return resp

View file

@ -1,102 +0,0 @@
"""Email-related public routes.
Currently:
- GET /email/unsubscribe?token=...
The token is `itsdangerous.URLSafeSerializer` over a small payload,
signed with CASSANDRA_SESSION_SECRET. No auth dependency: the whole
point of one-click unsubscribe is that the user does not have to
sign in.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse
from itsdangerous import BadSignature, URLSafeSerializer
from sqlalchemy.ext.asyncio import AsyncSession
from app import branding
from app.config import get_settings
from app.db import get_session
from app.logging import get_logger
from app.models import User
router = APIRouter()
log = get_logger("email_router")
_SALT = "digest-unsubscribe-v1"
def _serializer() -> URLSafeSerializer:
s = get_settings()
if not s.CASSANDRA_SESSION_SECRET:
# In tests with no secret configured, fall back to a constant.
# An empty CASSANDRA_SESSION_SECRET in prod would also break login,
# so this branch is "best-effort dev fallback", not a real prod path.
return URLSafeSerializer("dev-only-empty-secret", salt=_SALT)
return URLSafeSerializer(s.CASSANDRA_SESSION_SECRET, salt=_SALT)
def sign_unsubscribe_token(user_id: int) -> str:
return _serializer().dumps({"uid": int(user_id), "purpose": "digest_optout"})
def verify_unsubscribe_token(token: str) -> int | None:
try:
data = _serializer().loads(token)
except BadSignature:
return None
if not isinstance(data, dict):
return None
if data.get("purpose") != "digest_optout":
return None
try:
return int(data["uid"])
except (KeyError, TypeError, ValueError):
return None
_CONFIRM_PAGE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Unsubscribed {brand}</title>
<link rel="stylesheet" href="/static/css/cassandra.css">
</head>
<body class="auth-shell">
<div class="auth-card" style="max-width:480px;">
<div class="auth-card__brand">{brand}</div>
<div class="auth-card__hint">email preferences</div>
<p class="auth-card__lede">You're unsubscribed from email digests.</p>
<p style="font-size:13px; color:var(--muted); line-height:1.6;">
You can re-enable digests any time from
<a href="/settings" style="color:var(--accent);">Settings</a>.
</p>
</div>
</body>
</html>
"""
@router.get("/email/unsubscribe", response_class=HTMLResponse)
async def unsubscribe(
request: Request,
token: str = Query(...),
session: AsyncSession = Depends(get_session),
):
uid = verify_unsubscribe_token(token)
if uid is not None:
user = await session.get(User, uid)
if user is not None and user.email_digest_opt_in:
user.email_digest_opt_in = False
await session.commit()
log.info("email.unsubscribe.ok", user_id=uid)
else:
log.info("email.unsubscribe.noop_or_unknown", user_id=uid)
else:
log.info("email.unsubscribe.bad_token")
# Same confirmation page regardless — don't leak token validity.
return HTMLResponse(_CONFIRM_PAGE.format(brand=branding.BRAND_NAME))

View file

@ -11,8 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUser, maybe_current_user, require_auth, require_token
from app.config import get_settings, load_groups
from app.db import get_session
from app.models import EmailSend, Referral, StrategicLog, User
from app.services.access import is_paid_active, paid_status
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
@ -37,8 +37,7 @@ async def root_page(
return templates.TemplateResponse(
request,
"dashboard.html",
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE,
"cu": cu, "paid": is_paid_active(cu)},
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu},
)
@ -75,40 +74,41 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
return datetime.now(timezone.utc).date()
def _log_page_context(target: date, paid: bool) -> dict:
def _log_page_context(target: date) -> dict:
s = get_settings()
return {
"selected_iso": target.isoformat(),
"selected_month": target.strftime("%Y-%m"),
"current_tone": s.CASSANDRA_TONE.upper(),
"current_analysis": s.CASSANDRA_ANALYSIS.upper(),
"paid": paid,
}
@router.get("/log", response_class=HTMLResponse)
@router.get(
"/log",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def log_page(
request: Request,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
):
target = await _resolve_log_date(session, None)
return templates.TemplateResponse(
request, "log.html", _log_page_context(target, is_paid_active(cu)),
)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
@router.get("/log/{day}", response_class=HTMLResponse)
@router.get(
"/log/{day}",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def log_page_day(
request: Request,
day: str,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
):
target = await _resolve_log_date(session, day)
return templates.TemplateResponse(
request, "log.html", _log_page_context(target, is_paid_active(cu)),
)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
@router.get("/settings", response_class=HTMLResponse)
@ -117,10 +117,9 @@ async def settings_page(
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_auth),
):
"""Per-user settings. Shows email, tier, Stripe subscription
management, email-digest preferences, cloud-sync status, portfolio
import, and the referral block (own code + invite link + counts of
pending / converted / actively-credited referrals)."""
"""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.
@ -133,9 +132,8 @@ async def settings_page(
# 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, how
# many converted (paid), and how many of those credit grants are
# still live (referrer-side bonus runway not yet expired).
# 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)
@ -146,57 +144,9 @@ async def settings_page(
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_not(None))
)).scalar() or 0
# An "active credit" is a conversion whose credit window hasn't yet
# expired for the REFERRED user. We approximate by counting
# conversions in the last REFERRAL_CREDIT_DAYS days — simpler than
# joining against the referred user's credit_until, and matches the
# marketing copy ("45 days of paid access each").
from datetime import timedelta
from app.services.referral_service import REFERRAL_CREDIT_DAYS
credit_horizon = datetime.now(timezone.utc) - timedelta(days=REFERRAL_CREDIT_DAYS)
active_credit_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.credited_at.is_not(None))
.where(Referral.credited_at >= credit_horizon)
)).scalar() or 0
# Days of credit the user themselves has on their own account (from
# any source: referrer bonus, admin grant, refund-as-credit). None
# if no credit or it has already expired.
own_credit_days: int | None = None
if user.credit_until is not None:
cu = user.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
delta = cu - datetime.now(timezone.utc)
if delta.total_seconds() > 0:
own_credit_days = max(1, -(-int(delta.total_seconds()) // 86400))
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
last_email_send = (await session.execute(
select(EmailSend)
.where(EmailSend.user_id == user.id)
.order_by(desc(EmailSend.sent_at))
.limit(1)
)).scalar_one_or_none()
# Trial countdown — when the Stripe subscription is in its 14-day
# trial, show "N days remaining" on the tier row. Computed here
# rather than in the template because Jinja's date arithmetic is
# painful, and we already have to handle MariaDB's tz-naive
# round-trip via _aware-style normalisation.
trial_days_remaining: int | None = None
if user.stripe_trial_end_at is not None:
end = user.stripe_trial_end_at
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
delta = end - datetime.now(timezone.utc)
if delta.total_seconds() > 0:
# Round up so the last hours of the trial still read "1 day".
trial_days_remaining = max(1, -(-int(delta.total_seconds()) // 86400))
return templates.TemplateResponse(
request, "settings.html",
{
@ -204,10 +154,6 @@ async def settings_page(
"invite_url": invite_url,
"pending_count": int(pending_count),
"converted_count": int(converted_count),
"active_credit_count": int(active_credit_count),
"own_credit_days": own_credit_days,
"paid": paid_status(user),
"last_email_send": last_email_send,
"trial_days_remaining": trial_days_remaining,
},
)

View file

@ -1,304 +0,0 @@
"""Polar (merchant-of-record) webhook endpoint.
Polar uses the Standard Webhooks spec (https://www.standardwebhooks.com).
Every delivery carries three headers:
webhook-id unique ID for THIS delivery (use for idempotency).
webhook-timestamp Unix seconds at send time (use for replay defence).
webhook-signature space-separated list of `v1,<base64-hmac-sha256>`
tokens. Verifying any one of them means the
payload is authentic.
The signed content is the literal string `{id}.{timestamp}.{body}`, signed
with the raw secret bytes (the secret is base64-encoded after the
`whsec_` prefix). We verify in constant time and reject anything that
doesn't match — including stale deliveries older than ±5 minutes — before
parsing JSON or touching the database.
Idempotency is keyed on `webhook-id` via a unique constraint on
`polar_events.event_id`. A second delivery of the same id finds the row
already there and returns 200 without re-running the handler Polar
will retry on non-2xx, so we must always 2xx after a successful first
processing.
The router is mounted without the app's bearer-token dependency: webhook
authenticity is established via the HMAC, not the token."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import PolarEvent, User
log = get_logger("polar_webhook")
router = APIRouter()
# Max clock skew we'll tolerate on the `webhook-timestamp` header. Standard
# Webhooks recommends ±5 min; anything older is almost certainly replay.
_TIMESTAMP_TOLERANCE_S = 300
# Cap stored payload at 16 KiB so a hostile (or buggy) sender can't blow
# up a single row.
_PAYLOAD_STORE_MAX = 16 * 1024
def _decode_secret(secret: str) -> bytes:
"""Polar/Standard-Webhooks secrets are base64 with a `whsec_` prefix.
Returns the raw HMAC key. Raises ValueError on malformed input."""
if not secret:
raise ValueError("empty webhook secret")
s = secret
if s.startswith("whsec_"):
s = s[len("whsec_"):]
return base64.b64decode(s)
def _compute_signature(key: bytes, signed_payload: str) -> str:
"""Return `v1,<base64>` — the format a single signature token uses."""
mac = hmac.new(key, signed_payload.encode("utf-8"), hashlib.sha256).digest()
return "v1," + base64.b64encode(mac).decode("ascii")
def verify_standard_webhook(
*,
secret: str,
msg_id: str,
msg_timestamp: str,
msg_signature: str,
body: bytes,
now: float | None = None,
) -> None:
"""Verify a Standard Webhooks delivery. Raises HTTPException(401) on
any failure. No return value success is "did not raise"."""
try:
key = _decode_secret(secret)
except (ValueError, base64.binascii.Error) as e:
raise HTTPException(status_code=500, detail=f"bad webhook secret: {e}")
# Timestamp / replay window.
try:
ts = int(msg_timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="invalid timestamp")
drift = abs((now if now is not None else time.time()) - ts)
if drift > _TIMESTAMP_TOLERANCE_S:
raise HTTPException(status_code=401, detail="stale timestamp")
signed_payload = f"{msg_id}.{msg_timestamp}.{body.decode('utf-8')}"
expected = _compute_signature(key, signed_payload)
# The header can carry several space-separated tokens (key rotation).
# Any match — in constant time — is success.
candidates = msg_signature.split()
if not any(hmac.compare_digest(expected, c) for c in candidates):
raise HTTPException(status_code=401, detail="bad signature")
# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------
def _customer_id_from_payload(payload_data: dict[str, Any]) -> str | None:
"""Polar nests the customer object under `customer`. Some events also
surface `customer_id` at the top of `data` accept either."""
cust = payload_data.get("customer") or {}
return cust.get("id") or payload_data.get("customer_id")
def _customer_email_from_payload(payload_data: dict[str, Any]) -> str | None:
cust = payload_data.get("customer") or {}
return cust.get("email")
async def _find_user(session: AsyncSession, data: dict[str, Any]) -> User | None:
"""Locate the User row that owns this event.
Strategy: join by stored Polar customer id first (the only stable
link once we've seen a user). Fall back to email — the first time
Polar fires an event for a brand-new customer, we won't have the id
yet, but the customer record on Polar's side was created with the
user's email by our checkout call."""
cid = _customer_id_from_payload(data)
if cid:
row = (await session.execute(
select(User).where(User.polar_customer_id == cid)
)).scalar_one_or_none()
if row is not None:
return row
email = _customer_email_from_payload(data)
if email:
row = (await session.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
return row
return None
async def _grant_paid(
session: AsyncSession, user: User, data: dict[str, Any],
) -> None:
"""Flip the user to the paid tier and persist the Polar IDs we now
know. Safe to call repeatedly: tier is idempotent and the IDs only
change if Polar issued new ones."""
user.tier = "paid"
cid = _customer_id_from_payload(data)
if cid and user.polar_customer_id != cid:
user.polar_customer_id = cid
sub_id = data.get("id") # subscription event payloads put sub id at top
if sub_id and user.polar_subscription_id != sub_id:
user.polar_subscription_id = sub_id
async def _revoke_paid(session: AsyncSession, user: User) -> None:
"""Drop the user back to the free tier. We deliberately leave the
polar_customer_id in place so a re-subscription matches them back to
the same row."""
user.tier = "free"
user.polar_subscription_id = None
async def _handle_subscription_active(
session: AsyncSession, data: dict[str, Any], event_type: str,
) -> None:
user = await _find_user(session, data)
if user is None:
log.warning("polar.user_not_found", event_type=event_type,
customer_id=_customer_id_from_payload(data))
return
await _grant_paid(session, user, data)
async def _handle_subscription_revoked(
session: AsyncSession, data: dict[str, Any], event_type: str,
) -> None:
user = await _find_user(session, data)
if user is None:
log.warning("polar.user_not_found", event_type=event_type,
customer_id=_customer_id_from_payload(data))
return
await _revoke_paid(session, user)
async def _handle_no_state_change(
session: AsyncSession, data: dict[str, Any], event_type: str,
) -> None:
"""For events we want to record in the audit table but where the
tier doesn't move — canceled (still active until period end),
uncanceled, past_due, order events, refund created. The PolarEvent
row is the record."""
return None
# Map event type → handler. Anything not in this map is acknowledged
# (200) but ignored, on the principle that Polar may add new event types
# over time and we don't want to start 4xx-ing on unknown ones.
_HANDLERS = {
"subscription.created": _handle_subscription_active,
"subscription.active": _handle_subscription_active,
"subscription.updated": _handle_subscription_active,
"subscription.uncanceled": _handle_subscription_active,
"subscription.canceled": _handle_no_state_change,
"subscription.revoked": _handle_subscription_revoked,
"subscription.past_due": _handle_no_state_change,
"order.paid": _handle_no_state_change,
"order.refunded": _handle_no_state_change,
"refund.created": _handle_no_state_change,
}
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.post("/api/polar/webhook")
async def polar_webhook(
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
s = get_settings()
if not s.POLAR_WEBHOOK_SECRET:
# Loud failure rather than accepting an unsigned event.
raise HTTPException(status_code=503, detail="webhook not configured")
msg_id = request.headers.get("webhook-id", "")
msg_ts = request.headers.get("webhook-timestamp", "")
msg_sig = request.headers.get("webhook-signature", "")
if not (msg_id and msg_ts and msg_sig):
raise HTTPException(status_code=400, detail="missing standard-webhooks headers")
body = await request.body()
verify_standard_webhook(
secret=s.POLAR_WEBHOOK_SECRET,
msg_id=msg_id,
msg_timestamp=msg_ts,
msg_signature=msg_sig,
body=body,
)
try:
envelope = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="invalid JSON")
event_type = envelope.get("type") or "unknown"
data = envelope.get("data") or {}
# Idempotency: insert the audit row first. If the webhook-id was
# already delivered, the UNIQUE constraint short-circuits with a
# 200 (Polar will stop retrying).
body_text = body.decode("utf-8", errors="replace")[:_PAYLOAD_STORE_MAX]
audit = PolarEvent(
event_id=msg_id,
event_type=event_type,
received_at=utcnow(),
payload=body_text,
)
session.add(audit)
try:
await session.flush()
except IntegrityError:
# Already processed — return 200 so Polar doesn't keep retrying.
await session.rollback()
log.info("polar.duplicate_delivery", event_id=msg_id, type=event_type)
return {"status": "duplicate"}
handler = _HANDLERS.get(event_type)
if handler is None:
# Unknown but well-signed event — record it, ack 200.
audit.processed_at = utcnow()
await session.commit()
log.info("polar.event_unhandled", type=event_type, id=msg_id)
return {"status": "ignored"}
try:
await handler(session, data, event_type)
except Exception as e:
# Mark as errored so an operator can see what's stuck, then
# commit + ack 200. We do NOT want Polar to retry an event that
# broke handler logic — the same code will break the same way.
# Operator gets paged from the error column instead.
audit.error = str(e)[:1024]
await session.commit()
log.exception("polar.handler_error", type=event_type, id=msg_id)
return {"status": "handler_error"}
audit.processed_at = utcnow()
await session.commit()
log.info("polar.processed", type=event_type, id=msg_id)
return {"status": "ok"}

View file

@ -15,7 +15,6 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from app.auth import CurrentUser, maybe_current_user
from app.services.access import is_paid_active
from app.templates_env import templates
@ -34,9 +33,7 @@ async def pricing_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
ctx = _ctx(request, cu)
ctx["paid"] = is_paid_active(cu)
return templates.TemplateResponse(request, "pricing.html", ctx)
return templates.TemplateResponse(request, "pricing.html", _ctx(request, cu))
@router.get("/about", response_class=HTMLResponse)

View file

@ -1,431 +0,0 @@
"""Stripe billing endpoints — checkout, webhook, customer portal.
Stripe is the merchant-on-record for read.markets (after Polar/Paddle
both declined the financial-media category). We delegate payment UI to
Stripe-hosted Checkout and Customer Portal; the only state we keep on
our side is `users.stripe_customer_id` / `users.stripe_subscription_id`
so we can match incoming webhooks back to the right user.
The Stripe SDK is sync; we wrap calls in `asyncio.to_thread` so the
event loop doesn't block while Stripe answers. For our request volume
this is more reliable than the SDK's nascent async surface.
Routes
- POST /api/stripe/checkout logged-in user upgrades. Body: {cadence}.
- POST /api/stripe/webhook Stripe us, signature-verified.
- POST /api/stripe/portal logged-in user opens the customer portal.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any, Literal
import stripe
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app import branding
from app.auth import CurrentUser, require_auth
from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import StripeEvent, User
log = get_logger("stripe_billing")
router = APIRouter()
# Cap stored payload at 16 KiB so a hostile (or buggy) sender can't
# blow up a single row. Same pattern as polar_webhook.
_PAYLOAD_STORE_MAX = 16 * 1024
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_configured() -> None:
s = get_settings()
if not s.STRIPE_API_KEY:
raise HTTPException(status_code=503, detail="stripe not configured")
def _price_for(cadence: str) -> str:
s = get_settings()
if cadence == "monthly":
if not s.STRIPE_PRICE_MONTHLY:
raise HTTPException(status_code=503, detail="STRIPE_PRICE_MONTHLY not set")
return s.STRIPE_PRICE_MONTHLY
if cadence == "annual":
if not s.STRIPE_PRICE_ANNUAL:
raise HTTPException(status_code=503, detail="STRIPE_PRICE_ANNUAL not set")
return s.STRIPE_PRICE_ANNUAL
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
def _stripe_client() -> stripe.StripeClient:
"""Per-call client so we read the secret at request time (lets us
rotate the key by editing .env + reloading without rebuilding any
cached client)."""
return stripe.StripeClient(get_settings().STRIPE_API_KEY)
# ---------------------------------------------------------------------------
# POST /api/stripe/checkout
# ---------------------------------------------------------------------------
class CheckoutRequest(BaseModel):
cadence: Literal["monthly", "annual"]
class CheckoutResponse(BaseModel):
url: str
@router.post("/api/stripe/checkout", response_model=CheckoutResponse)
async def create_checkout(
body: CheckoutRequest,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
) -> CheckoutResponse:
_require_configured()
if cu.user is None:
# Admin bearer token has no User row — they shouldn't be buying.
raise HTTPException(status_code=400, detail="admin token cannot purchase")
user = await session.get(User, cu.user.id)
if user is None:
raise HTTPException(status_code=404, detail="user_not_found")
price_id = _price_for(body.cadence)
client = _stripe_client()
# Pass `customer` if we already minted one for this user (avoids
# creating duplicate Stripe customers on repeat checkouts);
# otherwise let Stripe create it via `customer_email`.
create_kwargs: dict[str, Any] = {
"mode": "subscription",
"line_items": [{"price": price_id, "quantity": 1}],
"client_reference_id": str(user.id),
"success_url": f"{branding.SITE_URL}/settings?upgraded=1",
"cancel_url": f"{branding.SITE_URL}/pricing",
# Lets us paste in a referral coupon at checkout once the
# referral redemption flow ships.
"allow_promotion_codes": True,
}
# Per-cadence cooling-off treatment:
#
# - Annual gets a 14-day free trial. No money moves during the
# trial, so the Consumer Contracts Regulations 14-day refund
# question is moot (nothing paid = nothing to refund). Card is
# still required at checkout so Stripe can charge on day 15.
#
# - Monthly bills immediately (a 14-day trial on a £7/month plan
# would give away ~50% of cycle one). The Reg-36 waiver lives
# on our own /pricing page as a required tick-box (see
# pricing.html); we deliberately do NOT use Stripe's
# consent_collection.terms_of_service here because that's an
# account-wide setting and we want per-product control (and
# per-product Terms URLs) as we grow.
if body.cadence == "annual":
create_kwargs["subscription_data"] = {"trial_period_days": 14}
if user.stripe_customer_id:
create_kwargs["customer"] = user.stripe_customer_id
else:
create_kwargs["customer_email"] = user.email
try:
sess = await asyncio.to_thread(
client.checkout.sessions.create, params=create_kwargs,
)
except stripe.StripeError as e:
log.error("stripe.checkout.create_failed", user_id=user.id, error=str(e))
raise HTTPException(status_code=502, detail=f"stripe error: {e.user_message or str(e)}")
if not sess.url:
raise HTTPException(status_code=502, detail="stripe returned no checkout URL")
log.info("stripe.checkout.created", user_id=user.id, session_id=sess.id,
cadence=body.cadence)
return CheckoutResponse(url=sess.url)
# ---------------------------------------------------------------------------
# POST /api/stripe/portal
# ---------------------------------------------------------------------------
class PortalResponse(BaseModel):
url: str
@router.post("/api/stripe/portal", response_model=PortalResponse)
async def create_portal_session(
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
) -> PortalResponse:
_require_configured()
if cu.user is None:
raise HTTPException(status_code=400, detail="admin token has no portal")
user = await session.get(User, cu.user.id)
if user is None or not user.stripe_customer_id:
raise HTTPException(
status_code=404,
detail="no_stripe_customer — start a subscription first",
)
client = _stripe_client()
try:
portal = await asyncio.to_thread(
client.billing_portal.sessions.create,
params={
"customer": user.stripe_customer_id,
"return_url": f"{branding.SITE_URL}/settings",
},
)
except stripe.StripeError as e:
log.error("stripe.portal.create_failed", user_id=user.id, error=str(e))
raise HTTPException(status_code=502, detail=f"stripe error: {e.user_message or str(e)}")
return PortalResponse(url=portal.url)
# ---------------------------------------------------------------------------
# POST /api/stripe/webhook
# ---------------------------------------------------------------------------
async def _find_user(
session: AsyncSession,
*,
client_ref: str | None = None,
customer_id: str | None = None,
) -> User | None:
"""Find the User row this event belongs to.
`client_reference_id` is the most reliable join key we set it
to `str(user.id)` at checkout creation. After the first event we
also know `stripe_customer_id`, which subsequent subscription /
invoice events arrive carrying."""
if client_ref:
try:
uid = int(client_ref)
except ValueError:
uid = None
if uid is not None:
u = await session.get(User, uid)
if u is not None:
return u
if customer_id:
row = (await session.execute(
select(User).where(User.stripe_customer_id == customer_id)
)).scalar_one_or_none()
return row
return None
async def _grant_paid(
session: AsyncSession,
user: User,
*,
customer_id: str | None,
subscription_id: str | None,
trial_end: int | None = None,
status: str | None = None,
) -> None:
# Capture "first paid transition" before mutating — drives the
# referral-conversion call below. Skipping the convert lookup on
# every renewal event saves a DB roundtrip per webhook.
first_paid_transition = user.tier != "paid"
user.tier = "paid"
if customer_id and user.stripe_customer_id != customer_id:
user.stripe_customer_id = customer_id
if subscription_id and user.stripe_subscription_id != subscription_id:
user.stripe_subscription_id = subscription_id
# Track trial_end so the settings page can show "N days remaining".
# Only populated when Stripe reports the sub as trialing — once the
# status flips to active (paid for real), we clear the trial marker.
if status == "trialing" and trial_end:
from datetime import datetime, timezone
user.stripe_trial_end_at = datetime.fromtimestamp(trial_end, tz=timezone.utc)
elif status == "active":
user.stripe_trial_end_at = None
# Apply referral credit on the FIRST paid transition only.
# convert_referral is itself idempotent (no-op on missing or
# already-converted rows), so this guard is purely a perf hint.
if first_paid_transition:
from app.services.referral_service import convert_referral
await convert_referral(session, user)
async def _revoke_paid(user: User) -> None:
user.tier = "free"
user.stripe_subscription_id = None
user.stripe_trial_end_at = None
# Keep stripe_customer_id so a re-subscription matches this row.
async def _handle_checkout_completed(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
user = await _find_user(
session,
client_ref=obj.get("client_reference_id"),
customer_id=obj.get("customer"),
)
if user is None:
log.warning("stripe.user_not_found", event_type=event_type)
return
# checkout.session.completed doesn't carry trial_end on the session
# object itself — the subscription.created event that fires right
# after will carry it. We grant paid here without trial info and
# let the subscription event fill in trial_end_at moments later.
await _grant_paid(
session,
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("subscription"),
)
async def _handle_subscription_event(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
"""customer.subscription.created / .updated — flip to paid if the
Stripe-side status says the subscription is active/trialing; drop
to free if it's an end-state."""
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
status = obj.get("status")
# Stripe statuses: trialing, active, past_due, canceled, unpaid,
# incomplete, incomplete_expired, paused. Treat trialing/active as
# paid; everything else holds tier the same until we get an explicit
# subscription.deleted (which fires after the final state lands).
if status in ("trialing", "active"):
await _grant_paid(
session,
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("id"),
trial_end=obj.get("trial_end"),
status=status,
)
async def _handle_subscription_deleted(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
await _revoke_paid(user)
async def _handle_audit_only(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
"""invoice.paid / invoice.payment_failed / charge.refunded — we
record these in stripe_events for the audit log but the tier doesn't
move until subscription.deleted fires."""
return None
_HANDLERS = {
"checkout.session.completed": _handle_checkout_completed,
"customer.subscription.created": _handle_subscription_event,
"customer.subscription.updated": _handle_subscription_event,
"customer.subscription.deleted": _handle_subscription_deleted,
"invoice.paid": _handle_audit_only,
"invoice.payment_failed": _handle_audit_only,
"charge.refunded": _handle_audit_only,
}
@router.post("/api/stripe/webhook")
async def stripe_webhook(
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
s = get_settings()
if not s.STRIPE_WEBHOOK_SECRET:
raise HTTPException(status_code=503, detail="stripe webhook not configured")
sig = request.headers.get("stripe-signature", "")
if not sig:
raise HTTPException(status_code=400, detail="missing stripe-signature header")
body = await request.body()
# construct_event handles HMAC verification + timestamp tolerance.
# We then re-parse the body as plain JSON for handler dispatch —
# the Stripe SDK's StripeObject doesn't expose dict.get(), and
# round-tripping through json gives us simple, typed-dict access.
try:
stripe.Webhook.construct_event(
payload=body, sig_header=sig, secret=s.STRIPE_WEBHOOK_SECRET,
)
except stripe.SignatureVerificationError:
raise HTTPException(status_code=401, detail="bad signature")
except ValueError:
raise HTTPException(status_code=400, detail="invalid payload")
envelope = json.loads(body)
event_id = envelope.get("id") or ""
event_type = envelope.get("type") or "unknown"
obj = (envelope.get("data") or {}).get("object") or {}
if not event_id:
raise HTTPException(status_code=400, detail="event missing id")
# Idempotency: insert audit row first. UNIQUE on event_id makes a
# replay of the same Stripe event id a no-op (Stripe retries on
# non-2xx, so always 2xx after first successful processing).
audit = StripeEvent(
event_id=event_id,
event_type=event_type,
received_at=utcnow(),
payload=body.decode("utf-8", errors="replace")[:_PAYLOAD_STORE_MAX],
)
session.add(audit)
try:
await session.flush()
except IntegrityError:
await session.rollback()
log.info("stripe.duplicate_delivery", event_id=event_id, type=event_type)
return {"status": "duplicate"}
handler = _HANDLERS.get(event_type)
if handler is None:
audit.processed_at = utcnow()
await session.commit()
log.info("stripe.event_unhandled", type=event_type, id=event_id)
return {"status": "ignored"}
try:
await handler(session, event_type, obj)
except Exception as e:
audit.error = str(e)[:1024]
await session.commit()
log.exception("stripe.handler_error", type=event_type, id=event_id)
# Ack 200 — we don't want Stripe retrying a handler that broke
# the same way on every delivery. An operator triages from the
# `error` column.
return {"status": "handler_error"}
audit.processed_at = utcnow()
await session.commit()
log.info("stripe.processed", type=event_type, id=event_id)
return {"status": "ok"}

View file

@ -43,7 +43,6 @@ class SyncBlobOut(BaseModel):
class SyncStatusOut(BaseModel):
exists: bool
orphaned: bool = False
updated_at: datetime | None = None
@ -74,8 +73,8 @@ async def get_status(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncStatusOut:
exists, orphaned, updated_at = await svc.fetch_status(session, principal.id)
return SyncStatusOut(exists=exists, orphaned=orphaned, updated_at=updated_at)
exists, updated_at = await svc.fetch_status(session, principal.id)
return SyncStatusOut(exists=exists, updated_at=updated_at)
@router.post("", response_model=SyncWriteOut)
@ -109,15 +108,6 @@ async def download_blob(
)
try:
result = await svc.fetch(session, principal.id)
except svc.SyncOrphanedError:
# Known state: pepper rotated. The frontend uses 410 to swap the
# restore form for a "stale — re-upload" CTA. Logged at INFO,
# not ERROR, because this isn't a server fault.
log.info("portfolio_sync.orphaned", user_id=principal.id)
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="stale_blob",
)
except svc.SyncCryptoError:
log.error("portfolio_sync.unwrap_failed", user_id=principal.id)
raise HTTPException(

View file

@ -1,156 +0,0 @@
"""Per-ticker validation + historical-price endpoints.
These power the dashboard's "Add a position" form. Neither endpoint
persists holdings they wrap the existing Yahoo chart fetcher and
optionally seed anonymous ticker_universe (validate only).
Both endpoints are gated behind ``require_paid`` so they match the rest
of the import surface.
"""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_auth
from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import Quote as QuoteModel
from app.services.access import require_paid
from app.services.market import (
UA, YAHOO_CHART, Quote, _yahoo_range_covering, fetch_yahoo,
)
from app.services.ticker_universe import upsert_tickers
log = get_logger("ticker_validate")
router = APIRouter(dependencies=[Depends(require_auth)])
@router.get(
"/ticker/validate",
dependencies=[Depends(require_paid)],
)
async def validate_ticker(
symbol: str,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Live quote for one ticker.
Returns ``{ok: true, symbol, price, currency, as_of}`` on success
or ``{ok: false, error}`` when the symbol isn't recognised. Seeds
ticker_universe + writes a Quote row as a side-effect on success
so the dashboard's /api/universe call picks it up on the next
refresh."""
symbol = symbol.strip().upper()[:32]
if not symbol:
return {"ok": False, "error": "symbol required"}
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
quote = await fetch_yahoo(client, symbol, symbol, "")
if quote.error or quote.price is None:
log.info("ticker.validate.miss", symbol=symbol,
error=(quote.error or "no price")[:120])
return {"ok": False, "error": "Symbol not recognised"}
# Side-effect: seed the universe + write the quote so /api/universe
# has data on the next minute-cycle refresh.
await upsert_tickers(session, [symbol])
session.add(QuoteModel(
symbol=quote.symbol, source=quote.source, label=quote.label,
group_name="universe", price=quote.price, currency=quote.currency,
as_of=quote.as_of, changes=quote.changes or None, error=None,
fetched_at=utcnow(),
))
await session.commit()
return {
"ok": True,
"symbol": quote.symbol,
"price": quote.price,
"currency": quote.currency,
"as_of": quote.as_of,
}
async def fetch_yahoo_historical(
client: httpx.AsyncClient,
symbol: str,
target_iso: str,
) -> tuple[float | None, str | None, str | None]:
"""Fetch the close on ``target_iso`` or the nearest preceding trading
day's close (within the available history window).
Returns ``(close, currency, actual_iso)`` or ``(None, None, None)``
when no usable data exists. Raises on provider-level HTTP errors
(the caller wraps these into a friendly ``ok:false`` response).
"""
range_param = _yahoo_range_covering(target_iso)
r = await client.get(
YAHOO_CHART.format(symbol=symbol),
params={"interval": "1d", "range": range_param,
"includePrePost": "false"},
headers=UA,
timeout=15,
)
r.raise_for_status()
result = r.json().get("chart", {}).get("result")
if not result:
return None, None, None
res = result[0]
currency = (res.get("meta") or {}).get("currency")
timestamps = res.get("timestamp") or []
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
if not series:
return None, None, None
target_dt = datetime.strptime(target_iso, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# Add a 24h buffer so the target day itself is included (Yahoo
# timestamps are at market open, not midnight).
cutoff_ts = int(target_dt.timestamp()) + 86400
selected: tuple[int, float] | None = None
for t, c in series:
if t <= cutoff_ts:
selected = (t, c)
else:
break
if selected is None:
return None, None, None
actual_iso = datetime.fromtimestamp(selected[0], timezone.utc).strftime("%Y-%m-%d")
return selected[1], currency, actual_iso
@router.get(
"/ticker/historical",
dependencies=[Depends(require_paid)],
)
async def get_historical(symbol: str, date: str) -> dict:
"""Historical daily close. If ``date`` is a non-trading day we walk
back to the last preceding trading day and surface ``actual_date``
so the UI can show the user which date we actually used."""
symbol = symbol.strip().upper()[:32]
if not symbol:
return {"ok": False, "error": "symbol required"}
try:
target = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="invalid date format (YYYY-MM-DD)")
if target > datetime.now(timezone.utc).date():
raise HTTPException(status_code=400, detail="date cannot be in the future")
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
try:
close, currency, actual = await fetch_yahoo_historical(client, symbol, date)
except Exception as e:
log.warning("ticker.historical.failed", symbol=symbol,
date=date, error=str(e)[:200])
return {"ok": False, "error": "Couldn't fetch historical price"}
if close is None:
return {"ok": False, "error": "No data for that date"}
return {"ok": True, "close": close, "currency": currency, "actual_date": actual}

View file

@ -189,7 +189,7 @@ async def get_sparkline(
# ---------------------------------------------------------------------------
@router.post("/portfolio/parse", dependencies=[Depends(require_paid)])
@router.post("/portfolio/parse")
async def parse_portfolio(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
@ -210,57 +210,27 @@ async def parse_portfolio(
try:
pie = parse_t212_csv(raw)
except CSVImportError:
# Unrecognised format — try the LLM-fallback parser. It hits a
# global format-fingerprint cache first; only the very first
# upload of each broker format pays an LLM call.
from app.services.llm_csv_parser import LLMParseError, parse_with_llm
try:
pie = await parse_with_llm(raw, session)
except LLMParseError as e:
raise HTTPException(status_code=400, detail=str(e))
except CSVImportError as e:
raise HTTPException(status_code=400, detail=str(e))
positions_out: list[dict] = []
yahoo_tickers: list[str] = []
unmapped: list[str] = []
for p in pie.positions:
if pie.tickers_resolved:
# LLM path: ``p.slice`` is already a Yahoo-ready ticker. We
# still do a best-effort InstrumentMap lookup so we can use
# the canonical name + currency when we happen to have one;
# but unlike the T212 path we never *drop* a position just
# because resolve_slice missed.
yahoo_ticker = p.slice.strip().upper()
if not yahoo_ticker:
unmapped.append(p.name or "?")
continue
resolved = await resolve_slice(session, yahoo_ticker)
name = (resolved.name if resolved else None) or p.name
currency = (
(resolved.currency if resolved else None)
or p.currency or "USD"
)
else:
# T212 path: ``p.slice`` is a shortcode that MUST round-trip
# through the InstrumentMap. Drop unmapped positions — the
# warnings block surfaces them to the user.
resolved = await resolve_slice(session, p.slice)
if resolved is None or not resolved.yahoo_ticker:
unmapped.append(p.slice or p.name or "?")
continue
yahoo_ticker = resolved.yahoo_ticker
name = resolved.name or p.name
currency = resolved.currency
resolved = await resolve_slice(session, p.slice)
if resolved is None or not resolved.yahoo_ticker:
unmapped.append(p.slice or p.name or "?")
continue
positions_out.append({
"yahoo_ticker": yahoo_ticker,
"yahoo_ticker": resolved.yahoo_ticker,
"t212_slice": p.slice,
"name": name,
"name": resolved.name or p.name,
"qty": p.quantity,
"avg_cost": p.average_price, # @property — no call parens
"currency": currency,
"currency": resolved.currency,
})
yahoo_tickers.append(yahoo_ticker)
yahoo_tickers.append(resolved.yahoo_ticker)
# Synchronous upsert: bypass the Redis buffer so the dashboard has
# live prices immediately. The buffer + flush machinery remains for
@ -351,7 +321,7 @@ async def analyze_portfolio(
is persisted. The ai_calls ledger row records tokens + cost, never
holdings.
Gated behind ``require_paid``: free-tier users get 402.
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.

View file

@ -13,7 +13,7 @@ from app.db import get_engine
from app.logging import configure_logging, get_logger
from app.jobs import (
market_job, news_job, ai_log_job, rollup_job,
indicator_summary_job, universe_flush_job, email_digest_job,
indicator_summary_job, universe_flush_job,
)
@ -58,11 +58,6 @@ async def main() -> None:
sched.add_job(universe_flush_job.evict_run,
CronTrigger(hour=0, minute=15),
name="universe_evict_job", id="universe_evict_job")
# Editorial email digests: daily Mon-Sat for paid opt-in, weekly Sunday
# recap for everyone opt-in. Job decides which kind based on weekday.
sched.add_job(email_digest_job.run,
CronTrigger(hour=6, minute=30),
name="email_digest_job", id="email_digest_job")
sched.start()
log.info("scheduler.started", jobs=[j.id for j in sched.get_jobs()])

View file

@ -2,12 +2,11 @@
Two sources can grant paid access:
1. ``user.tier in {"paid", "enterprise"}`` set by the Stripe webhook
once a subscription is active.
2. ``user.credit_until > now()`` non-subscription credit. Populated
by the admin CLI (``python -m app.cli grant-credit``) and by the
referral-conversion path (45 days per converted referral, both
parties).
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"
@ -23,17 +22,6 @@ from fastapi import Depends, HTTPException, status
from app.auth import CurrentUser, require_auth
from app.models import User
# How many hours of news the free tier sees. Paid sees whatever the
# endpoint's `since_hours` param requests (up to its own max).
FREE_NEWS_WINDOW_HOURS = 6.0
# The strategic-log job runs at :20 every hour (during trading windows).
# Free-tier users only see logs generated at these UTC hours — so the
# log refreshes for them roughly every 6 hours (00:20, 06:20, 12:20,
# 18:20). Paid users see the absolute latest log. Filtering happens
# read-side; we don't generate per-tier rows.
FREE_LOG_HOURS_UTC: tuple[int, ...] = (0, 6, 12, 18)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)

View file

@ -37,10 +37,7 @@ _REQUIRED_FIELDS = ("slice", "quantity")
@dataclass(frozen=True)
class ParsedPosition:
slice: str # T212 shortcode (e.g. "SGLN") or a
# Yahoo-ready ticker (e.g. "VOD.L")
# when produced by the LLM path —
# see ParsedPie.tickers_resolved.
slice: str # T212 shortcode, e.g. "SGLN"
name: str
invested_value: float | None
current_value: float | None
@ -49,10 +46,6 @@ class ParsedPosition:
dividends_gained: float | None = None
dividends_cash: float | None = None
dividends_reinvested: float | None = None
currency: str | None = None # Populated by the LLM path from the
# mapped currency_col; the T212 path
# leaves it None and gets currency
# from the InstrumentMap row.
@property
def average_price(self) -> float | None:
@ -74,11 +67,6 @@ class ParsedPie:
invested: float | None # totals from the Total row
value: float | None
result: float | None
tickers_resolved: bool = False # True when ``slice`` on each position
# is already a Yahoo-ready ticker
# (LLM path). False (default) means
# tickers must still be resolved via
# the T212 InstrumentMap.
def _normalise_header(h: str) -> str:

View file

@ -18,8 +18,6 @@ convenient for local dev that doesn't want a mail server configured.
"""
from __future__ import annotations
import html as _html_lib
import re as _re
from email.message import EmailMessage
import aiosmtplib
@ -198,231 +196,3 @@ def render_otp_email(code: str, ttl_minutes: int) -> tuple[str, str, str]:
async def send_otp(to: str, code: str, ttl_minutes: int) -> None:
subject, text, html = render_otp_email(code, ttl_minutes)
await send_email(to, subject, text, html_body=html)
# ---------------------------------------------------------------------------
# Welcome email — sent once on first successful login.
# ---------------------------------------------------------------------------
_WELCOME_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Welcome to {brand}</title>
<style>
@media (prefers-color-scheme: dark) {{
body {{ background:{D_bg} !important; }}
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
.h1 {{ color:{D_text} !important; }}
.muted {{ color:{D_muted} !important; }}
.lead {{ color:{D_text} !important; }}
.divider {{ border-color:{D_border} !important; }}
a {{ color:{D_accent} !important; }}
}}
@media (max-width: 540px) {{
.card {{ padding:24px 18px !important; }}
}}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text}; -webkit-font-smoothing:antialiased;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
&#9648;&nbsp;{brand_upper}
</div>
<div style="height:22px; line-height:22px; font-size:0;">&nbsp;</div>
<div class="h1" style="font-size:17px; font-weight:normal; color:{L_text}; letter-spacing:0.02em;">
Welcome to {brand}.
</div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
You&rsquo;re signed in. The dashboard is at
<a href="{app_url}" style="color:{L_accent}; text-decoration:none;">{app_url_short}</a> &mdash;
a rolling news feed, cross-asset indicator panels, and a written
strategic read of the session, all updated through the day.
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="divider" style="border-top:1px solid {L_border};"></div>
<div style="height:18px; line-height:18px; font-size:0;">&nbsp;</div>
<div class="h1" style="font-size:14px; font-weight:normal; color:{L_text};">
About the email digest
</div>
<div style="height:10px; line-height:10px; font-size:0;">&nbsp;</div>
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
We send one Sunday digest to every account &mdash; the week behind +
the week ahead. Paid subscribers also get a short daily digest
(Mon&ndash;Sat), each a ~600-word read of the session.
You&rsquo;re opted in by default; you can switch the digest off
at any time on the
<a href="{settings_url}" style="color:{L_accent}; text-decoration:none;">Settings page</a>,
or use the one-click unsubscribe link in every digest email.
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="divider" style="border-top:1px solid {L_border};"></div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
Sent automatically by {brand} &middot; do not reply
</div>
</td></tr>
</table>
</body>
</html>
"""
_WELCOME_TEXT_TEMPLATE = """\
{brand_upper} welcome
You're signed in. The dashboard is at {app_url}: a rolling news feed,
cross-asset indicator panels, and a written strategic read of the
session, all updated through the day.
About the email digest
----------------------
We send one Sunday digest to every account (the week behind + the
week ahead). Paid subscribers also get a short daily digest (Mon-Sat),
each a ~600-word read of the session.
You're opted in by default; switch it off any time at {settings_url},
or use the one-click unsubscribe link in every digest email.
Sent automatically by {brand} · do not reply
"""
def render_welcome_email() -> tuple[str, str, str]:
"""Returns (subject, text_body, html_body) for the post-signup welcome.
Single-shot email, sent the first time a user successfully verifies
an OTP. Explains the digest (which is opt-in by default) and how to
turn it off replaces the old verify-page checkbox which appeared
on every login and was misleading."""
subject = f"Welcome to {branding.BRAND_NAME}"
fmt = dict(
brand=branding.BRAND_NAME,
brand_upper=branding.BRAND_NAME.upper(),
app_url=branding.APP_URL,
app_url_short=branding.APP_URL.replace("https://", "").replace("http://", ""),
settings_url=f"{branding.APP_URL}/settings",
FONT_MONO=branding.FONT_MONO,
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
)
text = _WELCOME_TEXT_TEMPLATE.format(**fmt)
html = _WELCOME_HTML_TEMPLATE.format(**fmt)
return subject, text, html
async def send_welcome_email(to: str) -> None:
subject, text, html = render_welcome_email()
await send_email(to, subject, text, html_body=html)
# ---------------------------------------------------------------------------
# Digest email rendering
# ---------------------------------------------------------------------------
_DIGEST_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{brand} {label}</title>
<style>
@media (prefers-color-scheme: dark) {{
body {{ background:{D_bg} !important; }}
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
.h1, p, li {{ color:{D_text} !important; }}
.muted {{ color:{D_muted} !important; }}
a {{ color:{D_accent} !important; }}
}}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
&#9648;&nbsp;{brand_upper} &middot; {label_upper}
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="content" style="font-size:14px; line-height:1.65; color:{L_text};">
{content_html}
</div>
<div style="height:24px; line-height:24px; font-size:0;">&nbsp;</div>
<div style="border-top:1px solid {L_border};"></div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:11px; color:{L_muted};">
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
&middot; <a href="{settings_url}" style="color:{L_accent};">Manage preferences</a>
</div>
</td></tr>
</table>
</body>
</html>
"""
def _strip_html_to_text(html_body: str) -> str:
"""Best-effort HTML → plain text for the multipart fallback. We don't
need perfection just readable prose for clients that won't render
HTML."""
text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body)
text = _re.sub(r"<[^>]+>", "", text)
text = _html_lib.unescape(text)
text = _re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def render_digest_email(
*,
kind: str,
date_str: str,
content_html: str,
unsubscribe_url: str,
settings_url: str,
) -> tuple[str, str, str]:
"""Returns (subject, text_body, html_body) for a digest email.
`kind` is "daily" or "weekly". Anything else raises ValueError."""
if kind == "daily":
label = "Daily"
subject = f"{branding.BRAND_NAME} · Daily — {date_str}"
elif kind == "weekly":
label = "Weekly recap"
subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}"
else:
raise ValueError(f"unknown digest kind: {kind!r}")
html_body = _DIGEST_HTML_TEMPLATE.format(
brand=branding.BRAND_NAME,
brand_upper=branding.BRAND_NAME.upper(),
label=label,
label_upper=label.upper(),
FONT_MONO=branding.FONT_MONO,
content_html=content_html,
unsubscribe_url=unsubscribe_url,
settings_url=settings_url,
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
)
text_lines = [
f"{branding.BRAND_NAME}{label}",
date_str,
"",
_strip_html_to_text(content_html),
"",
f"Unsubscribe: {unsubscribe_url}",
f"Manage preferences: {settings_url}",
]
text_body = "\n".join(text_lines)
return subject, text_body, html_body

View file

@ -1,431 +0,0 @@
"""LLM-fallback CSV parser.
When the deterministic Trading 212 parser (``csv_import.parse_t212_csv``)
raises ``CSVImportError`` on an unrecognised format, this service kicks
in:
1. Detect the CSV dialect (delimiter, preamble offset).
2. Compute a fingerprint of the normalised header row.
3. Look up ``CsvFormatTemplate`` by fingerprint. On hit, replay the
cached column-mapping deterministically. On miss, ask the LLM for a
mapping, validate it, persist a new template, and apply it.
The LLM sees only headers + the first 3-5 sample rows. It returns a
column-mapping JSON, never transcribed numbers. The system never
auto-promotes a learned format to a hand-written parser the operator
does that by inspecting collected ``sample_row`` values.
"""
from __future__ import annotations
import csv
import hashlib
import io
import json
import httpx
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 CsvFormatTemplate
from app.services.csv_import import CSVImportError, ParsedPie, ParsedPosition
from app.services.openrouter import LogResult, call_llm
# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------
# Cap for how many leading lines we'll scan looking for the header row.
# Real broker preambles are typically 1-10 lines.
_MAX_PREAMBLE_SCAN = 30
# Number of sample rows to send to the LLM and max token budget for the reply.
_LLM_SAMPLES = 5
_LLM_MAX_TOKENS = 400
# Required and optional keys in the LLM-returned column mapping.
_REQUIRED_MAPPING_KEYS = ("ticker_col", "qty_col")
_OPTIONAL_MAPPING_KEYS = ("name_col", "cost_col", "currency_col")
# Maximum CSV payload size accepted by parse_with_llm.
_MAX_CSV_BYTES = 1_048_576
log = get_logger("llm_csv_parser")
_SYSTEM_PROMPT = """\
You are an expert at recognising broker portfolio CSV formats.
You will be given the header row and 3-5 sample data rows from a CSV.
Identify which column contains each field. Return ONLY a single JSON
object, no prose, no markdown fences.
Schema (use the EXACT header string from the input; use null if no
column is a good match):
{
"ticker_col": "<header name or null>",
"qty_col": "<header name or null>",
"name_col": "<header name or null>",
"cost_col": "<header name or null>", // average price per share
"currency_col": "<header name or null>",
"broker_label": "<short identifier like 'IBKR Activity Statement' or null>"
}
Rules:
- ticker_col and qty_col are required. If either is missing, return all nulls.
- Use the EXACT header string as it appears in the input do not paraphrase.
- Output JSON ONLY. No prose, no code fences.
"""
class LLMParseError(CSVImportError):
"""Raised when the LLM call fails or returns an unusable mapping.
Inherits from ``CSVImportError`` so route-level error handling can
treat both deterministic and LLM-path failures uniformly when
desired."""
def _fingerprint(headers: list[str]) -> str:
"""Stable hash of the header row.
Lowercases each header, strips surrounding whitespace, joins with
``|`` (a character extremely unlikely to appear inside a real
header), and returns the sha256 hex digest. Whitespace/case drift
in the same broker's export does not change the fingerprint;
adding or removing a column does."""
normalised = "|".join(h.strip().lower() for h in headers)
return hashlib.sha256(normalised.encode("utf-8")).hexdigest()
def _decode_raw(raw: bytes) -> str:
"""Best-effort UTF-8 decode with BOM strip and lossy fallback."""
return raw.decode("utf-8-sig", errors="replace")
def _looks_numeric(value: str) -> bool:
"""True if ``value`` parses as a number after stripping common
decoration (thousands separators, currency symbols, percent signs)."""
s = value.strip().replace(",", "").replace("$", "").replace("", "")
s = s.replace("£", "").replace("%", "").lstrip("-+")
if not s:
return False
try:
float(s)
return True
except ValueError:
return False
def _detect_dialect(raw: bytes) -> tuple[str, int]:
"""Detect (delimiter, preamble_rows).
``preamble_rows`` is the number of lines BEFORE the row we identify
as the actual table header. The header row is the first line whose
tokens are all non-numeric (so "Symbol,Quantity" is a header but
"AAPL,100" is data). Falls back to assuming the first line is the
header if no clear non-numeric line is found within the scan
window.
Raises ``LLMParseError`` on empty input."""
if not raw or not raw.strip():
raise LLMParseError("empty CSV")
text = _decode_raw(raw)
# csv.Sniffer is happy with ~4KB. Anything more and it gets slow.
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
delimiter = dialect.delimiter
except csv.Error:
# Most broker exports are comma-delimited; default rather than
# error out — the caller will still validate column shapes.
delimiter = ","
rows = list(csv.reader(io.StringIO(text), delimiter=delimiter))
# Build a flat list of (index, non_empty_tokens) for rows within scan limit
parsed = []
for i, row in enumerate(rows):
if i >= _MAX_PREAMBLE_SCAN:
break
non_empty = [c.strip() for c in row if c.strip()]
parsed.append((i, non_empty))
# Find the first all-alpha candidate row that is followed by a data
# row (one that contains at least one numeric token). This
# distinguishes real header rows from preamble metadata rows that
# also happen to be all-text.
for idx, (i, non_empty) in enumerate(parsed):
if len(non_empty) < 2:
continue
all_alpha = all(not _looks_numeric(c) for c in non_empty)
if not all_alpha:
continue
# Check whether the next non-empty row looks like data (has a numeric)
for _, next_non_empty in parsed[idx + 1:]:
if not next_non_empty:
continue
if any(_looks_numeric(c) for c in next_non_empty):
return delimiter, i
# Next row is also all-alpha → keep scanning
break
return delimiter, 0
def _validate_mapping(
mapping: dict, headers: list[str], first_row: list[str],
) -> None:
"""Verify the LLM-returned mapping is sane.
- ``ticker_col`` and ``qty_col`` are required (non-null).
- Every named column must exist in ``headers``.
- The value at ``qty_col`` on ``first_row`` must parse as a number.
- The value at ``cost_col`` on ``first_row`` (if present) must parse
as a number.
Raises ``LLMParseError`` on any failure, with a message that names
the specific problem (helpful for log forensics and for the
user-facing 400)."""
for key in _REQUIRED_MAPPING_KEYS:
if not mapping.get(key):
raise LLMParseError(
f"LLM mapping missing required column: {key.replace('_col', '')}"
)
headers_set = set(headers)
for key in _REQUIRED_MAPPING_KEYS + _OPTIONAL_MAPPING_KEYS:
col = mapping.get(key)
if col is not None and col not in headers_set:
raise LLMParseError(
f"LLM mapping references unknown column: {col!r}"
)
# Numeric sanity check: qty and (if present) cost must parse on row 1.
header_index = {h: i for i, h in enumerate(headers)}
qty_col = mapping["qty_col"]
qty_value = first_row[header_index[qty_col]] if header_index[qty_col] < len(first_row) else ""
if not _looks_numeric(qty_value):
raise LLMParseError(
f"LLM mapping qty_col={qty_col!r} maps to non-numeric value {qty_value!r}"
)
cost_col = mapping.get("cost_col")
if cost_col is not None:
cost_value = first_row[header_index[cost_col]] if header_index[cost_col] < len(first_row) else ""
if cost_value and not _looks_numeric(cost_value):
raise LLMParseError(
f"LLM mapping cost_col={cost_col!r} maps to non-numeric value {cost_value!r}"
)
def _parse_number(value: str) -> float | None:
"""Permissive float parse: strips thousands separators, currency
symbols, percent signs. Returns None on failure (so callers can
decide whether to skip or raise)."""
s = value.strip().replace(",", "").replace("$", "")
s = s.replace("", "").replace("£", "").replace("%", "")
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _apply_mapping(
headers: list[str],
data_rows: list[list[str]],
mapping: dict,
) -> ParsedPie:
"""Iterate ``data_rows`` and produce a ``ParsedPie``.
Rows that lack a parseable quantity (blank, non-numeric, zero) are
silently skipped broker exports often include summary or
placeholder rows after the position list. ``name_col`` falls back
to the ticker symbol when null."""
idx = {h: i for i, h in enumerate(headers)}
ticker_col = mapping["ticker_col"]
qty_col = mapping["qty_col"]
name_col = mapping.get("name_col")
cost_col = mapping.get("cost_col")
currency_col = mapping.get("currency_col")
positions: list[ParsedPosition] = []
invested_total = 0.0
invested_seen = False
for row in data_rows:
if not any(c.strip() for c in row):
continue
ticker_raw = row[idx[ticker_col]] if idx[ticker_col] < len(row) else ""
ticker = ticker_raw.strip().upper()
if not ticker:
continue
qty_raw = row[idx[qty_col]] if idx[qty_col] < len(row) else ""
qty = _parse_number(qty_raw)
if qty is None or qty <= 0:
continue
avg_cost: float | None = None
if cost_col is not None and idx[cost_col] < len(row):
avg_cost = _parse_number(row[idx[cost_col]])
invested_value: float | None = None
if avg_cost is not None:
invested_value = qty * avg_cost
invested_total += invested_value
invested_seen = True
name = ""
if name_col is not None and idx[name_col] < len(row):
name = row[idx[name_col]].strip()
if not name:
name = ticker
currency: str | None = None
if currency_col is not None and idx[currency_col] < len(row):
currency = row[idx[currency_col]].strip() or None
positions.append(ParsedPosition(
slice=ticker,
name=name,
invested_value=invested_value,
current_value=None,
result=None,
quantity=qty,
currency=currency,
))
return ParsedPie(
name=None,
positions=tuple(positions),
invested=(invested_total if invested_seen else None),
value=None,
result=None,
tickers_resolved=True,
)
def _build_user_prompt(headers: list[str], samples: list[list[str]]) -> str:
lines = ["headers: " + json.dumps(headers)]
lines.append("samples:")
for s in samples[:_LLM_SAMPLES]:
lines.append(" " + ",".join(s))
return "\n".join(lines)
async def _extract_mapping_via_llm(
client: httpx.AsyncClient,
headers: list[str],
samples: list[list[str]],
) -> tuple[dict, LogResult]:
"""Single LLM call returning ``(mapping_dict, LogResult)``.
The LLM is asked for a strict JSON object (no markdown). We attempt
to parse the returned content; ``LLMParseError`` wraps any failure
in a way callers can surface to the user."""
messages = [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": _build_user_prompt(headers, samples)},
]
try:
result = await call_llm(client, messages, max_tokens=_LLM_MAX_TOKENS)
except Exception as e:
raise LLMParseError(f"LLM provider failed: {e}") from e
content = (result.content or "").strip()
# Strip code fences if the model added them despite instructions.
if content.startswith("```"):
content = content.strip("`")
# Drop optional 'json' language tag.
if content.lstrip().lower().startswith("json"):
content = content.lstrip()[4:]
content = content.strip()
try:
mapping = json.loads(content)
except json.JSONDecodeError as e:
raise LLMParseError(f"LLM did not return valid JSON: {e}") from e
if not isinstance(mapping, dict):
raise LLMParseError("LLM JSON was not an object")
return mapping, result
async def parse_with_llm(raw: bytes, session: AsyncSession) -> ParsedPie:
"""Cache-first LLM-fallback CSV parse.
On cache hit, applies the stored mapping deterministically and
increments ``use_count``. On cache miss, calls the LLM, validates
the returned mapping against the first data row, and persists a
new ``CsvFormatTemplate``. Raises ``LLMParseError`` on any
failure; the caller (route layer) maps that to a 400."""
if len(raw) > _MAX_CSV_BYTES:
raise LLMParseError("CSV too large (1 MB max)")
if not raw or not raw.strip():
raise LLMParseError("empty CSV")
delimiter, preamble_rows = _detect_dialect(raw)
text = _decode_raw(raw)
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
rows = list(reader)
if preamble_rows >= len(rows):
raise LLMParseError("no header row found in CSV")
headers = [c.strip() for c in rows[preamble_rows]]
data_rows = rows[preamble_rows + 1:]
if not headers:
raise LLMParseError("empty header row")
first_data_row = next(
(r for r in data_rows if any(c.strip() for c in r)), None,
)
if first_data_row is None:
raise LLMParseError("CSV contains a header but no data rows")
fp = _fingerprint(headers)
existing = (await session.execute(
select(CsvFormatTemplate).where(CsvFormatTemplate.fingerprint == fp)
)).scalar_one_or_none()
if existing is not None:
log.info("csv.format.cache_hit", fingerprint=fp,
broker_label=existing.broker_label, use_count=existing.use_count)
pie = _apply_mapping(headers, data_rows, existing.mapping)
if not pie.positions:
raise LLMParseError(
"cached mapping produced no positions — the broker may have "
"changed their CSV shape; ask the operator to evict the "
"stale template"
)
existing.use_count += 1
existing.last_used_at = utcnow()
await session.commit()
return pie
log.info("csv.format.cache_miss", fingerprint=fp,
header_count=len(headers))
samples = [r for r in data_rows[:_LLM_SAMPLES] if any(c.strip() for c in r)]
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client:
mapping, llm_log = await _extract_mapping_via_llm(client, headers, samples)
_validate_mapping(mapping, headers, first_data_row)
pie = _apply_mapping(headers, data_rows, mapping)
if not pie.positions:
raise LLMParseError(
"LLM mapping validated but produced no positions — the file "
"may not contain portfolio data"
)
now = utcnow()
session.add(CsvFormatTemplate(
fingerprint=fp,
headers=headers,
sample_row=first_data_row,
mapping=mapping,
preamble_rows=preamble_rows,
delimiter=delimiter,
broker_label=mapping.get("broker_label"),
first_seen_at=now,
last_used_at=now,
use_count=1,
llm_model=llm_log.model,
llm_cost_usd=llm_log.cost_usd,
))
await session.commit()
return pie

View file

@ -30,8 +30,7 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
# the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context.
# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email.
PROMPT_VERSION = 9
PROMPT_VERSION = 8
# --- Core: invariant across tone/analysis settings ----------------------------
@ -508,107 +507,6 @@ def build_user_prompt(
return "\n".join(parts)
def _digest_tone_clause(tone: str) -> str:
if tone.upper() == "NOVICE":
return "Use plain English. Define any jargon on first use."
return "Write for a reader who already speaks markets fluently."
def build_daily_digest_prompt(
*,
tone: str,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> tuple[str, str]:
"""System + user prompt for the once-a-day editorial digest.
Different from the hourly log: the daily digest reflects on the past
24h and looks forward to the upcoming session. Longer, less
'live-blogging,' more contextual. Target ~600 words."""
system = (
"You write the daily editorial digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) what mattered yesterday, (2) what to watch in today's "
"EU and US sessions, (3) one cross-asset thread connecting them. "
"No predictions of price level, no buy/sell language. Target ~600 "
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)
user = _digest_user_prompt(
today=today, quotes_by_group=quotes_by_group,
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
)
return system, user
def build_weekly_digest_prompt(
*,
tone: str,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> tuple[str, str]:
"""System + user prompt for the Sunday weekly recap + look-ahead.
Sent to ALL opt-in users (free and paid). Target ~900 words."""
system = (
"You write the Sunday weekly digest for Read the Markets. "
f"Audience tone: {tone.upper()}. {_digest_tone_clause(tone)} "
"Cover: (1) the week behind — what moved and why, "
"(2) the week ahead — releases, earnings, central-bank meetings, "
"(3) the cross-asset story to keep in mind. "
"No predictions of price level, no buy/sell language. Target ~900 "
"words. Output HTML using only <p>, <h3>, <ul>, <li>, <strong>, "
"<em> — no <html>, <head>, or <body> wrapper, no inline styles."
)
user = _digest_user_prompt(
today=today, quotes_by_group=quotes_by_group,
headlines_by_bucket=headlines_by_bucket, reference_line=reference_line,
)
return system, user
def _digest_user_prompt(
*,
today,
quotes_by_group: dict,
headlines_by_bucket: dict,
reference_line: str,
) -> str:
"""Shared user-message body used by both digest prompts. Same data
shape as the hourly user prompt; reformatted for the digest context."""
today_str = today.strftime("%A %d %B %Y") if hasattr(today, "strftime") else str(today)
lines = [f"TODAY (UTC): {today_str}", "", f"REFERENCE: {reference_line}", ""]
if headlines_by_bucket:
lines.append("HEADLINES BY CATEGORY")
for cat, items in headlines_by_bucket.items():
lines.append(f" [{cat}]")
for h in items[:30]:
when = h.get("when", "")
src = h.get("source", "")
title = h.get("title", "")
lines.append(f" {when} · {src} · {title}")
lines.append("")
if quotes_by_group:
lines.append("LATEST QUOTES BY GROUP")
for grp, items in quotes_by_group.items():
lines.append(f" [{grp}]")
for q in items[:30]:
sym = q.get("symbol", "")
price = q.get("price", "")
lbl = q.get("label", "")
ccy = q.get("currency", "")
lines.append(f" {sym} ({lbl}) — {price} {ccy}")
lines.append("")
return "\n".join(lines)
def _provider_chain() -> list[str]:
"""Ordered list of providers to try: primary, then fallback (unless
the fallback is unset, the same as primary, or has no API key)."""

View file

@ -44,16 +44,8 @@ RATE_LIMIT_MAX = 6
class SyncCryptoError(Exception):
"""Outer-wrap decryption failed even though the pepper fingerprint
matched i.e. genuine corruption or tampering. The router maps this
to a 500."""
class SyncOrphanedError(Exception):
"""The row was wrapped with a different pepper than the one currently
configured (typically: dev-time pepper rotation). The data is
permanently unrecoverable, but this is a *known* state, not a server
fault the router maps this to a 410 Gone."""
"""Outer-wrap decryption failed — usually a pepper change or
bit-rotted row. The router maps this to a 500."""
def _utcnow() -> datetime:
@ -80,22 +72,6 @@ def _server_key(user_id: int) -> bytes:
).derive(_pepper_bytes())
_FP_LEN = 8
def current_pepper_fp() -> bytes:
"""8-byte HKDF-derived fingerprint of the current pepper. Doesn't
leak the pepper itself (HKDF is one-way) and is short enough to make
accidental collisions across rotations effectively zero (2^-32 birthday
floor fine for a few-row dev install)."""
return HKDF(
algorithm=hashes.SHA256(),
length=_FP_LEN,
salt=b"portfolio-sync-pepper-fp",
info=b"v1",
).derive(_pepper_bytes())
def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]:
"""Encrypt the client-side ciphertext (`inner_blob`) for storage.
Returns (outer_ct, outer_nonce). The nonce is random per write."""
@ -105,15 +81,9 @@ def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]:
def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes:
"""Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails.
AESGCM.decrypt takes (nonce, data, associated_data) not
(data, nonce). The original implementation had the arguments
swapped, which meant restore-from-cloud always failed even when
the pepper was correct.
"""
"""Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails."""
try:
return AESGCM(_server_key(user_id)).decrypt(outer_nonce, outer_ct, None)
return AESGCM(_server_key(user_id)).decrypt(outer_ct, outer_nonce, None)
except Exception as exc: # InvalidTag, malformed ciphertext, etc.
raise SyncCryptoError("outer wrap unwrap failed") from exc
@ -121,7 +91,6 @@ def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes:
async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> datetime:
"""Insert or replace this user's sync row. Returns the new updated_at."""
outer_ct, outer_nonce = wrap(user_id, inner_blob)
fp = current_pepper_fp()
now = _utcnow()
row = await session.get(PortfolioSync, user_id)
if row is None:
@ -132,7 +101,6 @@ async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> date
version=1,
created_at=now,
updated_at=now,
pepper_fp=fp,
)
session.add(row)
else:
@ -141,34 +109,19 @@ async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> date
row.updated_at = now
# Bump version field forward if we ever change the wrap scheme.
row.version = 1
row.pepper_fp = fp
await session.commit()
return now
def _is_orphaned(row: PortfolioSync) -> bool:
"""A row is orphaned when its stored pepper fingerprint is present
and differs from the current pepper's fingerprint. NULL fingerprint
(rows from before the pepper_fp column existed) is treated
optimistically: we don't know whether the pepper rotated, so we let
the fetch path probe with a real unwrap and self-heal on success.
Status returns orphaned=False for NULL so the user is offered the
Restore form; if unwrap then fails, the GET path returns 410 and the
UI flips to the stale state."""
return row.pepper_fp is not None and row.pepper_fp != current_pepper_fp()
async def fetch_status(
session: AsyncSession, user_id: int,
) -> tuple[bool, bool, datetime | None]:
"""Cheap existence check — does NOT decrypt. Returns
(exists, orphaned, updated_at). Used by the dashboard to decide
whether to show the restore prompt vs the "stale, re-upload" prompt.
"""
) -> tuple[bool, datetime | None]:
"""Cheap existence check — does NOT decrypt. Used by the dashboard to
decide whether to show the restore prompt."""
row = await session.get(PortfolioSync, user_id)
if row is None:
return False, False, None
return True, _is_orphaned(row), row.updated_at
return False, None
return True, row.updated_at
async def fetch(
@ -176,36 +129,13 @@ async def fetch(
) -> tuple[bytes, datetime] | None:
"""Returns (inner_blob, updated_at) or None if sync disabled.
Raises SyncOrphanedError if the row's pepper fingerprint mismatches
the current pepper, OR if a fingerprint-less legacy row fails to
unwrap (which can only mean a pepper rotation, since the arg-order
bug fix landed alongside the fingerprint column).
Raises SyncCryptoError if the fingerprint matched but the outer wrap
still failed (genuine corruption or tampering).
On a successful unwrap of a fingerprint-less legacy row, the current
pepper's fingerprint is backfilled so subsequent status checks
correctly report healthy (and future rotations are detectable).
Raises SyncCryptoError if the row exists but the outer wrap is
unreadable (typically: pepper was rotated without re-encrypting).
"""
row = await session.get(PortfolioSync, user_id)
if row is None:
return None
if _is_orphaned(row):
raise SyncOrphanedError("pepper fingerprint mismatch")
legacy = row.pepper_fp is None
try:
inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce)
except SyncCryptoError:
if legacy:
# Legacy row + decrypt fails = pepper rotated before the
# fingerprint column existed. Same observable state as a
# post-fingerprint orphan; report it that way.
raise SyncOrphanedError("legacy row, decrypt failed")
raise
if legacy:
row.pepper_fp = current_pepper_fp()
await session.commit()
inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce)
return inner, row.updated_at

View file

@ -1,18 +1,15 @@
"""Referral-code generation, lookup, signup-time linkage, and
conversion-time credit grants.
"""Referral-code generation, lookup, and signup-time linkage.
The flow:
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 referred user's first paid subscription, `convert_referral`
is called from the Stripe webhook: both parties get a credit-window
extension worth the promised "50% off for 3 months" (= 45 days of
full paid access via `users.credit_until`), and the Referral row's
`converted_at` + `credited_at` are stamped.
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.
@ -20,7 +17,6 @@ can read it off a phone screen or dictate it over the phone.
from __future__ import annotations
import secrets
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -28,7 +24,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.logging import get_logger
from app.models import Referral, User
from app.services.access import _aware
log = get_logger("referral")
@ -40,12 +35,6 @@ log = get_logger("referral")
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
_CODE_LEN = 8
# Value-equivalent of the public-facing "50% off for 3 months" promise,
# delivered as a credit-window extension. 50% × 3 months ≈ 1.5 months
# of free service ≈ 45 days. Pure-credit delivery means the mechanism
# is processor-agnostic and stacks cleanly when both parties refer.
REFERRAL_CREDIT_DAYS = 45
def generate_code() -> str:
"""Cryptographically random 8-char code from the unambiguous alphabet."""
@ -139,65 +128,3 @@ async def link_new_user(
referrer_id=referrer.id, referred_id=new_user.id,
)
return ref
def _extend_credit(user: User, days: int) -> None:
"""Stack `days` of paid-tier credit onto `user.credit_until`. Anchors
at max(now, current credit_until) so granting twice gives twice the
runway never shortens the window. Mirrors the cli.grant_credit
anchoring rule so manual + automatic grants compose."""
now = utcnow()
anchor = max(now, _aware(user.credit_until) or now)
user.credit_until = anchor + timedelta(days=days)
async def convert_referral(
session: AsyncSession, referred_user: User,
) -> Referral | None:
"""Stamp the Referral row for `referred_user` as converted and grant
both parties their credit. Idempotent safe to call from every
subscription event:
- Returns None if no Referral row exists for this user (direct
signup, no inviter).
- Returns the existing Referral (unchanged) if `converted_at` is
already set this is a renewal or duplicate webhook delivery.
- Otherwise: extends both users' `credit_until` by
REFERRAL_CREDIT_DAYS and sets `converted_at` + `credited_at`.
The caller is responsible for committing the session this lets
the Stripe webhook compose the conversion inside its outer
audit-row transaction, so a mid-flight failure rolls back the
tier flip AND the conversion together.
Self-referral cannot happen here in practice (link_new_user blocks
it at signup) but we guard anyway: if the row somehow names the
same user on both sides, we stamp the timestamps but only credit
once."""
row = (await session.execute(
select(Referral).where(Referral.referred_user_id == referred_user.id)
)).scalar_one_or_none()
if row is None:
return None
if row.converted_at is not None:
return row
referrer = await session.get(User, row.referrer_user_id)
now = utcnow()
# Always credit the buyer; credit the referrer too unless they're
# the same row (defence-in-depth) or have been deleted.
_extend_credit(referred_user, REFERRAL_CREDIT_DAYS)
if referrer is not None and referrer.id != referred_user.id:
_extend_credit(referrer, REFERRAL_CREDIT_DAYS)
row.converted_at = now
row.credited_at = now
log.info(
"referral.converted",
referral_id=row.id,
referrer_id=row.referrer_user_id,
referred_id=row.referred_user_id,
credit_days=REFERRAL_CREDIT_DAYS,
)
return row

View file

@ -566,53 +566,6 @@ table.dense tr.row-stale td { color: var(--dim); }
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-actions .pf-secondary { color: var(--muted); }
.pf-actions .pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
/* Settings-page action button same visual language as .pf-actions
button so buttons across /settings (Manage subscription, future
actions) read as one family. Standalone class (not nested under a
parent) so it can be dropped onto any button anywhere on the page. */
.settings-btn {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--surface-2);
color: var(--accent);
border: 1px solid var(--border);
padding: 7px 14px;
cursor: pointer;
border-radius: 2px;
text-decoration: none;
display: inline-block;
}
.settings-btn:hover { border-color: var(--accent); }
.settings-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Icon-button variant for inline row actions (e.g. Manage subscription
gear in the Tier row). Square hit area, accent on hover, tooltip via
title attribute. */
.settings-icon-btn {
background: transparent;
border: 1px solid transparent;
color: var(--muted);
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 3px;
flex-shrink: 0;
transition: color 80ms linear, border-color 80ms linear, background 80ms linear;
}
.settings-icon-btn:hover {
color: var(--accent);
border-color: var(--border);
background: var(--surface-2);
}
.settings-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-icon-btn svg { display: block; }
.pf-analysis {
margin-top: 14px;
background: var(--surface-2);
@ -728,25 +681,6 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
.log-page__cal { padding: 10px; }
.log-page__content { min-height: 60vh; }
.log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; }
.log-page__chat--locked { opacity: 0.92; }
.chat-locked {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 16px;
padding: 24px 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
border: 1px dashed var(--border);
border-radius: 4px;
margin: 8px 4px;
}
.chat-locked p { margin: 0; max-width: 280px; }
.chat-locked strong { color: var(--text); display: block; margin-bottom: 6px; }
/* --- Calendar widget --------------------------------------------------- */
@ -906,46 +840,16 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
letter-spacing: 0.06em;
gap: 4px;
}
.auth-card input[type="email"],
.auth-card input[type="password"],
.auth-card input[type="text"] {
.auth-card input[type="email"], .auth-card input[type="password"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 16px;
padding: 12px 14px;
font-size: 13px;
padding: 8px 10px;
outline: none;
border-radius: 3px;
}
/* The 6-digit OTP input wants to be visually loud it's the only
thing the user is doing on that page. Bigger, more spacing, taller. */
.auth-card input[name="code"] {
font-size: 24px;
padding: 16px 14px;
letter-spacing: 0.5em;
text-align: center;
}
.auth-card input:focus { border-color: var(--accent); }
/* --- Modal text inputs (cloud-sync PIN modal, etc.) ---------------- */
/* Same visual treatment as auth-card so prompts read as a coherent
family. Replaces the inline `style="padding:8px"` that left these
inputs feeling cramped. */
.modal-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 16px;
padding: 12px 14px;
margin-bottom: 12px;
outline: none;
border-radius: 3px;
box-sizing: border-box;
}
.modal-input:focus { border-color: var(--accent); }
.auth-card button {
margin-top: 8px;
background: transparent;
@ -1020,13 +924,7 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin-left: 8px;
}
/* Sections are <details> elements collapsed by default to keep the
settings page scannable. Click the summary to expand. */
.settings-section {
margin-top: 14px;
border-top: 1px solid var(--surface-2);
padding-top: 14px;
}
.settings-section { margin-top: 22px; }
.settings-section__head {
font-family: var(--font-mono);
font-size: 11px;
@ -1034,30 +932,8 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
cursor: pointer;
list-style: none;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
/* Suppress the native disclosure marker (Webkit + Firefox). */
.settings-section__head::-webkit-details-marker { display: none; }
.settings-section__head::marker { content: ""; }
.settings-section__head::before {
content: "▸";
color: var(--accent);
display: inline-block;
transition: transform 120ms ease-out;
font-size: 10px;
}
.settings-section[open] > .settings-section__head::before {
transform: rotate(90deg);
}
.settings-section[open] > .settings-section__head { margin-bottom: 10px; }
.settings-section__head:hover { color: var(--text); }
.settings-section__head:hover::before { color: var(--text); }
.settings-section__head::before { content: "▸ "; color: var(--accent); }
.settings-section__lede {
color: var(--muted);
font-size: 12.5px;
@ -1651,24 +1527,15 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin: 0 0 24px;
}
.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; }
/* Shared button shape was previously scoped to .hero__ctas, which made
the pricing-card CTAs render as bare anchors. */
.btn-primary,
.btn-secondary {
.hero__ctas .btn-primary,
.hero__ctas .btn-secondary {
display: inline-block;
padding: 10px 22px;
border-radius: 3px;
font-size: 13.5px;
font-weight: 500;
line-height: 1.4;
text-decoration: none;
text-align: center;
cursor: pointer;
}
/* Block variant: full-width within parent, slightly taller used inside
tier cards so each CTA spans the card and reads as the obvious action. */
.btn-block { display: block; width: 100%; padding: 12px 22px; font-size: 14px; }
/* Qualify with `a` so we beat `a { color: var(--accent) }` and any
:link/:visited UA defaults. Without `a.btn-primary` the cascade can
resolve in favour of the visited-link color on some browsers and the
@ -1703,11 +1570,6 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
border-radius: 4px;
padding: 22px 22px 24px;
background: var(--surface);
/* Flex column so the screenshot thumbnail can dock to the bottom via
margin-top:auto that's what lines the three thumbnails up across
cards regardless of body-text length. */
display: flex;
flex-direction: column;
}
.feature-card__tag {
font-family: var(--font-mono);
@ -1728,10 +1590,6 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
line-height: 1.6;
color: var(--muted);
margin: 0;
/* Grow to fill the flex column so the thumbnail below docks to the
bottom of the card. With grid-stretched equal-height cards, this is
what aligns the thumbnails across the three cards. */
flex-grow: 1;
}
/* --- Section primitives reused across pricing/about/legal ---------- */
@ -1798,749 +1656,55 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
.tier-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin: 8px 0 40px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
margin: 8px 0 24px;
}
.tier-card {
position: relative;
border: 1px solid var(--border);
border-radius: 6px;
padding: 28px 26px 28px;
border-radius: 4px;
padding: 22px 22px 26px;
background: var(--surface);
display: flex;
flex-direction: column;
}
.tier-card--featured {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset,
0 12px 32px rgba(15, 23, 42, 0.10);
box-shadow: 0 0 0 1px var(--accent) inset;
}
[data-theme="dark"] .tier-card--featured {
box-shadow: 0 0 0 1px var(--accent) inset,
0 12px 32px rgba(0, 0, 0, 0.45);
}
.tier-card__badge {
position: absolute;
top: -11px;
left: 24px;
background: var(--accent);
color: var(--bg);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.10em;
text-transform: uppercase;
font-weight: 600;
padding: 4px 10px;
border-radius: 3px;
}
/* Tier name the actual heading, not the small uppercase chip it used
to be. Pairs with .tier-card__tagline for a one-line value framing. */
.tier-card__name {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 4px;
line-height: 1.1;
}
.tier-card__tagline {
font-size: 13px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
line-height: 1.5;
margin-bottom: 22px;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.tier-card__price {
font-size: 40px;
font-size: 22px;
font-weight: 700;
color: var(--text);
line-height: 1;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.tier-card__price-unit {
font-size: 15px;
color: var(--muted);
font-weight: 400;
letter-spacing: 0;
margin-bottom: 4px;
}
.tier-card__price-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.55;
margin-bottom: 20px;
}
.tier-card__divider {
height: 1px;
background: var(--border);
margin: 0 0 18px;
}
.tier-card__list-head {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
letter-spacing: 0.10em;
text-transform: uppercase;
margin-bottom: 12px;
margin-bottom: 18px;
}
.tier-card ul {
list-style: none;
padding: 0;
margin: 0 0 24px;
margin: 0 0 22px;
flex: 1;
}
.tier-card li {
font-size: 13.5px;
color: var(--text);
line-height: 1.55;
padding: 8px 0 8px 22px;
position: relative;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.tier-card li:last-child { border-bottom: 0; }
.tier-card li::before {
content: "✓";
position: absolute;
left: 0;
top: 8px;
color: var(--positive);
font-weight: 700;
}
.tier-card__cta { margin-top: 18px; }
/* Consent block above the Subscribe buttons (paid card, logged-in
free user). The Subscribe buttons render disabled; ticking the box
is what enables them. Wording covers ToS agreement (both cadences)
+ the Reg 36 CCR 2013 waiver (monthly only). */
.tier-card__consent {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 14px;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
cursor: pointer;
}
.tier-card__consent input[type="checkbox"] {
flex-shrink: 0;
margin-top: 2px;
cursor: pointer;
}
.tier-card__consent a {
color: var(--accent);
text-decoration: underline;
}
.tier-card__consent strong { color: var(--text); }
.tier-card__more {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
font-size: 12px;
color: var(--muted);
line-height: 1.55;
}
/* Side-by-side feature comparison table. Lives below the cards and
makes the deltas readable at a glance the cards sell, the table
confirms. */
.compare-table {
width: 100%;
border-collapse: collapse;
margin: 0 0 16px;
font-size: 13.5px;
}
.compare-table th,
.compare-table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
line-height: 1.5;
}
.compare-table thead th {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.compare-table th[scope="row"] {
font-weight: 500;
color: var(--text);
width: 38%;
}
.compare-table td.compare-table__free { color: var(--muted); }
.compare-table td.compare-table__paid { color: var(--text); font-weight: 500; }
.compare-table td.compare-table__paid strong { color: var(--accent); font-weight: 600; }
.compare-table td.compare-table__none { color: var(--dim); }
@media (max-width: 520px) {
.compare-table th[scope="row"] { width: 50%; }
.compare-table th, .compare-table td { padding: 10px 8px; font-size: 13px; }
}
/* BETA indicator pill in the app header — see app/templates/base.html. */
.beta-chip {
display: inline-block;
margin-left: 8px;
padding: 2px 7px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
font-family: var(--font-mono);
color: var(--bg);
background: var(--accent);
border-radius: 2px;
vertical-align: middle;
user-select: none;
}
/* -----------------------------------------------------------------------------
Landing-page screenshots: hero shot, thumbnails inside feature cards, gallery
strip, and a <dialog>-based lightbox. See app/templates/landing.html. */
/* All clickable screenshots are <button>s reset the default chrome so they
read as image cards, not form controls. The shadow is the main "this is a
screenshot, not part of the page" signal; the border alone blends in. */
.shot {
appearance: none;
-webkit-appearance: none;
background: var(--surface);
border: 1px solid var(--border);
padding: 0;
margin: 0;
display: block;
width: 100%;
cursor: zoom-in;
border-radius: 4px;
overflow: hidden;
transition: border-color 120ms ease, transform 120ms ease,
box-shadow 160ms ease;
position: relative;
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.18),
0 2px 6px rgba(15, 23, 42, 0.10);
}
.shot:hover {
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22),
0 4px 10px rgba(15, 23, 42, 0.14);
}
.shot:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent),
0 6px 22px rgba(15, 23, 42, 0.18);
}
/* Dark mode: the soft slate shadow disappears against the near-black bg.
Use a deeper, slightly accent-tinted glow so the cards still lift. */
[data-theme="dark"] .shot {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.55),
0 2px 8px rgba(0, 0, 0, 0.35);
}
[data-theme="dark"] .shot:hover {
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.65),
0 0 0 1px rgba(0, 217, 255, 0.20);
}
@media (prefers-color-scheme: dark) {
.shot {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.55),
0 2px 8px rgba(0, 0, 0, 0.35);
}
}
.shot img {
display: block;
width: 100%;
height: auto;
}
/* Hero screenshot — sits just below the headline CTAs, full landing width. */
.shot-hero {
max-width: 960px;
margin: 0 auto 56px;
padding: 0 24px;
}
.shot--hero .shot__zoom {
position: absolute;
bottom: 10px;
right: 12px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bg);
background: var(--accent);
padding: 4px 9px;
border-radius: 3px;
opacity: 0.85;
pointer-events: none;
}
/* Thumbnail at the bottom of each feature card. Vertical alignment across
the three cards is achieved by `.feature-card__body { flex-grow: 1 }`
above, which lets the body fill all available space inside the
equal-height grid cell the thumbnail then sits at the same y across
cards. The fixed margin-top keeps a predictable gap above. */
.feature-card__shot {
margin-top: 18px;
}
.feature-card__shot img {
max-height: 200px;
object-fit: cover;
object-position: top left;
}
/* "More views" strip — flex so we can drop in 2-3 extra shots later. */
.shots-section {
margin-top: 8px;
}
.shots-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
margin-top: 12px;
}
.shot__caption {
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
background: var(--surface);
border-top: 1px solid var(--border);
text-align: left;
}
.shot__caption strong {
display: block;
font-size: 13px;
color: var(--text);
margin-bottom: 2px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
/* Lightbox. <dialog> handles the modal mechanics (focus trap, ESC-to-close,
inert background); we just style the surface. */
.shot-modal {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
max-width: min(96vw, 1400px);
max-height: 94vh;
padding: 0;
border-radius: 6px;
overflow: hidden;
}
.shot-modal::backdrop {
background: rgba(0, 0, 0, 0.78);
}
.shot-modal img {
display: block;
max-width: 100%;
max-height: 80vh;
width: auto;
height: auto;
margin: 0 auto;
}
.shot-modal p {
margin: 0;
padding: 14px 22px 18px;
font-size: 13.5px;
line-height: 1.6;
color: var(--muted);
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.shot-modal__close {
position: absolute;
top: 6px;
right: 8px;
background: transparent;
border: 0;
color: var(--text);
font-size: 28px;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
font-family: var(--font-mono);
}
.shot-modal__close:hover,
.shot-modal__close:focus-visible {
color: var(--accent);
outline: none;
}
/* --- Invite-a-friend callout (pricing) ----------------------------- */
/* A single-row visual banner that names the offer at a glance. The
detail text lives in a <dialog> behind the "How it works" button to
keep the pricing page scannable. */
.invite-callout {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
margin: 0 0 40px;
background: linear-gradient(135deg,
color-mix(in srgb, var(--accent) 12%, var(--surface)),
var(--surface));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
border-left: 4px solid var(--accent);
border-radius: 6px;
}
.invite-callout__icon {
font-size: 32px;
line-height: 1;
flex-shrink: 0;
filter: saturate(1.1);
}
.invite-callout__body { flex: 1; min-width: 0; }
.invite-callout__eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 4px;
}
.invite-callout__headline {
font-size: 19px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
margin-bottom: 4px;
}
.invite-callout__headline strong { color: var(--accent); font-weight: 700; }
.invite-callout__sub {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.invite-callout .btn-secondary { flex-shrink: 0; }
@media (max-width: 560px) {
.invite-callout { flex-direction: column; align-items: flex-start; gap: 14px; }
.invite-callout .btn-secondary { width: 100%; }
}
/* Generic text-only modal reuse for any "click for the details"
pattern. Same <dialog> mechanics as .shot-modal. */
.text-modal {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
max-width: min(92vw, 560px);
max-height: 88vh;
padding: 28px 28px 24px;
border-radius: 6px;
overflow-y: auto;
position: relative;
line-height: 1.6;
}
.text-modal::backdrop { background: rgba(0, 0, 0, 0.65); }
.text-modal__title {
font-size: 20px;
font-weight: 700;
margin: 0 0 14px;
padding-right: 36px;
color: var(--text);
}
.text-modal__head {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
margin: 20px 0 8px;
}
.text-modal p {
font-size: 13.5px;
color: var(--text);
margin: 0 0 12px;
}
.text-modal__list {
margin: 0 0 8px;
padding-left: 22px;
}
.text-modal__list li {
font-size: 13.5px;
color: var(--text);
margin-bottom: 6px;
line-height: 1.55;
}
.text-modal code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--surface-2);
padding: 1px 5px;
border-radius: 2px;
}
.text-modal__close {
position: absolute;
top: 8px;
right: 10px;
background: transparent;
border: 0;
color: var(--muted);
font-size: 26px;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
font-family: var(--font-mono);
}
.text-modal__close:hover,
.text-modal__close:focus-visible {
color: var(--accent);
outline: none;
}
/* ---------- Dashboard portfolio edit mode -----------------------------
*
* Inline composer that sits above the portfolio table. Aesthetic:
* terminal-style command line, no boxed-form chrome, ghost controls,
* tinted-neutral palette pulled from --border / --dim / --muted, accent
* is theme-aware (deep teal in light, electric cyan in dark).
*/
/* The portfolio panel header gains two extra children (the EDIT / Done
* pills). The global `.panel-header` uses `space-between`, which works
* for headers with only title+meta but collapses meta into title once
* any later child has `margin-left: auto`. Switch this header to a
* gap-based flow; meta now sits 12px from the title, edit pill at the
* far right via its own auto-margin. */
#portfolio-panel .panel-header {
justify-content: flex-start;
gap: 12px;
}
/* EDIT / Done toggle buttons in the panel header. */
.pf-edit-btn,
.pf-done-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid var(--border);
color: var(--dim);
padding: 2px 8px;
border-radius: 2px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: lowercase;
margin-left: auto;
transition: color 120ms ease-out, border-color 120ms ease-out;
}
.pf-edit-btn:hover, .pf-done-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
/* The JS toggles these via the `hidden` attribute. `display: inline-flex`
* above otherwise wins over the UA's `[hidden] { display: none }`. */
.pf-edit-btn[hidden], .pf-done-btn[hidden] { display: none; }
/* × button per row — hidden by default, visible only in edit mode. */
.pf-row-del-cell { width: 20px; text-align: center; }
.pf-row-del {
display: none;
background: transparent;
border: none;
color: var(--dim);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
font-family: inherit;
transition: color 120ms ease-out;
}
#portfolio-panel.pf-editing .pf-row-del { display: inline; }
#portfolio-panel.pf-editing .pf-row-del:hover { color: var(--negative); }
/* ---------- Inline add-position composer ----------------------------- */
.pf-add {
padding: 6px 12px 8px;
border-bottom: 1px dashed var(--border);
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 12px;
}
.pf-add__line {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pf-add__prompt {
color: var(--accent);
font-weight: 600;
user-select: none;
margin-right: 2px;
}
.pf-add__div {
width: 1px;
height: 14px;
background: var(--border);
margin: 0 2px;
}
.pf-add__at {
color: var(--dim);
font-size: 11px;
user-select: none;
}
.pf-add__line input[type="text"],
.pf-add__line input[type="number"],
.pf-add__line input[type="date"] {
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text);
padding: 2px 4px;
font-family: inherit;
font-size: 12px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
border-radius: 0;
}
.pf-add__line input:focus {
outline: none;
border-bottom-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.pf-add__line input::placeholder {
color: var(--dim);
font-style: italic;
}
.pf-add__ticker {
width: 80px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.pf-add__num--qty { width: 56px; text-align: right; }
.pf-add__num--cost { width: 76px; text-align: right; }
.pf-add__date { width: 128px; margin-left: 4px; }
/* Tiny pill that shows after a successful validate: "172.40 USD". */
.pf-add-currency {
color: var(--muted);
font-size: 11px;
min-width: 24px;
letter-spacing: 0.02em;
}
.pf-add-currency:empty { display: none; }
/* Calendar-icon button — ghost, square, terminal-feel. */
.pf-add__icon {
background: transparent;
border: 1px solid var(--border);
color: var(--dim);
width: 22px;
height: 22px;
padding: 0;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 120ms ease-out, border-color 120ms ease-out;
}
.pf-add__icon:hover {
color: var(--accent);
border-color: var(--accent);
}
.pf-add__icon:focus-visible {
outline: none;
color: var(--accent);
border-color: var(--accent);
}
/* Submit button a square accent-bordered plus glyph. Visually
* heavier than the ghost calendar icon (larger size, accent border)
* so the primary action reads as primary. Lights up to solid accent
* on hover/focus when enabled. */
.pf-add__submit {
margin-left: auto;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
width: 26px;
height: 26px;
padding: 0;
border-radius: 2px;
cursor: pointer;
font-family: inherit;
font-size: 18px;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 120ms ease-out, color 120ms ease-out,
border-color 120ms ease-out, transform 120ms ease-out;
}
.pf-add__submit:hover:not(:disabled),
.pf-add__submit:focus-visible:not(:disabled) {
background: var(--accent);
color: var(--bg);
outline: none;
}
.pf-add__submit:active:not(:disabled) {
transform: scale(0.94);
}
.pf-add__submit:disabled {
border-color: var(--border);
color: var(--dim);
cursor: not-allowed;
}
/* Status indicators next to the ticker + below the row. */
.pf-add-status {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.pf-add-status:empty { display: none; }
.pf-add-status--pending { color: var(--dim); font-style: italic; }
.pf-add-status--ok { color: var(--positive); }
.pf-add-status--err { color: var(--negative); }
/* Secondary line below the main row only takes space when a child has
* content. Holds the date-lookup status and the duplicate warning. */
.pf-add__notes {
display: flex;
gap: 14px;
margin-top: 4px;
font-size: 11px;
}
.pf-add__notes:has(:empty:only-child) { display: none; }
.pf-add-warning {
color: var(--warning);
}
.pf-add-warning:empty { display: none; }
/* Quietly explains the controls. Shown only when the form is visible,
* which is to say only in edit mode. */
.pf-add__hint {
margin: 6px 0 0;
font-size: 11px;
color: var(--dim);
line-height: 1.5;
font-style: italic;
}
.pf-add__hint kbd {
font-family: inherit;
font-style: normal;
font-size: 11px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: 2px;
color: var(--muted);
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
.tier-card li::before { content: "✓ "; color: var(--positive); font-weight: 700; margin-right: 4px; }
.tier-card li.tier-card__excluded { color: var(--muted); }
.tier-card li.tier-card__excluded::before { content: "✕ "; color: var(--dim); }
.tier-card__cta { margin-top: auto; }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View file

@ -216,23 +216,7 @@
if (r.status === 402) return { exists: false, paid: false };
if (!r.ok) throw new Error('sync status: HTTP ' + r.status);
const body = await r.json();
return {
exists: !!body.exists,
orphaned: !!body.orphaned,
updated_at: body.updated_at,
paid: true,
};
}
// Thrown by pullSync when the server reports the stored blob is
// wrapped with a different server key (pepper rotation). Distinct
// from BadPinError so the UI can swap the restore form for a
// re-upload CTA instead of asking again for the PIN.
class StaleBlobError extends Error {
constructor(msg) {
super(msg || 'Stored portfolio cannot be decrypted with the current server key.');
this.name = 'StaleBlobError';
}
return { exists: !!body.exists, updated_at: body.updated_at, paid: true };
}
async function pushSync(pie, pin) {
@ -261,10 +245,6 @@
headers: { 'Accept': 'application/json' },
});
if (r.status === 404) return null;
if (r.status === 410) {
const body = await r.json().catch(() => ({}));
throw new StaleBlobError(body.detail);
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
// 429 → server already throttling; bubble the message up unchanged.
@ -293,7 +273,6 @@
disableSync,
clearCachedKey,
BadPinError,
StaleBlobError,
// Exposed for tests / debugging:
_packBlob: packBlob,
_unpackBlob: unpackBlob,

View file

@ -164,55 +164,12 @@
}[c]));
}
// Tiny one-shot flag the orphan-cleanup path sets so renderEmpty can
// surface a plain-English "your previous backup needs to be re-uploaded"
// line. Read-once; cleared as soon as it's shown.
function consumeBackupExpiredNotice() {
try {
if (sessionStorage.getItem('cassandra.sync.backupExpired') === '1') {
sessionStorage.removeItem('cassandra.sync.backupExpired');
return true;
}
} catch (e) { /* ignore */ }
return false;
}
function renderEmpty(mount) {
const expired = consumeBackupExpiredNotice();
const notice = expired
? '<div class="empty-notice">Your encrypted cloud backup expired. ' +
'Please re-upload your portfolio to refresh it.' +
'</div>'
: '';
var panel = document.getElementById('portfolio-panel');
if (panel) panel.classList.add('pf-empty');
mount.innerHTML =
'<div class="empty" style="padding:16px;">' +
notice +
'No positions yet — click <strong>edit</strong> to add one, or ' +
'<a href="/settings#import">import a CSV from your broker →</a>' +
'No portfolio loaded in this browser. ' +
'<a href="/settings#import">Import a T212 CSV →</a>' +
'</div>';
// The form is only ever visible in edit mode — never auto-shown by
// the empty state. Defensive: ensure it stays hidden here.
var form = document.getElementById('pf-add-form');
if (form) form.hidden = true;
}
// Silently remove an unrecoverable cloud blob and re-render. The user
// sees the standard empty state with a soft one-liner — no jargon, no
// extra buttons. The decision to remove is safe: the blob is already
// permanently undecryptable, so we're cleaning up dead state, not
// discarding user data.
async function autoCleanStaleBlob(mount) {
try {
await window.CassandraSync.disableSync();
} catch (e) {
console.warn('cassandra.sync: auto-clean of stale blob failed', e);
}
try {
sessionStorage.setItem('cassandra.sync.backupExpired', '1');
} catch (e) { /* ignore */ }
renderEmpty(mount);
}
function renderRestoreFromCloud(mount, status) {
@ -226,11 +183,11 @@
'A synced portfolio is available for this account (last synced ' +
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
'</div>' +
'<form id="pf-restore-form" style="display:flex; gap:10px; align-items:center;">' +
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
'autocomplete="off" placeholder="PIN" ' +
'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
'<button type="submit" class="settings-btn">Restore</button>' +
'style="flex:0 0 140px;">' +
'<button type="submit">Restore</button>' +
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
'or import a new CSV →</a>' +
'</form>' +
@ -255,12 +212,6 @@
savePie(pie);
mountAndRender();
} catch (e2) {
if (e2 && e2.name === 'StaleBlobError') {
// Pepper rotated since the blob was written — silently clean
// up and fall through to the empty state with a soft notice.
autoCleanStaleBlob(mount);
return;
}
err.textContent = (e2 && e2.name === 'BadPinError')
? 'Incorrect PIN.'
: (e2.message || 'Could not restore.');
@ -270,16 +221,6 @@
}
function renderPanel(mount, pie, enriched, agg) {
var panel = document.getElementById('portfolio-panel');
if (panel) panel.classList.remove('pf-empty');
// The empty-state path forces the add form visible. When we move
// back to a populated view we re-hide it — unless edit mode is on,
// in which case the form stays visible for ongoing edits.
var form = document.getElementById('pf-add-form');
if (form && panel && !panel.classList.contains('pf-editing')) {
form.hidden = true;
}
const ccyPills = Object.keys(agg.by_currency)
.sort((a, b) => agg.by_currency[b] - agg.by_currency[a])
.map(c => {
@ -308,10 +249,6 @@
'<td class="num">' + lastDisplay + fxBadge + '</td>' +
'<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
'<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
'<td class="pf-row-del-cell">' +
'<button type="button" class="pf-row-del" data-idx="' + p._orig_idx + '" ' +
'title="Remove this position" aria-label="Remove">\xd7</button>' +
'</td>' +
'</tr>';
}).join('');
@ -368,7 +305,6 @@
'<th class="num">Qty</th><th class="num">Avg</th>' +
'<th class="num">Last</th><th class="num">P/L</th>' +
'<th class="num">%</th>' +
'<th></th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
@ -481,14 +417,7 @@
catch (e) { console.warn('sync status check failed', e); }
}
if (status && status.paid && status.exists) {
if (status.orphaned) {
// Pepper rotated since the blob was written — clean up
// silently and show the standard empty state with a soft
// "please re-upload" notice.
autoCleanStaleBlob(mount);
} else {
renderRestoreFromCloud(mount, status);
}
renderRestoreFromCloud(mount, status);
} else {
renderEmpty(mount);
}
@ -503,7 +432,7 @@
}
const base = pie.base_currency || 'GBP';
const fx = (universeCache && universeCache.fx) || null;
const enriched = pie.positions.map((p, i) => Object.assign(enrichPosition(p, base, fx), { _orig_idx: i }))
const enriched = pie.positions.map(p => enrichPosition(p, base, fx))
.sort((a, b) => (b._value || 0) - (a._value || 0));
const agg = aggregate(enriched);
renderPanel(mount, pie, enriched, agg);

View file

@ -1,255 +0,0 @@
/* Dashboard-native portfolio editing.
*
* Owns: the EDIT button toggle, the add-position form behaviour
* (ticker validation on blur, qty/cost inputs, date-mode historical
* lookup, Add click), and per-row delete via event delegation.
*
* Reads/writes the portfolio via window.CassandraPortfolio.loadPie /
* savePie / mountAndRender the same surface portfolio.js exposes
* for the CSV-import preview.
*/
(function () {
'use strict';
const panel = document.getElementById('portfolio-panel');
const editBtn = document.getElementById('pf-edit-btn');
const doneBtn = document.getElementById('pf-done-btn');
const form = document.getElementById('pf-add-form');
if (!panel || !editBtn || !doneBtn || !form) return;
function enterEditMode() {
panel.classList.add('pf-editing');
form.hidden = false;
editBtn.hidden = true;
doneBtn.hidden = false;
editBtn.setAttribute('aria-pressed', 'true');
document.getElementById('pf-add-ticker').focus();
}
function exitEditMode() {
panel.classList.remove('pf-editing');
// The form is edit-mode-only — always hide it on exit, including
// when the portfolio is empty. The empty state shows guidance text
// that nudges the user back to the Edit button.
form.hidden = true;
editBtn.hidden = false;
doneBtn.hidden = true;
editBtn.setAttribute('aria-pressed', 'false');
}
editBtn.addEventListener('click', enterEditMode);
doneBtn.addEventListener('click', exitEditMode);
// ---- Ticker validation on blur -------------------------------------
const tickerInput = document.getElementById('pf-add-ticker');
const tickerStatus = document.getElementById('pf-add-ticker-status');
const costCurrencyEl = document.getElementById('pf-add-cost-currency');
const submitBtn = document.getElementById('pf-add-submit');
const warningEl = document.getElementById('pf-add-warning');
let validated = null; // {symbol, price, currency, as_of} or null
function setStatus(el, text, kind) {
el.textContent = text;
el.className = 'pf-add-status' + (kind ? ' pf-add-status--' + kind : '');
}
function updateSubmitState() {
const qty = parseFloat(document.getElementById('pf-add-qty').value);
const cost = parseFloat(document.getElementById('pf-add-cost').value);
submitBtn.disabled = !(
validated && qty > 0 && cost > 0 && isFinite(qty) && isFinite(cost)
);
}
function clearDuplicateWarning() {
warningEl.hidden = true;
warningEl.textContent = '';
}
function showDuplicateWarning(existing) {
warningEl.hidden = false;
warningEl.textContent =
`Already in your portfolio (${existing.qty} shares @ ` +
`${existing.avg_cost.toFixed(2)}). Adding will create a duplicate row.`;
}
async function validateTicker() {
const raw = tickerInput.value.trim().toUpperCase();
if (!raw) {
validated = null;
setStatus(tickerStatus, '', '');
costCurrencyEl.textContent = '';
clearDuplicateWarning();
updateSubmitState();
return;
}
setStatus(tickerStatus, 'checking…', 'pending');
try {
const r = await fetch('/api/ticker/validate?symbol=' + encodeURIComponent(raw));
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
if (j.ok) {
validated = j;
setStatus(
tickerStatus,
'✓ ' + j.price.toFixed(2) + ' ' + (j.currency || ''),
'ok',
);
costCurrencyEl.textContent = j.currency || '';
// Duplicate detection.
const pie = window.CassandraPortfolio.loadPie();
const existing = pie && (pie.positions || []).find(
p => (p.yahoo_ticker || '').toUpperCase() === j.symbol
);
if (existing) showDuplicateWarning(existing);
else clearDuplicateWarning();
} else {
validated = null;
setStatus(tickerStatus, '✗ ' + (j.error || 'not recognised'), 'err');
costCurrencyEl.textContent = '';
clearDuplicateWarning();
}
} catch (e) {
validated = null;
setStatus(tickerStatus, '✗ couldn\'t validate — try again', 'err');
costCurrencyEl.textContent = '';
clearDuplicateWarning();
}
updateSubmitState();
}
tickerInput.addEventListener('blur', validateTicker);
document.getElementById('pf-add-qty').addEventListener('input', updateSubmitState);
document.getElementById('pf-add-cost').addEventListener('input', updateSubmitState);
// ---- Add button → localStorage merge -------------------------------
function resetForm() {
tickerInput.value = '';
document.getElementById('pf-add-qty').value = '';
document.getElementById('pf-add-cost').value = '';
document.getElementById('pf-add-date').value = '';
validated = null;
setStatus(tickerStatus, '', '');
costCurrencyEl.textContent = '';
clearDuplicateWarning();
updateSubmitState();
tickerInput.focus();
}
function addPosition() {
if (submitBtn.disabled) return;
const qty = parseFloat(document.getElementById('pf-add-qty').value);
const cost = parseFloat(document.getElementById('pf-add-cost').value);
const sym = validated.symbol;
const pie = window.CassandraPortfolio.loadPie() || {
pie_name: null,
base_currency: 'GBP',
positions: [],
totals: {invested: 0, value: 0, result: 0},
warnings: [],
};
pie.positions = pie.positions || [];
pie.positions.push({
yahoo_ticker: sym,
t212_slice: sym, // shared shape with CSV path
name: validated.name || sym,
qty: qty,
avg_cost: cost,
currency: validated.currency || 'USD',
});
window.CassandraPortfolio.savePie(pie);
window.CassandraPortfolio.mountAndRender();
resetForm();
}
submitBtn.addEventListener('click', addPosition);
// Submit on Enter from any input within the form.
form.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !submitBtn.disabled) {
e.preventDefault();
addPosition();
}
});
// ---- Calendar-icon → historical lookup -----------------------------
const dateBtn = document.getElementById('pf-add-date-btn');
const dateInput = document.getElementById('pf-add-date');
const dateStatus = document.getElementById('pf-add-date-status');
const costInput = document.getElementById('pf-add-cost');
dateBtn.addEventListener('click', function () {
if (!validated) {
setStatus(dateStatus, 'enter a valid ticker first', 'err');
return;
}
dateInput.hidden = !dateInput.hidden;
if (!dateInput.hidden) {
dateInput.focus();
if (typeof dateInput.showPicker === 'function') dateInput.showPicker();
} else {
setStatus(dateStatus, '', '');
}
});
async function fetchHistorical() {
if (!validated) {
setStatus(dateStatus, 'enter a valid ticker first', 'err');
return;
}
const d = dateInput.value;
if (!d) {
setStatus(dateStatus, '', '');
return;
}
setStatus(dateStatus, 'looking up…', 'pending');
try {
const url = '/api/ticker/historical?symbol=' +
encodeURIComponent(validated.symbol) +
'&date=' + encodeURIComponent(d);
const r = await fetch(url);
if (r.status === 400) {
const j = await r.json().catch(() => ({detail: 'invalid date'}));
setStatus(dateStatus, '✗ ' + (j.detail || 'invalid date'), 'err');
updateSubmitState();
return;
}
const j = await r.json();
if (j.ok) {
costInput.value = j.close.toFixed(2);
const tag = (j.actual_date && j.actual_date !== d)
? '✓ from ' + j.actual_date
: '✓';
setStatus(dateStatus, tag, 'ok');
// Hide the date picker after a successful fill — keeps the row clean.
dateInput.hidden = true;
} else {
setStatus(dateStatus, '✗ ' + (j.error || 'no data'), 'err');
}
} catch (e) {
setStatus(dateStatus, '✗ couldn\'t fetch — try again', 'err');
}
updateSubmitState();
}
dateInput.addEventListener('change', fetchHistorical);
// ---- Per-row delete (event delegation) -----------------------------
panel.addEventListener('click', function (e) {
const btn = e.target.closest('.pf-row-del');
if (!btn) return;
const idx = parseInt(btn.dataset.idx, 10);
if (!Number.isInteger(idx)) return;
const pie = window.CassandraPortfolio.loadPie();
if (!pie || !pie.positions || idx < 0 || idx >= pie.positions.length) return;
pie.positions.splice(idx, 1);
window.CassandraPortfolio.savePie(pie);
window.CassandraPortfolio.mountAndRender();
});
})();

View file

@ -4,29 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
{# Cross-user contamination guard.
localStorage is browser-wide; if User A uploads a portfolio and User B
logs in on the same browser, the stale `cassandra.pie` would otherwise
render as User B's holdings. We stamp the logged-in user's id in
localStorage on every authenticated page load and wipe per-user keys
if the id changed since last time. Theme stays — it's cosmetic. #}
<script>
(function() {
try {
var current = "{{ cu.user.id if cu and cu.user else '' }}";
if (!current) return;
var last = localStorage.getItem('cassandra.user_id') || '';
if (last && last !== current) {
var theme = localStorage.getItem('cassandra.theme');
localStorage.clear();
if (theme) localStorage.setItem('cassandra.theme', theme);
try { sessionStorage.clear(); } catch (e) {}
}
localStorage.setItem('cassandra.user_id', current);
} catch (e) {}
})();
</script>
{# Apply saved theme before stylesheet renders to avoid a flash. #}
<script>
(function() {
@ -161,7 +138,6 @@
<div class="app">
<header class="app-header">
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome at hello@read.markets">BETA</span>{% endif %}
<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>

View file

@ -47,56 +47,9 @@
<section id="portfolio-panel" class="panel">
<div class="panel-header">
<span class="title">Portfolio</span>
<button type="button" id="pf-edit-btn" class="pf-edit-btn"
title="Add or remove positions" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<span class="pf-edit-btn__label">Edit</span>
</button>
<button type="button" id="pf-done-btn" class="pf-done-btn" hidden>Done</button>
<span class="meta">held locally · prices via /api/universe</span>
</div>
<div class="panel-body">
<div id="pf-add-form" class="pf-add" hidden>
<div class="pf-add__line">
<span class="pf-add__prompt" aria-hidden="true">$</span>
<input type="text" id="pf-add-ticker" class="pf-add__ticker"
autocomplete="off" spellcheck="false" maxlength="32"
placeholder="ticker">
<span id="pf-add-ticker-status" class="pf-add-status"></span>
<span class="pf-add__div" aria-hidden="true"></span>
<input type="number" id="pf-add-qty" class="pf-add__num pf-add__num--qty"
min="0" step="any" placeholder="qty">
<span class="pf-add__at" aria-hidden="true">@</span>
<input type="number" id="pf-add-cost" class="pf-add__num pf-add__num--cost"
min="0" step="any" placeholder="cost">
<span id="pf-add-cost-currency" class="pf-add-currency"></span>
<button type="button" id="pf-add-date-btn" class="pf-add__icon"
title="Use a buy date to auto-fill cost" aria-label="Pick buy date">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</button>
<input type="date" id="pf-add-date" class="pf-add__date" hidden>
<button type="button" id="pf-add-submit" class="pf-add__submit"
disabled aria-label="Add position" title="Add position">+</button>
</div>
<div class="pf-add__notes">
<span id="pf-add-date-status" class="pf-add-status"></span>
<span id="pf-add-warning" class="pf-add-warning" hidden></span>
</div>
<p class="pf-add__hint">
Type a symbol, then quantity and cost &mdash; or use the calendar
to fill cost from a buy date &mdash; then <kbd>+</kbd> to add.
<kbd>&times;</kbd> next to an existing row removes it.
</p>
</div>
<div id="pf-mount">
<div class="empty">loading…</div>
</div>
@ -104,14 +57,11 @@
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}" defer></script>
<section id="log-panel" class="panel">
<div class="panel-header">
<span class="title">Strategic Log</span>
<span class="meta">
{% if paid %}refreshed hourly @ :20 UTC{% else %}refreshed every 6 hours &middot; <a href="/pricing">hourly on Paid</a>{% endif %}
</span>
<span class="meta">generated hourly @ :20 UTC</span>
</div>
<div class="panel-body"
hx-get="/api/log/latest?as=html"
@ -124,9 +74,7 @@
<section id="news-panel" class="panel">
<div class="panel-header">
<span class="title">Flash News</span>
<span class="meta">
{% if paid %}last 24h &middot; ingest hourly @ :10 UTC{% else %}last 6h &middot; <a href="/pricing">full 24h on Paid</a>{% endif %}
</span>
<span class="meta">last 24h · ingest hourly @ :10 UTC</span>
</div>
<div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=40"

View file

@ -12,7 +12,7 @@
</p>
<p style="margin-top:6px; font-size:12px; color: var(--muted);">
This page is part of, and qualifies, the
<a href="/terms">Terms and Conditions</a>.
<a href="/terms">Terms of Service</a>.
</p>
</section>
@ -95,7 +95,7 @@
The service is provided &ldquo;as is&rdquo; without warranties of any
kind. To the maximum extent permitted by applicable law, the operator
excludes liability for any loss arising from use of, or reliance on,
the content. See the <a href="/terms">Terms and Conditions</a> for the
the content. See the <a href="/terms">Terms of Service</a> for the
full limitation of liability.
</p>
</section>

View file

@ -11,8 +11,8 @@
tune out the high-frequency noise that comes from treating markets
like a casino. We aggregate cross-asset news and macro signals,
then write a plain-English read of what the underlying fundamentals
justify versus what the crowd is doing. Refreshed through the
trading day. A media service, not a financial one.
justify versus what the crowd is doing. Refreshed hourly. A media
service, not a financial one.
</p>
<div class="hero__ctas">
{% if cu and (cu.user or cu.is_admin) %}
@ -25,17 +25,6 @@
</div>
</section>
<section class="shot-hero">
<button class="shot shot--hero"
data-full="{{ url_for('static', path='/images/dashboard.png') }}"
data-alt="Read the Markets dashboard"
data-caption="The dashboard. An aggregate cross-asset read at the top, hand-picked indicator groups underneath. Reading level toggle (Novice / Intermediate) flips every AI-generated panel between plain-English and terse-pro framing.">
<img src="{{ url_for('static', path='/images/dashboard.png') }}"
alt="Dashboard preview" loading="lazy">
<span class="shot__zoom" aria-hidden="true">Click to enlarge</span>
</button>
</section>
<section class="feature-grid">
<div class="feature-card">
<div class="feature-card__tag">News, aggregated</div>
@ -47,13 +36,6 @@
stuff is easy to find. Ingestion follows the trading calendar &mdash;
off-hours stay quiet.
</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/news-feed.png') }}"
data-alt="News feed with auto-tagged headlines"
data-caption="The news feed. Each headline carries one or more theme tags (rates, AI, energy, geopolitics, …) so you can keep the threads you care about and mute the ones you don't. Click a tag to include; shift-click to exclude.">
<img src="{{ url_for('static', path='/images/news-feed.png') }}"
alt="News feed thumbnail" loading="lazy">
</button>
</div>
<div class="feature-card">
@ -65,17 +47,10 @@
explains what the move <em>means</em>, not what it was. Anchored
in earnings, policy, valuation &mdash; not chart patterns.
</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/indicators-read.png') }}"
data-alt="Indicators panel with AI commentary"
data-caption="The indicators panel. Tabs across asset classes (equity, rates, commodities, FX, bonds, …); each tab carries a one-paragraph 'read' written by the model on top of the live prices. The numbers anchor the prose so the commentary is checkable, not floating.">
<img src="{{ url_for('static', path='/images/indicators-read.png') }}"
alt="Indicators panel thumbnail" loading="lazy">
</button>
</div>
<div class="feature-card">
<div class="feature-card__tag">The strategic read</div>
<div class="feature-card__tag">The hourly read</div>
<h3 class="feature-card__title">Rational vs irrational, every paragraph</h3>
<p class="feature-card__body">
We tie the day&rsquo;s headlines and the cross-asset signals into
@ -86,40 +61,15 @@
intermediate. This is editorial commentary on public data &mdash;
not a forecast and not advice on any investment decision.
</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/strategic-log.png') }}"
data-alt="Strategic log — the editorial AI read"
data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
<img src="{{ url_for('static', path='/images/strategic-log.png') }}"
alt="Strategic log thumbnail" loading="lazy">
</button>
</div>
</section>
<section class="public-section shots-section">
<h2 class="public-section__head">More views</h2>
<div class="shots-grid">
<button class="shot"
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}"
data-alt="Ask follow-up questions against any past log"
data-caption="Ask follow-up questions against any past log. The chat panel inherits the log's full context, so you can pull on a thread without re-pasting headlines or re-explaining the setup.">
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}"
alt="Chat-with-log thumbnail" loading="lazy">
<div class="shot__caption">
<strong>Ask anything about a log</strong>
<span>Conversational follow-ups with the day's context loaded.</span>
</div>
</button>
</div>
</section>
<section class="public-section">
<p style="font-size: 13.5px; color: var(--muted);">
Paid users can also drop a portfolio CSV from their broker
(Trading 212 today, more brokers planned) for an AI sense-check on
concentration, regime fit, and currency exposure. Holdings stay in
your browser by default; opt in to encrypted cloud sync to restore
on another device.
Paid users can also drop a Trading 212 pie CSV for an AI
sense-check on concentration, regime fit, and currency exposure.
Holdings stay in your browser by default; opt in to encrypted cloud
sync to restore on another device.
</p>
</section>
@ -150,34 +100,4 @@
</div>
</section>
<dialog id="shot-modal" class="shot-modal" aria-label="Screenshot preview">
<button class="shot-modal__close" type="button" aria-label="Close">&times;</button>
<img id="shot-modal__img" alt="">
<p id="shot-modal__caption"></p>
</dialog>
<script>
(function () {
var dlg = document.getElementById('shot-modal');
var img = document.getElementById('shot-modal__img');
var cap = document.getElementById('shot-modal__caption');
if (!dlg || !dlg.showModal) return; // gracefully skip on ancient browsers
document.querySelectorAll('.shot').forEach(function (btn) {
btn.addEventListener('click', function () {
img.src = btn.dataset.full;
img.alt = btn.dataset.alt || '';
cap.textContent = btn.dataset.caption || '';
dlg.showModal();
});
});
// Backdrop click closes; clicking the image itself does not.
dlg.addEventListener('click', function (e) {
if (e.target === dlg) dlg.close();
});
dlg.querySelector('.shot-modal__close').addEventListener('click', function () {
dlg.close();
});
})();
</script>
{% endblock %}

View file

@ -30,7 +30,6 @@
<div class="empty">loading log…</div>
</article>
{% if paid %}
<aside id="chat-sidebar" class="log-page__chat">
<div class="chat-header">
<span class="chat-title">Ask Cassandra</span>
@ -50,24 +49,7 @@
<button id="chat-send" type="submit">Send</button>
</form>
</aside>
{% else %}
<aside class="log-page__chat log-page__chat--locked">
<div class="chat-header">
<span class="chat-title">Ask Cassandra</span>
<span class="chat-hint">paid-tier feature</span>
</div>
<div class="chat-locked">
<p>
<strong>Follow-up chat is a paid feature.</strong>
Ask the model a question about any past log &mdash; it sees the
full day's context: the strategic log, live market readings, and
the last 24 hours of thesis-filtered headlines.
</p>
<a class="btn-primary" href="/pricing">See pricing</a>
</div>
</aside>
{% endif %}
</div>
</section>
{% if paid %}<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>{% endif %}
<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>
{% endblock %}

View file

@ -33,10 +33,3 @@
</div>
{% endfor %}
{% endif %}
{% if capped %}
<div class="news-capped-note" style="margin-top:14px; padding:10px 12px; border:1px dashed var(--border); color:var(--muted); font-size:12px; line-height:1.55;">
Free tier — showing the last {{ window_hours|int }} hours of news.
<a href="/pricing" style="color:var(--accent);">Upgrade</a>
for the full 24-hour feed plus daily and weekly email digests.
</div>
{% endif %}

View file

@ -6,262 +6,66 @@
<section class="public-section">
<h1 class="public-section__head">Pricing</h1>
<p>
Two tiers. The core editorial is free today &mdash; a rolling
6-hour news feed, the cross-asset indicator panels, and a strategic
log refreshed every six hours. Paid stretches the news feed to a
full 24 hours, runs the strategic log hourly, unlocks the follow-up
chat against past logs, adds portfolio import with AI analysis, and
turns on the daily email digest on top of the Sunday recap everyone
gets.
Two tiers. The news aggregator and the hourly macro interpretation
are free for everyone &mdash; we want the read out where people can use
it. The paid tier extends the same editorial commentary to the
specific tickers in a portfolio you upload &mdash; an educational read
of public data, not advice on whether to hold them.
</p>
</section>
<section class="tier-grid">
<div class="tier-card">
<h2 class="tier-card__name">Free</h2>
<div class="tier-card__tagline">The core editorial &mdash; news, indicators, and a strategic log every 6 hours.</div>
<div class="tier-card__name">Free</div>
<div class="tier-card__price">&pound;0</div>
<div class="tier-card__price-hint">No card needed.</div>
<div class="tier-card__divider"></div>
<div class="tier-card__list-head">What you get</div>
<div class="tier-card__price-hint">Forever. No card needed.</div>
<ul>
<li>News feed &mdash; <strong>headlines from the last 6 hours</strong>, auto-tagged by theme, click-to-filter</li>
<li>Cross-asset indicator panels (equities, rates, FX, commodities, credit, &hellip;) with a one-paragraph AI read on each tab</li>
<li>Strategic log &mdash; a single editorial interpretation of the day, <strong>refreshed every 6 hours</strong></li>
<li>Two reading levels: <em>Novice</em> (defines jargon) or <em>Intermediate</em> (terse, for fluent readers)</li>
<li><strong>Sunday weekly digest</strong> by email &mdash; week behind + week ahead, one-click unsubscribe</li>
<li>News aggregator &mdash; auto-tagged by theme</li>
<li>Cross-asset macro signals across every asset class</li>
<li>Hourly AI interpretation of the news + the tape</li>
<li>Per-group cross-asset summaries</li>
<li>Novice / Intermediate reading levels</li>
<li class="tier-card__excluded">Portfolio import &amp; analysis</li>
<li class="tier-card__excluded">Encrypted cloud sync</li>
</ul>
<div class="tier-card__more">
Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See <strong>Paid</strong> &rarr;
</div>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary btn-block" href="/">Open dashboard</a>
<a class="btn-secondary" href="/">Open dashboard</a>
{% else %}
<a class="btn-primary btn-block" href="/login">Sign up free</a>
<a class="btn-primary" href="/login">Sign up free</a>
{% endif %}
</div>
</div>
<div class="tier-card tier-card--featured">
<div class="tier-card__badge">Best value</div>
<h2 class="tier-card__name">Paid</h2>
<div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.</div>
<div class="tier-card__price">&pound;7<span class="tier-card__price-unit"> / month</span></div>
<div class="tier-card__price-hint">
Or <strong>&pound;70 / year</strong> &mdash; two months free, and
starts with a <strong>14-day free trial</strong> (cancel during
the trial and you are not charged). Monthly plans start
immediately. Prices in GBP, VAT where applicable.
</div>
<div class="tier-card__divider"></div>
<div class="tier-card__list-head">Everything in Free, plus</div>
<div class="tier-card__name">Paid</div>
<div class="tier-card__price">Coming soon</div>
<div class="tier-card__price-hint">Checkout opens with our payments rollout.</div>
<ul>
<li><strong>News feed: headlines from the last 24 hours</strong> instead of 6 &mdash; a whole session in view, nothing rolls off</li>
<li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li>
<li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li>
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li>
<li><strong>Portfolio import</strong> from a broker CSV (Trading 212 supported today; more brokers planned)</li>
<li><strong>AI portfolio read</strong> &mdash; diversification, sector and currency concentration, macro-regime fit on your holdings</li>
<li><strong>Optional encrypted cloud sync</strong> &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
<li>Everything in Free</li>
<li>Portfolio import (Trading 212 CSV)</li>
<li>AI commentary on diversification, sector and currency concentration, and macro-regime context for the holdings you upload</li>
<li>Optional encrypted cloud sync across devices</li>
<li>Priority email when something material changes (later)</li>
</ul>
<p class="tier-card__more" style="font-style: italic;">
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary" href="/settings">Manage account</a>
{% else %}
<a class="btn-primary" href="/login">Sign up &mdash; paid unlocks soon</a>
{% endif %}
</div>
<p style="margin-top:14px; font-size:11.5px; color: var(--muted); font-style: italic; line-height:1.55;">
The portfolio feature does not produce buy, sell or hold
recommendations and does not consider your wider finances, debts,
recommendations. It does not consider your wider finances, debts,
tax position or objectives. It is not regulated investment advice
or a personal recommendation under FSMA / FCA COBS.
</p>
<div class="tier-card__cta">
{% if paid %}
<a class="btn-secondary btn-block" href="/settings">Manage subscription</a>
{% elif cu and cu.user %}
<label class="tier-card__consent">
<input type="checkbox" id="tos-consent">
<span>
I agree to the <a href="/terms" target="_blank" rel="noopener">Terms of Service</a>.
For <strong>monthly plans</strong>, I expressly consent to begin
immediate access and acknowledge this waives my 14-day cancellation
right under Regulation 36 of the Consumer Contracts Regulations 2013.
(<strong>Annual plans</strong> include a 14-day free trial, so the
cancellation right is preserved differently &mdash; see
<a href="/terms" target="_blank" rel="noopener">Terms &sect;6</a>.)
</span>
</label>
<button class="btn-primary btn-block" type="button"
data-stripe-checkout="monthly" disabled>Subscribe &mdash; &pound;7/month</button>
<button class="btn-secondary btn-block" type="button"
data-stripe-checkout="annual" disabled
style="margin-top:10px;">or &pound;70/year (with 14-day free trial)</button>
{% else %}
<a class="btn-primary btn-block" href="/login?next=/pricing">Sign in to subscribe</a>
{% endif %}
</div>
</div>
</section>
<script>
(function () {
// Gate the Subscribe buttons on the ToS / Reg-36 consent checkbox.
// The buttons render with `disabled` set; toggling the checkbox is
// what enables them. Server-side, the Reg-36 waiver applies to
// monthly only — but the single checkbox covers both ToS agreement
// (always needed) and the monthly waiver, so it's required for any
// subscribe path.
var consent = document.getElementById('tos-consent');
var subscribeBtns = document.querySelectorAll('[data-stripe-checkout]');
if (consent) {
consent.addEventListener('change', function () {
subscribeBtns.forEach(function (b) { b.disabled = !consent.checked; });
});
}
// Wire the buttons to /api/stripe/checkout. Stripe returns a
// hosted-checkout URL; we just redirect there. No Stripe.js needed.
subscribeBtns.forEach(function (btn) {
btn.addEventListener('click', async function () {
if (consent && !consent.checked) {
alert('Please agree to the Terms of Service before subscribing.');
return;
}
var cadence = btn.getAttribute('data-stripe-checkout');
btn.disabled = true;
var prev = btn.textContent;
btn.textContent = 'Opening checkout…';
try {
var r = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({cadence: cadence}),
credentials: 'same-origin',
});
if (!r.ok) {
var detail = '';
try { detail = (await r.json()).detail || ''; } catch (e) {}
throw new Error('Checkout failed: ' + (detail || r.status));
}
var data = await r.json();
window.location.href = data.url;
} catch (e) {
alert(e.message || 'Could not start checkout. Please try again.');
btn.disabled = (consent && !consent.checked);
btn.textContent = prev;
}
});
});
})();
</script>
<section class="public-section">
<h2 class="public-section__head">Free vs Paid at a glance</h2>
<table class="compare-table">
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Free</th>
<th scope="col">Paid</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">News feed &mdash; headlines from the last&hellip;</th>
<td class="compare-table__free">6 hours</td>
<td class="compare-table__paid"><strong>24 hours</strong></td>
</tr>
<tr>
<th scope="row">Strategic log refresh</th>
<td class="compare-table__free">Every 6 hours</td>
<td class="compare-table__paid"><strong>Every hour</strong></td>
</tr>
<tr>
<th scope="row">Cross-asset indicator panels</th>
<td class="compare-table__free">&check;</td>
<td class="compare-table__paid">&check;</td>
</tr>
<tr>
<th scope="row">Follow-up chat on past logs</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Email digest</th>
<td class="compare-table__free">Sunday only</td>
<td class="compare-table__paid"><strong>Sunday + daily Mon&ndash;Sat</strong></td>
</tr>
<tr>
<th scope="row">Portfolio import (broker CSV)</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">AI portfolio read</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Encrypted cloud sync</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
</tbody>
</table>
</section>
<section class="invite-callout">
<div class="invite-callout__icon" aria-hidden="true">&#x1F381;</div>
<div class="invite-callout__body">
<div class="invite-callout__eyebrow">Invite a friend</div>
<div class="invite-callout__headline">Both of you get <strong>45 days of paid access</strong></div>
<div class="invite-callout__sub">
Share your personal invite link from <a href="/settings">Settings</a>. The credit applies when they start a paid plan.
</div>
</div>
<button type="button" class="btn-secondary" id="invite-more">How it works</button>
</section>
<dialog id="invite-modal" class="text-modal" aria-label="How the referral works">
<button type="button" class="text-modal__close" aria-label="Close">&times;</button>
<h2 class="text-modal__title">Invite a friend</h2>
<p>
Every account gets an 8-character referral code and matching invite
link, both shown on your <a href="/settings">Settings</a> page. When
someone signs up through your link and starts a paid plan,
<strong>both of you get 45 days of paid access</strong> credited
to your account.
</p>
<h3 class="text-modal__head">How it works</h3>
<ol class="text-modal__list">
<li><strong>Sign up.</strong> Your code and link go live in Settings.</li>
<li><strong>Share.</strong> Send the link, or read the code &mdash; the alphabet drops <code>0/O</code> and <code>1/I/L</code> so it dictates cleanly.</li>
<li><strong>They sign up.</strong> The referral is recorded against your account when they verify their email.</li>
<li><strong>They subscribe.</strong> 45 days of paid access lands on both accounts &mdash; usable any time over the next month and a half.</li>
</ol>
<h3 class="text-modal__head">The fine print</h3>
<ul class="text-modal__list">
<li>One referral per new account &mdash; whichever link they used first.</li>
<li>No self-referral.</li>
<li>Credits stack: if you already have a credit window running, the new 45 days extend from its end, not from today.</li>
<li>Credits aren&rsquo;t refundable for cash &mdash; see <a href="/terms">Terms &amp; Conditions &sect; 6</a>.</li>
<li>Pending signups, conversions, and active credits are visible on the Settings page.</li>
</ul>
</dialog>
<script>
(function () {
var dlg = document.getElementById('invite-modal');
var open = document.getElementById('invite-more');
if (!dlg || !dlg.showModal || !open) return;
open.addEventListener('click', function () { dlg.showModal(); });
dlg.addEventListener('click', function (e) {
if (e.target === dlg) dlg.close();
});
dlg.querySelector('.text-modal__close').addEventListener('click', function () {
dlg.close();
});
})();
</script>
<section class="public-section">
<h2 class="public-section__head">How the data is handled</h2>
<p>

View file

@ -20,111 +20,53 @@
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;">
<div style="flex:1; min-width:0;">
<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 %}
{% if trial_days_remaining %}
<span class="settings-row__hint">
<strong>Free trial</strong> &mdash; {{ trial_days_remaining }}
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
Cancel before the trial ends and you won&rsquo;t be charged.
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% endif %}
{% else %}
<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">
Free tier &mdash; <a href="/pricing">upgrade for £7/month or £70/year</a>.
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 %}
</div>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<button type="button" id="stripe-portal-btn" class="settings-icon-btn"
title="Manage subscription — payment method, invoices, plan, cancel"
aria-label="Manage subscription">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
{% else %}
<span class="settings-row__hint">Paid features unlock with Paddle (D.3) or invite credits.</span>
{% endif %}
</div>
</div>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<script>
(function () {
var btn = document.getElementById('stripe-portal-btn');
if (!btn) return;
btn.addEventListener('click', async function () {
btn.disabled = true;
var prev = btn.textContent;
btn.textContent = 'Opening portal…';
try {
var r = await fetch('/api/stripe/portal', {
method: 'POST',
credentials: 'same-origin',
});
if (!r.ok) {
var detail = '';
try { detail = (await r.json()).detail || ''; } catch (e) {}
throw new Error('Could not open portal: ' + (detail || r.status));
}
var data = await r.json();
window.location.href = data.url;
} catch (e) {
alert(e.message || 'Could not open portal. Please try again.');
btn.disabled = false;
btn.textContent = prev;
}
});
})();
</script>
{% endif %}
{# --- Import portfolio --------------------------------------------- #}
{# Open by default because /settings#import is the deep-link target
from the dashboard's "import a portfolio" CTA — if you arrive via
that link the section should already be expanded. #}
<details class="settings-section" id="import" open>
<summary class="settings-section__head">Import portfolio (CSV)</summary>
<div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
<p class="settings-section__lede">
Drop a portfolio CSV from any broker &mdash; Trading 212 is recognised
natively and other formats (IBKR, Fidelity, Schwab&hellip;) are
auto-detected. We&rsquo;ll parse it and show a preview before importing
anywhere.
<br><span class="muted">T212 export path:
<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span>
Export your pie from T212
(<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>)
and drop the CSV here. We&rsquo;ll parse it and show a preview before
importing anywhere.
</p>
<div id="drop-zone" class="dz">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop your broker's portfolio CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> &middot; max 1 MB &middot; T212, IBKR and others auto-detected</div>
<div class="dz__label">Drop a T212 pie CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> &middot; max 1 MB</div>
<div class="dz__filename" id="dz-filename"></div>
</div>
<div id="import-preview" hidden style="margin-top:14px;"></div>
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
</details>
</div>
{# --- Referral block ---------------------------------------------- #}
<details class="settings-section">
<summary class="settings-section__head">Invite a friend</summary>
<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>45 days of paid access</strong> credited
to your account.
they each get <strong>50% off for 3 months</strong>.
</p>
<div class="invite-block">
@ -149,84 +91,14 @@
</div>
<div>
<div class="invite-stats__label">Active credits</div>
<div class="invite-stats__value">{{ active_credit_count }}</div>
{% if own_credit_days %}
<div class="settings-row__hint" style="margin-left:0;">
+{{ own_credit_days }} day{{ '' if own_credit_days == 1 else 's' }} on your account
</div>
{% endif %}
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
</div>
</div>
</details>
{# --- Email digests block ------------------------------------------ #}
<details class="settings-section">
<summary class="settings-section__head">Email digests</summary>
<p class="settings-section__lede">
Editorial commentary delivered to your inbox. Daily for paid (Mon&ndash;Sat) plus the Sunday recap; free tier gets the Sunday recap.
</p>
<div class="settings-row">
<div class="settings-row__label">Subscription</div>
<div class="settings-row__value">
<label style="display:block; margin-bottom:8px;">
<input type="checkbox" id="digest-opt-in"
{% if user.email_digest_opt_in %}checked{% endif %}>
Send me digests
</label>
<div class="settings-row__hint" style="margin-bottom:8px;">
One-click unsubscribe in every email.
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Reading level</div>
<div class="settings-row__value">
<div style="display:flex; gap:14px;">
<label><input type="radio" name="digest-tone" value="NOVICE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Novice</label>
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Intermediate</label>
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Last delivery</div>
<div class="settings-row__value settings-row__hint">
<span id="digest-last">{% if last_email_send %}{{ last_email_send.sent_at.strftime("%Y-%m-%d %H:%M") }} UTC &mdash; {{ last_email_send.status }}{% else %}&mdash;{% endif %}</span>
</div>
</div>
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
</details>
<script>
(function () {
const opt = document.getElementById('digest-opt-in');
const tones = document.querySelectorAll('input[name="digest-tone"]');
const fb = document.getElementById('digest-feedback');
if (!opt || !fb) return;
function patch() {
fb.textContent = 'Saving…';
const tone = Array.from(tones).find(t => t.checked)?.value || 'INTERMEDIATE';
fetch('/api/settings/digest', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opt_in: opt.checked, tone: tone }),
}).then(r => {
fb.textContent = r.ok ? 'Saved.' : 'Could not save — try again.';
}).catch(() => { fb.textContent = 'Network error.'; });
}
opt.addEventListener('change', patch);
tones.forEach(t => t.addEventListener('change', patch));
})();
</script>
</div>
{# --- Cloud sync block --------------------------------------------- #}
<details class="settings-section">
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
<div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div>
<p class="settings-section__lede">
Manage the encrypted server-side copy of your portfolio. Sync is
opted-in per import (see the Import section above).
@ -247,7 +119,7 @@
above to enable cloud sync.
</p>
{% endif %}
</details>
</div>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
@ -273,10 +145,10 @@
<form id="sync-modal-form" autocomplete="off">
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
<input id="sync-pin1" type="password" inputmode="numeric"
class="modal-input" required>
style="width:100%;padding:8px;margin-bottom:10px;" required>
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
<input id="sync-pin2" type="password" inputmode="numeric"
class="modal-input" required>
style="width:100%;padding:8px;margin-bottom:10px;" required>
<label style="display:flex;align-items:flex-start;gap:8px;
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
<input id="sync-ack" type="checkbox" required>
@ -384,18 +256,7 @@
}
const valueEl = statusEl.querySelector('.settings-row__value');
actionsEl.innerHTML = '';
if (status.exists && status.orphaned) {
// The stored blob can no longer be decrypted (server key rotated
// since it was written). The data is permanently unrecoverable,
// so silently clean up the dead row and re-render in the
// standard "off" state — leaving a soft one-liner so the user
// knows why they need to re-import.
try { await window.CassandraSync.disableSync(); }
catch (e) { console.warn('auto-clear stale sync failed', e); }
setFeedback('Your previous cloud backup couldnt be restored. Re-import your portfolio to enable cloud sync again.', true);
await refresh();
return;
} else if (status.exists) {
if (status.exists) {
const when = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';

View file

@ -1,12 +1,12 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Terms and Conditions{% endblock %}
{% block title %}{{ BRAND_NAME }} &middot; Terms of Service{% endblock %}
{% block main %}
<section class="public-section">
<h1 class="public-section__head">Terms and Conditions</h1>
<h1 class="public-section__head">Terms of Service</h1>
<p style="color: var(--muted); font-size: 13px;">
Last updated: 2026-05-26. {{ LEGAL_OPERATOR }} operates {{ BRAND_NAME }}
Last updated: 2026-05-24. {{ LEGAL_OPERATOR }} operates {{ BRAND_NAME }}
(the &ldquo;Service&rdquo;) from {{ OPERATOR_JURISDICTION }}.
</p>
</section>
@ -96,74 +96,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">6. Refunds</h2>
<p>
<strong>14-day cooling-off period (UK / EU consumers).</strong>
The Consumer Contracts (Information, Cancellation and Additional
Charges) Regulations 2013 give consumers a 14-day right to cancel
digital service contracts. We honour this right in different ways
depending on your billing cadence.
</p>
<p>
<strong>1a. Annual subscriptions.</strong> Every new annual
subscription begins with a 14-day free trial. No payment is taken
during the trial; cancel any time before the trial ends and you
are not charged. This is offered as a substitute for, and is at
least as generous as, the statutory 14-day refund right. After
the trial ends and the first payment is taken, the rules in
paragraph 2 below apply.
</p>
<p>
<strong>1b. Monthly subscriptions.</strong> Monthly subscribers
receive immediate access to paid features at checkout and are
billed on day one. Before you can start a monthly subscription
you must tick a required consent box on the pricing page in
which you (i) agree to these Terms, (ii) give express consent to
begin performance immediately, and (iii) acknowledge that, under
Regulation 36 of the Consumer Contracts Regulations 2013, doing
so extinguishes your statutory 14-day right to cancel in respect
of digital content already delivered. The rules in paragraph 2
below apply from the first day.
</p>
<p>
<strong>Cancellation after the cooling-off window.</strong> You can
cancel a paid subscription at any time. Cancellation takes effect at
the end of the current billing period; we do not pro-rate refunds
for the unused portion of a period you have already started, unless
a separate paragraph below applies.
</p>
<p>
<strong>Termination by us without fault on your part.</strong> If we
terminate or materially reduce a paid feature for reasons that are
not a breach by you (including a service shutdown), we will refund
the unused portion of any prepaid fees on a pro-rata basis. The same
applies under clause 8 if we terminate for a breach you did not
cause, and under clause 11 if you close your account because you do
not accept a material change to these Terms.
</p>
<p>
<strong>Service faults.</strong> Nothing in this clause limits your
statutory rights as a UK consumer under Part 1 of the Consumer
Rights Act 2015. If a paid feature is not supplied with reasonable
care and skill, you may be entitled to a repeat performance or a
price reduction (which can be a full refund) under that Act.
</p>
<p>
<strong>How to request a refund.</strong> Email
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a> from
the address tied to your account, with the order reference if you
have one. We aim to acknowledge within 5 working days. Refunds are
returned to the original payment method and typically arrive within
14 days of approval, subject to your bank&rsquo;s processing time.
</p>
<p>
Referral credits and any other non-cash credits applied to your
account are not refundable for cash.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">7. Service availability</h2>
<h2 class="public-section__head">6. Service availability</h2>
<p>
The Service is provided on a best-effort basis. There is no service
level agreement: outages, data delays, and feature changes may occur
@ -172,7 +105,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">8. Content &amp; ownership</h2>
<h2 class="public-section__head">7. Content &amp; ownership</h2>
<p>
The Service&rsquo;s code, design, indicator selection, and prompts
are owned or licensed by {{ LEGAL_OPERATOR }}. To the extent any
@ -193,7 +126,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">9. Suspension &amp; termination</h2>
<h2 class="public-section__head">8. Suspension &amp; termination</h2>
<p>
We may suspend or terminate access without notice for violation of
these Terms or for activity that risks the integrity, security, or
@ -205,13 +138,12 @@
respond, unless immediate suspension is necessary to protect the
Service, its users, or any third party. If we terminate a paid
subscription for a breach you did not cause, we will refund the
unused portion of any prepaid fees on a pro-rata basis (see
clause 6).
unused portion of any prepaid fees on a pro-rata basis.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">10. No warranty</h2>
<h2 class="public-section__head">9. No warranty</h2>
<p>
The Service is provided &ldquo;as is&rdquo; and &ldquo;as
available&rdquo;, without warranties of any kind, express or implied,
@ -221,7 +153,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">11. Limitation of liability</h2>
<h2 class="public-section__head">10. Limitation of liability</h2>
<p>
To the maximum extent permitted by law, {{ LEGAL_OPERATOR }} is not
liable for any indirect, incidental, special, consequential, or
@ -250,7 +182,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">12. Changes</h2>
<h2 class="public-section__head">11. Changes</h2>
<p>
These Terms may change. Material changes will be flagged in-app or
by email. Continued use after a change means you accept the updated
@ -261,7 +193,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">13. Governing law and jurisdiction</h2>
<h2 class="public-section__head">12. Governing law and jurisdiction</h2>
<p>
These Terms are governed by the laws of England and Wales. Subject
to any mandatory law of the consumer&rsquo;s country of residence,
@ -273,7 +205,7 @@
</section>
<section class="public-section">
<h2 class="public-section__head">14. Contact</h2>
<h2 class="public-section__head">13. Contact</h2>
<p>
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>
</p>

View file

@ -29,7 +29,8 @@
<form method="post" action="/verify" autocomplete="off">
<label>Verification code
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}"
minlength="6" maxlength="6" required autofocus>
minlength="6" maxlength="6" required autofocus
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
</label>
<button type="submit">Verify</button>
</form>

View file

@ -9,7 +9,6 @@ from fastapi.templating import Jinja2Templates
from markupsafe import Markup, escape
from app import branding
from app.config import get_settings
from app.services.glossary import wrap_glossary
@ -76,4 +75,3 @@ templates.env.globals["TAGLINE"] = branding.TAGLINE
templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION
templates.env.globals["BETA_MODE"] = get_settings().BETA_MODE

View file

@ -1,46 +0,0 @@
# Ad-hoc test runner.
#
# STANDALONE — do not combine with docker-compose.yml. The `name:` field
# below puts the test container in its own Compose project (`cassandra-test`)
# so it CANNOT collide with the live prod stack on this host (containers,
# networks, volumes are all namespaced by project).
#
# Usage:
# # Run the full suite:
# docker compose -f docker-compose.test.yml run --rm test
#
# # Run a specific file or test:
# docker compose -f docker-compose.test.yml run --rm test pytest tests/test_email_digest_job.py -v
# docker compose -f docker-compose.test.yml run --rm test pytest -k unsubscribe
#
# # Open a shell in the test image (e.g. to poke around with pytest --pdb):
# docker compose -f docker-compose.test.yml run --rm test bash
#
# # Rebuild after a pyproject.toml change:
# docker compose -f docker-compose.test.yml build test
#
# Tests use an in-memory aiosqlite DB (see tests/conftest.py), so there is
# no MariaDB / Redis dependency and nothing touches the prod database.
name: cassandra-test
services:
test:
build:
context: .
target: test
# Same volume mounts as the dev override — edits on the host take effect
# on the next `run` without rebuilding the image.
volumes:
- ./app:/app/app
- ./tests:/app/tests
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini:ro
- ./config:/app/config:ro
- ./pyproject.toml:/app/pyproject.toml:ro
environment:
# Sentinels so app.config can be imported without a real .env / DB.
# tests/conftest.py also sets these defensively.
DATABASE_URL: "sqlite+aiosqlite:///:memory:"
CASSANDRA_MOCK: "1"
PYTHONDONTWRITEBYTECODE: "1"

View file

@ -43,12 +43,6 @@ services:
REDIS_URL: redis://redis:6379/0
volumes:
- ./config:/app/config:ro
# Mount app code + migrations from the host so edits take effect
# on a plain `docker compose restart app` — no image rebuild.
# Image still bakes a copy at build time as a fallback.
- ./app:/app/app
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini:ro
depends_on:
db:
condition: service_healthy
@ -68,9 +62,6 @@ services:
REDIS_URL: redis://redis:6379/0
volumes:
- ./config:/app/config:ro
- ./app:/app/app
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini:ro
depends_on:
db:
condition: service_healthy

View file

@ -1,146 +0,0 @@
# Paddle merchant onboarding — Read the Markets
Use this when filling Paddle's seller-application / business-description
fields. Framing is deliberately **media / publishing**, not financial
services — "trading", "signals", "advice" wording triggers rejection or
sends the application to extra compliance review.
---
## Business description (one paragraph)
**Read the Markets** is a UK-based subscription publishing service for
retail investors who want to *understand* what's moving in markets
without acting on tips or signals. The site aggregates public market
data (prices via Yahoo Finance) and public RSS news feeds, then
generates plain-English written commentary using a large language model.
Subscribers read; the service does not trade, hold client funds, or give
personal financial advice. Operated by Giorgio Gilestro, ICO-registered
as ZC098928.
## What we sell
A single B2C subscription that unlocks extended access to our written
market commentary and personal-portfolio analysis features. There is
one product, two billing cadences:
- **Read the Markets — Paid plan, Monthly** — £7 GBP / month
- **Read the Markets — Paid plan, Annual** — £70 GBP / year
A free tier exists indefinitely (no card required) and gives access to
the core editorial at reduced refresh cadence. Pricing in GBP; VAT
handled by Paddle as merchant of record.
## What subscribers get on the Paid plan
- 24-hour news headline window (free: 6 hours)
- Strategic interpretation log refreshed every hour during market hours
(free: every six hours)
- Daily written digest emailed MondaySaturday
- The ability to ask follow-up questions to the AI about any past
published interpretation
- Optional upload of a personal portfolio CSV (currently Trading 212
export) for an AI commentary on diversification and macro-regime fit
— purely descriptive, no buy/sell calls
- Optional end-to-end encrypted cloud sync of the portfolio file
## What we explicitly do **not** do (regulatory framing)
- **Not a financial-advice service.** We do not produce personalised
recommendations or consider a user's wider finances, debts, tax
position, or objectives.
- **No buy/sell/hold signals.** Output is editorial commentary on
public data.
- **No brokerage.** We never execute trades, hold client money, or
custody assets.
- **Not regulated under FSMA / FCA COBS.** This is explicitly stated on
the site disclaimer and in the portfolio-analysis feature
description.
- **No crypto trading, no margin/leverage products, no copy-trading,
no managed accounts.**
- **No tipster service.** All copy emphasises the difference between
"understanding markets" and "gambling on them."
## Audience
Retail readers in the UK and EU who want a daily macro briefing in
plain English. Comparable to a paid newsletter (e.g. Substack finance
writers) or a personal-finance magazine subscription, delivered as a
web app + email.
## Refund & cancellation policy
Published at <https://read.markets/terms> §6: 14-day statutory
cooling-off (Consumer Contracts Regulations 2013), cancel-any-time
taking effect at end of billing period, pro-rata refund if we terminate
service through no fault of the user. Refund requests handled by email
at <hello@read.markets>.
---
## Comprehensive product overview (single-field answer)
> Use this when Paddle asks **"Could you provide a comprehensive
> overview of your product?"** — one self-contained block, ~400
> words, designed so the reviewer hits the "not a financial product"
> framing within the first two sentences.
**Read the Markets** (<https://read.markets>) is a UK-based
subscription publishing service that helps retail readers *understand*
what is moving in financial markets — through plain-English written
commentary, not through trading signals, advice, or recommendations.
It is best understood as a digital newsletter / news-and-media
subscription product, with an AI-content layer, comparable to a paid
financial newsletter on Substack or a digital news magazine. We do not
execute trades, hold client funds, custody any assets, or operate as a
broker or investment adviser. We are not authorised by the FCA and we
are not a regulated financial service. This positioning is explicit on
the live site disclaimer at <https://read.markets/disclaimer> and in
the body copy of every paid feature.
The product works as follows. We aggregate public market data (stock,
FX, commodity, and rate quotes via Yahoo Finance) and public RSS news
feeds across the macro universe. A large language model (via
OpenRouter) then writes a short editorial interpretation of what the
underlying public data appears to be saying. The output is a written
article — a "strategic log" — refreshed through the trading day, plus
per-asset-class commentary panels and a daily / weekly written digest
delivered by email. Subscribers can also optionally upload a personal
portfolio holdings CSV (currently exported from Trading 212) to receive
a written sense-check of diversification, currency exposure, and
macro-regime fit on those holdings; this output is purely descriptive
and contains no buy, sell, or hold recommendations.
A free tier exists indefinitely (no card required) and serves the core
editorial at a reduced refresh cadence (6-hour news window, strategic
log refreshed every six hours, weekly Sunday digest only). The Paid
plan extends those to a 24-hour news window, hourly strategic log
refresh, daily MonSat email digest, the optional portfolio upload +
AI commentary, an interactive follow-up chat against any past
published article, and optional encrypted cloud sync of the portfolio
file.
Pricing is in GBP, with Paddle as merchant of record handling VAT:
**£7 / month** or **£70 / year** (two months free). Subscribers can
cancel any time, taking effect at the end of the current billing
period. A 14-day statutory cooling-off period applies under the UK
Consumer Contracts Regulations 2013, plus pro-rata refunds where we
terminate service through no fault of the user — full refund policy at
<https://read.markets/terms> §6. Operated by Giorgio Gilestro,
ICO-registered as ZC098928, contact <hello@read.markets>.
---
## Practical tips when completing the Paddle form
- **Category dropdown:** pick **"Software / SaaS — Content &
publishing"** or **"Digital subscription — News & media"** if those
options exist. Avoid anything containing the words *financial
services*, *trading*, *investing tools*, or *fintech*.
- **Self-declaration:** describe the product as **media / publishing
with an AI-content angle** — not a financial service.
- **Linkable references for the reviewer:**
- Pricing & tier breakdown: <https://read.markets/pricing>
- Disclaimer (the legal "not advice" statement): <https://read.markets/disclaimer>
- Terms & Conditions (incl. §6 Refunds): <https://read.markets/terms>
- Privacy notice (ICO ZC098928 surfaced here): <https://read.markets/privacy>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,338 +0,0 @@
# Beta Mode + Wider Paid/Free Gap — Design Spec
**Date:** 2026-05-25
**Status:** Draft — awaiting approval
**Scope:** Three coordinated changes that ship together because they all
target the closed-beta launch: (1) a visible BETA indicator in the app
chrome, (2) a free-tier cap on the news feed window, and (3) email
digests — daily for paid, Sunday-weekly for everyone.
## 1. Goals
- Set expectations for closed-beta testers that the product is still
evolving, without making the app look unfinished.
- Create a tangible reason to upgrade beyond portfolio import + sync.
Today the free tier carries nearly the entire editorial layer; after
this change, paid gets meaningfully more.
- Use existing infra (scheduler, SMTP, OpenRouter, settings page)
rather than introducing new dependencies.
## 2. Non-goals
- No payment / Paddle work. Pricing copy is updated, but checkout is
still gated behind the existing "Coming soon" CTA.
- No per-user personalised digests. Same content for all recipients.
- No timezone handling for users. Fixed 06:30 UTC daily send.
- No new analytics / metrics dashboards for opens or clicks beyond a
simple audit row per send.
## 3. Component overview
| Component | Change |
|-----------|--------|
| `app/templates/base.html` | BETA chip in header next to brand. |
| `app/static/css/cassandra.css` | `.beta-chip` styles. |
| `app/config.py` | `BETA_MODE: bool = True` env flag. |
| `app/services/access.py` | `FREE_NEWS_WINDOW_HOURS = 6` constant. |
| `app/routers/api.py:222-280` (`news_list`) | Soft-auth dep + free-tier window clamp. |
| `app/templates/partials/news.html` | "Showing last 6h — upgrade for 24h" footer when capped. |
| `app/models.py` | New columns on `User`: `email_digest_opt_in: bool`, `digest_tone: str | None`. New table `EmailSend(user_id, kind, sent_at, status, error)`. |
| `alembic/versions/0017_email_digest.py` | Migration. |
| `app/services/openrouter.py` | Add `build_daily_digest_prompt(tone)` and `build_weekly_digest_prompt(tone)`, bump `PROMPT_VERSION`. |
| `app/services/email_service.py` | Add `render_digest_email(kind, tone, content)` + `send_digest(user, kind, tone, html, text)`. |
| `app/jobs/email_digest_job.py` (new) | Daily-or-weekly orchestrator. Generates content once per tone, fans out to recipients. |
| `app/scheduler_main.py` | Register the new job at 06:30 UTC. |
| `app/templates/settings.html` | "Email digests" section: opt-in toggle, tone radio. |
| `app/templates/login.html` (post-OTP-verify flow) | Default-checked "send me the digest" checkbox. |
| `app/routers/auth.py` | OTP-verify handler reads the new `subscribe_to_digests` form field and sets `email_digest_opt_in` on the new `User`. |
| `app/routers/settings.py` (or wherever existing settings PATCH lives) | New `PATCH /api/settings/digest` endpoint: body `{opt_in: bool, tone: "NOVICE"|"INTERMEDIATE"}`. |
| `app/routers/email.py` (new) | `GET /email/unsubscribe?token=...` — HMAC-verified one-click off switch. |
| `app/templates/pricing.html` | Updated bullets — free gets weekly digest + last 6h news; paid gets daily digest + last 24h news. |
## 4. Detailed design
### 4.1 BETA chip
`app/templates/base.html` only (not `public_base.html`). Insert
immediately after the brand link:
```html
<a href="/" class="brand">{{ BRAND_NAME }}</a>
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome">BETA</span>{% endif %}
```
The `BETA_MODE` flag is injected into the template context globally —
add it to `app/templates_env.py` so every render gets it (analogous to
how `BRAND_NAME` is provided today).
CSS: small uppercase pill with `var(--accent)` background and brand-bg
foreground; spaced ~8px from the brand link. Sizing matches the
existing top-right `.meta` chip so the visual weight is balanced.
`BETA_MODE` defaults to `True` in `app/config.py`. Flip to `False` for
general launch — one-line change.
### 4.2 Free-tier news cap (6h window)
`app/routers/api.py`'s `news_list` (lines 222-280) currently has no
auth dep. Add `principal: CurrentUser | None = Depends(maybe_current_user)`
(soft-auth — keeps the endpoint reachable by anonymous visitors,
matching today's behaviour).
Compute the effective window:
```python
window = since_hours
if not is_paid_active(principal):
window = min(window, FREE_NEWS_WINDOW_HOURS) # 6.0
cutoff = utcnow() - timedelta(hours=window)
```
`FREE_NEWS_WINDOW_HOURS = 6.0` lives in `app/services/access.py` as a
module-level constant. Tuning it later is one edit; promoting it to env
config is YAGNI for now.
`is_paid_active` already accepts `CurrentUser | User | None` and admins
auto-pass — no special-casing needed here.
When the cap is in effect, pass `capped: True` into the news partial so
it can render a soft footer:
> Free tier — showing the last 6 hours of news. [Upgrade] for the full
> 24-hour feed plus daily and weekly email digests.
When the user is paid (or admin), the footer is absent. When the
visitor is anonymous, the link goes to `/pricing`.
### 4.3 Database changes
```python
# app/models.py — User columns added:
email_digest_opt_in: Mapped[bool] = mapped_column(Boolean, nullable=False,
default=True, server_default=text("1"))
digest_tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE | INTERMEDIATE
```
Defaults to opted-in (matches the chosen UX). `digest_tone` is nullable;
NULL is interpreted as INTERMEDIATE at render time.
New table:
```python
class EmailSend(Base):
__tablename__ = "email_sends"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, index=True)
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"),
)
```
Idempotency: the job queries `EmailSend` for the current day before
sending, so a job restart can't double-deliver.
Migration: `alembic/versions/0017_email_digest.py`.
### 4.4 Digest content generation
Two new prompts in `app/services/openrouter.py`:
- `build_daily_digest_prompt(tone)` — returns a `(system, user)` pair.
Pulls the same `quotes_by_group` + `headlines_by_bucket` data as
the hourly log but with a 24h headline window and a different
instruction set: less "current state" framing, more "what mattered in
the past day, what to watch today". Target length ~600 words.
- `build_weekly_digest_prompt(tone)` — 7-day headline window, weekly
recap + week-ahead anticipation. Target length ~900 words.
Both reuse `call_llm()` and the existing cost-cap / ledger plumbing.
`PROMPT_VERSION` is bumped so the audit trail is unambiguous.
For each digest run, the job generates **two** variants
(NOVICE + INTERMEDIATE) and stores them in memory for the fan-out
batch. There is no DB persistence of digest content — emails are the
artefact. If we later want to render them on a web archive, that's a
separate spec.
Cost: at our current model pricing, two daily generations ≈ $0.04/day,
two weekly generations ≈ $0.06/week. Both well under the existing
`OPENROUTER_MONTHLY_CAP_USD` headroom.
### 4.5 Email rendering and delivery
`render_digest_email(kind, tone, html_body, text_body) -> (subject,
text, html)` in `email_service.py`. Wraps the LLM output in the same
multipart template family used for OTPs (light/dark palette, inline
styles, monospace stack, 520px max-width). Adds two footer rows:
- "Don't want these? [Unsubscribe in one click](.../email/unsubscribe?token=...)"
- "Or change your preferences in [Settings](.../settings)"
Subjects:
- Daily: `"Read the Markets · Daily — {date}"`
- Weekly: `"Read the Markets · Weekly recap — {date}"`
Fan-out: one SMTP send per recipient. Sequential with a small
`asyncio.sleep(0.1)` between sends to stay under common SMTP rate
limits. Failures are caught per-recipient, logged into `EmailSend`,
and don't block the rest of the batch.
### 4.6 Sign-up opt-in checkbox
The OTP-verify POST handler in `app/routers/auth.py` is where the user
is first established — that handler reads a `subscribe_to_digests`
form field and persists it into `User.email_digest_opt_in`. Default to
`True` if the field is absent (covers older clients or curl flows).
The verify template (`app/templates/verify.html`) gets a checkbox:
```html
<label>
<input type="checkbox" name="subscribe_to_digests" checked>
Email me the digest (daily for paid, Sunday for everyone). One-click
unsubscribe in every email.
</label>
```
Pre-existing users get `email_digest_opt_in=True` from the migration's
server-side default — but see §6 for the cutover plan.
### 4.7 Settings page
In `app/templates/settings.html`, add a section:
```
Email digests
[✓] Send me digests
Free tier: Sunday weekly. Paid: daily + Sunday.
Reading level: ( ) Novice (•) Intermediate
Last delivery: 2026-05-24 06:30 UTC — sent
```
The "Last delivery" row reads the most recent `EmailSend` row for this
user. If none, shows "—".
Wire it via the existing settings JS pattern (look for the sync /
tone-toggle handlers in `static/js/`); the endpoints are
`PATCH /api/settings/digest` with body `{opt_in: bool, tone: str}`.
### 4.8 One-click unsubscribe
`GET /email/unsubscribe?token=<base64>`:
- Token is `itsdangerous.URLSafeSerializer` over `{"uid": user_id,
"purpose": "digest_optout"}`, signed with `CASSANDRA_SECRET`.
- Handler verifies, flips `email_digest_opt_in=False`, renders a tiny
confirmation page ("You're unsubscribed. Re-enable any time in
[Settings](/settings).").
- No auth required — that's the whole point of one-click unsubscribe.
- Replay-safe: re-running the same URL is idempotent (the column
is already false; the page renders the same confirmation).
### 4.9 Scheduler integration
`app/scheduler_main.py` already runs the hourly jobs. Add:
```python
schedule_daily( # whatever helper exists, or apscheduler equivalent
"email_digest_job",
hour=6, minute=30,
target=email_digest_job.run,
)
```
The job itself decides what to do:
```python
async def run():
today = utcnow().date()
if today.weekday() == 6: # Sunday — weekly digest for everyone
await _run_weekly()
else:
await _run_daily() # paid only
```
`_run_weekly()` queries all users with `email_digest_opt_in=True`.
`_run_daily()` queries paid users with `email_digest_opt_in=True`.
### 4.10 Pricing copy updates
`app/templates/pricing.html` — modify the bullet lists. New copy:
Free:
- "News aggregator — last 6 hours, auto-tagged by theme" (was: no time qualifier)
- "Cross-asset macro signals across every asset class"
- "Hourly AI interpretation of the news + the tape"
- "Per-group cross-asset summaries"
- "Novice / Intermediate reading levels"
- "**Sunday weekly digest by email**" *(new)*
- "❌ Portfolio import & analysis"
- "❌ Encrypted cloud sync"
Paid (replaces the "Priority email when something material changes (later)" line):
- "Everything in Free"
- "**News aggregator — full 24 hours**" *(replaces the implicit 24h)*
- "Portfolio import (Trading 212 CSV)"
- "AI commentary on diversification, sector and currency concentration, …"
- "Optional encrypted cloud sync across devices"
- "**Daily email digest** (MonSat) + Sunday weekly" *(replaces 'Priority email')*
The intro paragraph at lines 8-13 needs to soften:
> Two tiers. The news aggregator and hourly AI interpretation are
> available to everyone — paid extends the time window from 6h to 24h
> and adds daily editorial by email, plus the portfolio-import features.
(Old copy said "free for everyone — we want the read out where people
can use it." That stance is moderated, not abandoned.)
## 5. Error handling
- **SMTP failure**: per-recipient try/except. Log to `EmailSend` with
`status="error"`, `error=str(exc)[:255]`. Job continues. Job-level
failure metrics surface via existing `JobRun` mechanism.
- **OpenRouter failure**: if the content generation fails for both
tones, the job records `JobRun.status="error"` and sends nothing.
Half-success (one tone) → send the variant that worked, skip the
other; users on the failed tone get nothing today (rather than wrong
content).
- **Cost-cap hit**: same pattern as the hourly job — skip the run with
a logged reason.
- **Unsubscribe token invalid / tampered**: render the same
confirmation page generically; do not leak whether the token was
valid (avoid enumeration).
## 6. Cutover plan
- Migration sets `email_digest_opt_in=True` for all existing users via
`server_default`. This is the user-requested default — they want
every paid beta-tester actually receiving the email.
- BETA mode is on from the first deploy.
- News cap is on from the first deploy.
- The first daily run lands at 06:30 UTC the morning after deploy.
- The first weekly run lands at 06:30 UTC the next Sunday.
- A pre-deploy admin CLI command (`python -m app.cli send-test-digest
--email me@…`) is added for the operator to dry-run a digest into
their own inbox before flipping the scheduler.
## 7. Testing
- Unit: `is_paid_active` window-clamping, opt-in flag round-trip,
token signing/verification.
- Integration: `tests/test_news_api.py` — anonymous + free vs paid
windowing. `tests/test_email_digest.py` — job runs, EmailSend rows
written, idempotency on re-run within the same day.
- Manual: send-test-digest CLI, click unsubscribe link, verify Settings
toggle round-trips.
## 8. Open questions
None at design time — all earlier ambiguities (chip scope, window
shape, content shape, send time, tone variants, opt-in default,
unsubscribe model) were resolved during brainstorming.

View file

@ -1,279 +0,0 @@
# LLM-fallback CSV parser — Design Spec
**Date:** 2026-05-27
**Status:** Draft — pending implementation plan
## Context
Today the only supported broker import is Trading 212. `parse_t212_csv` expects
T212's exact column set (`Slice`, `Owned quantity`, etc.) and raises
`CSVImportError` on anything else. Every non-T212 user hits a wall at
onboarding.
Rather than write a hand-rolled parser per broker (IBKR, Vanguard, Fidelity,
Schwab, eToro, Degiro, …) — and chase format drift forever — we use an LLM as
a transparent fallback. The LLM never sees holdings as data; it only looks at
**headers plus a handful of sample rows** and returns a JSON column-mapping.
Our existing Python code does the row iteration.
The first time a broker format appears, the LLM produces a mapping. We
fingerprint the format (sha256 of normalized headers) and cache the mapping
in a new `csv_format_templates` table. Every subsequent upload of the same
format — by any user — replays the cached mapping deterministically, with no
LLM call.
The cache row stores the header row and a single anonymous sample data row
(the first row from the originating upload, verbatim). No user identifier is
recorded — the row is not linked back to whoever uploaded it. The purpose of
the sample is to give the operator material to look at when designing future
native parsers; this collection is **passive learning only**, the system
never attempts to author or modify parser code automatically.
Portfolio import is already advertised as a paid-only feature; we make that
explicit at the route level as part of this work.
## Goals
- Accept CSV exports from any broker, not just T212.
- Pay the LLM cost only once per **format**, not once per user.
- Never persist user holdings on the server (already a system-wide invariant).
- Surface the same response shape to the browser regardless of which parser
branch ran — no client changes beyond a copy tweak.
## Non-goals
- Per-broker UI customisation. The drop-zone stays generic.
- A human admin queue for reviewing LLM-discovered formats. Operator can
inspect rows directly in the DB if curious.
- **Auto-promoting learned formats to native parsers.** The operator will
hand-write any native parser by looking at the collected sample rows. The
system never writes or modifies code.
- Self-healing or auto-evicting stale cache entries. If a broker silently
changes their export shape under us, the cached mapping will start
producing parse errors; the operator deletes the row manually. We do not
invalidate cache entries automatically.
- Multi-stage / verification LLM passes. One call per first-time format.
## Architecture
```
POST /api/portfolio/parse (paid-only)
├─ parse_t212_csv(raw) ── happy path, unchanged
│ └─ CSVImportError ↴
├─ parse_with_llm(raw, session)
│ ├─ detect delimiter + preamble offset
│ ├─ fingerprint = sha256(normalised headers)
│ ├─ SELECT csv_format_templates WHERE fingerprint=?
│ │ ├─ HIT → apply mapping (bump use_count/last_used_at after successful parse)
│ │ └─ MISS → openrouter.call_llm(headers + 3-5 sample rows)
│ │ → validate mapping
│ │ → INSERT csv_format_templates
│ │ → apply mapping
│ └─ returns ParsedPie (same shape as T212 path)
└─ resolve_slice → upsert_tickers → inline Yahoo fetch → JSON response
(existing pipeline, unchanged)
```
### Why column-mapping, not full extraction
We pass the LLM only **headers plus 35 sample rows**, not the full CSV. The
LLM returns column names, not transcribed numbers. Three benefits:
1. **Safety** — LLMs hallucinate digits; they don't hallucinate column names
that aren't there. Mapping validation can verify every named column exists
in the actual header row.
2. **Cost** — prompt is ~1 KB regardless of portfolio size.
3. **Cacheability** — the mapping IS the cache. Replay is deterministic Python,
no LLM in the loop on re-imports.
### Why global cache, not per-user
The column structure of an IBKR Activity Statement is a property of IBKR, not
of any individual user. The cache row contains no user identifier — the
sample data row is stored verbatim but anonymously, with nothing linking it
to the uploader. Global cache is strictly better: faster onboarding for the
second IBKR user, and the collected samples form a small, useful corpus for
hand-writing native parsers later.
## Data model
New table `csv_format_templates`:
| Column | Type | Notes |
|---|---|---|
| `id` | int PK | |
| `fingerprint` | `VARCHAR(64) UNIQUE NOT NULL` | sha256 hex of normalised header tuple |
| `headers` | JSON | List of strings — actual header row from the upload |
| `sample_row` | JSON | First data row from the originating upload, verbatim. Not linked to any user. |
| `mapping` | JSON | `{ticker_col, qty_col, name_col, cost_col, currency_col}` |
| `preamble_rows` | INT NOT NULL DEFAULT 0 | Non-data lines before the header row |
| `delimiter` | CHAR(1) NOT NULL DEFAULT ',' | |
| `broker_label` | VARCHAR(128) | LLM-identified label, e.g. "Interactive Brokers Activity Statement" |
| `first_seen_at` | DATETIME(tz) NOT NULL | When the format was first cached |
| `use_count` | INT NOT NULL DEFAULT 1 | Bumped on each successful cache hit |
| `last_used_at` | DATETIME(tz) NOT NULL | |
| `llm_model` | VARCHAR(64) | Provenance of the initial extraction |
| `llm_cost_usd` | FLOAT | Same |
Migration: `alembic/versions/0021_csv_format_template.py` (based on `0020`).
The full uploaded CSV is **not** stored — only the header row plus a single
data row (`sample_row`). No `user_id` column exists on this table; the sample
is anonymous by construction. This is a deliberate, narrow exception to the
otherwise-strict "no holdings persisted" invariant: we keep one row per
format so the operator has concrete material to look at when hand-writing a
future native parser. One anonymous row carries no portfolio context (no
totals, no other positions) and cannot be linked back to an account.
## Components
### `app/services/llm_csv_parser.py` — new
Public surface:
```python
async def parse_with_llm(
raw: bytes,
session: AsyncSession,
) -> ParsedPie:
"""LLM-fallback CSV parser.
Decodes raw bytes, detects delimiter and preamble offset, fingerprints
the header row, hits the csv_format_templates cache. On miss, calls
openrouter.call_llm with headers + 3-5 sample rows to extract a
column-mapping, validates it, persists a new template, and applies the
mapping. Returns the same ParsedPie shape as parse_t212_csv.
"""
class LLMParseError(ValueError):
"""Raised when the LLM call fails or returns an unusable mapping."""
```
Internal helpers (not exported):
- `_detect_dialect(raw: bytes) -> tuple[str, int]` — returns `(delimiter, preamble_rows)`. Uses Python's `csv.Sniffer` for delimiter, then walks rows until the first row whose tokens look like column headers (heuristic: all-strings, none parse as numbers).
- `_fingerprint(headers: list[str]) -> str` — lowercases, strips whitespace, joins with `|`, returns sha256 hex.
- `_extract_mapping_via_llm(client, headers, samples) -> dict` — builds the system prompt, calls `openrouter.call_llm`, parses the JSON envelope, raises `LLMParseError` on malformed output.
- `_validate_mapping(mapping, headers, first_row) -> None` — every named column must exist in `headers`; `qty_col`'s value on `first_row` must parse as a positive number; `cost_col` (if present) must parse as a number. Raises `LLMParseError` on failure.
- `_apply_mapping(rows, mapping) -> ParsedPie` — iterates remaining rows, builds `ParsedPosition` instances, computes totals from `qty * avg_cost` when explicit totals aren't present.
Reuses without modification:
- `app/services/openrouter.py::call_llm` — provider fallback chain + AICall ledger logging
- `app/services/csv_import.py::ParsedPie, ParsedPosition, CSVImportError` — same return type, same error hierarchy. `LLMParseError` inherits from `CSVImportError` so the route can catch both as one.
### `app/routers/universe.py::parse_portfolio` — modified
Two small changes:
1. Add `Depends(require_paid)` to the route decorator. (Portfolio import has always been advertised as paid; this aligns the implementation.)
2. Wrap the existing `parse_t212_csv` call in a try/except that falls through to `parse_with_llm` on `CSVImportError`:
```python
try:
pie = parse_t212_csv(raw)
except CSVImportError:
from app.services.llm_csv_parser import parse_with_llm, LLMParseError
try:
pie = await parse_with_llm(raw, session)
except LLMParseError as e:
raise HTTPException(status_code=400, detail=str(e))
```
Everything below this point in the function — resolve_slice loop, upsert_tickers, inline Yahoo fetch, response build — is unchanged. `pie` has the same shape regardless of branch.
### `app/models.py` — new model
`CsvFormatTemplate` declared alongside the other tables. Columns as in the data model table above.
### `app/templates/settings.html` — copy tweak
- Section heading: "Import portfolio (Trading 212 CSV)" → "Import portfolio (CSV)"
- Drop-zone label: "Drop a T212 pie CSV here" → "Drop your broker's portfolio CSV here"
- Drop-zone hint: append " · T212, IBKR, and others auto-detected" after the size limit
- The "Export your pie from T212" instructions paragraph stays as a help link — T212 is still the best-documented happy path — but its phrasing softens to "If you use Trading 212…"
## LLM prompt shape
System prompt fixes the schema. User message contains headers + samples.
```
SYSTEM: You are an expert at recognising broker portfolio CSV formats.
You will be given the header row and 3-5 sample data rows from a CSV.
Identify which column contains each field. Return ONLY JSON, no prose.
Schema:
{
"ticker_col": "<header name or null>",
"qty_col": "<header name or null>",
"name_col": "<header name or null>",
"cost_col": "<header name or null>", // average price per share or unit cost
"currency_col": "<header name or null>",
"broker_label": "<short identifier like 'IBKR Activity Statement' or null>"
}
Rules:
- Use null when no column is a good match.
- ticker_col and qty_col are required; if either is missing return all nulls.
- Use the EXACT header string as it appears in the input.
USER: headers: ["Symbol","Position","Avg Price","Currency"]
samples:
AAPL,100,150.00,USD
MSFT,50,300.00,USD
...
```
The LLM never sees the entire file; it sees only the first ~5 data rows.
Token cost is bounded and uniform regardless of portfolio size.
## Error handling
| Failure | Response | Ledger |
|---|---|---|
| LLM provider down | 502 "couldn't parse — try again later" | AICall status=failed |
| LLM returns non-JSON | 400 "couldn't recognise as portfolio CSV" | AICall status=ok, no template stored |
| Mapping missing required columns (ticker/qty) | 400 same | AICall status=ok, no template stored |
| Mapping references non-existent column | 400 same | AICall status=ok, no template stored |
| Mapping validates but row parse fails on numerics | 400 same | template NOT stored |
| Cache hit but row parse fails (format drifted under us) | 400 with parse error | — |
If a broker quietly changes their CSV shape such that a previously-good
cached mapping starts producing parse failures, the user sees an error and
the operator deletes the offending `csv_format_templates` row by hand. No
automatic eviction, no automatic retry. The cache is a learning store, not
a self-managing system.
## Testing
`tests/test_llm_csv_parser.py`:
- **Fingerprint stability** — case/whitespace/BOM variants of the same headers hash to the same fingerprint.
- **Cache hit path** — pre-populate a `CsvFormatTemplate` row, mock `call_llm` to fail loudly, assert it is NOT called, assert positions come out correct, assert `use_count` is incremented.
- **Cache miss path** — mock `call_llm` to return a valid mapping JSON, assert a row is inserted with the upload's actual first data row as `sample_row` and no user_id anywhere, assert positions come out correct.
- **LLM returns malformed JSON** — raises `LLMParseError`, no template stored.
- **LLM maps to non-existent column** — raises `LLMParseError`, no template stored.
- **LLM maps qty to a non-numeric column** — raises `LLMParseError` on validation.
- **Stale cached mapping on parse failure** — pre-populate a template whose mapping no longer matches the file content, assert a 400 is returned and the template is NOT deleted automatically (operator owns eviction).
- **Integration** — POST a fabricated IBKR-shaped fixture to `/api/portfolio/parse`, assert ParsedPie round-trips, assert no second LLM call on a repeat upload.
Existing `tests/test_csv_import.py` must still pass — the T212 happy path is unchanged.
## Verification
End-to-end manual check after deploy:
1. Upload a T212 fixture → exists path stays unchanged (same dashboard load behaviour).
2. Upload a fabricated IBKR CSV → first upload calls LLM, returns positions, template row created in DB.
3. Re-upload the same IBKR CSV → second call has zero LLM cost (verify by counting `ai_calls` rows before/after), `use_count` increments to 2.
4. Inspect `csv_format_templates` row: confirm `headers` matches the upload's headers, `sample_row` is the first real data row, no `user_id` column exists on the table.
5. Upload random garbage (e.g. a screenshot renamed `.csv`) → 400 with clean error, no template stored, AICall row logged.
6. Free-tier account attempts import → 402 (paid gating).
## Open questions for the implementation plan
- Whether to read sample rows with `csv.reader` and re-encode them as text for the LLM (safer for embedded commas/quotes), or pass the raw first-N-lines verbatim. Default: the safer reader path.
- Whether to cap LLM-parsed portfolios at the same 1 MB limit as T212 (yes) and whether to add a separate cap on number-of-rows fed to the LLM as samples (yes, 5).
- Whether to log the fingerprint to the request log on cache hit/miss for operability. Default: yes, at INFO level, with `event_type="csv.format.cache_hit"` / `"csv.format.cache_miss"`.

View file

@ -1,312 +0,0 @@
# Manual portfolio composition — Design Spec
**Date:** 2026-05-27
**Status:** Draft — pending implementation plan
## Context
Today the only way to populate the dashboard portfolio is to upload a CSV
(Trading 212 natively, anything else via the LLM-fallback parser landed in
the spec at `2026-05-27-llm-csv-fallback-parser-design.md`). That covers
users who already keep their holdings in a broker that can export. It does
not cover:
- New users who want to try the dashboard with a handful of holdings without
hunting for an export feature in their broker.
- Existing users who bought something after their last export and want to
add a single position without re-importing.
- Users whose broker provides no usable export (some legacy ISAs, employer
share schemes, niche EU brokers).
This feature adds a **dashboard-native edit mode**. A pencil-icon EDIT
button next to the portfolio heading toggles inline editing: each row
reveals a × delete button, and a small "Add a position" form appears at
the top of the portfolio block. Live prices continue updating while
editing. Brand-new users (no portfolio in localStorage) see the add form
directly in place of the existing "No portfolio loaded — import a CSV"
message.
The CSV import flow in `/settings` is unchanged.
## Goals
- Let the user add a position to their dashboard portfolio in under 15
seconds without leaving the page.
- Sanity-check tickers before they hit localStorage, so the dashboard never
shows symbols that won't price.
- Stay consistent with the existing data-flow invariant: the browser
localStorage owns the portfolio; the server persists no holdings.
- Accept either an average-cost-per-share or a "I bought on date X" input;
both produce the same stored shape (`avg_cost + qty`).
## Non-goals
- Multi-pie / named portfolios. Single localStorage pie remains the model.
- Persisting acquisition dates. The date input is a UX helper that
populates the cost field; only `avg_cost + qty` are stored.
- In-place edit of an existing row's qty or cost. Delete + re-add.
- Saving the portfolio to the server. localStorage stays the source of
truth.
- A separate manual-entry section on `/settings`. The feature lives on the
dashboard only.
## Architecture
```
Dashboard (/)
├─ Portfolio div
│ ├─ [✎ Edit] button (toggles edit mode in place)
│ ├─ Add-position form (visible only in edit mode, or always when empty)
│ │ ├─ Ticker input → on blur → GET /api/ticker/validate
│ │ ├─ Qty input
│ │ ├─ Cost mode toggle: ● Avg cost / ○ Bought on date
│ │ ├─ Cost field (number, or date-picker that auto-fills cost
│ │ │ via GET /api/ticker/historical)
│ │ └─ [Add] button (enabled only when validated + qty > 0 + cost > 0)
│ └─ Positions table
│ └─ Per-row [×] (visible only in edit mode)
└─ (everything else unchanged)
```
Two new **paid-only** endpoints are added; both wrap the existing
`app/services/market.fetch` machinery and do not persist holdings. The
edit-mode JavaScript merges new positions directly into the same
localStorage pie that the CSV import path writes.
### Why dashboard-native, not Settings
Editing a portfolio is a portfolio-management action. Putting it on the
dashboard puts the affordance where users naturally look — alongside the
thing they're editing. It also makes the empty-state CTA actionable in
place rather than redirecting to another page first.
### Why two endpoints instead of one
`/api/ticker/validate` returns the current quote — that's the live signal
the user wants for sanity reassurance ("yes, this symbol exists, and it's
worth $X today"). `/api/ticker/historical` returns the close on a chosen
date — only needed when the user picks "Bought on date" mode. Splitting
them keeps each endpoint's contract tight; combining would force the
common-case validate call to ship unused historical machinery.
## Data flow
### Validation flow (every add)
1. User types `AAPL` and tabs out of the ticker field.
2. JS calls `GET /api/ticker/validate?symbol=AAPL`.
3. Server calls `market.fetch` for a single ticker. If a quote comes back,
returns `{ok: true, name: "Apple Inc", currency: "USD", price: 172.40,
as_of: "2026-05-27"}`. Side-effect: seeds anonymous `ticker_universe`
(same as the CSV path does).
4. JS shows a green inline check + `Apple Inc · $172.40 USD`.
5. Subsequent fields enable; Add button enables when all required filled.
### Historical-price flow (only in "Bought on date" mode)
1. User selects a date in the picker (e.g. `2024-01-15`).
2. On blur, JS calls `GET /api/ticker/historical?symbol=AAPL&date=2024-01-15`.
3. Server fetches the daily close for that date from Yahoo. If the date is
a non-trading day (weekend / holiday), uses the **last preceding trading
day** and returns its actual date.
4. Returns `{ok: true, close: 185.92, currency: "USD", actual_date: "2024-01-12"}`.
5. JS auto-fills the cost field with the close, shows a subtle "from 2024-01-12"
tag the user can dismiss. User can edit the auto-filled number.
### Add flow
1. User clicks **+ Add**.
2. JS reads localStorage pie (or creates a fresh `{positions: []}` shape if
none), appends a new position object:
```json
{
"yahoo_ticker": "AAPL",
"t212_slice": "AAPL",
"name": "Apple Inc",
"qty": 100,
"avg_cost": 150.25,
"currency": "USD"
}
```
3. Writes back to localStorage.
4. Re-renders the positions table; the new row appears with live price
columns populating on the next `/api/universe` refresh.
5. Clears the form, focus returns to the ticker input for rapid serial entry.
### Delete flow
1. User clicks `×` on a row.
2. JS removes that position by index (not by ticker — so duplicates can be
removed independently).
3. Writes localStorage, re-renders.
No undo. A confirmation dialog would be friction; the row can be re-added
in 10 seconds.
## Server endpoints
Both **require `Depends(require_paid)`** — matches the existing import
path. Anonymous / free-tier users get 402.
### `GET /api/ticker/validate?symbol={t}`
| Field | Meaning |
|---|---|
| `symbol` (query) | The ticker the user typed. Server uppercases + strips. Length capped at 32 chars. |
Returns JSON:
```json
{
"ok": true,
"name": "Apple Inc",
"currency": "USD",
"price": 172.40,
"as_of": "2026-05-27"
}
```
or on unrecognised symbol:
```json
{ "ok": false, "error": "Symbol not recognised" }
```
The endpoint returns 200 with `ok:false` for unrecognised symbols (so the
JS can render an inline error without parsing HTTP status). It returns 502
for upstream provider failures (so the JS shows "Try again" rather than
"Not recognised").
Side effect: on `ok:true`, the symbol is upserted into anonymous
`ticker_universe` so the next `/api/universe` request includes its price.
### `GET /api/ticker/historical?symbol={t}&date={YYYY-MM-DD}`
| Field | Meaning |
|---|---|
| `symbol` (query) | Same shape as validate. |
| `date` (query) | ISO date the user picked. Server validates format + rejects future dates with 400. |
Returns JSON:
```json
{
"ok": true,
"close": 185.92,
"currency": "USD",
"actual_date": "2024-01-12"
}
```
If the requested date is a non-trading day, the server walks back to the
last preceding day with a quote (up to a 7-day window) and returns
`actual_date` for transparency.
On a symbol that's never had price data on the platform or before the
ticker's earliest available date:
```json
{ "ok": false, "error": "No data for that date" }
```
## Client-side modules
### `app/static/js/portfolio_edit.js` (NEW)
A small module loaded by the dashboard template. Owns:
- The `enterEditMode()` / `exitEditMode()` toggle.
- The add-position form's event wiring (validate-on-blur, mode toggle,
historical lookup, Add click).
- The × button click handler.
- A `mergePosition(pos)` helper that takes a position object, reads
localStorage, appends, writes back, dispatches a custom `portfolio:changed`
event for the existing portfolio.js renderer to pick up.
### `app/static/js/portfolio.js` (MODIFIED, lightly)
Three small additions:
1. Empty-state CTA replaced. Instead of `"No portfolio loaded in this
browser. Import a portfolio CSV →"` linking to settings, the empty
state shows the inline add-position form (the same markup that edit
mode reveals) plus a small secondary link: `"Or import a CSV from your
broker →"` to `/settings#import`.
2. Listen for `portfolio:changed` and re-render.
3. Expose `window.CassandraPortfolio.merge(pos)` so `portfolio_edit.js`
can call it without circular module dependencies.
### `app/templates/dashboard.html` (MODIFIED)
Add:
- The `[✎ Edit]` button next to the portfolio heading.
- The add-position form markup (hidden by default; visible in edit mode
via a CSS class on the parent container, or always visible when the
pie is empty).
- A `<script src="/static/js/portfolio_edit.js" defer>` tag.
## Error / edge cases
| Case | Response |
|---|---|
| Yahoo provider down on validate | 502 → JS shows "Couldn't validate, try again." |
| Symbol not recognised | 200 `{ok:false}` → red inline "Not recognised" |
| Date in future | 400 → JS shows "Date can't be in the future" |
| Date before earliest data | 200 `{ok:false}` → "No data for that date" |
| Duplicate ticker (already in pie) | JS shows inline warning under the ticker field: `"Already in your portfolio (100 shares @ $150.25). Adding will create a duplicate row."` User can proceed; we do not auto-merge. |
| Currency mismatch when adding two lots of the same symbol | Not detected. Yahoo gives one currency per ticker; both rows get the same. |
| User refreshes the page mid-edit | Form state is lost; no draft preserved. Edit mode itself is not preserved either — the page loads in display mode by default. |
## Tests
Backend (`tests/test_ticker_validate.py`):
- `validate` happy path → 200 `{ok:true}` with mocked `market.fetch`.
- `validate` unknown symbol (mock returns no price) → 200 `{ok:false}`.
- `validate` provider failure → 502.
- `validate` paid-gate inspection (same pattern as the CSV route's gate test).
- `historical` happy path → 200 `{ok:true, close, currency, actual_date}`.
- `historical` future-date → 400.
- `historical` weekend date → returns nearest preceding trading day, `actual_date` reflects it.
- `historical` paid-gate inspection.
- `validate` side-effect: ticker is upserted into `ticker_universe` (assert by querying the table after the call).
Frontend: untested (this repo has no JS test framework). Manual smoke
covers the JS paths in the verification section.
## Verification
End-to-end manual check after deploy:
1. Empty-state path: log in as a user with no localStorage portfolio.
Confirm the dashboard shows the inline add form, not the old
"Import a CSV" link.
2. Add a position: enter `AAPL`, tab out, see "Apple Inc · $X USD" green
check. Enter qty `100`, avg cost `150.25`, click Add. Row appears in
the table with live price column populating shortly after.
3. Switch to **Bought on date** mode for a second add: enter `MSFT`, tab,
green check. Pick a recent weekday. Cost field auto-fills with the
close. Click Add; row appears.
4. Pick a Saturday: confirm the form shows `from 2024-01-12` (or
whichever Friday) tag.
5. Try a bogus symbol like `XYZNOTREAL`: red "Not recognised", Add stays
disabled.
6. Pick a future date: red inline error, Add stays disabled.
7. Edit mode round-trip: with a populated pie, click EDIT → × buttons
appear, add form appears. Delete a row, add another, confirm both
reflect on the next dashboard refresh.
8. Free-tier user: confirm a direct `GET /api/ticker/validate?symbol=AAPL`
returns 402.
9. Confirm `csv_format_templates` is untouched (no overlap between
features).
## Out-of-scope clarifications
- We do **not** implement an in-place "edit this row's qty" UX. Users who
averaged down can delete and re-add the position with the new average.
- We do **not** persist acquisition dates anywhere — only `avg_cost + qty`
reach localStorage. The date is a UX helper for filling the cost field.
- We do **not** offer to fetch the historical close for an "avg cost"
position. Date mode is the only path that triggers the historical lookup.
- We do **not** introduce a "Save to server" affordance. localStorage
remains authoritative.

View file

@ -23,7 +23,6 @@ dependencies = [
"email-validator>=2.2",
"aiosmtplib>=3.0",
"redis[hiredis]>=5.2",
"stripe>=11.0",
]
[project.optional-dependencies]
@ -31,7 +30,6 @@ dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-httpx>=0.34",
"aiosqlite>=0.20",
"ruff>=0.7",
]

View file

@ -1,10 +0,0 @@
Statement,Header,Field Name,Field Value
Statement,Data,BrokerName,Interactive Brokers LLC
Statement,Data,Title,Activity Statement
Statement,Data,Period,"January 1, 2026 - January 31, 2026"
Symbol,Quantity,Avg Price,Currency,Description
AAPL,100,150.25,USD,Apple Inc
MSFT,50,310.00,USD,Microsoft Corp
NVDA,40,425.50,USD,NVIDIA Corp
VOD.L,2000,0.74,GBP,Vodafone Group Plc
ASML.AS,10,650.00,EUR,ASML Holding NV
Can't render this file because it has a wrong number of fields in line 5.

View file

@ -1,145 +0,0 @@
"""Free-vs-paid gating on /api/chat and the strategic-log read endpoints.
Mirrors the integration-style setup from test_news_window.py: real router
over an in-memory aiosqlite DB, with two seeded users (free + paid)."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import pytest
_TONE = "INTERMEDIATE" # matches CASSANDRA_TONE default
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import StrategicLog, User
from app.routers import api as api_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/gates.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
# Two logs on the same UTC day: the newer one is at hour 1 (non-boundary)
# and the older one is at hour 0 (boundary). Free users should see the
# boundary one; paid users should see the newer one.
base = datetime(2026, 5, 25, tzinfo=timezone.utc)
boundary_at = base.replace(hour=0, minute=20)
newer_at = base.replace(hour=1, minute=20)
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="free@x", tier="free"))
s.add(User(id=2, email="paid@x", tier="paid"))
s.add(StrategicLog(
generated_at=boundary_at, model="m", tone=_TONE,
analysis="DRY", content="boundary-log",
))
s.add(StrategicLog(
generated_at=newer_at, model="m", tone=_TONE,
analysis="DRY", content="non-boundary-log",
))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(api_router.router, prefix="/api")
client = TestClient(app)
return client, sign_session(1), sign_session(2)
# --- /api/chat gating ------------------------------------------------------
def test_chat_blocks_free_user_with_402(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.post(
"/api/chat",
json={"messages": [{"role": "user", "content": "hi"}]},
cookies={"cassandra_session": free_sess},
)
assert r.status_code == 402, r.text
body = r.json()
assert body["detail"]["code"] == "paid_required"
def test_chat_lets_paid_user_past_the_gate(tmp_path, monkeypatch):
"""Paid users should clear the tier gate. The next check is the
OPENROUTER_API_KEY presence without a key it 503s, which is fine:
the point is that they got past the paid gate (not 402)."""
client, _, paid_sess = _build_app(tmp_path)
# Make sure no real key leaks in from the host environment.
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
from app.config import get_settings
get_settings.cache_clear() # type: ignore[attr-defined]
r = client.post(
"/api/chat",
json={"messages": [{"role": "user", "content": "hi"}]},
cookies={"cassandra_session": paid_sess},
)
# Paid clears the tier gate. With no API key configured the endpoint
# then 503s — that's the next check in the handler.
assert r.status_code in (503, 200), r.text
# --- /api/log/latest free-tier 6-hour throttle -----------------------------
def test_log_latest_free_user_sees_boundary_hour_only(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.get(
f"/api/log/latest?tone={_TONE}",
cookies={"cassandra_session": free_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "boundary-log", (
"free user must see the 00:20 boundary log, not the newer 01:20 one"
)
def test_log_latest_paid_user_sees_most_recent(tmp_path):
client, _, paid_sess = _build_app(tmp_path)
r = client.get(
f"/api/log/latest?tone={_TONE}",
cookies={"cassandra_session": paid_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "non-boundary-log", (
"paid user must see the most recent log regardless of generation hour"
)
# --- /api/log/by-date free-tier filter -------------------------------------
def test_log_by_date_free_user_filters_to_boundary(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.get(
f"/api/log/by-date/2026-05-25?tone={_TONE}",
cookies={"cassandra_session": free_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "boundary-log"
def test_log_by_date_paid_user_sees_most_recent(tmp_path):
client, _, paid_sess = _build_app(tmp_path)
r = client.get(
f"/api/log/by-date/2026-05-25?tone={_TONE}",
cookies={"cassandra_session": paid_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "non-boundary-log"

View file

@ -17,9 +17,7 @@ PORTFOLIO = ROOT / "config" / "portfolio.toml"
def test_default_groups_present():
g = load_groups(DEFAULT, PORTFOLIO)
# "pie" was removed in v0.2 when the portfolio composition moved to
# live Trading 212 sourcing (see config/portfolio.toml header).
for expected in ("equity", "mag7", "rates", "macro", "commodities", "fx"):
for expected in ("equity", "mag7", "rates", "macro", "commodities", "fx", "pie"):
assert expected in g, f"missing group: {expected}"
# Every item is a 3-tuple of strings.
for items in g.values():

View file

@ -1,64 +0,0 @@
"""Unit tests for the daily / weekly digest prompt builders."""
from __future__ import annotations
from datetime import datetime, timezone
from app.services.openrouter import (
build_daily_digest_prompt,
build_weekly_digest_prompt,
)
def _ctx():
return dict(
today=datetime(2026, 5, 25, 6, 30, tzinfo=timezone.utc),
quotes_by_group={"equities": [{"symbol": "SPX", "price": 7500.0,
"label": "S&P 500", "currency": "USD",
"source": "test", "note": "",
"as_of": None, "changes": {}}]},
headlines_by_bucket={"general": [{"when": "2026-05-25T05:00:00+00:00",
"source": "FT", "title": "Brent slides"}]},
reference_line="S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45%",
)
def test_daily_prompt_tone_intermediate():
sys_, usr = build_daily_digest_prompt(tone="INTERMEDIATE", **_ctx())
assert "INTERMEDIATE" in sys_.upper() or "intermediate" in sys_.lower()
assert "Brent slides" in usr
assert "daily" in sys_.lower()
def test_daily_prompt_tone_novice_differs():
sys_int, _ = build_daily_digest_prompt(tone="INTERMEDIATE", **_ctx())
sys_nov, _ = build_daily_digest_prompt(tone="NOVICE", **_ctx())
assert sys_int != sys_nov
def test_weekly_prompt_mentions_week():
sys_, usr = build_weekly_digest_prompt(tone="INTERMEDIATE", **_ctx())
assert "week" in sys_.lower() or "weekly" in sys_.lower()
assert "Brent slides" in usr
def test_prompts_return_strings():
for fn in (build_daily_digest_prompt, build_weekly_digest_prompt):
sys_, usr = fn(tone="INTERMEDIATE", **_ctx())
assert isinstance(sys_, str) and isinstance(usr, str)
assert len(sys_) > 50 and len(usr) > 50
def test_prompts_tolerate_empty_data():
"""No quotes, no headlines — builders must still produce non-empty
prompts without raising. Guards the `if headlines_by_bucket` and
`if quotes_by_group` branches in _digest_user_prompt."""
empty_ctx = dict(
today=datetime(2026, 5, 25, 6, 30, tzinfo=timezone.utc),
quotes_by_group={},
headlines_by_bucket={},
reference_line="S&P 7,501 (ATH)",
)
for fn in (build_daily_digest_prompt, build_weekly_digest_prompt):
sys_, usr = fn(tone="INTERMEDIATE", **empty_ctx)
assert "S&P 7,501" in usr
assert len(sys_) > 50

View file

@ -1,99 +0,0 @@
"""Recipient selection + idempotency for the digest job."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, patch
def _bootstrap(tmp_path):
"""Spin up an in-memory DB with three users: a paid opt-in, a paid
opt-out, a free opt-in."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
from app.models import User
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/dj.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="paid_in@x", tier="paid", email_digest_opt_in=True))
s.add(User(id=2, email="paid_out@x", tier="paid", email_digest_opt_in=False))
s.add(User(id=3, email="free_in@x", tier="free", email_digest_opt_in=True))
await s.commit()
asyncio.run(_seed())
return factory
def _patch_today(weekday: int):
"""Return a datetime whose weekday() == `weekday` (0=Mon, 6=Sun)."""
base = datetime(2026, 5, 25, 6, 30, tzinfo=timezone.utc) # Monday
return base + timedelta(days=(weekday - base.weekday()) % 7)
def _stub_generate(content="<p>x</p>"):
"""Stub out the LLM call so the test never hits the network. Use a
SimpleNamespace so we don't have to know the real result class name."""
from types import SimpleNamespace
async def _fake(_client, messages, **kwargs):
return SimpleNamespace(
content=content, model="stub",
prompt_tokens=10, completion_tokens=10, cost_usd=0.0,
)
return _fake
def test_daily_run_only_paid_opt_in(tmp_path):
_bootstrap(tmp_path)
from app.jobs import email_digest_job
with patch("app.jobs.email_digest_job._now",
return_value=_patch_today(0)), \
patch("app.jobs.email_digest_job.send_email",
new=AsyncMock()) as send_mock, \
patch("app.jobs.email_digest_job.llm_configured", return_value=True), \
patch("app.jobs.email_digest_job.call_llm",
new=AsyncMock(side_effect=_stub_generate())):
asyncio.run(email_digest_job.run())
addresses_sent = {call.kwargs.get("to") for call in send_mock.await_args_list}
assert addresses_sent == {"paid_in@x"}
def test_weekly_run_includes_free_and_paid_opt_in(tmp_path):
_bootstrap(tmp_path)
from app.jobs import email_digest_job
with patch("app.jobs.email_digest_job._now",
return_value=_patch_today(6)), \
patch("app.jobs.email_digest_job.send_email",
new=AsyncMock()) as send_mock, \
patch("app.jobs.email_digest_job.llm_configured", return_value=True), \
patch("app.jobs.email_digest_job.call_llm",
new=AsyncMock(side_effect=_stub_generate())):
asyncio.run(email_digest_job.run())
addresses_sent = {call.kwargs.get("to") for call in send_mock.await_args_list}
assert addresses_sent == {"paid_in@x", "free_in@x"}
def test_second_run_same_day_is_idempotent(tmp_path):
_bootstrap(tmp_path)
from app.jobs import email_digest_job
with patch("app.jobs.email_digest_job._now",
return_value=_patch_today(0)), \
patch("app.jobs.email_digest_job.send_email",
new=AsyncMock()) as send_mock, \
patch("app.jobs.email_digest_job.llm_configured", return_value=True), \
patch("app.jobs.email_digest_job.call_llm",
new=AsyncMock(side_effect=_stub_generate())):
asyncio.run(email_digest_job.run())
first_count = len(send_mock.await_args_list)
asyncio.run(email_digest_job.run())
second_count = len(send_mock.await_args_list)
assert first_count > 0
assert second_count == first_count, "second run should not re-send"

View file

@ -1,44 +0,0 @@
"""Unit tests for render_digest_email."""
from __future__ import annotations
from app.services.email_service import render_digest_email
def test_daily_subject_and_bodies():
subj, text, html = render_digest_email(
kind="daily",
date_str="2026-05-25",
content_html="<p>Markets did stuff today.</p>",
unsubscribe_url="https://read.markets/email/unsubscribe?token=abc",
settings_url="https://read.markets/settings",
)
assert "Daily" in subj
assert "2026-05-25" in subj
assert "Markets did stuff today" in html
assert "abc" in html # unsubscribe link landed
assert "/settings" in html
# Plain-text fallback strips HTML.
assert "<p>" not in text
assert "Markets did stuff today" in text
def test_weekly_subject_says_recap():
subj, _, _ = render_digest_email(
kind="weekly",
date_str="2026-05-25",
content_html="<p>x</p>",
unsubscribe_url="https://x/u",
settings_url="https://x/s",
)
assert "Weekly" in subj
assert "recap" in subj.lower()
def test_invalid_kind_raises():
import pytest
with pytest.raises(ValueError):
render_digest_email(
kind="bogus", date_str="2026-05-25",
content_html="<p>x</p>",
unsubscribe_url="u", settings_url="s",
)

View file

@ -1,82 +0,0 @@
"""Unsubscribe token roundtrip + endpoint."""
from __future__ import annotations
import asyncio
def _build_app(tmp_path, monkeypatch, secret="rt-secret-32-bytes-or-so-padding-here"):
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", secret)
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
from app.config import get_settings
from app.models import User
from app.routers import email as email_router
get_settings.cache_clear() # pick up the new env var
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/u.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=42, email="u@x", tier="paid", email_digest_opt_in=True))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(email_router.router)
return TestClient(app)
def test_sign_and_verify_token_roundtrip(monkeypatch):
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "rt-secret-32-bytes-or-so-padding-here")
from app.config import get_settings
get_settings.cache_clear()
from app.routers.email import sign_unsubscribe_token, verify_unsubscribe_token
tok = sign_unsubscribe_token(42)
assert verify_unsubscribe_token(tok) == 42
assert verify_unsubscribe_token("garbage") is None
def test_get_unsubscribe_flips_flag(tmp_path, monkeypatch):
client = _build_app(tmp_path, monkeypatch)
from app.routers.email import sign_unsubscribe_token
tok = sign_unsubscribe_token(42)
r = client.get(f"/email/unsubscribe?token={tok}")
assert r.status_code == 200
assert "unsubscribed" in r.text.lower()
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 42)
assert u.email_digest_opt_in is False
asyncio.run(_check())
def test_get_unsubscribe_invalid_token_returns_generic_page(tmp_path, monkeypatch):
client = _build_app(tmp_path, monkeypatch)
r = client.get("/email/unsubscribe?token=garbage")
# We don't 4xx — that would leak token validity. Show the generic page.
assert r.status_code == 200
assert "you're unsubscribed" in r.text.lower()
def test_replay_is_idempotent(tmp_path, monkeypatch):
client = _build_app(tmp_path, monkeypatch)
from app.routers.email import sign_unsubscribe_token
tok = sign_unsubscribe_token(42)
r1 = client.get(f"/email/unsubscribe?token={tok}")
r2 = client.get(f"/email/unsubscribe?token={tok}")
assert r1.status_code == 200
assert r2.status_code == 200

View file

@ -1,548 +0,0 @@
"""Unit + integration tests for the LLM-fallback CSV parser."""
from __future__ import annotations
import pytest
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory).
Matches the pattern used in tests/test_referral_conversion.py."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401 — registers models on Base.metadata
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/csv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine, factory, _setup
def test_csv_format_template_model_columns():
"""Model exposes every column the spec requires, with correct types."""
from sqlalchemy import inspect
from app.models import CsvFormatTemplate
cols = {c.name: c for c in inspect(CsvFormatTemplate).columns}
assert "fingerprint" in cols
assert "headers" in cols
assert "sample_row" in cols
assert "mapping" in cols
assert "preamble_rows" in cols
assert "delimiter" in cols
assert "broker_label" in cols
assert "first_seen_at" in cols
assert "use_count" in cols
assert "last_used_at" in cols
assert "llm_model" in cols
assert "llm_cost_usd" in cols
# Crucially, no user attribution.
assert "user_id" not in cols
assert "first_seen_user_id" not in cols
# Fingerprint is the cache key.
assert cols["fingerprint"].unique is True
assert cols["fingerprint"].nullable is False
def test_fingerprint_stable_across_case_and_whitespace():
from app.services.llm_csv_parser import _fingerprint
a = _fingerprint(["Symbol", "Quantity", "Avg Price"])
b = _fingerprint(["symbol", "quantity", "avg price"])
c = _fingerprint([" SYMBOL ", "Quantity", " AVG PRICE"])
assert a == b == c
def test_fingerprint_differs_for_different_columns():
from app.services.llm_csv_parser import _fingerprint
a = _fingerprint(["Symbol", "Quantity"])
b = _fingerprint(["Symbol", "Quantity", "Avg Price"])
assert a != b
def test_fingerprint_is_sha256_hex_64_chars():
from app.services.llm_csv_parser import _fingerprint
f = _fingerprint(["Symbol", "Quantity"])
assert len(f) == 64
assert all(c in "0123456789abcdef" for c in f)
def test_detect_dialect_no_preamble_comma():
from app.services.llm_csv_parser import _detect_dialect
raw = b"Symbol,Quantity,Avg Price\nAAPL,100,150.25\nMSFT,50,310.00\n"
delimiter, preamble = _detect_dialect(raw)
assert delimiter == ","
assert preamble == 0
def test_detect_dialect_with_preamble():
from app.services.llm_csv_parser import _detect_dialect
raw = (
b"Statement,Header,Field Name,Field Value\n"
b"Statement,Data,BrokerName,Interactive Brokers LLC\n"
b"Statement,Data,Title,Activity Statement\n"
b"Statement,Data,Period,\"January 1, 2026 - January 31, 2026\"\n"
b"Symbol,Quantity,Avg Price,Currency,Description\n"
b"AAPL,100,150.25,USD,Apple Inc\n"
)
delimiter, preamble = _detect_dialect(raw)
assert delimiter == ","
# The data-row header line is the FIFTH line (index 4); preamble = 4.
assert preamble == 4
def test_detect_dialect_tab_delimited():
from app.services.llm_csv_parser import _detect_dialect
raw = b"Symbol\tQuantity\tAvg Price\nAAPL\t100\t150.25\n"
delimiter, preamble = _detect_dialect(raw)
assert delimiter == "\t"
assert preamble == 0
def test_detect_dialect_empty_raises():
from app.services.llm_csv_parser import LLMParseError, _detect_dialect
with pytest.raises(LLMParseError):
_detect_dialect(b"")
def test_validate_mapping_accepts_well_formed():
from app.services.llm_csv_parser import _validate_mapping
headers = ["Symbol", "Quantity", "Avg Price", "Currency"]
first_row = ["AAPL", "100", "150.25", "USD"]
mapping = {
"ticker_col": "Symbol",
"qty_col": "Quantity",
"cost_col": "Avg Price",
"currency_col": "Currency",
"name_col": None,
}
_validate_mapping(mapping, headers, first_row) # no raise
def test_validate_mapping_missing_ticker_raises():
from app.services.llm_csv_parser import LLMParseError, _validate_mapping
headers = ["Symbol", "Quantity"]
first_row = ["AAPL", "100"]
mapping = {"ticker_col": None, "qty_col": "Quantity"}
with pytest.raises(LLMParseError, match="ticker"):
_validate_mapping(mapping, headers, first_row)
def test_validate_mapping_missing_qty_raises():
from app.services.llm_csv_parser import LLMParseError, _validate_mapping
headers = ["Symbol", "Quantity"]
first_row = ["AAPL", "100"]
mapping = {"ticker_col": "Symbol", "qty_col": None}
with pytest.raises(LLMParseError, match="qty"):
_validate_mapping(mapping, headers, first_row)
def test_validate_mapping_unknown_column_raises():
from app.services.llm_csv_parser import LLMParseError, _validate_mapping
headers = ["Symbol", "Quantity"]
first_row = ["AAPL", "100"]
mapping = {"ticker_col": "Symbol", "qty_col": "NotARealColumn"}
with pytest.raises(LLMParseError, match="NotARealColumn"):
_validate_mapping(mapping, headers, first_row)
def test_validate_mapping_non_numeric_qty_raises():
from app.services.llm_csv_parser import LLMParseError, _validate_mapping
headers = ["Symbol", "Description"]
first_row = ["AAPL", "Apple Inc"]
# Mapping says qty is "Description", but "Apple Inc" can't parse as a number.
mapping = {"ticker_col": "Symbol", "qty_col": "Description"}
with pytest.raises(LLMParseError, match="numeric"):
_validate_mapping(mapping, headers, first_row)
def test_apply_mapping_builds_parsed_pie():
from app.services.csv_import import ParsedPie, ParsedPosition
from app.services.llm_csv_parser import _apply_mapping
headers = ["Symbol", "Quantity", "Avg Price", "Currency", "Description"]
data_rows = [
["AAPL", "100", "150.25", "USD", "Apple Inc"],
["MSFT", "50", "310.00", "USD", "Microsoft Corp"],
]
mapping = {
"ticker_col": "Symbol",
"qty_col": "Quantity",
"cost_col": "Avg Price",
"currency_col": "Currency",
"name_col": "Description",
}
pie = _apply_mapping(headers, data_rows, mapping)
assert isinstance(pie, ParsedPie)
assert len(pie.positions) == 2
p0 = pie.positions[0]
assert isinstance(p0, ParsedPosition)
assert p0.slice == "AAPL"
assert p0.name == "Apple Inc"
assert p0.quantity == 100.0
assert p0.invested_value == pytest.approx(15025.0)
# invested = qty * avg_cost = 100 * 150.25 = 15025
assert pie.invested == pytest.approx(15025.0 + 50 * 310.00)
def test_apply_mapping_handles_missing_optional_columns():
from app.services.llm_csv_parser import _apply_mapping
headers = ["Symbol", "Quantity"]
data_rows = [["AAPL", "100"]]
mapping = {
"ticker_col": "Symbol",
"qty_col": "Quantity",
"cost_col": None,
"currency_col": None,
"name_col": None,
}
pie = _apply_mapping(headers, data_rows, mapping)
p = pie.positions[0]
assert p.slice == "AAPL"
assert p.quantity == 100.0
assert p.invested_value is None
assert p.name == "AAPL" # falls back to ticker when name_col absent
def test_apply_mapping_skips_blank_and_unparseable_rows():
from app.services.llm_csv_parser import _apply_mapping
headers = ["Symbol", "Quantity"]
data_rows = [
["AAPL", "100"],
["", ""], # blank
["MSFT", "not-a-number"], # bad qty
["NVDA", "40"],
]
mapping = {"ticker_col": "Symbol", "qty_col": "Quantity"}
pie = _apply_mapping(headers, data_rows, mapping)
assert [p.slice for p in pie.positions] == ["AAPL", "NVDA"]
@pytest.mark.asyncio
async def test_extract_mapping_via_llm_parses_valid_json():
from unittest.mock import AsyncMock, MagicMock
from app.services.llm_csv_parser import _extract_mapping_via_llm
from app.services.openrouter import LogResult
fake_result = LogResult(
content='{"ticker_col": "Symbol", "qty_col": "Quantity", '
'"cost_col": "Avg Price", "currency_col": "Currency", '
'"name_col": null, "broker_label": "IBKR Activity Statement"}',
model="deepseek/deepseek-v4-flash",
prompt_tokens=100,
completion_tokens=50,
cost_usd=0.0001,
)
fake_client = MagicMock()
fake_call_llm = AsyncMock(return_value=fake_result)
import app.services.llm_csv_parser as mod
mod.call_llm = fake_call_llm # monkeypatch
headers = ["Symbol", "Quantity", "Avg Price", "Currency"]
samples = [["AAPL", "100", "150.25", "USD"]]
mapping, log = await _extract_mapping_via_llm(fake_client, headers, samples)
assert mapping["ticker_col"] == "Symbol"
assert mapping["qty_col"] == "Quantity"
assert mapping["broker_label"] == "IBKR Activity Statement"
assert log.model == "deepseek/deepseek-v4-flash"
fake_call_llm.assert_awaited_once()
@pytest.mark.asyncio
async def test_extract_mapping_via_llm_malformed_json_raises():
from unittest.mock import AsyncMock, MagicMock
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
from app.services.openrouter import LogResult
fake_result = LogResult(
content="Sure thing — here is the mapping! ticker=Symbol",
model="deepseek/deepseek-v4-flash",
prompt_tokens=10,
completion_tokens=20,
cost_usd=0.00005,
)
fake_client = MagicMock()
fake_call_llm = AsyncMock(return_value=fake_result)
import app.services.llm_csv_parser as mod
mod.call_llm = fake_call_llm
with pytest.raises(LLMParseError, match="JSON"):
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
@pytest.mark.asyncio
async def test_extract_mapping_via_llm_provider_failure_wraps():
from unittest.mock import AsyncMock, MagicMock
from app.services.llm_csv_parser import LLMParseError, _extract_mapping_via_llm
fake_client = MagicMock()
fake_call_llm = AsyncMock(side_effect=RuntimeError("provider down"))
import app.services.llm_csv_parser as mod
mod.call_llm = fake_call_llm
with pytest.raises(LLMParseError, match="provider"):
await _extract_mapping_via_llm(fake_client, ["Symbol"], [["AAPL"]])
@pytest.mark.asyncio
async def test_parse_with_llm_cache_miss_inserts_template(tmp_path):
from unittest.mock import AsyncMock
from sqlalchemy import select
from app.models import CsvFormatTemplate
from app.services.llm_csv_parser import parse_with_llm
from app.services.openrouter import LogResult
_, factory, setup = _build_session_factory(tmp_path)
await setup()
raw = (
b"Symbol,Quantity,Avg Price,Currency\n"
b"AAPL,100,150.25,USD\n"
b"MSFT,50,310.00,USD\n"
)
import app.services.llm_csv_parser as mod
mod.call_llm = AsyncMock(return_value=LogResult(
content='{"ticker_col":"Symbol","qty_col":"Quantity",'
'"cost_col":"Avg Price","currency_col":"Currency",'
'"name_col":null,"broker_label":"Generic broker"}',
model="deepseek/deepseek-v4-flash",
prompt_tokens=120, completion_tokens=40, cost_usd=0.0002,
))
async with factory() as session:
pie = await parse_with_llm(raw, session)
assert len(pie.positions) == 2
assert pie.positions[0].slice == "AAPL"
async with factory() as session:
rows = (await session.execute(select(CsvFormatTemplate))).scalars().all()
assert len(rows) == 1
tmpl = rows[0]
assert tmpl.headers == ["Symbol", "Quantity", "Avg Price", "Currency"]
assert tmpl.sample_row == ["AAPL", "100", "150.25", "USD"]
assert tmpl.mapping["ticker_col"] == "Symbol"
assert tmpl.broker_label == "Generic broker"
assert tmpl.use_count == 1
assert tmpl.llm_cost_usd == pytest.approx(0.0002)
# The crucial PII guarantee:
assert not hasattr(tmpl, "user_id"), "sample row must not be linked to a user"
@pytest.mark.asyncio
async def test_parse_with_llm_cache_hit_skips_llm(tmp_path):
from unittest.mock import AsyncMock
from sqlalchemy import select
from app.db import utcnow
from app.models import CsvFormatTemplate
from app.services.llm_csv_parser import _fingerprint, parse_with_llm
_, factory, setup = _build_session_factory(tmp_path)
await setup()
headers = ["Symbol", "Quantity", "Avg Price", "Currency"]
fp = _fingerprint(headers)
# Pre-populate a cache hit row.
async with factory() as session:
session.add(CsvFormatTemplate(
fingerprint=fp,
headers=headers,
sample_row=["AAPL", "100", "150.25", "USD"],
mapping={
"ticker_col": "Symbol", "qty_col": "Quantity",
"cost_col": "Avg Price", "currency_col": "Currency",
"name_col": None,
},
preamble_rows=0,
delimiter=",",
broker_label="Cached broker",
first_seen_at=utcnow(),
last_used_at=utcnow(),
use_count=1,
llm_model="seed",
llm_cost_usd=0.0,
))
await session.commit()
raw = (
b"Symbol,Quantity,Avg Price,Currency\n"
b"NVDA,40,425.50,USD\n"
)
import app.services.llm_csv_parser as mod
mod.call_llm = AsyncMock(side_effect=AssertionError("call_llm must NOT be called on cache hit"))
async with factory() as session:
pie = await parse_with_llm(raw, session)
assert pie.positions[0].slice == "NVDA"
async with factory() as session:
rows = (await session.execute(select(CsvFormatTemplate))).scalars().all()
assert len(rows) == 1
assert rows[0].use_count == 2
@pytest.mark.asyncio
async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
from unittest.mock import AsyncMock
from sqlalchemy import select
from app.db import utcnow
from app.models import CsvFormatTemplate
from app.services.llm_csv_parser import LLMParseError, _fingerprint, parse_with_llm
_, factory, setup = _build_session_factory(tmp_path)
await setup()
headers = ["Symbol", "Quantity"]
fp = _fingerprint(headers)
# Cached mapping says qty is in column "Symbol" — clearly wrong; will
# never produce a parseable row.
async with factory() as session:
session.add(CsvFormatTemplate(
fingerprint=fp, headers=headers,
sample_row=["AAPL", "100"],
mapping={"ticker_col": "Symbol", "qty_col": "Symbol"},
preamble_rows=0, delimiter=",", broker_label=None,
first_seen_at=utcnow(), last_used_at=utcnow(), use_count=1,
llm_model="seed", llm_cost_usd=0.0,
))
await session.commit()
raw = b"Symbol,Quantity\nAAPL,100\nMSFT,50\n"
import app.services.llm_csv_parser as mod
mod.call_llm = AsyncMock(side_effect=AssertionError("must not be called"))
async with factory() as session:
with pytest.raises(LLMParseError):
await parse_with_llm(raw, session)
# Stale template must NOT have been auto-deleted (operator owns eviction).
async with factory() as session:
rows = (await session.execute(select(CsvFormatTemplate))).scalars().all()
assert len(rows) == 1
@pytest.mark.asyncio
async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch):
"""End-to-end: T212 parser raises CSVImportError, LLM fallback runs,
response shape matches the existing JSON contract."""
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
from fastapi import UploadFile
_, factory, setup = _build_session_factory(tmp_path)
await setup()
import app.services.llm_csv_parser as mod
from app.services.openrouter import LogResult
mod.call_llm = AsyncMock(return_value=LogResult(
content='{"ticker_col":"Symbol","qty_col":"Quantity",'
'"cost_col":"Avg Price","currency_col":"Currency",'
'"name_col":"Description",'
'"broker_label":"IBKR Activity Statement"}',
model="deepseek/deepseek-v4-flash",
prompt_tokens=150, completion_tokens=60, cost_usd=0.0003,
))
# The route's inline Yahoo-fetch block would otherwise hit the network.
# Patch market.fetch to return a benign placeholder per ticker.
from app.services import market as market_mod
async def _fake_fetch(client, symbol, label, group, anchor):
return SimpleNamespace(
symbol=symbol, source="test", label=label,
price=None, currency="USD", as_of="2026-05-27",
changes=None, error=None,
)
monkeypatch.setattr(market_mod, "fetch", _fake_fetch)
# ticker_universe.upsert_tickers uses MySQL ON DUPLICATE KEY UPDATE
# which SQLite doesn't compile. Mock the two universe-side effects;
# neither contributes to the JSON contract we're testing here.
from app.services import ticker_universe as tu_mod
async def _fake_upsert(session, tickers):
return len(list(tickers))
async def _fake_buffer(tickers):
return len(list(tickers))
monkeypatch.setattr(tu_mod, "upsert_tickers", _fake_upsert)
monkeypatch.setattr(tu_mod, "buffer_tickers", _fake_buffer)
raw = open("tests/fixtures/ibkr_sample.csv", "rb").read()
upload = UploadFile(filename="ibkr.csv", file=BytesIO(raw))
from app.routers.universe import parse_portfolio
async with factory() as session:
result = await parse_portfolio(file=upload, session=session)
assert result["base_currency"] == "GBP"
# All 5 IBKR positions should round-trip — the LLM path trusts the
# Yahoo-ready tickers from the file and does NOT drop on a
# resolve_slice miss (that's the T212 path's behaviour).
tickers = {p["yahoo_ticker"] for p in result["positions"]}
assert tickers == {"AAPL", "MSFT", "NVDA", "VOD.L", "ASML.AS"}
# LLM was called exactly once (cache miss).
assert mod.call_llm.await_count == 1
# Currency comes from the LLM-mapped currency_col, falling back to
# USD only when neither InstrumentMap nor the file specified one.
by_t = {p["yahoo_ticker"]: p["currency"] for p in result["positions"]}
assert by_t["VOD.L"] == "GBP"
assert by_t["ASML.AS"] == "EUR"
def test_parse_portfolio_route_requires_paid():
"""Static check that the /portfolio/parse route is gated by require_paid."""
from app.routers.universe import router
from app.services.access import require_paid
parse_route = next(
r for r in router.routes
if getattr(r, "path", "") == "/portfolio/parse"
)
# FastAPI stores each Depends(...) as a Dependant whose `.call` attribute
# is the wrapped callable (`.dependency` is the older name, removed in
# recent FastAPI versions).
dep_callables = [d.call for d in parse_route.dependant.dependencies]
assert require_paid in dep_callables, (
"The /portfolio/parse route must have Depends(require_paid)"
)

View file

@ -1,74 +0,0 @@
"""Free vs paid window clamp on /api/news.
Integration-style: spins up a real router over an in-memory aiosqlite DB.
Skips on hosts that lack aiosqlite + httpx same pattern as
test_portfolio_sync_api.py."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import Headline, User
from app.routers import api as api_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/news.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
now = datetime.now(timezone.utc)
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="free@x", tier="free"))
s.add(User(id=2, email="paid@x", tier="paid"))
for hours_old, title in ((1, "fresh"), (12, "mid"), (20, "old")):
s.add(Headline(
source="test", title=title, url=f"https://e/{title}",
category="general",
published_at=now - timedelta(hours=hours_old),
fetched_at=now,
fingerprint=f"fp-{title}",
tags=[],
))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(api_router.router, prefix="/api")
client = TestClient(app)
return client, sign_session(1), sign_session(2)
def test_free_user_clamped_to_6h(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.get("/api/news?since_hours=24",
cookies={"cassandra_session": free_sess})
assert r.status_code == 200, r.text
titles = [h["title"] for h in r.json()]
assert "fresh" in titles
assert "mid" not in titles # 12h ago, beyond 6h
assert "old" not in titles
def test_paid_user_full_24h(tmp_path):
client, _, paid_sess = _build_app(tmp_path)
r = client.get("/api/news?since_hours=24",
cookies={"cassandra_session": paid_sess})
assert r.status_code == 200, r.text
titles = [h["title"] for h in r.json()]
assert {"fresh", "mid", "old"} <= set(titles)

View file

@ -1,215 +0,0 @@
"""Polar (Standard Webhooks) endpoint: signature verification, idempotency,
and the subscription.active -> tier=paid handler.
Integration-style: real router + in-memory aiosqlite. Same scaffold as
test_news_window.py / test_chat_and_log_gates.py."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import time
import pytest
_SECRET_RAW = b"this-is-a-deterministic-test-secret-32b!"
_SECRET = "whsec_" + base64.b64encode(_SECRET_RAW).decode("ascii")
def _sign(msg_id: str, ts: str, body: bytes) -> str:
"""Produce the `v1,<b64>` token Polar would send."""
signed = f"{msg_id}.{ts}.{body.decode('utf-8')}"
mac = hmac.new(_SECRET_RAW, signed.encode("utf-8"), hashlib.sha256).digest()
return "v1," + base64.b64encode(mac).decode("ascii")
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.config import get_settings
from app.db import Base
from app.models import User
from app.routers import polar_webhook as polar_router
# Inject the secret into the cached Settings. We override the
# field rather than monkeypatching env because the secret is read
# via get_settings() at request time.
s = get_settings()
s.POLAR_WEBHOOK_SECRET = _SECRET # type: ignore[misc]
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/polar.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as session:
session.add(User(id=1, email="paying@x", tier="free"))
await session.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(polar_router.router)
return TestClient(app), factory
def _post(client, *, body: dict, msg_id="msg_001", ts: str | None = None,
sig: str | None = None):
raw = json.dumps(body).encode("utf-8")
ts = ts or str(int(time.time()))
sig = sig or _sign(msg_id, ts, raw)
return client.post(
"/api/polar/webhook",
content=raw,
headers={
"webhook-id": msg_id,
"webhook-timestamp": ts,
"webhook-signature": sig,
"content-type": "application/json",
},
)
# --- signature gate --------------------------------------------------------
def test_rejects_bad_signature(tmp_path):
client, _ = _build_app(tmp_path)
raw = json.dumps({"type": "subscription.active", "data": {}}).encode("utf-8")
ts = str(int(time.time()))
r = client.post(
"/api/polar/webhook",
content=raw,
headers={
"webhook-id": "msg_bad",
"webhook-timestamp": ts,
"webhook-signature": "v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"content-type": "application/json",
},
)
assert r.status_code == 401, r.text
def test_rejects_stale_timestamp(tmp_path):
client, _ = _build_app(tmp_path)
body = {"type": "subscription.active", "data": {}}
# 10 minutes in the past — beyond the 5-minute tolerance window.
stale = str(int(time.time()) - 600)
r = _post(client, body=body, ts=stale, msg_id="msg_stale")
assert r.status_code == 401, r.text
def test_rejects_missing_headers(tmp_path):
client, _ = _build_app(tmp_path)
r = client.post("/api/polar/webhook", content=b"{}",
headers={"content-type": "application/json"})
assert r.status_code == 400, r.text
# --- happy paths -----------------------------------------------------------
def test_subscription_active_flips_tier_to_paid(tmp_path):
client, factory = _build_app(tmp_path)
body = {
"type": "subscription.active",
"data": {
"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"},
},
}
r = _post(client, body=body, msg_id="msg_active")
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.polar_customer_id, u.polar_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "paid"
assert cid == "cust_xyz"
assert sid == "sub_abc"
def test_subscription_revoked_drops_to_free(tmp_path):
client, factory = _build_app(tmp_path)
# First, activate.
_post(client, body={
"type": "subscription.active",
"data": {"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"}},
}, msg_id="msg_act")
# Then, revoke.
r = _post(client, body={
"type": "subscription.revoked",
"data": {"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"}},
}, msg_id="msg_rev")
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.polar_customer_id, u.polar_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "free"
# Customer linkage preserved so a future resub matches the same row.
assert cid == "cust_xyz"
assert sid is None
# --- idempotency -----------------------------------------------------------
def test_replayed_event_id_is_a_noop(tmp_path):
client, factory = _build_app(tmp_path)
body = {
"type": "subscription.active",
"data": {"id": "sub_abc",
"customer": {"id": "cust_xyz", "email": "paying@x"}},
}
# Two POSTs with the same msg_id and body — second should be deduped.
r1 = _post(client, body=body, msg_id="msg_dup")
r2 = _post(client, body=body, msg_id="msg_dup")
assert r1.status_code == 200 and r1.json()["status"] == "ok"
assert r2.status_code == 200 and r2.json()["status"] == "duplicate"
async def _count():
from sqlalchemy import select, func
from app.models import PolarEvent
async with factory() as session:
n = (await session.execute(
select(func.count(PolarEvent.id))
.where(PolarEvent.event_id == "msg_dup")
)).scalar_one()
return n
assert asyncio.run(_count()) == 1
def test_unknown_event_type_is_acked(tmp_path):
client, _ = _build_app(tmp_path)
body = {"type": "benefit_grant.cycled", "data": {}}
r = _post(client, body=body, msg_id="msg_unknown")
assert r.status_code == 200
assert r.json()["status"] == "ignored"

View file

@ -72,7 +72,7 @@ def test_paid_user_round_trip(tmp_path):
# status before any upload
r = client.get("/api/portfolio/sync/status", cookies=cookies)
assert r.status_code == 200
assert r.json() == {"exists": False, "orphaned": False, "updated_at": None}
assert r.json() == {"exists": False, "updated_at": None}
# upload
blob = os.urandom(512)

View file

@ -1,417 +0,0 @@
"""DB-backed tests for the referral-conversion path: when a referred
user becomes paid, both parties should get REFERRAL_CREDIT_DAYS of
extra credit, the Referral row should be stamped, and the operation
should be idempotent so renewal events don't double-credit.
Also exercises the Stripe-webhook integration to confirm the credit
actually lands when a subscription.created event fires for a referred
user."""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import time
from datetime import datetime, timedelta, timezone
import pytest
# ---------------------------------------------------------------------------
# Shared scaffolding
# ---------------------------------------------------------------------------
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory).
Mirrors test_stripe_billing._build_app's seeding strategy but
skips the FastAPI app most conversion tests only need the
session factory."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(_seed())
return factory
async def _add_pair(factory, *, referrer_id=1, referred_id=2):
"""Insert a referrer + referred user pair and a linking Referral row.
Returns nothing tests re-fetch via the factory."""
from app.db import utcnow
from app.models import Referral, User
async with factory() as s:
s.add(User(id=referrer_id, email=f"r{referrer_id}@x",
tier="paid", referral_code="REFREFXX"))
s.add(User(id=referred_id, email=f"u{referred_id}@x",
tier="free", referred_by_user_id=referrer_id))
s.add(Referral(referrer_user_id=referrer_id,
referred_user_id=referred_id,
created_at=utcnow()))
await s.commit()
# ---------------------------------------------------------------------------
# convert_referral happy path
# ---------------------------------------------------------------------------
def test_first_conversion_credits_both_parties(tmp_path):
"""Calling convert_referral on a freshly-paid referred user should
extend credit_until by REFERRAL_CREDIT_DAYS for BOTH the buyer and
the referrer, and stamp converted_at + credited_at."""
from app.models import Referral, User
from app.services.referral_service import (
REFERRAL_CREDIT_DAYS, convert_referral,
)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
assert ref is not None
assert ref.converted_at is not None
assert ref.credited_at is not None
await s.commit()
# Re-open a fresh session so we read committed state, not the
# session-cached version.
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
now = datetime.now(timezone.utc)
# Both windows should sit ~REFERRAL_CREDIT_DAYS in the
# future (allow 1 day slack for clock + rounding).
for u in (referrer, referred):
assert u.credit_until is not None
cu = u.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
delta_days = (cu - now).total_seconds() / 86400
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
asyncio.run(_run())
def test_idempotent_on_repeat_call(tmp_path):
"""A second convert_referral call (e.g. from a duplicate webhook or
renewal event) must NOT extend credit a second time. The Referral
row is already stamped, so we should early-return unchanged."""
from app.models import User
from app.services.referral_service import convert_referral
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
await s.commit()
# Snapshot credit_until after first conversion.
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
first_referrer_credit = referrer.credit_until
first_referred_credit = referred.credit_until
# Second call — should no-op.
async with factory() as s:
referred = await s.get(User, 2)
ref2 = await convert_referral(s, referred)
assert ref2 is not None # we still return the row
await s.commit()
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
assert referrer.credit_until == first_referrer_credit
assert referred.credit_until == first_referred_credit
asyncio.run(_run())
def test_no_referral_row_returns_none(tmp_path):
"""A user signing up directly (no inviter) has no Referral row.
convert_referral must return None and touch nothing."""
from app.models import User
from app.services.referral_service import convert_referral
factory = _build_session_factory(tmp_path)
async def _seed_orphan():
async with factory() as s:
s.add(User(id=9, email="lone@x", tier="free"))
await s.commit()
asyncio.run(_seed_orphan())
async def _run():
async with factory() as s:
user = await s.get(User, 9)
result = await convert_referral(s, user)
assert result is None
assert user.credit_until is None
asyncio.run(_run())
def test_credit_stacks_from_existing_window(tmp_path):
"""If the user already has a future credit_until (admin grant, prior
referral), the new credit should extend from THAT anchor not from
now. Mirrors cli.grant_credit's stacking semantics."""
from app.models import User
from app.services.referral_service import (
REFERRAL_CREDIT_DAYS, convert_referral,
)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
# Pre-load 30 days of credit on the referred user.
existing = datetime.now(timezone.utc) + timedelta(days=30)
async def _preload():
async with factory() as s:
u = await s.get(User, 2)
u.credit_until = existing
await s.commit()
asyncio.run(_preload())
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
await s.commit()
async with factory() as s:
referred = await s.get(User, 2)
cu = referred.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
# Expected: existing + REFERRAL_CREDIT_DAYS days, not now + days.
expected = existing + timedelta(days=REFERRAL_CREDIT_DAYS)
delta_seconds = abs((cu - expected).total_seconds())
assert delta_seconds < 60, (
f"new credit anchored at now, not existing window: "
f"got {cu}, expected ~{expected}"
)
asyncio.run(_run())
def test_deleted_referrer_does_not_crash(tmp_path):
"""If the referrer's User row has been deleted, the referred user
should still be credited and the Referral still stamped we just
skip the missing referrer."""
from app.models import Referral, User
from app.services.referral_service import convert_referral
factory = _build_session_factory(tmp_path)
async def _seed():
from app.db import utcnow
async with factory() as s:
# Referrer with FK SET NULL — we don't delete the row, we
# instead create a Referral pointing at a non-existent id
# to simulate a deleted referrer.
s.add(User(id=2, email="u2@x", tier="free"))
s.add(Referral(referrer_user_id=999, # nonexistent
referred_user_id=2,
created_at=utcnow()))
await s.commit()
asyncio.run(_seed())
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
await s.commit()
assert ref is not None
assert ref.converted_at is not None
# Referred still got their credit even though referrer is gone.
assert referred.credit_until is not None
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Stripe-webhook integration
# ---------------------------------------------------------------------------
_WEBHOOK_SECRET = "whsec_dummy_test_secret_for_unit_tests"
def _stripe_sig(body: bytes, secret: str, ts: int | None = None) -> str:
ts = ts if ts is not None else int(time.time())
signed = f"{ts}.{body.decode('utf-8')}"
mac = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"),
hashlib.sha256).hexdigest()
return f"t={ts},v1={mac}"
def _build_webhook_app(tmp_path):
"""Same as the helper in test_stripe_billing but pre-seeds a
referrer+referred pair so the webhook can drive a full conversion."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.config import get_settings
from app.db import Base, utcnow
from app.models import Referral, User
from app.routers import stripe_billing as stripe_router
s = get_settings()
s.STRIPE_API_KEY = "sk_test_dummy" # type: ignore[misc]
s.STRIPE_WEBHOOK_SECRET = _WEBHOOK_SECRET # type: ignore[misc]
s.STRIPE_PRICE_MONTHLY = "price_test_monthly_xxxxxxxxxxxxxxxxxxxx" # type: ignore[misc]
s.STRIPE_PRICE_ANNUAL = "price_test_annual_xxxxxxxxxxxxxxxxxxxxx" # type: ignore[misc]
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv-int.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as session:
session.add(User(id=1, email="referrer@x", tier="paid",
referral_code="REFCODE1"))
session.add(User(id=2, email="buyer@x", tier="free",
stripe_customer_id="cus_test_referred",
referred_by_user_id=1))
session.add(Referral(referrer_user_id=1, referred_user_id=2,
created_at=utcnow()))
await session.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(stripe_router.router)
return TestClient(app), factory
def _post_webhook(client, body: dict):
body.setdefault("object", "event")
raw = json.dumps(body).encode("utf-8")
sig = _stripe_sig(raw, _WEBHOOK_SECRET)
return client.post(
"/api/stripe/webhook",
content=raw,
headers={"stripe-signature": sig, "content-type": "application/json"},
)
def test_subscription_active_event_triggers_referral_conversion(tmp_path):
"""End-to-end: fire a customer.subscription.created event for the
referred user. The webhook should flip their tier AND extend
credit_until on both parties via convert_referral."""
from app.models import Referral, User
from app.services.referral_service import REFERRAL_CREDIT_DAYS
client, factory = _build_webhook_app(tmp_path)
body = {
"id": "evt_conv_1",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_test_1",
"customer": "cus_test_referred",
"status": "active",
"trial_end": None,
}},
}
r = _post_webhook(client, body)
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
async def _check():
async with factory() as s:
referred = await s.get(User, 2)
referrer = await s.get(User, 1)
assert referred.tier == "paid"
now = datetime.now(timezone.utc)
for u in (referred, referrer):
assert u.credit_until is not None, (
f"user {u.id} got no credit"
)
cu = u.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
delta_days = (cu - now).total_seconds() / 86400
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1, (
f"user {u.id} credit days={delta_days} not ~{REFERRAL_CREDIT_DAYS}"
)
# Referral row stamped.
from sqlalchemy import select
ref = (await s.execute(
select(Referral).where(Referral.referred_user_id == 2)
)).scalar_one()
assert ref.converted_at is not None
assert ref.credited_at is not None
asyncio.run(_check())
def test_renewal_event_does_not_double_credit(tmp_path):
"""A subscription.updated event firing later (renewal, status change)
on an already-paid user must NOT re-trigger conversion. The
first_paid_transition guard inside _grant_paid should skip the
convert_referral lookup; convert_referral itself is also
idempotent as a second line of defence."""
from app.models import User
client, factory = _build_webhook_app(tmp_path)
# First event: created → conversion fires.
_post_webhook(client, {
"id": "evt_first",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_test_1", "customer": "cus_test_referred",
"status": "active",
}},
})
async def _snapshot():
async with factory() as s:
referred = await s.get(User, 2)
referrer = await s.get(User, 1)
return referred.credit_until, referrer.credit_until
before = asyncio.run(_snapshot())
# Second event: updated → must not extend credit further.
r = _post_webhook(client, {
"id": "evt_second",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_test_1", "customer": "cus_test_referred",
"status": "active",
}},
})
assert r.status_code == 200
after = asyncio.run(_snapshot())
assert before == after, (
f"renewal event extended credit: before={before}, after={after}"
)

View file

@ -1,71 +0,0 @@
"""PATCH /api/settings/digest persists opt-in + tone."""
from __future__ import annotations
import asyncio
def _build(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import User
from app.routers import api as api_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/s.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="u@x", tier="paid",
email_digest_opt_in=True))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(api_router.router, prefix="/api")
return TestClient(app), sign_session(1)
def test_patch_round_trip(tmp_path):
client, sess = _build(tmp_path)
r = client.patch(
"/api/settings/digest",
json={"opt_in": False, "tone": "NOVICE"},
cookies={"cassandra_session": sess},
)
assert r.status_code == 200, r.text
assert r.json() == {"opt_in": False, "tone": "NOVICE"}
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 1)
assert u.email_digest_opt_in is False
assert u.digest_tone == "NOVICE"
asyncio.run(_check())
def test_patch_rejects_invalid_tone(tmp_path):
client, sess = _build(tmp_path)
r = client.patch(
"/api/settings/digest",
json={"opt_in": True, "tone": "PRO"},
cookies={"cassandra_session": sess},
)
assert r.status_code == 422
def test_patch_requires_auth(tmp_path):
client, _ = _build(tmp_path)
r = client.patch("/api/settings/digest",
json={"opt_in": True, "tone": "NOVICE"})
assert r.status_code in (401, 303)

View file

@ -1,465 +0,0 @@
"""Stripe billing endpoints: signature verification, idempotency, tier
flips, and checkout creation.
Same integration-style scaffold as test_polar_webhook.py real router
over in-memory aiosqlite. Stripe SDK calls (sessions.create, portal
sessions.create) are mocked so the suite never makes a real HTTP call.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import time
from types import SimpleNamespace
from unittest.mock import patch
import pytest
_API_KEY = "sk_test_dummy_for_unit_tests"
_WEBHOOK_SECRET = "whsec_dummy_test_secret_for_unit_tests"
_PRICE_MONTHLY = "price_test_monthly_xxxxxxxxxxxxxxxxxxxx"
_PRICE_ANNUAL = "price_test_annual_xxxxxxxxxxxxxxxxxxxxx"
def _stripe_sig(body: bytes, secret: str, ts: int | None = None) -> str:
"""Produce a Stripe-Signature header matching the bytes signed.
Format: `t=<ts>,v1=<hex hmac sha256>` over `<ts>.<body>`."""
ts = ts if ts is not None else int(time.time())
signed = f"{ts}.{body.decode('utf-8')}"
mac = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"),
hashlib.sha256).hexdigest()
return f"t={ts},v1={mac}"
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.config import get_settings
from app.db import Base
from app.models import User
from app.routers import stripe_billing as stripe_router
s = get_settings()
s.STRIPE_API_KEY = _API_KEY # type: ignore[misc]
s.STRIPE_WEBHOOK_SECRET = _WEBHOOK_SECRET # type: ignore[misc]
s.STRIPE_PRICE_MONTHLY = _PRICE_MONTHLY # type: ignore[misc]
s.STRIPE_PRICE_ANNUAL = _PRICE_ANNUAL # type: ignore[misc]
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/stripe.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as session:
session.add(User(id=1, email="buyer@x", tier="free"))
await session.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(stripe_router.router)
return TestClient(app), factory, sign_session(1)
def _post_webhook(client, *, body: dict, secret: str = _WEBHOOK_SECRET,
sig: str | None = None):
# Stripe's SDK requires a top-level `object: "event"` field to know
# this is a v1 webhook envelope — tests that omit it fail in
# construct_event before the signature check matters. We inject the
# default here so individual tests can stay terse.
body.setdefault("object", "event")
raw = json.dumps(body).encode("utf-8")
sig = sig if sig is not None else _stripe_sig(raw, secret)
return client.post(
"/api/stripe/webhook",
content=raw,
headers={"stripe-signature": sig, "content-type": "application/json"},
)
# --- signature gate --------------------------------------------------------
def test_webhook_rejects_bad_signature(tmp_path):
client, _, _ = _build_app(tmp_path)
raw = json.dumps({"id": "evt_x", "type": "invoice.paid",
"data": {"object": {}}}).encode("utf-8")
r = client.post(
"/api/stripe/webhook",
content=raw,
headers={
"stripe-signature": "t=0,v1=deadbeef",
"content-type": "application/json",
},
)
assert r.status_code == 401, r.text
def test_webhook_rejects_missing_signature(tmp_path):
client, _, _ = _build_app(tmp_path)
r = client.post(
"/api/stripe/webhook",
content=b"{}",
headers={"content-type": "application/json"},
)
assert r.status_code == 400, r.text
# --- happy paths -----------------------------------------------------------
def test_checkout_session_completed_flips_tier_to_paid(tmp_path):
client, factory, _ = _build_app(tmp_path)
body = {
"id": "evt_checkout_1",
"type": "checkout.session.completed",
"data": {
"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}
},
}
r = _post_webhook(client, body=body)
assert r.status_code == 200, r.text
assert r.json()["status"] == "ok"
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_customer_id, u.stripe_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "paid"
assert cid == "cus_abc"
assert sid == "sub_xyz"
def test_subscription_deleted_drops_tier_to_free(tmp_path):
client, factory, _ = _build_app(tmp_path)
# First, activate.
_post_webhook(client, body={
"id": "evt_act",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}},
})
# Then, delete the subscription.
r = _post_webhook(client, body={
"id": "evt_del",
"type": "customer.subscription.deleted",
"data": {"object": {
"id": "sub_xyz",
"customer": "cus_abc",
"status": "canceled",
}},
})
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_customer_id, u.stripe_subscription_id
tier, cid, sid = asyncio.run(_check())
assert tier == "free"
# Customer linkage preserved so a future resub matches this row.
assert cid == "cus_abc"
assert sid is None
def test_subscription_trialing_stores_trial_end(tmp_path):
"""customer.subscription.created with status=trialing + trial_end
should grant paid AND persist the trial_end timestamp so the
settings page can show 'N days remaining'.
Realistic flow: checkout.session.completed fires first (linking
customer_id to user.id via client_reference_id), then
subscription.created fires moments later carrying trial_end."""
import datetime as _dt
client, factory, _ = _build_app(tmp_path)
# First: link the user to the Stripe customer via checkout.
_post_webhook(client, body={
"id": "evt_link",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_trial",
"subscription": "sub_trial",
}},
})
# Then: the subscription event carrying trial_end (12 days out).
trial_end_ts = int((_dt.datetime.now(_dt.timezone.utc)
+ _dt.timedelta(days=12)).timestamp())
r = _post_webhook(client, body={
"id": "evt_trial",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_trial",
"customer": "cus_trial",
"status": "trialing",
"trial_end": trial_end_ts,
}},
})
assert r.status_code == 200, r.text
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_trial_end_at
tier, end = asyncio.run(_check())
assert tier == "paid", "trial users must have paid features"
assert end is not None
# Stored value should match the trial_end we sent (within a second).
expected = _dt.datetime.fromtimestamp(trial_end_ts, tz=_dt.timezone.utc)
if end.tzinfo is None:
end = end.replace(tzinfo=_dt.timezone.utc)
assert abs((end - expected).total_seconds()) < 2
def test_subscription_active_clears_trial_end(tmp_path):
"""When the subscription transitions trialing -> active (day 15),
the trial_end marker should be cleared so settings stops showing
'trial — N days remaining'."""
import datetime as _dt
client, factory, _ = _build_app(tmp_path)
# Link the customer first via checkout, then plant a trial state.
_post_webhook(client, body={
"id": "evt_link2",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_t",
"subscription": "sub_t",
}},
})
trial_end_ts = int((_dt.datetime.now(_dt.timezone.utc)
+ _dt.timedelta(days=12)).timestamp())
_post_webhook(client, body={
"id": "evt_t1",
"type": "customer.subscription.created",
"data": {"object": {
"id": "sub_t", "customer": "cus_t",
"status": "trialing", "trial_end": trial_end_ts,
}},
})
# Now transition to active.
_post_webhook(client, body={
"id": "evt_t2",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_t", "customer": "cus_t",
"status": "active",
}},
})
async def _check():
from sqlalchemy import select
from app.models import User
async with factory() as session:
u = (await session.execute(
select(User).where(User.id == 1)
)).scalar_one()
return u.tier, u.stripe_trial_end_at
tier, end = asyncio.run(_check())
assert tier == "paid"
assert end is None, "trial_end_at must be cleared once active"
def test_subscription_active_grants_paid(tmp_path):
"""customer.subscription.updated with status=active should also
grant paid covers the case where checkout.session.completed
arrives after subscription.created and we want either to work."""
client, factory, _ = _build_app(tmp_path)
# Seed the linkage first via checkout (so customer_id is known).
_post_webhook(client, body={
"id": "evt_ck",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}},
})
# Drop to free manually so we can prove the updated event re-grants.
async def _reset():
from sqlalchemy import update
from app.models import User
async with factory() as session:
await session.execute(
update(User).where(User.id == 1).values(tier="free")
)
await session.commit()
asyncio.run(_reset())
r = _post_webhook(client, body={
"id": "evt_upd",
"type": "customer.subscription.updated",
"data": {"object": {
"id": "sub_xyz",
"customer": "cus_abc",
"status": "active",
}},
})
assert r.status_code == 200
async def _check_tier():
from sqlalchemy import select
from app.models import User
async with factory() as session:
return (await session.execute(
select(User.tier).where(User.id == 1)
)).scalar_one()
assert asyncio.run(_check_tier()) == "paid"
# --- idempotency + unknown ------------------------------------------------
def test_replayed_event_id_is_a_noop(tmp_path):
client, factory, _ = _build_app(tmp_path)
body = {
"id": "evt_dup",
"type": "checkout.session.completed",
"data": {"object": {
"client_reference_id": "1",
"customer": "cus_abc",
"subscription": "sub_xyz",
}},
}
r1 = _post_webhook(client, body=body)
r2 = _post_webhook(client, body=body)
assert r1.json()["status"] == "ok"
assert r2.json()["status"] == "duplicate"
async def _count_rows():
from sqlalchemy import select, func
from app.models import StripeEvent
async with factory() as session:
n = (await session.execute(
select(func.count(StripeEvent.id))
.where(StripeEvent.event_id == "evt_dup")
)).scalar_one()
return n
assert asyncio.run(_count_rows()) == 1
def test_unknown_event_is_acked(tmp_path):
client, _, _ = _build_app(tmp_path)
r = _post_webhook(client, body={
"id": "evt_unknown",
"type": "product.something.new",
"data": {"object": {}},
})
assert r.status_code == 200
assert r.json()["status"] == "ignored"
# --- /api/stripe/checkout (with Stripe SDK mocked) ------------------------
def _fake_checkout_client(asserter):
"""Build a fake Stripe client whose checkout.sessions.create calls
the supplied asserter on the params dict and returns a stub URL."""
fake_session = SimpleNamespace(
id="cs_test_123", url="https://checkout.stripe.com/test",
)
class _FakeSessions:
@staticmethod
def create(params): # noqa: ANN001
asserter(params)
return fake_session
class _FakeCheckout:
sessions = _FakeSessions()
class _FakeClient:
checkout = _FakeCheckout()
return _FakeClient()
def test_checkout_monthly_has_no_trial_and_no_stripe_consent(tmp_path):
"""Monthly checkout must NOT carry a free trial (£7 × 14 days would
halve cycle-1 revenue) AND must NOT use Stripe's account-wide
consent_collection the Reg-36 waiver is collected on /pricing
so each product can use its own Terms URL."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["mode"] == "subscription"
assert params["line_items"][0]["price"] == _PRICE_MONTHLY
assert params["client_reference_id"] == "1"
assert params["customer_email"] == "buyer@x"
assert "subscription_data" not in params, "no trial on monthly"
assert "consent_collection" not in params, (
"consent is collected on /pricing, not via Stripe's account-wide setting"
)
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "monthly"},
cookies={"cassandra_session": session_cookie},
)
assert r.status_code == 200, r.text
assert r.json()["url"] == "https://checkout.stripe.com/test"
def test_checkout_annual_uses_trial_not_consent_collection(tmp_path):
"""Annual checkout gets the 14-day free trial (substitutes for the
statutory cooling-off right; no money moves during the trial)."""
client, _, session_cookie = _build_app(tmp_path)
def asserter(params):
assert params["mode"] == "subscription"
assert params["line_items"][0]["price"] == _PRICE_ANNUAL
assert params["subscription_data"]["trial_period_days"] == 14
assert "consent_collection" not in params, "annual relies on trial, not consent"
with patch("app.routers.stripe_billing._stripe_client",
return_value=_fake_checkout_client(asserter)):
r = client.post(
"/api/stripe/checkout",
json={"cadence": "annual"},
cookies={"cassandra_session": session_cookie},
)
assert r.status_code == 200, r.text
assert r.json()["url"] == "https://checkout.stripe.com/test"
def test_checkout_endpoint_requires_login(tmp_path):
client, _, _ = _build_app(tmp_path)
r = client.post("/api/stripe/checkout", json={"cadence": "monthly"})
# No session cookie → require_auth bounces with 401.
assert r.status_code == 401, r.text

View file

@ -1,278 +0,0 @@
"""Tests for /api/ticker/validate and /api/ticker/historical."""
from __future__ import annotations
from datetime import datetime, timezone
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory, setup).
Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine, factory, _setup
@pytest.mark.asyncio
async def test_validate_happy_path(tmp_path, monkeypatch):
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
_, factory, setup = _build_session_factory(tmp_path)
await setup()
# Mock fetch_yahoo to return a successful quote.
async def _fake_yahoo(client, symbol, label, note, anchor=None):
return Quote(
symbol=symbol, source="yahoo", label=label, note=note,
price=172.40, currency="USD", as_of="2026-05-27", changes={},
)
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
# Avoid the MySQL-only upsert on SQLite.
async def _fake_upsert(session, tickers):
return len(list(tickers))
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
async with factory() as session:
result = await validate_ticker(symbol="aapl", session=session)
assert result["ok"] is True
assert result["symbol"] == "AAPL"
assert result["price"] == 172.40
assert result["currency"] == "USD"
assert result["as_of"] == "2026-05-27"
@pytest.mark.asyncio
async def test_validate_unknown_symbol(tmp_path, monkeypatch):
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
_, factory, setup = _build_session_factory(tmp_path)
await setup()
# Mock fetch_yahoo to return a Quote with error and no price.
async def _fake_yahoo(client, symbol, label, note, anchor=None):
return Quote(symbol=symbol, source="yahoo", label=label, note=note,
price=None, currency=None, as_of=None,
error="empty result")
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
async with factory() as session:
result = await validate_ticker(symbol="XYZNOTREAL", session=session)
assert result["ok"] is False
assert "not recognised" in result["error"].lower()
@pytest.mark.asyncio
async def test_validate_empty_symbol_rejects():
from app.routers.ticker_validate import validate_ticker
# Direct call — no session needed because we short-circuit before any DB use.
result = await validate_ticker(symbol=" ", session=None)
assert result["ok"] is False
assert "required" in result["error"].lower()
@pytest.mark.asyncio
async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
"""Side-effect check: on success, the symbol is upserted into the
universe and a Quote row is written."""
from sqlalchemy import select
from app.models import Quote as QuoteModel
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
_, factory, setup = _build_session_factory(tmp_path)
await setup()
upsert_calls: list[list[str]] = []
async def _fake_yahoo(client, symbol, label, note, anchor=None):
return Quote(symbol=symbol, source="yahoo", label=label, note=note,
price=100.0, currency="USD", as_of="2026-05-27", changes={})
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
async def _fake_upsert(session, tickers):
upsert_calls.append(list(tickers))
return len(list(tickers))
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
async with factory() as session:
result = await validate_ticker(symbol="MSFT", session=session)
assert result["ok"] is True
assert upsert_calls == [["MSFT"]]
# Quote row was written.
async with factory() as session:
rows = (await session.execute(
select(QuoteModel).where(QuoteModel.symbol == "MSFT")
)).scalars().all()
assert len(rows) == 1
assert rows[0].price == 100.0
assert rows[0].currency == "USD"
@pytest.mark.asyncio
async def test_historical_happy_path(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
async def _fake_hist(client, symbol, target_iso):
# close, currency, actual_iso
return 185.92, "USD", "2024-01-12"
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
result = await get_historical(symbol="aapl", date="2024-01-15")
assert result["ok"] is True
assert result["close"] == 185.92
assert result["currency"] == "USD"
# 2024-01-15 was a Monday — but our fake says it walked back to Jan 12 (Fri).
assert result["actual_date"] == "2024-01-12"
@pytest.mark.asyncio
async def test_historical_future_date_rejected():
from fastapi import HTTPException
from app.routers.ticker_validate import get_historical
future = "2099-01-01"
with pytest.raises(HTTPException) as exc:
await get_historical(symbol="AAPL", date=future)
assert exc.value.status_code == 400
assert "future" in str(exc.value.detail).lower()
@pytest.mark.asyncio
async def test_historical_bad_date_format_rejected():
from fastapi import HTTPException
from app.routers.ticker_validate import get_historical
with pytest.raises(HTTPException) as exc:
await get_historical(symbol="AAPL", date="not-a-date")
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_historical_no_data(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
async def _fake_hist(client, symbol, target_iso):
return None, None, None
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
result = await get_historical(symbol="ZZZNEW", date="2020-01-15")
assert result["ok"] is False
assert "no data" in result["error"].lower()
@pytest.mark.asyncio
async def test_historical_provider_failure(monkeypatch):
import httpx
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
async def _fake_hist(client, symbol, target_iso):
raise httpx.RequestError("connection failed")
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
result = await get_historical(symbol="AAPL", date="2024-01-15")
assert result["ok"] is False
assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()
@pytest.mark.asyncio
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
"""Unit test for the helper itself: feed a hand-crafted series with a
weekend gap, ask for the Saturday close, expect Friday's close."""
from app.routers.ticker_validate import fetch_yahoo_historical
# Build a fake httpx client that returns a chart payload with a
# Thu-Fri-Mon-Tue series; we ask for Saturday and expect Friday.
thu_ts = int(datetime(2024, 1, 11, tzinfo=timezone.utc).timestamp())
fri_ts = int(datetime(2024, 1, 12, tzinfo=timezone.utc).timestamp())
mon_ts = int(datetime(2024, 1, 15, tzinfo=timezone.utc).timestamp())
payload = {
"chart": {"result": [{
"meta": {"currency": "USD"},
"timestamp": [thu_ts, fri_ts, mon_ts],
"indicators": {"quote": [{"close": [184.0, 185.92, 186.10]}]},
}]}
}
class _FakeResponse:
def __init__(self, data): self._data = data
def json(self): return self._data
def raise_for_status(self): pass
class _FakeClient:
async def get(self, *args, **kwargs):
return _FakeResponse(payload)
close, currency, actual = await fetch_yahoo_historical(
_FakeClient(), "AAPL", "2024-01-13", # a Saturday
)
assert close == 185.92
assert currency == "USD"
assert actual == "2024-01-12"
def test_validate_route_requires_paid():
"""Static check that the /ticker/validate route is gated by require_paid."""
from app.routers.ticker_validate import router
from app.services.access import require_paid
route = next(
r for r in router.routes
if getattr(r, "path", "") == "/ticker/validate"
)
dep_callables = [d.call for d in route.dependant.dependencies]
assert require_paid in dep_callables
def test_historical_route_requires_paid():
from app.routers.ticker_validate import router
from app.services.access import require_paid
route = next(
r for r in router.routes
if getattr(r, "path", "") == "/ticker/historical"
)
dep_callables = [d.call for d in route.dependant.dependencies]
assert require_paid in dep_callables
def test_routes_mounted_under_api_prefix():
"""Confirm the router is mounted on the FastAPI app under /api."""
from app.main import app
paths = {getattr(r, "path", "") for r in app.routes}
assert "/api/ticker/validate" in paths
assert "/api/ticker/historical" in paths

View file

@ -1,179 +0,0 @@
"""/verify behaviour: returning users keep their digest preference, and
first-login triggers the welcome email exactly once.
The verify page used to carry a "Email me the digest" checkbox; that
was removed (it was misleading on repeat logins). Default-opt-in lives
in the User row at creation; per-user changes happen on /settings.
"""
from __future__ import annotations
import asyncio
def _build(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
from app.models import User
from app.routers import auth as auth_router
from app.auth import _pending_serializer
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/v.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=10, email="newbie@x", tier="free",
email_digest_opt_in=True))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(auth_router.router)
pending = _pending_serializer().dumps({"email": "newbie@x", "uid": 10, "ref": None})
return TestClient(app), pending
def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
"""A user who unsubscribed (via Settings or the one-click link) must
not be silently re-enrolled when they log in again. The handler now
never touches email_digest_opt_in, so this is a regression guard
against accidentally adding that back."""
from datetime import datetime, timezone
from app.services import otp_service
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
client, pending = _build(tmp_path)
# Simulate a returning user: previously logged in, then opted out.
async def _make_returning():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
u.email_digest_opt_in = False
await s.commit()
asyncio.run(_make_returning())
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
assert u.email_digest_opt_in is False, "returning user re-enrolled"
asyncio.run(_check())
def test_first_login_triggers_welcome_email(tmp_path, monkeypatch):
"""A user signing in for the first time gets exactly one welcome
email. The send is best-effort failure must not block login."""
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
send_mock = AsyncMock()
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
assert send_mock.await_count == 1, "first login should send a welcome email"
assert send_mock.await_args.args == ("newbie@x",)
def test_returning_user_login_does_not_resend_welcome(tmp_path, monkeypatch):
"""The welcome email is one-shot: a returning user (last_login_at
is not None) must not get a second copy."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
send_mock = AsyncMock()
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
client, pending = _build(tmp_path)
# Mark the user as already-known.
async def _make_returning():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
await s.commit()
asyncio.run(_make_returning())
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
assert send_mock.await_count == 0, "returning user should not re-get welcome"
def test_welcome_email_failure_does_not_block_login(tmp_path, monkeypatch):
"""SMTP errors are best-effort — the user still gets a session cookie
and lands on /. We rely on a log line for operational visibility."""
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
async def _boom(*args, **kwargs):
raise RuntimeError("SMTP down")
monkeypatch.setattr(auth_router, "send_welcome_email",
AsyncMock(side_effect=_boom))
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
# Login still succeeds; redirect to dashboard, session cookie set.
assert r.status_code == 303, r.text
assert r.headers.get("location") == "/"