Compare commits
No commits in common. "11662c0ea8ba0e20947b016c8d7c2f6d2b8251a8" and "f1903e1e6130c269083b8661a67801e27c6045f3" have entirely different histories.
11662c0ea8
...
f1903e1e61
79 changed files with 317 additions and 14132 deletions
28
Dockerfile
28
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
63
app/cli.py
63
app/cli.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. Mon–Sat → 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())
|
||||
13
app/main.py
13
app/main.py
|
|
@ -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)
|
||||
|
|
|
|||
192
app/models.py
192
app/models.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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…</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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
▰ {brand_upper}
|
||||
</div>
|
||||
<div style="height:22px; line-height:22px; font-size:0;"> </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;"> </div>
|
||||
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
|
||||
You’re signed in. The dashboard is at
|
||||
<a href="{app_url}" style="color:{L_accent}; text-decoration:none;">{app_url_short}</a> —
|
||||
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;"> </div>
|
||||
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:18px; line-height:18px; font-size:0;"> </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;"> </div>
|
||||
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
|
||||
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; 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;"> </div>
|
||||
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
||||
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
|
||||
Sent automatically by {brand} · 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;">
|
||||
▰ {brand_upper} · {label_upper}
|
||||
</div>
|
||||
<div style="height:20px; line-height:20px; font-size:0;"> </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;"> </div>
|
||||
<div style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
||||
<div class="muted" style="font-size:11px; color:{L_muted};">
|
||||
<a href="{unsubscribe_url}" style="color:{L_accent};">Unsubscribe in one click</a>
|
||||
· <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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 — or use the calendar
|
||||
to fill cost from a buy date — then <kbd>+</kbd> to add.
|
||||
<kbd>×</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 · <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 · ingest hourly @ :10 UTC{% else %}last 6h · <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"
|
||||
|
|
|
|||
|
|
@ -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 “as is” 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>
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
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 — 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’s headlines and the cross-asset signals into
|
||||
|
|
@ -86,40 +61,15 @@
|
|||
intermediate. This is editorial commentary on public data —
|
||||
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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -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 — 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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -6,262 +6,66 @@
|
|||
<section class="public-section">
|
||||
<h1 class="public-section__head">Pricing</h1>
|
||||
<p>
|
||||
Two tiers. The core editorial is free today — 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 — 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 — 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 — news, indicators, and a strategic log every 6 hours.</div>
|
||||
<div class="tier-card__name">Free</div>
|
||||
<div class="tier-card__price">£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 — <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, …) with a one-paragraph AI read on each tab</li>
|
||||
<li>Strategic log — 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 — week behind + week ahead, one-click unsubscribe</li>
|
||||
<li>News aggregator — 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 & 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> →
|
||||
</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">£7<span class="tier-card__price-unit"> / month</span></div>
|
||||
<div class="tier-card__price-hint">
|
||||
Or <strong>£70 / year</strong> — 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 — a whole session in view, nothing rolls off</li>
|
||||
<li><strong>Strategic log refreshed every hour</strong> instead of every six — track intraday moves as they unfold</li>
|
||||
<li><strong>Follow-up chat on any past log</strong> — ask the model a question against the day’s full context</li>
|
||||
<li><strong>Daily email digest</strong> (Mon–Sat) — ~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> — diversification, sector and currency concentration, macro-regime fit on your holdings</li>
|
||||
<li><strong>Optional encrypted cloud sync</strong> — 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 — 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 — see
|
||||
<a href="/terms" target="_blank" rel="noopener">Terms §6</a>.)
|
||||
</span>
|
||||
</label>
|
||||
<button class="btn-primary btn-block" type="button"
|
||||
data-stripe-checkout="monthly" disabled>Subscribe — £7/month</button>
|
||||
<button class="btn-secondary btn-block" type="button"
|
||||
data-stripe-checkout="annual" disabled
|
||||
style="margin-top:10px;">or £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 — headlines from the last…</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">✓</td>
|
||||
<td class="compare-table__paid">✓</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Follow-up chat on past logs</th>
|
||||
<td class="compare-table__none">—</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–Sat</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Portfolio import (broker CSV)</th>
|
||||
<td class="compare-table__none">—</td>
|
||||
<td class="compare-table__paid"><strong>Included</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">AI portfolio read</th>
|
||||
<td class="compare-table__none">—</td>
|
||||
<td class="compare-table__paid"><strong>Included</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Encrypted cloud sync</th>
|
||||
<td class="compare-table__none">—</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">🎁</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">×</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 — 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 — 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 — 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’t refundable for cash — see <a href="/terms">Terms & Conditions § 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>
|
||||
|
|
|
|||
|
|
@ -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> — {{ trial_days_remaining }}
|
||||
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
|
||||
Cancel before the trial ends and you won’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 — <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 — Trading 212 is recognised
|
||||
natively and other formats (IBKR, Fidelity, Schwab…) are
|
||||
auto-detected. We’ll parse it and show a preview before importing
|
||||
anywhere.
|
||||
<br><span class="muted">T212 export path:
|
||||
<span class="neu">Investing → Your Pie → ··· → Export</span>.</span>
|
||||
Export your pie from T212
|
||||
(<span class="neu">Investing → Your Pie → ··· → Export</span>)
|
||||
and drop the CSV here. We’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> · max 1 MB · 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> · 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–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 — {{ last_email_send.status }}{% else %}—{% 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 couldn’t 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'
|
||||
: '—';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{% extends "public_base.html" %}
|
||||
{% block title %}{{ BRAND_NAME }} · Terms and Conditions{% endblock %}
|
||||
{% block title %}{{ BRAND_NAME }} · 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 “Service”) 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’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 & ownership</h2>
|
||||
<h2 class="public-section__head">7. Content & ownership</h2>
|
||||
<p>
|
||||
The Service’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 & termination</h2>
|
||||
<h2 class="public-section__head">8. Suspension & 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 “as is” and “as
|
||||
available”, 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’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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Monday–Saturday
|
||||
- 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 Mon–Sat 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
File diff suppressed because it is too large
Load diff
|
|
@ -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** (Mon–Sat) + 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.
|
||||
|
|
@ -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 3–5 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"`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
10
tests/fixtures/ibkr_sample.csv
vendored
10
tests/fixtures/ibkr_sample.csv
vendored
|
|
@ -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.
|
|
|
@ -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"
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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") == "/"
|
||||
Loading…
Add table
Add a link
Reference in a new issue