read.markets/app/main.py

110 lines
4.4 KiB
Python
Raw Permalink Normal View History

"""FastAPI entrypoint. Runs Alembic migrations on startup, bootstraps the
feeds table from TOML, mounts the API + HTML routers.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from alembic import command
from alembic.config import Config as AlembicConfig
from fastapi import FastAPI
phase G: data minimisation + passwordless auth + DeepSeek-first LLM Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:16:57 +01:00
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from app import branding
from app.config import get_settings
from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
from app.routers import auth as auth_router
from app.routers import chat as chat_router
from app.routers import email as email_router
from app.routers import ops as ops_router
from app.routers import pages as pages_router
polar: build /api/polar/webhook handler Standalone router for inbound Polar (merchant-of-record) deliveries. No bearer-token dep — authenticity comes from the Standard Webhooks HMAC instead. Wired up so it's safe to deploy dark: empty POLAR_WEBHOOK_SECRET makes the endpoint return 503 (loud) rather than accept unsigned events. Behaviour - Standard Webhooks signature verification: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`, base64 secret prefixed whsec_, ±5min replay window, constant-time compare against any of the space-separated v1 tokens. - Idempotency via UNIQUE on polar_events.event_id — a replayed webhook-id short-circuits to 200 "duplicate" without re-running. - Event dispatch table covers the 10 events we subscribed to: subscription.{created,active,updated,uncanceled} -> tier=paid + persist polar_customer_id / polar_subscription_id. subscription.revoked -> tier=free (customer id kept so a resub matches the same User row). canceled / past_due / order.* / refund.created -> audit only. - Unknown event types are acked 200 + recorded; we don't want to 4xx on something Polar adds in the future and trigger their retry loop. Schema (migration 0018) - users.polar_customer_id, users.polar_subscription_id (both nullable String(64)); UNIQUE on polar_customer_id so two users can't claim the same Polar identity. - polar_events table: event_id (unique), event_type, received_at, processed_at, error, raw payload (truncated to 16 KiB). Tests - 7 in tests/test_polar_webhook.py: bad signature -> 401, stale timestamp -> 401, missing headers -> 400, subscription.active flips tier to paid + stores IDs, subscription.revoked drops to free while keeping customer link, replayed webhook-id is no-op, unknown event is acked. - Full suite: 212 passed, 5 skipped. Operator next steps before saving the webhook in Polar 1. Pull this branch to prod and apply migration 0018. 2. Save the webhook in Polar pointing at https://read.markets/api/polar/webhook — Polar will accept the save even though our endpoint still 503s (no secret yet). 3. Copy the secret Polar reveals into the prod .env as POLAR_WEBHOOK_SECRET=whsec_... and restart the app. 4. Trigger a test event from Polar's dashboard to confirm 200 OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:42:41 +02:00
from app.routers import polar_webhook as polar_webhook_router
public: landing + pricing + legal pages, apex-ready, lawyer-reviewed Adds the unauthenticated surface that's needed to invite outsiders: - Landing (/) — dual-purpose root: dashboard for logged-in users, landing for everyone else. New maybe_current_user soft-auth helper in app/auth.py supports it without disturbing the per-route require_token deps on /news, /log, /upload, /settings. - About, Pricing, Disclaimer, Terms, Privacy — own router (app/routers/public.py), no auth dep, shared public_base layout (brand link, thin nav, footer with legal links + ICO ref + date). - Editorial positioning: news aggregator with a macro brain; tagline "Understand markets. Don't gamble on them."; anti-trading-as-gambling stance carried through About and Landing. Legal pass following an independent lawyer-style review: - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22 automated-decision line; explicit consent for sessionStorage sync key (PECR); 30-day IP-log retention; Art. 21 objection right; Children clause; Art. 33/34 breach-notification clause; international-transfer mechanism (IDTA + UK Addendum). ICO registration ZC098928 surfaced at the top. - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove advice-shaped wording ("what would invalidate the posture" gone); added italic carve-out citing FSMA / FCA COBS. - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34) commentator safe-harbour; "qualifies the Terms" line; hallucination wording fixed. - Terms: cl.4 explicit AI-training prohibition + harassment line; cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension + pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights carve-out from the liability cap; cl.11 right to close account on material change; cl.12 non-exclusive jurisdiction + UK consumer local courts. Code-side enforcement of the Privacy claim: - openrouter.py: outbound OpenRouter calls now carry X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request flag; the Privacy page discloses this caveat verbatim. Apex domain prep: - branding.APP_URL flipped to https://read.markets (was app.). DNS for the apex already resolves; pending operator NPM step is a cert that covers the bare apex + a 301 from app.read.markets. No hard-coded subdomain references remain in code (verified with grep). Nav + chrome: - app dropdown gains Pricing / Terms / Privacy / Disclaimer links. - login.html gains a small legal-links footer for the highest-leverage moment to surface them. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:08:02 +02:00
from app.routers import public as public_router
stripe: wire checkout, customer portal, and webhook for read.markets Stripe is the merchant-on-record for read.markets after Polar/Paddle both declined the financial-media category. This commit lands the full subscription flow: an "Upgrade" button on /pricing now opens a real Stripe-hosted Checkout, completes the subscription, and the webhook flips user.tier to "paid" idempotently. Endpoints - POST /api/stripe/checkout (require_auth) — creates a hosted Checkout Session in subscription mode, passes user.id as client_reference_id + email as customer_email, returns the URL for the page-side JS to redirect to. Reuses an existing stripe_customer_id to avoid duplicate Stripe customers on repeat checkouts. allow_promotion_codes=True so the referral-credit redemption can attach a coupon at checkout once that flow ships. - POST /api/stripe/portal (require_auth) — mints a Stripe Customer Portal session. Used by /settings; returns 404 until the user has a stripe_customer_id (i.e. completed at least one checkout). - POST /api/stripe/webhook — signature-verified via stripe.Webhook.construct_event. Idempotent via UNIQUE on stripe_events.event_id. Event dispatch: checkout.session.completed → grant paid, store IDs customer.subscription.created → grant paid (active/trialing) customer.subscription.updated → grant paid (active/trialing) customer.subscription.deleted → drop to free, clear sub id invoice.paid / failed → audit only charge.refunded → audit only Stripe-SDK objects don't expose dict.get(); we use the SDK for signature verification then re-parse the JSON body for handler dispatch — cleaner than reaching into StripeObject internals. Schema (migration 0019) - users.stripe_customer_id, users.stripe_subscription_id (nullable String(64), UNIQUE on customer_id). - stripe_events table mirroring polar_events: event_id (unique), event_type, received_at, processed_at, error, raw payload (truncated to 16 KiB). Settings (.env) - STRIPE_API_KEY (rk_test_… for dev, rk_live_… for GA) - STRIPE_WEBHOOK_SECRET (whsec_… from the dashboard endpoint) - STRIPE_PRICE_MONTHLY (price_xxx for £7/month) - STRIPE_PRICE_ANNUAL (price_xxx for £70/year) Pricing page - Free tier CTA unchanged. - Paid CTA branches three ways: paid → "Manage subscription" to /settings; logged-in free → two buttons (£7/mo, £70/yr) that POST to /api/stripe/checkout and redirect; anonymous → /login?next=/pricing. - Inline JS intercepts the button click, calls the checkout endpoint, redirects on success, surfaces errors via alert(). No Stripe.js dep — we use the hosted-checkout URL directly. Polar handler stays in place for berengar.io / flyroom.net which still ship through Polar. polar_* and stripe_* columns coexist independently on the User row. Tests - 9 in tests/test_stripe_billing.py covering: bad signature → 401, missing signature → 400, checkout.session.completed flips tier + stores IDs, subscription.updated active grants paid, subscription.deleted drops to free with customer id preserved, replayed event id is no-op (one row in stripe_events), unknown event acked 200, checkout endpoint mocks the SDK and returns the hosted URL, checkout requires login. - Full suite: 221 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:45:13 +02:00
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
phase G: data minimisation + passwordless auth + DeepSeek-first LLM Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:16:57 +01:00
from app.routers import universe as universe_router
from app.services.feeds_bootstrap import bootstrap_feeds
log = get_logger("cassandra")
APP_DIR = Path(__file__).resolve().parent
PROJECT_DIR = APP_DIR.parent
def _run_migrations() -> None:
"""Synchronous Alembic upgrade. Called once at lifespan startup."""
cfg = AlembicConfig(str(PROJECT_DIR / "alembic.ini"))
cfg.set_main_option("script_location", str(PROJECT_DIR / "alembic"))
cfg.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
command.upgrade(cfg, "head")
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
log.info("cassandra.startup")
s = get_settings()
if not s.PORTFOLIO_SYNC_PEPPER and not s.DATABASE_URL.startswith("sqlite"):
# Outer wrap still works (it just degrades to a per-user derived
# key with no shared secret), but a DB leak would let an attacker
# brute-force the PIN offline. Loud warning, not a hard failure.
log.warning("cassandra.portfolio_sync.pepper_missing")
try:
# Alembic's env.py uses asyncio.run() internally; offload it to a
# worker thread so it doesn't collide with FastAPI's running loop.
await asyncio.to_thread(_run_migrations)
log.info("cassandra.migrations.applied")
except Exception as e:
log.error("cassandra.migrations.failed", error=str(e))
raise
async with get_session_factory()() as session:
inserted = await bootstrap_feeds(session)
log.info("cassandra.feeds.bootstrap", inserted=inserted)
yield
log.info("cassandra.shutdown")
app = FastAPI(
title=branding.BRAND_NAME,
description="Macro-strategy dashboard",
version="0.1.0",
lifespan=lifespan,
)
phase G: data minimisation + passwordless auth + DeepSeek-first LLM Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:16:57 +01:00
# Gzip responses ≥500 bytes when the client sends Accept-Encoding: gzip.
# The Phase G universe payload is repetitive JSON that gzips to ~25-30%
# of raw size; compression is mandatory for that endpoint to be cheap.
app.add_middleware(GZipMiddleware, minimum_size=500)
app.mount(
"/static",
StaticFiles(directory=str(APP_DIR / "static")),
name="static",
)
phase A: user accounts + session-cookie auth Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00
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(chat_router.router, prefix="/api", tags=["chat"])
app.include_router(ops_router.router, prefix="/api", tags=["ops"])
phase G: data minimisation + passwordless auth + DeepSeek-first LLM Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:16:57 +01:00
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: build /api/polar/webhook handler Standalone router for inbound Polar (merchant-of-record) deliveries. No bearer-token dep — authenticity comes from the Standard Webhooks HMAC instead. Wired up so it's safe to deploy dark: empty POLAR_WEBHOOK_SECRET makes the endpoint return 503 (loud) rather than accept unsigned events. Behaviour - Standard Webhooks signature verification: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`, base64 secret prefixed whsec_, ±5min replay window, constant-time compare against any of the space-separated v1 tokens. - Idempotency via UNIQUE on polar_events.event_id — a replayed webhook-id short-circuits to 200 "duplicate" without re-running. - Event dispatch table covers the 10 events we subscribed to: subscription.{created,active,updated,uncanceled} -> tier=paid + persist polar_customer_id / polar_subscription_id. subscription.revoked -> tier=free (customer id kept so a resub matches the same User row). canceled / past_due / order.* / refund.created -> audit only. - Unknown event types are acked 200 + recorded; we don't want to 4xx on something Polar adds in the future and trigger their retry loop. Schema (migration 0018) - users.polar_customer_id, users.polar_subscription_id (both nullable String(64)); UNIQUE on polar_customer_id so two users can't claim the same Polar identity. - polar_events table: event_id (unique), event_type, received_at, processed_at, error, raw payload (truncated to 16 KiB). Tests - 7 in tests/test_polar_webhook.py: bad signature -> 401, stale timestamp -> 401, missing headers -> 400, subscription.active flips tier to paid + stores IDs, subscription.revoked drops to free while keeping customer link, replayed webhook-id is no-op, unknown event is acked. - Full suite: 212 passed, 5 skipped. Operator next steps before saving the webhook in Polar 1. Pull this branch to prod and apply migration 0018. 2. Save the webhook in Polar pointing at https://read.markets/api/polar/webhook — Polar will accept the save even though our endpoint still 503s (no secret yet). 3. Copy the secret Polar reveals into the prod .env as POLAR_WEBHOOK_SECRET=whsec_... and restart the app. 4. Trigger a test event from Polar's dashboard to confirm 200 OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:42:41 +02:00
# 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: wire checkout, customer portal, and webhook for read.markets Stripe is the merchant-on-record for read.markets after Polar/Paddle both declined the financial-media category. This commit lands the full subscription flow: an "Upgrade" button on /pricing now opens a real Stripe-hosted Checkout, completes the subscription, and the webhook flips user.tier to "paid" idempotently. Endpoints - POST /api/stripe/checkout (require_auth) — creates a hosted Checkout Session in subscription mode, passes user.id as client_reference_id + email as customer_email, returns the URL for the page-side JS to redirect to. Reuses an existing stripe_customer_id to avoid duplicate Stripe customers on repeat checkouts. allow_promotion_codes=True so the referral-credit redemption can attach a coupon at checkout once that flow ships. - POST /api/stripe/portal (require_auth) — mints a Stripe Customer Portal session. Used by /settings; returns 404 until the user has a stripe_customer_id (i.e. completed at least one checkout). - POST /api/stripe/webhook — signature-verified via stripe.Webhook.construct_event. Idempotent via UNIQUE on stripe_events.event_id. Event dispatch: checkout.session.completed → grant paid, store IDs customer.subscription.created → grant paid (active/trialing) customer.subscription.updated → grant paid (active/trialing) customer.subscription.deleted → drop to free, clear sub id invoice.paid / failed → audit only charge.refunded → audit only Stripe-SDK objects don't expose dict.get(); we use the SDK for signature verification then re-parse the JSON body for handler dispatch — cleaner than reaching into StripeObject internals. Schema (migration 0019) - users.stripe_customer_id, users.stripe_subscription_id (nullable String(64), UNIQUE on customer_id). - stripe_events table mirroring polar_events: event_id (unique), event_type, received_at, processed_at, error, raw payload (truncated to 16 KiB). Settings (.env) - STRIPE_API_KEY (rk_test_… for dev, rk_live_… for GA) - STRIPE_WEBHOOK_SECRET (whsec_… from the dashboard endpoint) - STRIPE_PRICE_MONTHLY (price_xxx for £7/month) - STRIPE_PRICE_ANNUAL (price_xxx for £70/year) Pricing page - Free tier CTA unchanged. - Paid CTA branches three ways: paid → "Manage subscription" to /settings; logged-in free → two buttons (£7/mo, £70/yr) that POST to /api/stripe/checkout and redirect; anonymous → /login?next=/pricing. - Inline JS intercepts the button click, calls the checkout endpoint, redirects on success, surfaces errors via alert(). No Stripe.js dep — we use the hosted-checkout URL directly. Polar handler stays in place for berengar.io / flyroom.net which still ship through Polar. polar_* and stripe_* columns coexist independently on the User row. Tests - 9 in tests/test_stripe_billing.py covering: bad signature → 401, missing signature → 400, checkout.session.completed flips tier + stores IDs, subscription.updated active grants paid, subscription.deleted drops to free with customer id preserved, replayed event id is no-op (one row in stripe_events), unknown event acked 200, checkout endpoint mocks the SDK and returns the hosted URL, checkout requires login. - Full suite: 221 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:45:13 +02:00
# 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: landing + pricing + legal pages, apex-ready, lawyer-reviewed Adds the unauthenticated surface that's needed to invite outsiders: - Landing (/) — dual-purpose root: dashboard for logged-in users, landing for everyone else. New maybe_current_user soft-auth helper in app/auth.py supports it without disturbing the per-route require_token deps on /news, /log, /upload, /settings. - About, Pricing, Disclaimer, Terms, Privacy — own router (app/routers/public.py), no auth dep, shared public_base layout (brand link, thin nav, footer with legal links + ICO ref + date). - Editorial positioning: news aggregator with a macro brain; tagline "Understand markets. Don't gamble on them."; anti-trading-as-gambling stance carried through About and Landing. Legal pass following an independent lawyer-style review: - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22 automated-decision line; explicit consent for sessionStorage sync key (PECR); 30-day IP-log retention; Art. 21 objection right; Children clause; Art. 33/34 breach-notification clause; international-transfer mechanism (IDTA + UK Addendum). ICO registration ZC098928 surfaced at the top. - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove advice-shaped wording ("what would invalidate the posture" gone); added italic carve-out citing FSMA / FCA COBS. - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34) commentator safe-harbour; "qualifies the Terms" line; hallucination wording fixed. - Terms: cl.4 explicit AI-training prohibition + harassment line; cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension + pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights carve-out from the liability cap; cl.11 right to close account on material change; cl.12 non-exclusive jurisdiction + UK consumer local courts. Code-side enforcement of the Privacy claim: - openrouter.py: outbound OpenRouter calls now carry X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request flag; the Privacy page discloses this caveat verbatim. Apex domain prep: - branding.APP_URL flipped to https://read.markets (was app.). DNS for the apex already resolves; pending operator NPM step is a cert that covers the bare apex + a 301 from app.read.markets. No hard-coded subdomain references remain in code (verified with grep). Nav + chrome: - app dropdown gains Pricing / Terms / Privacy / Disclaimer links. - login.html gains a small legal-links footer for the highest-leverage moment to surface them. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:08:02 +02:00
# 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)
app.include_router(pages_router.router, tags=["pages"])