diff --git a/Dockerfile b/Dockerfile index 0fd7ec9..1123177 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,3 +32,31 @@ 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"] diff --git a/alembic/versions/0016_portfolio_sync_pepper_fp.py b/alembic/versions/0016_portfolio_sync_pepper_fp.py new file mode 100644 index 0000000..1a5f6c0 --- /dev/null +++ b/alembic/versions/0016_portfolio_sync_pepper_fp.py @@ -0,0 +1,39 @@ +"""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") diff --git a/alembic/versions/0017_email_digest.py b/alembic/versions/0017_email_digest.py new file mode 100644 index 0000000..38cfd4e --- /dev/null +++ b/alembic/versions/0017_email_digest.py @@ -0,0 +1,55 @@ +"""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") diff --git a/alembic/versions/0018_polar_webhook.py b/alembic/versions/0018_polar_webhook.py new file mode 100644 index 0000000..bc085a7 --- /dev/null +++ b/alembic/versions/0018_polar_webhook.py @@ -0,0 +1,55 @@ +"""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") diff --git a/alembic/versions/0019_stripe.py b/alembic/versions/0019_stripe.py new file mode 100644 index 0000000..3ea4018 --- /dev/null +++ b/alembic/versions/0019_stripe.py @@ -0,0 +1,56 @@ +"""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") diff --git a/alembic/versions/0020_trial_end.py b/alembic/versions/0020_trial_end.py new file mode 100644 index 0000000..c845673 --- /dev/null +++ b/alembic/versions/0020_trial_end.py @@ -0,0 +1,31 @@ +"""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") diff --git a/alembic/versions/0021_csv_format_template.py b/alembic/versions/0021_csv_format_template.py new file mode 100644 index 0000000..bc6ca0a --- /dev/null +++ b/alembic/versions/0021_csv_format_template.py @@ -0,0 +1,40 @@ +"""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") diff --git a/app/cli.py b/app/cli.py index 47152fb..c780f0b 100644 --- a/app/cli.py +++ b/app/cli.py @@ -11,8 +11,9 @@ 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 Phase D.2. In D.3 the Paddle webhook will -call the same helper for both sides of a referral conversion. +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. """ from __future__ import annotations @@ -94,6 +95,57 @@ 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) @@ -108,6 +160,11 @@ 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 @@ -122,6 +179,8 @@ 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() diff --git a/app/config.py b/app/config.py index 70c0f1c..0aabb1d 100644 --- a/app/config.py +++ b/app/config.py @@ -90,6 +90,24 @@ 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") diff --git a/app/jobs/_helpers.py b/app/jobs/_helpers.py index 211110f..fe4b6b1 100644 --- a/app/jobs/_helpers.py +++ b/app/jobs/_helpers.py @@ -23,17 +23,21 @@ 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.""" + 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.""" factory = get_session_factory() async with factory() as session: - # 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 + 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 run = JobRun(name=name, started_at=utcnow(), status="running") session.add(run) await session.commit() @@ -53,6 +57,7 @@ async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]] log.error("job.failed", name=name, error=str(e)) raise finally: - await session.execute(text("SELECT RELEASE_LOCK(:n)"), - {"n": f"cassandra_{name}"}) - await session.commit() + if use_lock: + await session.execute(text("SELECT RELEASE_LOCK(:n)"), + {"n": f"cassandra_{name}"}) + await session.commit() diff --git a/app/jobs/_market_context.py b/app/jobs/_market_context.py new file mode 100644 index 0000000..5dd591f --- /dev/null +++ b/app/jobs/_market_context.py @@ -0,0 +1,86 @@ +"""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) diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index 7d63eb2..bc8b488 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -4,8 +4,6 @@ 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 @@ -13,7 +11,13 @@ 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.models import AICall, Headline, JobRun, Quote, StrategicLog +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.services.cadence import DEFAULT_POLICY from app.services.openrouter import ( PROMPT_VERSION, @@ -22,79 +26,9 @@ 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": @@ -119,7 +53,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) @@ -127,8 +61,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" @@ -169,7 +103,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) diff --git a/app/jobs/email_digest_job.py b/app/jobs/email_digest_job.py new file mode 100644 index 0000000..1f38777 --- /dev/null +++ b/app/jobs/email_digest_job.py @@ -0,0 +1,224 @@ +"""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()) diff --git a/app/main.py b/app/main.py index a9dcb50..fe987f5 100644 --- a/app/main.py +++ b/app/main.py @@ -19,9 +19,13 @@ 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 @@ -83,9 +87,18 @@ 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) diff --git a/app/models.py b/app/models.py index bdc884a..665a8cd 100644 --- a/app/models.py +++ b/app/models.py @@ -22,15 +22,23 @@ 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(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) symbol: Mapped[str] = mapped_column(String(128), nullable=False) source: Mapped[str] = mapped_column(String(32), nullable=False) label: Mapped[str] = mapped_column(String(128), default="") @@ -61,7 +69,7 @@ class QuoteDaily(Base): class Headline(Base): __tablename__ = "headlines" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) source: Mapped[str] = mapped_column(String(64), nullable=False) category: Mapped[str] = mapped_column(String(32), nullable=False) title: Mapped[str] = mapped_column(String(512), nullable=False) @@ -99,7 +107,7 @@ class Feed(Base): class StrategicLog(Base): __tablename__ = "strategic_logs" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) model: Mapped[str] = mapped_column(String(64), nullable=False) anchor_date: Mapped[str | None] = mapped_column(String(16)) @@ -116,7 +124,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(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) group_name: Mapped[str] = mapped_column(String(64), nullable=False) generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) model: Mapped[str] = mapped_column(String(64), nullable=False) @@ -134,7 +142,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(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) called_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) model: Mapped[str] = mapped_column(String(64), nullable=False) prompt_tokens: Mapped[int | None] = mapped_column(Integer) @@ -161,23 +169,50 @@ class User(Base): settings_json: Mapped[dict | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Referrals (Phase D.1). The code is unique + URL-safe; generated on - # first need rather than at row creation so existing accounts get one - # the next time they hit /settings. + # Referral code is unique + URL-safe; generated on first need rather + # than at row creation so existing accounts get one the next time + # they hit /settings. referral_code: Mapped[str | None] = mapped_column(String(16), nullable=True) referred_by_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) - # Paid-tier credit window (Phase D.2). Null = no credit. When set and - # > now(), the user gets paid-tier features regardless of `tier`. - # Populated by admin CLI (manual grants) or Paddle webhook (D.3). + # Paid-tier credit window. Null = no credit. When set and > now(), + # the user gets paid-tier features regardless of `tier`. Populated + # by admin CLI (manual grants) and by referral conversion (45 days + # per converted referral, both parties). credit_until: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) + email_digest_opt_in: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=text("1"), + ) + # NULL = use INTERMEDIATE at render time. Server-side mirror of the + # dashboard tone, decoupled because the dashboard pref is localStorage. + digest_tone: Mapped[str | None] = mapped_column(String(16)) + # 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"), ) @@ -204,16 +239,21 @@ 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=`. The conversion fields (`converted_at`, `credited_at`) stay null until the referred - user makes their first paid subscription — Phase D.3 fills them in - via the Paddle webhook.""" + user makes their first paid subscription — the Stripe webhook calls + ``referral_service.convert_referral`` to fill them in and extend + both parties' ``credit_until``.""" __tablename__ = "referrals" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) referrer_user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) @@ -235,7 +275,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(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) email: Mapped[str] = mapped_column(String(255), nullable=False) code_hash: Mapped[str] = mapped_column(String(255), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -256,7 +296,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(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) t212_ticker: Mapped[str] = mapped_column(String(64), nullable=False) t212_shortname: Mapped[str] = mapped_column(String(32), nullable=False) yahoo_ticker: Mapped[str | None] = mapped_column(String(32)) @@ -298,7 +338,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(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(64), nullable=False) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -307,3 +347,121 @@ 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) diff --git a/app/routers/api.py b/app/routers/api.py index 84e8ad6..5e06090 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -8,6 +8,7 @@ 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 @@ -19,7 +20,7 @@ from collections import defaultdict import httpx from pydantic import BaseModel, Field -from app.auth import require_token +from app.auth import require_token, maybe_current_user, CurrentUser from app.config import get_settings from app.db import get_session, utcnow from app.services.openrouter import ( @@ -36,6 +37,7 @@ from app.models import ( JobRun, Quote, StrategicLog, + User, ) from app.schemas import ( HealthOut, @@ -49,7 +51,8 @@ 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") + "indicator_summary_job", "universe_flush_job", + "email_digest_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, @@ -228,11 +231,18 @@ 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 - cutoff = utcnow() - timedelta(hours=since_hours) + 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) stmt = select(Headline).where(Headline.published_at >= cutoff) if category: stmt = stmt.where(Headline.category == category) @@ -275,7 +285,9 @@ async def news_list( "tag_vocabulary": TAG_VOCABULARY, "tag_labels": TAG_LABELS, "active_include": sorted(include), - "active_exclude": sorted(exclude)}, + "active_exclude": sorted(exclude), + "capped": capped, + "window_hours": effective_hours}, ) return [HeadlineOut.model_validate(r, from_attributes=True) for r in filtered] @@ -310,31 +322,54 @@ 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) - row = (await session.execute( + + stmt = ( select(StrategicLog) .where(StrategicLog.tone == wanted_tone) .order_by(desc(StrategicLog.generated_at)) .limit(1) - )).scalar_one_or_none() + ) + if free_only: + stmt = stmt.where(_free_tier_hour_filter()) + row = (await session.execute(stmt)).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: - row = (await session.execute( - select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1) - )).scalar_one_or_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() if as_ == "html": return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row), "tone": wanted_tone}, + {"log": _log_partial_payload(row), "tone": wanted_tone, + "paid": not free_only}, ) if row is None: @@ -349,34 +384,46 @@ 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).""" + filtered by tone (NOVICE | INTERMEDIATE; default from settings). + Free-tier users only see logs generated at the 6-hour boundary slots.""" 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) - row = (await session.execute( + + stmt = ( select(StrategicLog) .where(func.date(StrategicLog.generated_at) == target) .where(StrategicLog.tone == wanted_tone) .order_by(desc(StrategicLog.generated_at)) .limit(1) - )).scalar_one_or_none() + ) + if free_only: + stmt = stmt.where(_free_tier_hour_filter()) + row = (await session.execute(stmt)).scalar_one_or_none() if row is None: - # Fallback: any tone for that day. - row = (await session.execute( + # Fallback: any tone for that day (still tier-filtered). + fallback = ( select(StrategicLog) .where(func.date(StrategicLog.generated_at) == target) .order_by(desc(StrategicLog.generated_at)) .limit(1) - )).scalar_one_or_none() + ) + if free_only: + fallback = fallback.where(_free_tier_hour_filter()) + row = (await session.execute(fallback)).scalar_one_or_none() if as_ == "html": return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row), "tone": wanted_tone}, + {"log": _log_partial_payload(row), "tone": wanted_tone, + "paid": not free_only}, ) if row is None: raise HTTPException(status_code=404, detail="No log on this date") @@ -732,11 +779,22 @@ 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") @@ -800,3 +858,40 @@ 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) diff --git a/app/routers/auth.py b/app/routers/auth.py index 59733a9..28a7d4d 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 +from app.services.email_service import EmailSendError, send_otp, send_welcome_email from app.templates_env import templates @@ -239,10 +239,26 @@ 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) @@ -276,9 +292,32 @@ async def verify_resend( # --------------------------------------------------------------------------- +_LOGOUT_HTML = """ + +Signing out… + + +Signing out…""" + + @router.post("/logout") async def logout(request: Request): - resp = RedirectResponse(url="/login", status_code=303) + resp = HTMLResponse(content=_LOGOUT_HTML) resp.delete_cookie(SESSION_COOKIE_NAME, path="/") _clear_pending_cookie(resp) return resp diff --git a/app/routers/email.py b/app/routers/email.py new file mode 100644 index 0000000..429101b --- /dev/null +++ b/app/routers/email.py @@ -0,0 +1,102 @@ +"""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 = """\ + + + + + Unsubscribed — {brand} + + + +
+
{brand}
+
email preferences
+

You're unsubscribed from email digests.

+

+ You can re-enable digests any time from + Settings. +

+
+ + +""" + + +@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)) diff --git a/app/routers/pages.py b/app/routers/pages.py index a00bf56..f7ef42b 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -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 Referral, StrategicLog, User -from app.services.access import paid_status +from app.models import EmailSend, Referral, StrategicLog, User +from app.services.access import is_paid_active, paid_status from app.services.referral_service import assign_code_if_missing from app.templates_env import templates @@ -37,7 +37,8 @@ async def root_page( return templates.TemplateResponse( request, "dashboard.html", - {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu}, + {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, + "cu": cu, "paid": is_paid_active(cu)}, ) @@ -74,41 +75,40 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date: return datetime.now(timezone.utc).date() -def _log_page_context(target: date) -> dict: +def _log_page_context(target: date, paid: bool) -> 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, - dependencies=[Depends(require_token)], -) +@router.get("/log", response_class=HTMLResponse) 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)) + return templates.TemplateResponse( + request, "log.html", _log_page_context(target, is_paid_active(cu)), + ) -@router.get( - "/log/{day}", - response_class=HTMLResponse, - dependencies=[Depends(require_token)], -) +@router.get("/log/{day}", response_class=HTMLResponse) 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)) + return templates.TemplateResponse( + request, "log.html", _log_page_context(target, is_paid_active(cu)), + ) @router.get("/settings", response_class=HTMLResponse) @@ -117,9 +117,10 @@ async def settings_page( session: AsyncSession = Depends(get_session), principal: CurrentUser = Depends(require_auth), ): - """Per-user settings. Currently shows email, tier, and the referral - block (own code + invite link + counts of pending/converted - referrals). The Credit / Paddle pieces land in D.3.""" + """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).""" user = principal.user if user is None: # Bearer-token admin path — no per-user settings to show. @@ -132,8 +133,9 @@ 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, and - # how many of those converted (paid). D.3 will fill `converted_at`. + # 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). pending_count = (await session.execute( select(func.count(Referral.id)) .where(Referral.referrer_user_id == user.id) @@ -144,9 +146,57 @@ 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", { @@ -154,6 +204,10 @@ 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, }, ) diff --git a/app/routers/polar_webhook.py b/app/routers/polar_webhook.py new file mode 100644 index 0000000..a60acd4 --- /dev/null +++ b/app/routers/polar_webhook.py @@ -0,0 +1,304 @@ +"""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,` + 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,` — 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"} diff --git a/app/routers/public.py b/app/routers/public.py index a040ccd..33bd245 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -15,6 +15,7 @@ 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 @@ -33,7 +34,9 @@ async def pricing_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), ): - return templates.TemplateResponse(request, "pricing.html", _ctx(request, cu)) + ctx = _ctx(request, cu) + ctx["paid"] = is_paid_active(cu) + return templates.TemplateResponse(request, "pricing.html", ctx) @router.get("/about", response_class=HTMLResponse) diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py new file mode 100644 index 0000000..60bc7f7 --- /dev/null +++ b/app/routers/stripe_billing.py @@ -0,0 +1,431 @@ +"""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"} diff --git a/app/routers/sync.py b/app/routers/sync.py index e6496e0..0fa1174 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -43,6 +43,7 @@ class SyncBlobOut(BaseModel): class SyncStatusOut(BaseModel): exists: bool + orphaned: bool = False updated_at: datetime | None = None @@ -73,8 +74,8 @@ async def get_status( principal: CurrentUser = Depends(require_paid), session: AsyncSession = Depends(get_session), ) -> SyncStatusOut: - exists, updated_at = await svc.fetch_status(session, principal.id) - return SyncStatusOut(exists=exists, updated_at=updated_at) + exists, orphaned, updated_at = await svc.fetch_status(session, principal.id) + return SyncStatusOut(exists=exists, orphaned=orphaned, updated_at=updated_at) @router.post("", response_model=SyncWriteOut) @@ -108,6 +109,15 @@ 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( diff --git a/app/routers/ticker_validate.py b/app/routers/ticker_validate.py new file mode 100644 index 0000000..53bc783 --- /dev/null +++ b/app/routers/ticker_validate.py @@ -0,0 +1,156 @@ +"""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} diff --git a/app/routers/universe.py b/app/routers/universe.py index 163e99d..a77585f 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -189,7 +189,7 @@ async def get_sparkline( # --------------------------------------------------------------------------- -@router.post("/portfolio/parse") +@router.post("/portfolio/parse", dependencies=[Depends(require_paid)]) async def parse_portfolio( file: UploadFile = File(...), session: AsyncSession = Depends(get_session), @@ -210,27 +210,57 @@ async def parse_portfolio( try: pie = parse_t212_csv(raw) - except CSVImportError as e: - raise HTTPException(status_code=400, detail=str(e)) + 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)) positions_out: list[dict] = [] yahoo_tickers: list[str] = [] unmapped: list[str] = [] for p in pie.positions: - 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 + 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 positions_out.append({ - "yahoo_ticker": resolved.yahoo_ticker, + "yahoo_ticker": yahoo_ticker, "t212_slice": p.slice, - "name": resolved.name or p.name, + "name": name, "qty": p.quantity, "avg_cost": p.average_price, # @property — no call parens - "currency": resolved.currency, + "currency": currency, }) - yahoo_tickers.append(resolved.yahoo_ticker) + yahoo_tickers.append(yahoo_ticker) # Synchronous upsert: bypass the Redis buffer so the dashboard has # live prices immediately. The buffer + flush machinery remains for @@ -321,7 +351,7 @@ async def analyze_portfolio( is persisted. The ai_calls ledger row records tokens + cost, never holdings. - Gated behind ``require_paid`` (Phase D.2): free-tier users get 402. + Gated behind ``require_paid``: 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. diff --git a/app/scheduler_main.py b/app/scheduler_main.py index e20d15e..54dec6d 100644 --- a/app/scheduler_main.py +++ b/app/scheduler_main.py @@ -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, + indicator_summary_job, universe_flush_job, email_digest_job, ) @@ -58,6 +58,11 @@ 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()]) diff --git a/app/services/access.py b/app/services/access.py index 9066f1d..2f91f7a 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -2,11 +2,12 @@ Two sources can grant paid access: -1. ``user.tier in {"paid", "enterprise"}`` — set by Paddle webhook in - Phase D.3 once a subscription is active. -2. ``user.credit_until > now()`` — non-subscription credit. Currently - populated by the admin CLI (`python -m app.cli grant-credit`) and, in - D.3, by the referral-conversion path (3 months at 50% off). +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). Either is sufficient. We use a single ``paid_status`` function so the Settings page can show *why* a user has paid access ("paid subscription" @@ -22,6 +23,17 @@ 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) diff --git a/app/services/csv_import.py b/app/services/csv_import.py index 770ff1e..cacd84d 100644 --- a/app/services/csv_import.py +++ b/app/services/csv_import.py @@ -37,7 +37,10 @@ _REQUIRED_FIELDS = ("slice", "quantity") @dataclass(frozen=True) class ParsedPosition: - slice: str # T212 shortcode, e.g. "SGLN" + 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. name: str invested_value: float | None current_value: float | None @@ -46,6 +49,10 @@ 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: @@ -67,6 +74,11 @@ 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: diff --git a/app/services/email_service.py b/app/services/email_service.py index 274e526..d3ed9f7 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -18,6 +18,8 @@ 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 @@ -196,3 +198,231 @@ 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 = """\ + + + + + + + + Welcome to {brand} + + + + + +
+
+ ▰ {brand_upper} +
+
 
+
+ Welcome to {brand}. +
+
 
+
+ You’re signed in. The dashboard is at + {app_url_short} — + 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; you can switch the digest off + at any time on the + Settings page, + or use the one-click unsubscribe link in every digest email. +
+
 
+
+
 
+
+ Sent automatically by {brand} · do not reply +
+
+ + +""" + + +_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 = """\ + + + + + + + {brand} — {label} + + + + + +
+
+ ▰ {brand_upper} · {label_upper} +
+
 
+
+ {content_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 diff --git a/app/services/llm_csv_parser.py b/app/services/llm_csv_parser.py new file mode 100644 index 0000000..7bb84af --- /dev/null +++ b/app/services/llm_csv_parser.py @@ -0,0 +1,431 @@ +"""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": "
", + "qty_col": "
", + "name_col": "
", + "cost_col": "
", // average price per share + "currency_col": "
", + "broker_label": "" +} + +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 diff --git a/app/services/openrouter.py b/app/services/openrouter.py index 759e9f5..a542b98 100644 --- a/app/services/openrouter.py +++ b/app/services/openrouter.py @@ -30,7 +30,8 @@ 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. -PROMPT_VERSION = 8 +# v9 (2026-05-25): Adds daily + weekly digest prompt builders for email. +PROMPT_VERSION = 9 # --- Core: invariant across tone/analysis settings ---------------------------- @@ -507,6 +508,107 @@ 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

,

,
    ,
  • , , " + " — no , , or 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

    ,

    ,
      ,
    • , , " + " — no , , or 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).""" diff --git a/app/services/portfolio_sync.py b/app/services/portfolio_sync.py index 15c9c41..c0bbbe9 100644 --- a/app/services/portfolio_sync.py +++ b/app/services/portfolio_sync.py @@ -44,8 +44,16 @@ RATE_LIMIT_MAX = 6 class SyncCryptoError(Exception): - """Outer-wrap decryption failed — usually a pepper change or - bit-rotted row. The router maps this to a 500.""" + """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.""" def _utcnow() -> datetime: @@ -72,6 +80,22 @@ 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.""" @@ -81,9 +105,15 @@ 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.""" + """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. + """ try: - return AESGCM(_server_key(user_id)).decrypt(outer_ct, outer_nonce, None) + return AESGCM(_server_key(user_id)).decrypt(outer_nonce, outer_ct, None) except Exception as exc: # InvalidTag, malformed ciphertext, etc. raise SyncCryptoError("outer wrap unwrap failed") from exc @@ -91,6 +121,7 @@ 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: @@ -101,6 +132,7 @@ 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: @@ -109,19 +141,34 @@ 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, datetime | None]: - """Cheap existence check — does NOT decrypt. Used by the dashboard to - decide whether to show the restore prompt.""" +) -> 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. + """ row = await session.get(PortfolioSync, user_id) if row is None: - return False, None - return True, row.updated_at + return False, False, None + return True, _is_orphaned(row), row.updated_at async def fetch( @@ -129,13 +176,36 @@ async def fetch( ) -> tuple[bytes, datetime] | None: """Returns (inner_blob, updated_at) or None if sync disabled. - Raises SyncCryptoError if the row exists but the outer wrap is - unreadable (typically: pepper was rotated without re-encrypting). + 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). """ row = await session.get(PortfolioSync, user_id) if row is None: return None - inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce) + 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() return inner, row.updated_at diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 5f663e6..05ee579 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -1,15 +1,18 @@ -"""Referral-code generation, lookup, and signup-time linkage. +"""Referral-code generation, lookup, signup-time linkage, and +conversion-time credit grants. -D.1 lays down the bookkeeping only — actual credit application happens -in D.3 when the Paddle webhook fires. The flow: +The flow: 1. /login renders an "invited" banner when the URL carries `?ref=`. 2. The code travels through the email-OTP flow inside the pending cookie so it survives the GET /login → POST /login → /verify hops. 3. When the new user's row is first created (POST /login on an unknown email), `referred_by_user_id` is set and a `Referral` row is written. -4. On the new user's first paid subscription (D.3), we read the - `Referral` row to apply discounts to both parties. +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. 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. @@ -17,6 +20,7 @@ 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 @@ -24,6 +28,7 @@ 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") @@ -35,6 +40,12 @@ 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.""" @@ -128,3 +139,65 @@ 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 diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index e5f1d79..b4b6f6b 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -566,6 +566,53 @@ 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); @@ -681,6 +728,25 @@ 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 --------------------------------------------------- */ @@ -840,16 +906,46 @@ 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="email"], +.auth-card input[type="password"], +.auth-card input[type="text"] { background: var(--bg); border: 1px solid var(--border); color: var(--text); font-family: var(--font-mono); - font-size: 13px; - padding: 8px 10px; + font-size: 16px; + padding: 12px 14px; 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; @@ -924,7 +1020,13 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } margin-left: 8px; } -.settings-section { margin-top: 22px; } +/* Sections are
      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__head { font-family: var(--font-mono); font-size: 11px; @@ -932,8 +1034,30 @@ 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; } -.settings-section__head::before { content: "▸ "; color: var(--accent); } +/* 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__lede { color: var(--muted); font-size: 12.5px; @@ -1527,15 +1651,24 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } margin: 0 0 24px; } .hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; } -.hero__ctas .btn-primary, -.hero__ctas .btn-secondary { +/* Shared button shape — was previously scoped to .hero__ctas, which made + the pricing-card CTAs render as bare anchors. */ +.btn-primary, +.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 @@ -1570,6 +1703,11 @@ 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); @@ -1590,6 +1728,10 @@ 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 ---------- */ @@ -1656,55 +1798,749 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); } .tier-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 18px; - margin: 8px 0 24px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin: 8px 0 40px; } .tier-card { + position: relative; border: 1px solid var(--border); - border-radius: 4px; - padding: 22px 22px 26px; + border-radius: 6px; + padding: 28px 26px 28px; background: var(--surface); display: flex; flex-direction: column; } .tier-card--featured { border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent) inset; + box-shadow: 0 0 0 1px var(--accent) inset, + 0 12px 32px rgba(15, 23, 42, 0.10); } -.tier-card__name { +[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: 11px; - color: var(--muted); - letter-spacing: 0.08em; + font-size: 10px; + letter-spacing: 0.10em; text-transform: uppercase; - margin-bottom: 8px; + 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; + color: var(--muted); + line-height: 1.5; + margin-bottom: 22px; } .tier-card__price { - font-size: 22px; + font-size: 40px; font-weight: 700; color: var(--text); - margin-bottom: 4px; + 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; } .tier-card__price-hint { font-size: 12px; color: var(--muted); - margin-bottom: 18px; + 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; } .tier-card ul { list-style: none; padding: 0; - margin: 0 0 22px; + margin: 0 0 24px; flex: 1; } .tier-card li { font-size: 13.5px; color: var(--text); - padding: 6px 0; + line-height: 1.55; + padding: 8px 0 8px 22px; + position: relative; border-bottom: 1px solid var(--border); } .tier-card li:last-child { border-bottom: 0; } -.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; } +.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 -based lightbox. See app/templates/landing.html. */ + +/* All clickable screenshots are ' + + 'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' + + '' + '' + 'or import a new CSV →' + '' + @@ -212,6 +255,12 @@ 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.'); @@ -221,6 +270,16 @@ } 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 => { @@ -249,6 +308,10 @@ '' + lastDisplay + fxBadge + '' + '' + signed(p._ppl) + '' + '' + pct(p._ppl_pct) + '' + + '' + + '' + + '' + ''; }).join(''); @@ -305,6 +368,7 @@ 'QtyAvg' + 'LastP/L' + '%' + + '' + '' + '' + rows + '' + '' + @@ -417,7 +481,14 @@ catch (e) { console.warn('sync status check failed', e); } } if (status && status.paid && status.exists) { - renderRestoreFromCloud(mount, status); + 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); + } } else { renderEmpty(mount); } @@ -432,7 +503,7 @@ } const base = pie.base_currency || 'GBP'; const fx = (universeCache && universeCache.fx) || null; - const enriched = pie.positions.map(p => enrichPosition(p, base, fx)) + const enriched = pie.positions.map((p, i) => Object.assign(enrichPosition(p, base, fx), { _orig_idx: i })) .sort((a, b) => (b._value || 0) - (a._value || 0)); const agg = aggregate(enriched); renderPanel(mount, pie, enriched, agg); diff --git a/app/static/js/portfolio_edit.js b/app/static/js/portfolio_edit.js new file mode 100644 index 0000000..1354fe2 --- /dev/null +++ b/app/static/js/portfolio_edit.js @@ -0,0 +1,255 @@ +/* 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(); + }); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index fa05eb4..9fdb0d1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,6 +4,29 @@ {% block title %}{{ BRAND_NAME }}{% endblock %} + {# 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. #} + {# Apply saved theme before stylesheet renders to avoid a flash. #} +
      Strategic Log - generated hourly @ :20 UTC + + {% if paid %}refreshed hourly @ :20 UTC{% else %}refreshed every 6 hours · hourly on Paid{% endif %} +
      Flash News - last 24h · ingest hourly @ :10 UTC + + {% if paid %}last 24h · ingest hourly @ :10 UTC{% else %}last 6h · full 24h on Paid{% endif %} +

      This page is part of, and qualifies, the - Terms of Service. + Terms and Conditions.

      @@ -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 Terms of Service for the + the content. See the Terms and Conditions for the full limitation of liability.

      diff --git a/app/templates/landing.html b/app/templates/landing.html index e275377..2732a68 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -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 hourly. A media - service, not a financial one. + justify versus what the crowd is doing. Refreshed through the + trading day. A media service, not a financial one.

      {% if cu and (cu.user or cu.is_admin) %} @@ -25,6 +25,17 @@
      +
      + +
      +
      News, aggregated
      @@ -36,6 +47,13 @@ stuff is easy to find. Ingestion follows the trading calendar — off-hours stay quiet.

      +
      @@ -47,10 +65,17 @@ explains what the move means, not what it was. Anchored in earnings, policy, valuation — not chart patterns.

      +
      -
      The hourly read
      +
      The strategic read

      Rational vs irrational, every paragraph

      We tie the day’s headlines and the cross-asset signals into @@ -61,15 +86,40 @@ intermediate. This is editorial commentary on public data — not a forecast and not advice on any investment decision.

      + +
      +
      + +
      +

      More views

      +
      +

      - 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. + 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.

      @@ -100,4 +150,34 @@ + + + +

      +
      + + + {% endblock %} diff --git a/app/templates/log.html b/app/templates/log.html index 3e56727..8abee4c 100644 --- a/app/templates/log.html +++ b/app/templates/log.html @@ -30,6 +30,7 @@
      loading log…
      + {% if paid %} + {% else %} + + {% endif %} - +{% if paid %}{% endif %} {% endblock %} diff --git a/app/templates/partials/news.html b/app/templates/partials/news.html index 36b8a59..5f19f4d 100644 --- a/app/templates/partials/news.html +++ b/app/templates/partials/news.html @@ -33,3 +33,10 @@ {% endfor %} {% endif %} +{% if capped %} +
      + Free tier — showing the last {{ window_hours|int }} hours of news. + Upgrade + for the full 24-hour feed plus daily and weekly email digests. +
      +{% endif %} diff --git a/app/templates/pricing.html b/app/templates/pricing.html index 8d88352..93f1562 100644 --- a/app/templates/pricing.html +++ b/app/templates/pricing.html @@ -6,66 +6,262 @@

      Pricing

      - 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. + 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.

      -
      Free
      +

      Free

      +
      The core editorial — news, indicators, and a strategic log every 6 hours.
      £0
      -
      Forever. No card needed.
      +
      No card needed.
      +
      +
      What you get
        -
      • News aggregator — auto-tagged by theme
      • -
      • 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
      • -
      • Portfolio import & analysis
      • -
      • Encrypted cloud sync
      • +
      • News feed — headlines from the last 6 hours, auto-tagged by theme, click-to-filter
      • +
      • Cross-asset indicator panels (equities, rates, FX, commodities, credit, …) with a one-paragraph AI read on each tab
      • +
      • Strategic log — a single editorial interpretation of the day, refreshed every 6 hours
      • +
      • Two reading levels: Novice (defines jargon) or Intermediate (terse, for fluent readers)
      • +
      • Sunday weekly digest by email — week behind + week ahead, one-click unsubscribe
      +
      + Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See Paid → +
      {% if cu and (cu.user or cu.is_admin) %} - Open dashboard + Open dashboard {% else %} - Sign up free + Sign up free {% endif %}
      + + +
      +

      Free vs Paid at a glance

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      FeatureFreePaid
      News feed — headlines from the last…6 hours24 hours
      Strategic log refreshEvery 6 hoursEvery hour
      Cross-asset indicator panels
      Follow-up chat on past logsIncluded
      Email digestSunday onlySunday + daily Mon–Sat
      Portfolio import (broker CSV)Included
      AI portfolio readIncluded
      Encrypted cloud syncIncluded
      +
      + +
      + +
      +
      Invite a friend
      +
      Both of you get 45 days of paid access
      +
      + Share your personal invite link from Settings. The credit applies when they start a paid plan. +
      +
      + +
      + + + +

      Invite a friend

      +

      + Every account gets an 8-character referral code and matching invite + link, both shown on your Settings page. When + someone signs up through your link and starts a paid plan, + both of you get 45 days of paid access credited + to your account. +

      +

      How it works

      +
        +
      1. Sign up. Your code and link go live in Settings.
      2. +
      3. Share. Send the link, or read the code — the alphabet drops 0/O and 1/I/L so it dictates cleanly.
      4. +
      5. They sign up. The referral is recorded against your account when they verify their email.
      6. +
      7. They subscribe. 45 days of paid access lands on both accounts — usable any time over the next month and a half.
      8. +
      +

      The fine print

      +
        +
      • One referral per new account — whichever link they used first.
      • +
      • No self-referral.
      • +
      • Credits stack: if you already have a credit window running, the new 45 days extend from its end, not from today.
      • +
      • Credits aren’t refundable for cash — see Terms & Conditions § 6.
      • +
      • Pending signups, conversions, and active credits are visible on the Settings page.
      • +
      +
      + + +

      How the data is handled

      diff --git a/app/templates/settings.html b/app/templates/settings.html index ecdd25a..20dfa57 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -20,53 +20,111 @@

      Tier
      -
      - - {% if paid and paid.active %} - {% if paid.source == "credit" %} - - Paid features active via credit · {{ paid.days_remaining }} day(s) remaining - (expires {{ paid.expires_at.strftime("%Y-%m-%d") }}). - +
      +
      + + {% if paid and paid.active %} + {% if paid.source == "credit" %} + + Paid features active via credit · {{ paid.days_remaining }} day(s) remaining + (expires {{ paid.expires_at.strftime("%Y-%m-%d") }}). + + {% else %} + {% if trial_days_remaining %} + + Free trial — {{ trial_days_remaining }} + day{{ '' if trial_days_remaining == 1 else 's' }} remaining. + Cancel before the trial ends and you won’t be charged. + + {% else %} + Paid subscription active. + {% endif %} + {% endif %} {% else %} - Paid subscription active. + + Free tier — upgrade for £7/month or £70/year. + {% endif %} - {% else %} - Paid features unlock with Paddle (D.3) or invite credits. +
      + {% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %} + {% endif %}
      + {% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %} + + {% endif %} + {# --- Import portfolio --------------------------------------------- #} -
      -
      Import portfolio (Trading 212 CSV)
      + {# 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. #} +
      + Import portfolio (CSV)

      - Export your pie from T212 - (Investing → Your Pie → ··· → Export) - and drop the CSV here. We’ll parse it and show a preview before - importing anywhere. + 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. +
      T212 export path: + Investing → Your Pie → ··· → Export.

      -
      Drop a T212 pie CSV here
      -
      or browse · max 1 MB
      +
      Drop your broker's portfolio CSV here
      +
      or browse · max 1 MB · T212, IBKR and others auto-detected
      -
      +
      {# --- Referral block ---------------------------------------------- #} -
      -
      Invite a friend
      +
      + Invite a friend

      Share your invite link. When your friend subscribes, you and - they each get 50% off for 3 months. + they each get 45 days of paid access credited + to your account.

      @@ -91,14 +149,84 @@
      Active credits
      -
      — (D.3)
      +
      {{ active_credit_count }}
      + {% if own_credit_days %} +
      + +{{ own_credit_days }} day{{ '' if own_credit_days == 1 else 's' }} on your account +
      + {% endif %}
      - + + + {# --- Email digests block ------------------------------------------ #} +
      + Email digests +

      + Editorial commentary delivered to your inbox. Daily for paid (Mon–Sat) plus the Sunday recap; free tier gets the Sunday recap. +

      + +
      +
      Subscription
      +
      + +
      + One-click unsubscribe in every email. +
      +
      +
      + +
      +
      Reading level
      +
      +
      + + +
      +
      +
      + +
      +
      Last delivery
      +
      + {% if last_email_send %}{{ last_email_send.sent_at.strftime("%Y-%m-%d %H:%M") }} UTC — {{ last_email_send.status }}{% else %}—{% endif %} +
      +
      + +
      +
      + + {# --- Cloud sync block --------------------------------------------- #} -
      -
      Cloud sync (encrypted)
      +
      + Cloud sync (encrypted)

      Manage the encrypted server-side copy of your portfolio. Sync is opted-in per import (see the Import section above). @@ -119,7 +247,7 @@ above to enable cloud sync.

      {% endif %} -
      + {# Future: Paddle subscription block, AI-spend ledger summary, etc. #} @@ -145,10 +273,10 @@
      + class="modal-input" required> + class="modal-input" required>