From d98a90f35e694dd380a8eca61a2478300639e693 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 16:54:40 +0200 Subject: [PATCH 01/11] landing: surface multilingual support Adds a single centred line between the feature grid and the "More views" block calling out that the AI surfaces (strategic log, indicator reads, portfolio analysis, chat, daily digest) are available in English and Italian. Cross-cutting feature so it reads better as a one-line callout than as a 4th feature card without its own screenshot. Also notes that tickers and numbers stay verbatim across the toggle, which the prose now guarantees. --- app/templates/landing.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/templates/landing.html b/app/templates/landing.html index 0726226..77fcbf8 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -96,6 +96,17 @@ +
+

+ Every AI-generated surface — strategic log, indicator reads, + portfolio analysis, chat, daily digest — is available in + English and Italian. Toggle the + language pill in the header and the panels refresh in place; + ticker symbols, currency codes and numbers stay verbatim across + languages. +

+
+

More views

From ee8384f1baad997c68827a901d4199879cfc7f79 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 17:10:45 +0200 Subject: [PATCH 02/11] i18n: bilingual landing page (EN / IT) with auto-detect routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public landing page is now served in English and Italian via path- prefixed URLs (/en/ and /it/), with the bare / detecting the visitor's language and 302-ing to the right one. Routing ------- * GET / : if authed → dashboard (unchanged). Otherwise the visitor's language is resolved from (in priority order) the rtm.lang cookie, the Accept-Language header, the cf-ipcountry geolocation header, and finally DEFAULT_LANG, then a 302 redirects to //. * GET /en/ + /it/ : render the localised landing template, set the rtm.lang cookie (1-year, SameSite=Lax) so the next /-visit goes straight to the same translation. Logged-in users with user.lang set bypass detection for / (same priority chain — user.lang wins on every public surface they touch). Storage ------- * app/locales/.yaml — flat-ish nested copy files. YAML chosen so a future translator can edit without touching Python. Strings containing inline HTML (, , ) are rendered with the Jinja `safe` filter in the template. * app/services/locales.py — loads at startup, exposes get_locale() and detect_public_lang(). Wraps each YAML tree in a small _Dotted view so templates can write {{ t.hero.subhead }} (deliberately NOT a dict subclass — dict's built-in method names would shadow YAML keys like `items`). Template + chrome ----------------- * landing.html ported in full to {{ t. }} references. * public_base.html gets + hreflang link tags (en/it/ x-default) when a route opts in via lang_switch=true. A tiny EN | IT link group lands in the public header, only visible on surfaces that opt in. * public.css picks up the small lang-switch widget styles. Scope (intentionally narrow) ---------------------------- * Only the landing page is localised. Pricing, terms, privacy, disclaimer, login, verify all stay English-only for now; their header chrome stays English too because localising labels there while the linked content is still EN would be a worse mismatch. * When other public pages get translated, lang_switch=true on those routes will surface the same widget there with no template changes. Tests ----- * tests/test_locales.py covers YAML load parity (every active language has a file), dotted access through the tree, the detection precedence chain, and the unknown-locale fallback. Deps ---- * pyyaml was already in requirements.lock as a transitive but not declared. Added to pyproject so it stays pinned as an explicit direct dependency. Co-Authored-By: Claude Opus 4.7 --- app/locales/en.yaml | 121 +++++++++++++++++++++++ app/locales/it.yaml | 128 ++++++++++++++++++++++++ app/main.py | 6 ++ app/routers/pages.py | 70 ++++++++++++- app/services/locales.py | 176 +++++++++++++++++++++++++++++++++ app/static/css/public.css | 24 +++++ app/templates/landing.html | 130 ++++++++---------------- app/templates/public_base.html | 21 +++- pyproject.toml | 1 + tests/test_locales.py | 137 +++++++++++++++++++++++++ 10 files changed, 721 insertions(+), 93 deletions(-) create mode 100644 app/locales/en.yaml create mode 100644 app/locales/it.yaml create mode 100644 app/services/locales.py create mode 100644 tests/test_locales.py diff --git a/app/locales/en.yaml b/app/locales/en.yaml new file mode 100644 index 0000000..a36e314 --- /dev/null +++ b/app/locales/en.yaml @@ -0,0 +1,121 @@ +# English copy for the public landing page. +# +# Strings that contain inline HTML are rendered with the `safe` filter +# in the template. Keep markup minimal — , , and the +# brand placeholders only. + +hero: + tagline: "Understand markets. Don't gamble on them." + subhead: >- + Built for investors who want to act rationally and + tune out the high-frequency noise that comes from treating markets + like a casino. We aggregate cross-asset news and macro signals, + then write a plain-English read of what the underlying fundamentals + justify versus what the crowd is doing. Refreshed through the + trading day. A media service, not a financial one. + cta_dashboard: "Open dashboard" + cta_pricing: "See pricing" + cta_signup: "Sign up free" + +shot_dashboard: + alt: "Dashboard preview" + zoom_hint: "Click to enlarge" + caption: >- + The dashboard. An aggregate cross-asset read at the top, + hand-picked indicator groups underneath. Reading level toggle + (Novice / Pro) flips every AI-generated panel between + plain-English and terse-pro framing. + +features: + news: + tag: "News, aggregated" + title: "Headlines from across the macro universe" + body: >- + RSS and per-ticker feeds covering equities, rates, credit, FX, + commodities, and geopolitics. Every headline is auto-tagged by + theme so the noise stays as noise and the fundamentals-relevant + stuff is easy to find. Ingestion follows the trading calendar — + off-hours stay quiet. + shot_alt: "News feed with auto-tagged headlines" + shot_caption: >- + The news feed. Each headline carries one or more theme tags + (rates, AI, energy, geopolitics, …) so you can keep the threads + you care about and mute the ones you don't. Click a tag to + include; shift-click to exclude. + indicators: + tag: "Macro signals" + title: "A curated cross-asset tape" + body: >- + A hand-picked set of indicators across every asset class, + refreshed hourly during market hours. Each group gets a short + read that explains what the move means, not what it + was. Anchored in earnings, policy, valuation — not chart + patterns. + shot_alt: "Indicators panel with AI commentary" + shot_caption: >- + The indicators panel. Tabs across asset classes (equity, rates, + commodities, FX, bonds, …); each tab carries a one-paragraph + 'read' written by the model on top of the live prices. The + numbers anchor the prose so the commentary is checkable, not + floating. + strategic: + tag: "The strategic read" + title: "Rational vs irrational, every paragraph" + body: >- + We tie the day's headlines and the cross-asset signals into a + single short interpretation. Each paragraph separates + rational drivers (earnings, policy, valuation) + from irrational ones (positioning, narrative, + flows) and names the gap. Two reading levels: Novice and Pro. + This is editorial commentary on public data — not a forecast + and not advice on any investment decision. + shot_alt: "Strategic log — the editorial AI read" + shot_caption: >- + The strategic log. The model writes a fresh interpretation + through the trading day, taking the previous draft as context + so it updates rather than starts over. Paid users get a refresh + every hour; free users get one every six. + +multilang_callout: >- + Every AI-generated surface — strategic log, indicator reads, + portfolio analysis, chat, daily digest — is available in + English and Italian. Toggle the + language pill in the header and the panels refresh in place; + ticker symbols, currency codes and numbers stay verbatim across + languages. + +more_views: + head: "More views" + chat: + alt: "Ask follow-up questions against any past log" + caption: >- + Ask follow-up questions against any past log. The chat panel + inherits the log's full context, so you can pull on a thread + without re-pasting headlines or re-explaining the setup. + caption_strong: "Ask anything about a log" + caption_span: "Conversational follow-ups with the day's context loaded." + +portfolio_blurb: >- + Paid users can also drop a portfolio CSV from their broker 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. + +not_strip: + head: "What this isn't." + items: + - "Not investment advice." + - "Not trading signals." + - "Not a day-trading tool." + - "No buy/sell calls, ever." + - "No chart-pattern predictions." + - "Not a regulated service." + +footer: + legal: >- + By signing up you agree to our Terms and + Privacy notice, and confirm you've read + the financial disclaimer. + +meta: + description: "Understand markets. Don't gamble on them." diff --git a/app/locales/it.yaml b/app/locales/it.yaml new file mode 100644 index 0000000..b0188aa --- /dev/null +++ b/app/locales/it.yaml @@ -0,0 +1,128 @@ +# Italian copy for the public landing page. +# +# Note: brand names ("Read the Markets") and proper nouns +# (tickers, currency codes) stay verbatim — they aren't translated. + +hero: + tagline: "Capisci i mercati. Non scommetterci sopra." + subhead: >- + Pensato per investitori che vogliono ragionare con + lucidità e tagliare fuori il rumore ad alta frequenza + che nasce dal trattare i mercati come un casinò. Aggreghiamo + notizie cross-asset e segnali macro, e ne scriviamo una lettura + in prosa chiara: cosa giustificano i fondamentali sottostanti, + rispetto a cosa sta facendo la folla. Aggiornato durante la + giornata di trading. Un servizio editoriale, non finanziario. + cta_dashboard: "Apri dashboard" + cta_pricing: "Vedi i piani" + cta_signup: "Iscriviti gratis" + +shot_dashboard: + alt: "Anteprima dashboard" + zoom_hint: "Clicca per ingrandire" + caption: >- + La dashboard. In alto una lettura cross-asset aggregata, sotto + gruppi di indicatori selezionati a mano. Il toggle del livello + di lettura (Novice / Pro) cambia il taglio di ogni pannello + generato dall'IA, da prosa accessibile a registro tecnico + sintetico. + +features: + news: + tag: "Notizie, aggregate" + title: "Titoli dall'intero universo macro" + body: >- + Feed RSS e per-ticker che coprono azioni, tassi, credito, FX, + materie prime e geopolitica. Ogni titolo è etichettato + automaticamente per tema, così il rumore resta rumore e il + materiale rilevante per i fondamentali è facile da trovare. + L'ingestione segue il calendario di trading — fuori orario + tutto resta in pausa. + shot_alt: "Feed notizie con tag automatici" + shot_caption: >- + Il feed delle notizie. Ogni titolo porta uno o più tag tematici + (tassi, AI, energia, geopolitica, …) per tenere i fili che ti + interessano e silenziare gli altri. Click su un tag per + includerlo; shift-click per escluderlo. + indicators: + tag: "Segnali macro" + title: "Una tape cross-asset curata" + body: >- + Un set di indicatori selezionato a mano su ogni classe di + attivo, aggiornato ogni ora durante le sessioni di mercato. + Ogni gruppo ha una lettura breve che spiega cosa il movimento + significa, non cosa è stato. Ancorata a utili, + politica monetaria, valutazione — non a pattern grafici. + shot_alt: "Pannello indicatori con commento IA" + shot_caption: >- + Il pannello indicatori. Tab per classe di attivo (azionario, + tassi, materie prime, FX, obbligazioni, …); ogni tab porta una + 'lettura' di un paragrafo scritta dal modello sopra i prezzi + live. I numeri ancorano la prosa, così il commento è + verificabile, non sospeso nel vuoto. + strategic: + tag: "Lettura strategica" + title: "Razionale vs irrazionale, paragrafo per paragrafo" + body: >- + Leghiamo i titoli della giornata e i segnali cross-asset in + un'unica interpretazione breve. Ogni paragrafo distingue i + driver razionali (utili, politica, valutazione) + dagli irrazionali (posizionamento, narrativa, + flussi) e dà un nome al divario. Due livelli di lettura: Novice + e Pro. È commento editoriale su dati pubblici — non una + previsione e non un consiglio su nessuna decisione di + investimento. + shot_alt: "Log strategico — la lettura editoriale IA" + shot_caption: >- + Il log strategico. Il modello scrive un'interpretazione + aggiornata durante la giornata, partendo dalla versione + precedente come contesto, in modo da aggiornare invece di + ricominciare. Gli utenti paganti ricevono un refresh ogni ora; + gli utenti gratuiti uno ogni sei. + +multilang_callout: >- + Ogni superficie generata dall'IA — log strategico, letture degli + indicatori, analisi di portafoglio, chat, digest giornaliero — è + disponibile in Inglese e Italiano. + Usa il selettore di lingua nell'header e i pannelli si aggiornano + in tempo reale; simboli ticker, codici valuta e numeri restano + invariati tra le lingue. + +more_views: + head: "Altre viste" + chat: + alt: "Fai domande di approfondimento su qualunque log passato" + caption: >- + Fai domande di approfondimento su qualunque log passato. Il + pannello chat eredita tutto il contesto del log, così puoi + tirare un filo senza re-incollare titoli o re-spiegare la + situazione. + caption_strong: "Chiedi qualsiasi cosa su un log" + caption_span: "Domande conversazionali con il contesto della giornata già caricato." + +portfolio_blurb: >- + Gli utenti paganti possono anche caricare un CSV di portafoglio + dal loro broker per un sense-check IA su concentrazione, + coerenza con il regime di mercato ed esposizione valutaria. Le + posizioni restano nel browser per impostazione predefinita; + l'attivazione opzionale del cloud sync (crittografato) + permette di ritrovarle su un altro dispositivo. + +not_strip: + head: "Cosa questo NON è." + items: + - "Non è consulenza finanziaria." + - "Non sono segnali di trading." + - "Non è uno strumento per day-trading." + - "Nessuna chiamata buy/sell, mai." + - "Nessuna previsione basata su pattern grafici." + - "Non è un servizio regolamentato." + +footer: + legal: >- + Iscrivendoti accetti i nostri Termini e + l'Informativa privacy, e confermi di aver + letto il disclaimer finanziario. + +meta: + description: "Capisci i mercati. Non scommetterci sopra." diff --git a/app/main.py b/app/main.py index 7f1729f..148f5dc 100644 --- a/app/main.py +++ b/app/main.py @@ -66,6 +66,12 @@ async def lifespan(app: FastAPI): async with get_session_factory()() as session: inserted = await bootstrap_feeds(session) log.info("cassandra.feeds.bootstrap", inserted=inserted) + # Load public-page translation YAMLs into memory once at startup. + # Lazy-loaded otherwise, but pre-loading lets startup fail loudly + # if a YAML file is corrupted instead of failing on the first + # landing-page request. + from app.services.locales import load_locales + load_locales() yield log.info("cassandra.shutdown") diff --git a/app/routers/pages.py b/app/routers/pages.py index 1801f93..01d68a8 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -13,9 +13,44 @@ from app.config import get_settings, load_groups from app.db import get_session from app.models import EmailSend, Referral, StrategicLog, User from app.services.access import is_paid_active, paid_status +from app.services.locales import ( + ACTIVE_PUBLIC_LANGS, + DEFAULT_LANG, + detect_public_lang, + get_locale, +) from app.services.referral_service import assign_code_if_missing from app.templates_env import templates +# Cookie used to remember an explicit language toggle on public pages. +# Distinct from the in-app user.lang preference (which lives in the +# DB for authenticated users). +_LANG_COOKIE = "rtm.lang" +_LANG_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 # 1 year + + +def _render_landing( + request: Request, cu: CurrentUser | None, lang: str, +) -> HTMLResponse: + """Render the localised landing page and stamp the language + cookie so a return visitor lands on the same translation without + another detection pass.""" + t = get_locale(lang) + response = templates.TemplateResponse( + request, + "landing.html", + {"cu": cu, "t": t, "lang": lang, "lang_switch": True}, + ) + # `secure` would block the cookie in local-dev HTTP; rely on the + # reverse proxy to upgrade everything to HTTPS in prod. samesite=Lax + # is the cookie we want for first-party navigation. + response.set_cookie( + _LANG_COOKIE, lang, + max_age=_LANG_COOKIE_MAX_AGE, samesite="lax", + httponly=False, + ) + return response + # Router-level auth removed in favour of per-route deps so that `/` can be # dual-purpose: logged-in users see the dashboard, logged-out visitors see # the landing page. @@ -27,11 +62,19 @@ async def root_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), ): - """Dual-purpose root: dashboard when authenticated, landing otherwise.""" + """Dual-purpose root: dashboard when authenticated, otherwise + detect the visitor's language and redirect to the localised + landing URL. Detection considers (in order) the rtm.lang cookie, + the Accept-Language header, the cf-ipcountry geolocation header, + and finally DEFAULT_LANG.""" if cu is None: - return templates.TemplateResponse( - request, "landing.html", {"cu": None}, + lang = detect_public_lang( + cookie_lang=request.cookies.get(_LANG_COOKIE), + accept_language=request.headers.get("accept-language"), + cf_country=request.headers.get("cf-ipcountry"), + user_lang=None, ) + return RedirectResponse(url=f"/{lang}/", status_code=302) s = get_settings() groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) return templates.TemplateResponse( @@ -42,6 +85,27 @@ async def root_page( ) +@router.get("/en/", response_class=HTMLResponse) +async def landing_en( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + """English landing. For logged-in users with a non-en `user.lang` + we still serve EN content here because the URL is an explicit + request — same shape as a manual toggle click. The cookie gets + set to en so subsequent /-visits keep them in English.""" + return _render_landing(request, cu, lang="en") + + +@router.get("/it/", response_class=HTMLResponse) +async def landing_it( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + """Italian landing. Same explicit-URL contract as landing_en.""" + return _render_landing(request, cu, lang="it") + + @router.get( "/news", response_class=HTMLResponse, diff --git a/app/services/locales.py b/app/services/locales.py new file mode 100644 index 0000000..cb12dcf --- /dev/null +++ b/app/services/locales.py @@ -0,0 +1,176 @@ +"""Locale loader for the public landing page (and, eventually, other +public surfaces). Loads YAML translation files from ``app/locales/`` +at process startup into in-memory dicts; templates access them via the +``t`` context variable. + +Adding a new language: drop ``.yaml`` into app/locales/ and add +the code to ``ACTIVE_PUBLIC_LANGS`` here. No code changes elsewhere +should be required. + +Format: arbitrary nested dict. Values that contain inline HTML are +rendered with the Jinja2 ``safe`` filter in the template — keep +markup minimal (````, ````, ````) and don't put user +input through here. The dicts are wrapped in a small dotted-access +helper so templates can write ``{{ t.hero.subhead }}`` instead of +``{{ t['hero']['subhead'] }}``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from app.logging import get_logger + +log = get_logger("locales") + +LOCALES_DIR = Path(__file__).resolve().parent.parent / "locales" + +# Public-surface languages. Mirrors ``services.i18n.ACTIVE_LANGUAGES`` +# but is kept separate because the public surface may roll a new +# language out independently of the in-app translations. +ACTIVE_PUBLIC_LANGS = ("en", "it") +DEFAULT_LANG = "en" + + +class _Dotted: + """Read-only dotted-access view over a YAML-loaded dict tree. + + Templates write ``{{ t.hero.tagline }}`` instead of + ``{{ t['hero']['tagline'] }}``. The wrapper deliberately does NOT + subclass ``dict`` — dict's built-in methods (``items``, ``keys``, + ``values``, ``copy``, ``update``, ``pop`` …) would shadow YAML + keys with the same name. With this wrapper, a YAML key called + ``items`` (which the landing copy actually has: ``not_strip.items``) + resolves through ``__getattr__`` like any other key. + + Mutability is intentionally not supported: locale data is loaded + once at startup and read-only afterwards. + """ + __slots__ = ("_data",) + + def __init__(self, data: dict): + self._data = data + + def __getattr__(self, key: str) -> Any: + if key.startswith("_"): + raise AttributeError(key) + if key in self._data: + return _wrap(self._data[key]) + raise AttributeError(key) + + def __getitem__(self, key: str) -> Any: + return _wrap(self._data[key]) + + def __contains__(self, key: object) -> bool: + return key in self._data + + def __iter__(self): + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def __bool__(self) -> bool: + return bool(self._data) + + def __eq__(self, other: object) -> bool: + if isinstance(other, _Dotted): + return self._data == other._data + return self._data == other + + def __repr__(self) -> str: + return f"_Dotted({self._data!r})" + + +def _wrap(value: Any) -> Any: + if isinstance(value, dict): + return _Dotted(value) + if isinstance(value, list): + return [_wrap(v) for v in value] + return value + + +_LOADED: dict[str, _Dotted] = {} + + +def _load_one(lang: str) -> _Dotted: + path = LOCALES_DIR / f"{lang}.yaml" + if not path.exists(): + log.warning("locale.missing_file", lang=lang, path=str(path)) + return _Dotted({}) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return _Dotted(raw) + + +def load_locales() -> None: + """Populate the module-level cache. Called once at app startup; + subsequent calls re-read from disk (useful for tests).""" + _LOADED.clear() + for lang in ACTIVE_PUBLIC_LANGS: + _LOADED[lang] = _load_one(lang) + log.info("locales.loaded", languages=list(_LOADED.keys())) + + +def get_locale(lang: str) -> _Dotted: + """Return the loaded translations for ``lang``, falling back to + ``DEFAULT_LANG`` for an unknown code. Lazy-loads on first call + so importers don't have to remember to call ``load_locales()`` + explicitly.""" + if not _LOADED: + load_locales() + if lang in _LOADED: + return _LOADED[lang] + return _LOADED.get(DEFAULT_LANG, _Dotted({})) + + +# ----- request-time language detection --------------------------------------- + + +# Country codes whose primary language is Italian. cf-ipcountry uses +# ISO-3166 alpha-2. +_GEO_TO_LANG = { + "IT": "it", "SM": "it", "VA": "it", + # Italian-speaking Swiss canton: we can't detect canton from country + # so a Swiss visitor defaults to en here. The Accept-Language path + # above catches the actual Italian-speakers in CH. +} + + +def detect_public_lang( + cookie_lang: str | None, + accept_language: str | None, + cf_country: str | None, + user_lang: str | None, +) -> str: + """Resolve the language for a public-page request. + + Priority (highest first): + 1. user_lang — a logged-in user's stored preference. Consistent + with their dashboard, never overridden by detection. + 2. cookie_lang — sticky from a previous explicit toggle. + 3. accept_language — browser locale. First language tag only, + stripped to its base subtag ("en-US" -> "en"). + 4. cf_country — Cloudflare's IP-derived country code, mapped to + a primary language via ``_GEO_TO_LANG``. + 5. ``DEFAULT_LANG`` ("en"). + + Returns a value guaranteed to be in ``ACTIVE_PUBLIC_LANGS``. + """ + if user_lang and user_lang in ACTIVE_PUBLIC_LANGS: + return user_lang + if cookie_lang and cookie_lang in ACTIVE_PUBLIC_LANGS: + return cookie_lang + if accept_language: + first = accept_language.split(",", 1)[0].split(";", 1)[0] + base = first.split("-", 1)[0].strip().lower() + if base in ACTIVE_PUBLIC_LANGS: + return base + if cf_country: + cc = cf_country.strip().upper() + if cc in _GEO_TO_LANG: + mapped = _GEO_TO_LANG[cc] + if mapped in ACTIVE_PUBLIC_LANGS: + return mapped + return DEFAULT_LANG diff --git a/app/static/css/public.css b/app/static/css/public.css index 9b1f753..bbd01d7 100644 --- a/app/static/css/public.css +++ b/app/static/css/public.css @@ -46,6 +46,30 @@ } .public-header__cta:hover { background: var(--accent); color: var(--bg) !important; } +/* Tiny EN | IT link group sitting in the public header next to the + nav links. Rendered only on pages that opt in via lang_switch=true. + Visually low-key — the language pair is informational, not a + primary CTA. */ +.public-header__lang-switch { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.public-header__lang-switch a.public-header__lang { + color: var(--muted); + padding: 2px 6px; + border: 1px solid transparent; + border-radius: 2px; +} +.public-header__lang-switch a.public-header__lang:hover { color: var(--accent); } +.public-header__lang-switch a.public-header__lang.active { + color: var(--text); + border-color: var(--border); +} + .public-main { flex: 1; padding: 48px 0 64px; diff --git a/app/templates/landing.html b/app/templates/landing.html index 77fcbf8..62af9f8 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -1,26 +1,19 @@ {% extends "public_base.html" %} -{% block title %}{{ BRAND_NAME }} · {{ TAGLINE }}{% endblock %} +{% block title %}{{ BRAND_NAME }} · {{ t.hero.tagline }}{% endblock %} {% block main %}
{{ BRAND_NAME }}
-

{{ TAGLINE }}

-

- Built for investors who want to act rationally and - tune out the high-frequency noise that comes from treating markets - like a casino. We aggregate cross-asset news and macro signals, - then write a plain-English read of what the underlying fundamentals - justify versus what the crowd is doing. Refreshed through the - trading day. A media service, not a financial one. -

+

{{ t.hero.tagline }}

+

{{ t.hero.subhead | safe }}

@@ -28,135 +21,96 @@
-
News, aggregated
-

Headlines from across the macro universe

-

- RSS and per-ticker feeds covering equities, rates, credit, FX, - commodities, and geopolitics. Every headline is auto-tagged by - theme so the noise stays as noise and the fundamentals-relevant - stuff is easy to find. Ingestion follows the trading calendar — - off-hours stay quiet. -

+
{{ t.features.news.tag }}
+

{{ t.features.news.title }}

+

{{ t.features.news.body | safe }}

-
Macro signals
-

A curated cross-asset tape

-

- A hand-picked set of indicators across every asset class, refreshed - hourly during market hours. Each group gets a short read that - explains what the move means, not what it was. Anchored - in earnings, policy, valuation — not chart patterns. -

+
{{ t.features.indicators.tag }}
+

{{ t.features.indicators.title }}

+

{{ t.features.indicators.body | safe }}

-
The strategic read
-

Rational vs irrational, every paragraph

-

- We tie the day’s headlines and the cross-asset signals into - a single short interpretation. Each paragraph separates - rational drivers (earnings, policy, valuation) - from irrational ones (positioning, narrative, - flows) and names the gap. Two reading levels: novice and - intermediate. This is editorial commentary on public data — - not a forecast and not advice on any investment decision. -

+
{{ t.features.strategic.tag }}
+

{{ t.features.strategic.title }}

+

{{ t.features.strategic.body | safe }}

- Every AI-generated surface — strategic log, indicator reads, - portfolio analysis, chat, daily digest — is available in - English and Italian. Toggle the - language pill in the header and the panels refresh in place; - ticker symbols, currency codes and numbers stay verbatim across - languages. + {{ t.multilang_callout | safe }}

-

More views

+

{{ t.more_views.head }}

-

- Paid users can also drop a portfolio CSV from their broker - — Trading 212 natively, other formats auto-detected — - 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. -

+

{{ t.portfolio_blurb | safe }}

- What this isn’t. + {{ t.not_strip.head }}
    -
  • Not investment advice.
  • -
  • Not trading signals.
  • -
  • Not a day-trading tool.
  • -
  • No buy/sell calls, ever.
  • -
  • No chart-pattern predictions.
  • -
  • Not a regulated service.
  • + {% for item in t.not_strip.items %}
  • {{ item }}
  • {% endfor %}
-

- By signing up you agree to our Terms and - Privacy notice, and confirm you’ve read - the financial disclaimer. -

+

{{ t.footer.legal | safe }}

{% if cu and (cu.user or cu.is_admin) %} - Open dashboard + {{ t.hero.cta_dashboard }} {% else %} - Sign up free + {{ t.hero.cta_signup }} {% endif %}
diff --git a/app/templates/public_base.html b/app/templates/public_base.html index b1cef24..47dd096 100644 --- a/app/templates/public_base.html +++ b/app/templates/public_base.html @@ -1,10 +1,18 @@ - + {% block title %}{{ BRAND_NAME }}{% endblock %} - + + {# Hreflang signals for search engines: tells crawlers that the + same page exists in another language at a different URL. Only + rendered when the route explicitly opts in via lang_switch=true. #} + {% if lang_switch %} + + + + {% endif %} {# Same flash-prevention theme bootstrap as the app shell. #} diff --git a/tests/test_signup_acknowledgement.py b/tests/test_signup_acknowledgement.py new file mode 100644 index 0000000..8224ade --- /dev/null +++ b/tests/test_signup_acknowledgement.py @@ -0,0 +1,388 @@ +"""Sign-up acknowledgement: the affirmative checkbox at /login. + +Covers: +- POST /login without the box ticked → 400, form re-rendered with the error. +- New email with box ticked → User row + UserAcknowledgement row at the + current version, in the language the user actually saw. +- Existing user with a current-version ack row → POST succeeds, no duplicate. +- Existing user with only an older-version ack row → new row at current. +- has_acknowledged_current() unit tests. +""" +from __future__ import annotations + +import asyncio + + +def _build(tmp_path): + """Spin up a fresh app + sqlite DB + tables. Returns (TestClient, factory). + + Patches otp_service and email send into no-ops so POST /login can complete + without hitting SMTP. The acknowledgement is captured during POST /login + (before OTP), so /verify never needs to be exercised here. Static files + are mounted because the rejection path re-renders login.html which + references ``url_for('static', ...)``.""" + from pathlib import Path + from fastapi import FastAPI + from fastapi.staticfiles import StaticFiles + from fastapi.testclient import TestClient + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base + import app.models # noqa: F401 — registers tables + from app.routers import auth as auth_router + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/ack.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + async def _create_all(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + asyncio.run(_create_all()) + + app = FastAPI() + app.include_router(auth_router.router) + static_dir = Path(__file__).resolve().parent.parent / "app" / "static" + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + return TestClient(app), factory + + +def _patch_otp_email(monkeypatch): + """Stub OTP issuance and email send so POST /login can run end-to-end + without a Redis-backed OTP service or real SMTP.""" + from app.services import otp_service + from app.routers import auth as auth_router + + async def _allowed(*_a, **_kw): + return (True, 0) + + async def _issue(*_a, **_kw): + return "123456" + + async def _send_ok(*_a, **_kw): + return True + + monkeypatch.setattr(otp_service, "can_request_new", _allowed) + monkeypatch.setattr(otp_service, "issue", _issue) + # _issue_and_send_otp lives on the router and wraps the email send; + # easier to stub the whole helper than to thread through email_service. + monkeypatch.setattr(auth_router, "_issue_and_send_otp", _send_ok) + + +async def _count_acks(factory, user_id: int, version: int | None = None) -> int: + from sqlalchemy import select, func + from app.models import UserAcknowledgement + async with factory() as s: + q = select(func.count()).select_from(UserAcknowledgement).where( + UserAcknowledgement.user_id == user_id, + ) + if version is not None: + q = q.where(UserAcknowledgement.version == version) + return (await s.execute(q)).scalar() or 0 + + +# --------------------------------------------------------------------------- +# POST /login validation: missing-checkbox path +# --------------------------------------------------------------------------- + + +def test_post_login_rejects_when_acknowledged_unchecked(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, _ = _build(tmp_path) + + r = client.post( + "/login", + data={"email": "alice@example.com", "next": "/", "lang": "en"}, + follow_redirects=False, + ) + assert r.status_code == 400 + # The localised error message goes back in the rendered template. + assert "tick the box" in r.text.lower() or "confirm" in r.text.lower() + + +def test_post_login_rejection_preserves_email(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, _ = _build(tmp_path) + + r = client.post( + "/login", + data={"email": "alice@example.com", "next": "/", "lang": "en"}, + follow_redirects=False, + ) + assert r.status_code == 400 + assert "alice@example.com" in r.text + + +def test_post_login_localises_error_in_italian(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, _ = _build(tmp_path) + + r = client.post( + "/login", + data={"email": "anna@example.com", "next": "/", "lang": "it"}, + follow_redirects=False, + ) + assert r.status_code == 400 + # IT error: "Spunta la casella per confermare prima di continuare." + assert "spunta la casella" in r.text.lower() or "confermare" in r.text.lower() + + +# --------------------------------------------------------------------------- +# Successful POST /login: writes User + UserAcknowledgement +# --------------------------------------------------------------------------- + + +def test_new_signup_writes_acknowledgement_row(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + r = client.post( + "/login", + data={ + "email": "alice@example.com", + "next": "/", + "lang": "en", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + # 303 → /verify + assert r.status_code == 303 + assert r.headers["location"].startswith("/verify") + + # Find the new user and assert exactly one acknowledgement row at v1. + from app.models import User, UserAcknowledgement + from sqlalchemy import select + + async def _check(): + async with factory() as s: + user = (await s.execute( + select(User).where(User.email == "alice@example.com") + )).scalar_one() + rows = (await s.execute( + select(UserAcknowledgement).where( + UserAcknowledgement.user_id == user.id, + ) + )).scalars().all() + return user, rows + + user, rows = asyncio.run(_check()) + assert len(rows) == 1 + ack = rows[0] + assert ack.version == 1 + assert ack.lang == "en" + assert ack.accepted_at is not None + + +def test_acknowledgement_records_displayed_language(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + r = client.post( + "/login", + data={ + "email": "anna@example.it", + "next": "/", + "lang": "it", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + + from app.models import User, UserAcknowledgement + from sqlalchemy import select + + async def _check(): + async with factory() as s: + user = (await s.execute( + select(User).where(User.email == "anna@example.it") + )).scalar_one() + ack = (await s.execute( + select(UserAcknowledgement).where( + UserAcknowledgement.user_id == user.id, + ) + )).scalar_one() + return ack.lang + + assert asyncio.run(_check()) == "it" + + +# --------------------------------------------------------------------------- +# Idempotency: existing user already at current version → no dup row +# --------------------------------------------------------------------------- + + +def test_existing_user_current_version_no_duplicate(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + # Pre-seed: User + one acknowledgement at the current version. + async def _seed(): + from app.models import User, UserAcknowledgement + from app.legal import ACKNOWLEDGEMENT_VERSION + from app.db import utcnow + + async with factory() as s: + u = User(email="repeat@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, + version=ACKNOWLEDGEMENT_VERSION, + lang="en", + accepted_at=utcnow(), + )) + await s.commit() + return u.id + + user_id = asyncio.run(_seed()) + before = asyncio.run(_count_acks(factory, user_id)) + assert before == 1 + + r = client.post( + "/login", + data={ + "email": "repeat@example.com", + "next": "/", + "lang": "en", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + + after = asyncio.run(_count_acks(factory, user_id)) + assert after == 1, "must not write a duplicate row when user already at current version" + + +# --------------------------------------------------------------------------- +# Version bump: existing user only at older version → new current-version row +# --------------------------------------------------------------------------- + + +def test_existing_user_older_version_writes_new_current_row(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + # Pre-seed a user with an OLD-version acknowledgement (version=0). + # The current version constant is 1 → this user is "stale" and should + # be prompted again. + async def _seed(): + from app.models import User, UserAcknowledgement + from app.db import utcnow + + async with factory() as s: + u = User(email="bump@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, version=0, lang="en", accepted_at=utcnow(), + )) + await s.commit() + return u.id + + user_id = asyncio.run(_seed()) + + r = client.post( + "/login", + data={ + "email": "bump@example.com", + "next": "/", + "lang": "en", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + + # Total rows: 1 old + 1 new = 2. Current-version rows: exactly 1. + total = asyncio.run(_count_acks(factory, user_id)) + current = asyncio.run(_count_acks(factory, user_id, version=1)) + assert total == 2 + assert current == 1 + + +# --------------------------------------------------------------------------- +# has_acknowledged_current() — unit-ish, no HTTP +# --------------------------------------------------------------------------- + + +def test_has_acknowledged_current_no_row(tmp_path): + _, factory = _build(tmp_path) + + async def _go(): + from app.models import User + from app.services.auth_service import has_acknowledged_current + from app.db import utcnow + + async with factory() as s: + u = User(email="empty@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + return await has_acknowledged_current(s, u) + + assert asyncio.run(_go()) is False + + +def test_has_acknowledged_current_only_old(tmp_path): + _, factory = _build(tmp_path) + + async def _go(): + from app.models import User, UserAcknowledgement + from app.services.auth_service import has_acknowledged_current + from app.db import utcnow + + async with factory() as s: + u = User(email="oldonly@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, version=0, lang="en", accepted_at=utcnow(), + )) + await s.commit() + return await has_acknowledged_current(s, u) + + assert asyncio.run(_go()) is False + + +def test_has_acknowledged_current_at_current(tmp_path): + _, factory = _build(tmp_path) + + async def _go(): + from app.models import User, UserAcknowledgement + from app.services.auth_service import has_acknowledged_current + from app.legal import ACKNOWLEDGEMENT_VERSION + from app.db import utcnow + + async with factory() as s: + u = User(email="atcurrent@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, + version=ACKNOWLEDGEMENT_VERSION, + lang="en", + accepted_at=utcnow(), + )) + await s.commit() + return await has_acknowledged_current(s, u) + + assert asyncio.run(_go()) is True From 8946dee2e0d6de92a49e5d30e9a015c3a34df2bf Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 21:28:03 +0200 Subject: [PATCH 05/11] feedback: thumb up/down on logs + reviewer self-score 0-10 Two unrelated features bundled because they ship together and share migration 0028. Strategic-log feedback (thumb up/down): - New strategic_log_feedback table with UNIQUE(log_id, user_id) so each user has one vote per log, flippable in place (up -> down -> clear). UI shows aggregate counts only. - app/services/log_feedback.py: set_vote, get_counts, sign/verify feedback tokens (same itsdangerous pattern as auth.sign_pending, 30-day TTL for email links). - POST /api/log/{id}/feedback: web vote, auth required, returns counts + the requesting user's own vote. - GET /feedback?token=...&vote=...: email-link target, no auth, signed token encodes (user, log, vote), renders feedback_thanks.html. - partials/log.html: thumbs row below content, JS-driven swap via the POST endpoint. Dashboard latest-log card and /log page both render this partial via htmx, so the buttons appear in all three surfaces. - digest emails: a "How was today's read?" row above the unsub footer, with signed-token URLs against the latest StrategicLog at send time. Plain-text fallback included. Reviewer self-score (0-10): - _SYSTEM_PROMPT asks for an integer score with anchors (10 exemplary, 5 borderline, 0 unfit). Verdict gains score: int | None. - Deterministic-layer hits get score=0 (hard rule, no nuance); error rows get None; LLM rows get the model's score clamped 0..10. - ReviewerVerdict.score, StrategicLog.reviewer_score, and IndicatorSummary.reviewer_score all new SMALLINT NULL columns. - ai_log_job + indicator_summary_job persist verdict.score onto their content rows when committing the row alongside content. Tests: - tests/test_strategic_log_feedback.py: vote, flip, clear, aggregate across users, invalid vote, token round-trip + tamper + garbage + 'clear' not signable for email path. - tests/test_output_review.py: score parsing, clamping (>10, <0), missing/non-numeric -> None, deterministic-layer score=0. Full suite: 427 passed (was 412), 5 skipped, no regressions. Co-Authored-By: Claude Opus 4.7 --- .../0028_log_feedback_and_reviewer_score.py | 93 +++++++++ app/jobs/ai_log_job.py | 1 + app/jobs/email_digest_job.py | 32 ++- app/jobs/indicator_summary_job.py | 2 + app/models.py | 45 +++++ app/routers/api.py | 75 ++++++- app/routers/pages.py | 54 +++++ app/services/digest_email.py | 53 ++++- app/services/log_feedback.py | 177 +++++++++++++++++ app/services/output_review.py | 48 ++++- app/templates/feedback_thanks.html | 30 +++ app/templates/partials/log.html | 74 +++++++ tests/test_output_review.py | 106 ++++++++++ tests/test_strategic_log_feedback.py | 186 ++++++++++++++++++ 14 files changed, 962 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/0028_log_feedback_and_reviewer_score.py create mode 100644 app/services/log_feedback.py create mode 100644 app/templates/feedback_thanks.html create mode 100644 tests/test_strategic_log_feedback.py diff --git a/alembic/versions/0028_log_feedback_and_reviewer_score.py b/alembic/versions/0028_log_feedback_and_reviewer_score.py new file mode 100644 index 0000000..ac4cd7f --- /dev/null +++ b/alembic/versions/0028_log_feedback_and_reviewer_score.py @@ -0,0 +1,93 @@ +"""strategic_log_feedback + reviewer_score columns. + +Revision ID: 0028 +Revises: 0027 +Create Date: 2026-05-29 + +Two unrelated features bundled because they ship together: + +1. **strategic_log_feedback** — thumb up/down votes per (log, user). + UNIQUE on (log_id, user_id) enforces one vote per user per log, + flippable in place. The UI shows aggregate counts only. + +2. **reviewer_score** — the output reviewer now self-rates each + verdict 0-10 (10 = exemplary editorial, 0 = unfit). Stored on + strategic_logs, indicator_summaries, and every reviewer_verdicts + audit row, as nullable SMALLINT so existing rows aren't backfilled. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0028" +down_revision: Union[str, None] = "0027" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- reviewer_score columns (nullable SMALLINT) ----------------------- + op.add_column( + "strategic_logs", + sa.Column("reviewer_score", sa.SmallInteger(), nullable=True), + ) + op.add_column( + "indicator_summaries", + sa.Column("reviewer_score", sa.SmallInteger(), nullable=True), + ) + op.add_column( + "reviewer_verdicts", + sa.Column("score", sa.SmallInteger(), nullable=True), + ) + + # --- strategic_log_feedback table ------------------------------------- + op.create_table( + "strategic_log_feedback", + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, autoincrement=True, + ), + sa.Column( + "log_id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + sa.ForeignKey("strategic_logs.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + # 'up' or 'down'. Storing as varchar keeps the column readable in + # the DB shell; the enum-ness is enforced at the service layer. + sa.Column("vote", sa.String(length=8), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.UniqueConstraint( + "log_id", "user_id", name="uq_slf_log_user", + ), + ) + op.create_index( + "ix_strategic_log_feedback_log", + "strategic_log_feedback", ["log_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_strategic_log_feedback_log", + table_name="strategic_log_feedback", + ) + op.drop_table("strategic_log_feedback") + op.drop_column("reviewer_verdicts", "score") + op.drop_column("indicator_summaries", "reviewer_score") + op.drop_column("strategic_logs", "reviewer_score") diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py index fdf2a23..ed1b5e0 100644 --- a/app/jobs/ai_log_job.py +++ b/app/jobs/ai_log_job.py @@ -234,6 +234,7 @@ async def run() -> None: prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, cost_usd=full_cost, + reviewer_score=verdict.score, ) session.add(slog) session.add(AICall( diff --git a/app/jobs/email_digest_job.py b/app/jobs/email_digest_job.py index 4cbd865..ea20688 100644 --- a/app/jobs/email_digest_job.py +++ b/app/jobs/email_digest_job.py @@ -198,17 +198,32 @@ def _pick_variant( async def _send_one(user: User, kind: str, content_html: str, date_str: str, - session) -> None: + session, *, latest_log_id: int | None = None) -> None: settings_url = f"{branding.SITE_URL}/settings" unsubscribe_url = ( f"{branding.SITE_URL}/email/unsubscribe" f"?token={sign_unsubscribe_token(user.id)}" ) + + # Build signed feedback URLs against the latest strategic log at send + # time. The token encodes (user, log, vote) so the recipient can + # click without being logged in; the receiving /feedback endpoint + # verifies the signature and applies the vote. + feedback_up_url = feedback_down_url = None + if latest_log_id is not None: + from app.services.log_feedback import sign_feedback_token + up_tok = sign_feedback_token(user.id, latest_log_id, "up") + down_tok = sign_feedback_token(user.id, latest_log_id, "down") + feedback_up_url = f"{branding.SITE_URL}/feedback?token={up_tok}&vote=up" + feedback_down_url = f"{branding.SITE_URL}/feedback?token={down_tok}&vote=down" + 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, + feedback_up_url=feedback_up_url, + feedback_down_url=feedback_down_url, ) try: await send_email(to=user.email, subject=subject, @@ -288,6 +303,18 @@ async def run() -> None: client, variants, active_non_en, ) + # Resolve the latest strategic log once per job — used as the + # target of the email's thumb up/down feedback links. None if + # nothing has been generated yet (shouldn't happen at this point + # in the flow, but defensible). + from sqlalchemy import desc, select + from app.models import StrategicLog + latest_log_id = (await session.execute( + select(StrategicLog.id) + .order_by(desc(StrategicLog.generated_at)) + .limit(1) + )).scalar_one_or_none() + written = 0 for u in fresh: tone = (u.digest_tone or "INTERMEDIATE").upper() @@ -296,7 +323,8 @@ async def run() -> None: tone=tone, lang=(u.lang or "en"), ) - await _send_one(u, kind, content, date_str, session) + await _send_one(u, kind, content, date_str, session, + latest_log_id=latest_log_id) await asyncio.sleep(0.1) written += 1 diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py index 4a81aaf..3d401d5 100644 --- a/app/jobs/indicator_summary_job.py +++ b/app/jobs/indicator_summary_job.py @@ -215,6 +215,7 @@ async def _generate_one( # Include the reviewer's cost in the row's recorded spend so the # monthly budget tracking covers the full pipeline cost. cost_usd=(result.cost_usd or 0.0) + (verdict.cost_usd or 0.0), + reviewer_score=verdict.score, ) session.add(summary) session.add(AICall( @@ -342,6 +343,7 @@ async def run() -> None: prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, cost_usd=full_cost, + reviewer_score=verdict.score, ) session.add(agg_summary) session.add(AICall( diff --git a/app/models.py b/app/models.py index f64d2e4..d8d3c25 100644 --- a/app/models.py +++ b/app/models.py @@ -118,6 +118,10 @@ class StrategicLog(Base): prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) cost_usd: Mapped[float | None] = mapped_column(Float) + # Reviewer self-rating 0-10 (10 = exemplary, 0 = unfit). Nullable for + # rows generated before the score field existed; new rows always + # carry the value the reviewer returned alongside its clean verdict. + reviewer_score: Mapped[int | None] = mapped_column(SmallInteger) class StrategicLogTranslation(Base): @@ -170,6 +174,8 @@ class IndicatorSummary(Base): prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) cost_usd: Mapped[float | None] = mapped_column(Float) + # Reviewer self-rating 0-10. See StrategicLog.reviewer_score. + reviewer_score: Mapped[int | None] = mapped_column(SmallInteger) __table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),) @@ -244,6 +250,45 @@ class ReviewerVerdict(Base): layer: Mapped[str] = mapped_column(String(16), nullable=False) # LLM-layer model id, nullable for deterministic / error rows. model: Mapped[str | None] = mapped_column(String(64)) + # Reviewer self-rating 0-10. Deterministic-layer hits get 0 (hard + # reject by rule), error rows get NULL, LLM rows get the model's score. + score: Mapped[int | None] = mapped_column(SmallInteger) + + +class StrategicLogFeedback(Base): + """Anonymous-in-UI thumb up/down votes on strategic-log rows. + + One row per (log_id, user_id) — flippable: a user can change their + vote (up → down) by overwriting, or clear it by deleting. The UI + surfaces only aggregate counts; user attribution is server-side + only and exists purely so we can dedup and let the voter see/flip + their own vote. See app/services/log_feedback.py.""" + __tablename__ = "strategic_log_feedback" + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + log_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer(), "sqlite"), + ForeignKey("strategic_logs.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + # 'up' or 'down'. Service layer enforces the enum. + vote: Mapped[str] = mapped_column(String(8), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, + onupdate=utcnow, + ) + + __table_args__ = ( + UniqueConstraint("log_id", "user_id", name="uq_slf_log_user"), + Index("ix_strategic_log_feedback_log", "log_id"), + ) class UserAcknowledgement(Base): diff --git a/app/routers/api.py b/app/routers/api.py index 5075654..bf78eb0 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -285,11 +285,13 @@ async def news_list( def _log_partial_payload( row: StrategicLog | None, content_override: str | None = None, + feedback: object | None = None, ) -> dict | None: if row is None: return None content = content_override if content_override is not None else row.content return { + "id": row.id, "content_html": _md_to_html(content), "generated_at": row.generated_at, "model": row.model, @@ -299,6 +301,7 @@ def _log_partial_payload( "cost_usd": row.cost_usd, "prompt_tokens": row.prompt_tokens, "completion_tokens": row.completion_tokens, + "feedback": feedback, } @@ -404,9 +407,12 @@ async def log_latest( if as_ == "html": content_override = await _localized_content(session, row, principal) + feedback = await _feedback_for(session, row, principal) return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row, content_override=content_override), + {"log": _log_partial_payload( + row, content_override=content_override, feedback=feedback, + ), "tone": wanted_tone, "paid": not free_only}, ) @@ -415,6 +421,21 @@ async def log_latest( return StrategicLogOut.model_validate(row, from_attributes=True) +async def _feedback_for( + session: AsyncSession, + row: StrategicLog | None, + principal: CurrentUser | None, +): + """Aggregate up/down counts + the principal's own vote, or None when + there's no log to fetch feedback for. Always safe to await; runs two + indexed queries.""" + if row is None: + return None + from app.services.log_feedback import get_counts + user_id = principal.user.id if (principal and principal.user) else None + return await get_counts(session, log_id=row.id, user_id=user_id) + + @router.get("/log/by-date/{day}") async def log_by_date( request: Request, @@ -459,9 +480,12 @@ async def log_by_date( if as_ == "html": content_override = await _localized_content(session, row, principal) + feedback = await _feedback_for(session, row, principal) return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row, content_override=content_override), + {"log": _log_partial_payload( + row, content_override=content_override, feedback=feedback, + ), "tone": wanted_tone, "paid": not free_only}, ) if row is None: @@ -469,6 +493,53 @@ async def log_by_date( return StrategicLogOut.model_validate(row, from_attributes=True) +# --- Log feedback (thumb up/down) -------------------------------------------- + + +class FeedbackIn(BaseModel): + vote: Literal["up", "down", "clear"] + + +class FeedbackOut(BaseModel): + up: int + down: int + user_vote: str | None + + +@router.post("/log/{log_id}/feedback", response_model=FeedbackOut) +async def post_log_feedback( + log_id: int, + body: FeedbackIn, + session: AsyncSession = Depends(get_session), + principal: CurrentUser = Depends(require_token), +) -> FeedbackOut: + """Record (or flip / clear) the authenticated user's thumb on a log. + + Anonymous-in-UI: the response carries only aggregate counts plus the + *requesting* user's own vote (so the UI can highlight it). Other + users' votes are never exposed.""" + if principal.user is None: + raise HTTPException(status_code=400, detail="admin token cannot vote") + + # Guard against votes on non-existent logs (don't want orphan FKs). + exists = (await session.execute( + select(StrategicLog.id).where(StrategicLog.id == log_id).limit(1) + )).scalar_one_or_none() + if exists is None: + raise HTTPException(status_code=404, detail="log not found") + + from app.services.log_feedback import FeedbackError, set_vote + try: + counts = await set_vote( + session, log_id=log_id, user_id=principal.user.id, vote=body.vote, + ) + except FeedbackError as e: + raise HTTPException(status_code=400, detail=str(e)) + return FeedbackOut( + up=counts.up, down=counts.down, user_vote=counts.user_vote, + ) + + # --- Calendar archive -------------------------------------------------------- diff --git a/app/routers/pages.py b/app/routers/pages.py index 01d68a8..4be5276 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -176,6 +176,60 @@ async def log_page_day( ) +@router.get("/feedback", response_class=HTMLResponse) +async def log_feedback_via_token( + request: Request, + token: str, + vote: str | None = None, + session: AsyncSession = Depends(get_session), +): + """Email-link target for thumb up/down votes on a strategic log. + + The signed token encodes (user_id, log_id, intended_vote). The query + param ``vote`` is informational (lets the URL be self-describing in + the inbox); the canonical vote is what's in the token. If the two + disagree the token wins. + + Renders a small thank-you confirmation. No auth required — the token + is the auth-equivalent for this single side-effecting action.""" + from app.services.log_feedback import ( + FeedbackError, set_vote, verify_feedback_token, + ) + + payload = verify_feedback_token(token) + if payload is None: + return templates.TemplateResponse( + request, "feedback_thanks.html", + {"ok": False, "message": "This link has expired or is invalid.", + "log_id": None, "vote": None}, + status_code=400, + ) + + try: + counts = await set_vote( + session, + log_id=payload["log_id"], + user_id=payload["user_id"], + vote=payload["vote"], + ) + except FeedbackError as e: + return templates.TemplateResponse( + request, "feedback_thanks.html", + {"ok": False, "message": str(e), "log_id": payload["log_id"], + "vote": payload["vote"]}, + status_code=400, + ) + + return templates.TemplateResponse( + request, "feedback_thanks.html", + {"ok": True, + "vote": payload["vote"], + "log_id": payload["log_id"], + "counts": counts, + "message": None}, + ) + + @router.get("/settings", response_class=HTMLResponse) async def settings_page( request: Request, diff --git a/app/services/digest_email.py b/app/services/digest_email.py index 3d416f6..760a004 100644 --- a/app/services/digest_email.py +++ b/app/services/digest_email.py @@ -47,6 +47,7 @@ _DIGEST_HTML_TEMPLATE = """\
 
+ {feedback_row}
 
Unsubscribe in one click @@ -70,6 +71,32 @@ def _strip_html_to_text(html_body: str) -> str: return text.strip() +def _feedback_row_html( + feedback_up_url: str | None, + feedback_down_url: str | None, + light_accent: str, + light_muted: str, +) -> str: + """Build the optional 'How was today's read?' row that sits between + the digest content and the unsubscribe footer. Empty string when no + feedback URLs were supplied (e.g. there's no latest log to vote on).""" + if not feedback_up_url or not feedback_down_url: + return "" + return ( + '
 
' + f'
' + "How was today’s read? " + f'' + "👍 Helpful" + " · " + f'' + "👎 Not useful" + "
" + ) + + def render_digest_email( *, kind: str, @@ -77,10 +104,17 @@ def render_digest_email( content_html: str, unsubscribe_url: str, settings_url: str, + feedback_up_url: str | None = None, + feedback_down_url: str | None = None, ) -> tuple[str, str, str]: """Returns (subject, text_body, html_body) for a digest email. - `kind` is "daily" or "weekly". Anything else raises ValueError.""" + `kind` is "daily" or "weekly". Anything else raises ValueError. + + When ``feedback_up_url`` and ``feedback_down_url`` are both supplied, + a small thumb up/down row is rendered above the unsubscribe footer. + Both must be signed-token URLs pointing at /feedback (see + ``app.services.log_feedback.sign_feedback_token``).""" if kind == "daily": label = "Daily" subject = f"{branding.BRAND_NAME} · Daily — {date_str}" @@ -90,6 +124,12 @@ def render_digest_email( else: raise ValueError(f"unknown digest kind: {kind!r}") + feedback_row = _feedback_row_html( + feedback_up_url, feedback_down_url, + light_accent=branding.LIGHT["accent"], + light_muted=branding.LIGHT["muted"], + ) + html_body = _DIGEST_HTML_TEMPLATE.format( brand=branding.BRAND_NAME, brand_upper=branding.BRAND_NAME.upper(), @@ -99,6 +139,7 @@ def render_digest_email( content_html=content_html, unsubscribe_url=unsubscribe_url, settings_url=settings_url, + feedback_row=feedback_row, **{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()}, **{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()}, ) @@ -109,8 +150,16 @@ def render_digest_email( "", _strip_html_to_text(content_html), "", + ] + if feedback_up_url and feedback_down_url: + text_lines.extend([ + f"Was this read useful? Helpful: {feedback_up_url}", + f" Not useful: {feedback_down_url}", + "", + ]) + text_lines.extend([ 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/log_feedback.py b/app/services/log_feedback.py new file mode 100644 index 0000000..04a6544 --- /dev/null +++ b/app/services/log_feedback.py @@ -0,0 +1,177 @@ +"""Thumb up/down votes on strategic-log rows. + +The model is one row per (log_id, user_id) — see +``app/models.py::StrategicLogFeedback``. The UI shows aggregate counts +only; user attribution is server-side state, not surface state. A user +can flip up → down (or vice versa) by re-submitting; "clear" deletes +the row. + +For email-digest feedback links we sign a short payload with the same +itsdangerous serialiser pattern used elsewhere (``app/auth.py``). The +link is single-purpose: it identifies the (user, log, intended vote) +combination, lets the recipient click without being logged in, and +expires after EMAIL_FEEDBACK_TTL_SECONDS. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.db import utcnow +from app.models import StrategicLogFeedback + + +# Email-link tokens are valid for 30 days so a user can click the thumb +# in last week's Sunday digest. Long enough for slow readers; short +# enough that the signing-secret rotation can shake stale tokens off. +EMAIL_FEEDBACK_TTL_SECONDS = 30 * 24 * 60 * 60 + +# Discriminated enum of acceptable votes. "clear" is a sentinel that +# means "remove your existing vote, if any". +VALID_VOTES = ("up", "down", "clear") + + +class FeedbackError(ValueError): + """Raised on bad input. Message is safe to surface to the user.""" + + +@dataclass(frozen=True) +class FeedbackCounts: + up: int + down: int + user_vote: str | None # 'up' | 'down' | None + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + + +def _validate_vote(vote: str) -> str: + v = (vote or "").strip().lower() + if v not in VALID_VOTES: + raise FeedbackError( + f"vote must be one of {VALID_VOTES!r}; got {vote!r}" + ) + return v + + +async def set_vote( + session: AsyncSession, + *, + log_id: int, + user_id: int, + vote: str, +) -> FeedbackCounts: + """Insert / update / clear the user's vote on ``log_id``. Returns the + resulting aggregate counts plus the user's new vote state. + + "clear" removes the row entirely; "up"/"down" upserts. The unique + index on (log_id, user_id) guarantees there's never more than one + row per pair.""" + v = _validate_vote(vote) + + if v == "clear": + await session.execute( + delete(StrategicLogFeedback) + .where(StrategicLogFeedback.log_id == log_id) + .where(StrategicLogFeedback.user_id == user_id) + ) + await session.commit() + return await get_counts(session, log_id=log_id, user_id=user_id) + + # Upsert by hand — SQLAlchemy's portable dialect doesn't expose + # ON DUPLICATE KEY UPDATE across MySQL+SQLite reliably for our test + # path, so SELECT then INSERT-or-UPDATE is the simplest correct shape. + existing = (await session.execute( + select(StrategicLogFeedback) + .where(StrategicLogFeedback.log_id == log_id) + .where(StrategicLogFeedback.user_id == user_id) + )).scalar_one_or_none() + if existing is None: + session.add(StrategicLogFeedback( + log_id=log_id, user_id=user_id, vote=v, + created_at=utcnow(), updated_at=utcnow(), + )) + else: + existing.vote = v + existing.updated_at = utcnow() + await session.commit() + return await get_counts(session, log_id=log_id, user_id=user_id) + + +async def get_counts( + session: AsyncSession, + *, + log_id: int, + user_id: int | None = None, +) -> FeedbackCounts: + """Aggregate up/down counts for one log, plus the requesting user's + own vote (if ``user_id`` is supplied). One indexed query for the + counts; a second indexed lookup for the personal vote.""" + rows = (await session.execute( + select( + StrategicLogFeedback.vote, + func.count(StrategicLogFeedback.id), + ) + .where(StrategicLogFeedback.log_id == log_id) + .group_by(StrategicLogFeedback.vote) + )).all() + counts = {vote: int(n) for vote, n in rows} + user_vote: str | None = None + if user_id is not None: + user_vote = (await session.execute( + select(StrategicLogFeedback.vote) + .where(StrategicLogFeedback.log_id == log_id) + .where(StrategicLogFeedback.user_id == user_id) + )).scalar_one_or_none() + return FeedbackCounts( + up=counts.get("up", 0), + down=counts.get("down", 0), + user_vote=user_vote, + ) + + +# --------------------------------------------------------------------------- +# Email-link token helpers (parallel to app.auth.sign_pending) +# --------------------------------------------------------------------------- + + +def _feedback_serializer() -> URLSafeTimedSerializer: + s = get_settings() + secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret" + return URLSafeTimedSerializer(secret, salt="cassandra-log-feedback-v1") + + +def sign_feedback_token(user_id: int, log_id: int, vote: str) -> str: + """Signed token for an email-digest thumb link. Encodes the intended + (user, log, vote) tuple. The recipient clicks the link without being + logged in; the receiving endpoint verifies the signature, applies the + vote, and shows a thank-you page.""" + v = _validate_vote(vote) + if v == "clear": + raise FeedbackError("clear is not a valid email-link vote") + return _feedback_serializer().dumps({ + "uid": int(user_id), "lid": int(log_id), "v": v, + }) + + +def verify_feedback_token(token: str) -> dict | None: + """Returns {"user_id": int, "log_id": int, "vote": "up"|"down"} on + valid + un-expired tokens, or None on bad signature / expired / bad + payload. The TTL is ``EMAIL_FEEDBACK_TTL_SECONDS``.""" + try: + data = _feedback_serializer().loads( + token, max_age=EMAIL_FEEDBACK_TTL_SECONDS, + ) + return { + "user_id": int(data["uid"]), + "log_id": int(data["lid"]), + "vote": str(data["v"]), + } + except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError): + return None diff --git a/app/services/output_review.py b/app/services/output_review.py index a63f4e9..ca6b6c6 100644 --- a/app/services/output_review.py +++ b/app/services/output_review.py @@ -123,8 +123,19 @@ Mark UNCLEAN if the text contains ANY of: claim on a *named* instrument is not. - Anything else other than the finished, publishable commentary. +Also assign a SCORE 0-10 to the candidate: +- 10 = exemplary editorial: sharp, well-grounded, no perimeter risk, clean prose. +- 7-9 = publishable as-is, varying degrees of polish. +- 4-6 = borderline: scratchpad leakage, mild perimeter drift, or weak structure, + but not yet outright unfit. (Anything ≤4 should usually be clean=false.) +- 1-3 = unfit: clear chain-of-thought, partial / truncated, or financial-advice drift. +- 0 = unfit by hard rule (deterministic catch territory). + +Clean=true implies a score of ~7+; clean=false implies ~4 or lower. Use the +score to communicate confidence within the verdict. + Return ONLY a JSON object with this exact shape: -{"clean": true | false, "reason": "<≤20 words, plain text>"} +{"clean": true | false, "reason": "<≤20 words, plain text>", "score": 0-10} No preamble, no markdown fences, no other fields. """ @@ -171,6 +182,12 @@ class Verdict: reason: str cost_usd: float | None # cost of the review call itself, for the ledger layer: str = "llm" # "deterministic" | "llm" | "error" + # Integer 0-10. None for error rows; 0 for deterministic-layer hits + # (rejected by hard rule, no nuance to score); 0-10 from the model + # on LLM-layer verdicts. Stored alongside the content row for + # future analysis — see StrategicLog.reviewer_score and + # IndicatorSummary.reviewer_score. + score: int | None = None # Truncation cap for the audit log's candidate_text column. Generous enough @@ -199,6 +216,7 @@ async def _record_verdict( reason=verdict.reason[:240] if verdict.reason else None, layer=verdict.layer, model=model, + score=verdict.score, ) session.add(row) await session.flush() @@ -237,7 +255,7 @@ async def review_read( if not candidate or not candidate.strip(): verdict = Verdict(clean=False, reason="empty candidate", cost_usd=0.0, - layer="deterministic") + layer="deterministic", score=0) await _record_verdict(session, surface=surface, candidate=candidate or "", verdict=verdict, model=None) return verdict @@ -250,6 +268,7 @@ async def review_read( reason=f"lexicon:{hit.rule}: {hit.snippet}", cost_usd=0.0, layer="deterministic", + score=0, ) log.info("review.deterministic_reject", rule=hit.rule, snippet=hit.snippet, surface=surface) @@ -293,7 +312,7 @@ async def review_read( except Exception as e: log.warning("review.call_failed", error=str(e)[:200]) verdict = Verdict(clean=False, reason=f"reviewer error: {str(e)[:80]}", - cost_usd=None, layer="error") + cost_usd=None, layer="error", score=None) await _record_verdict(session, surface=surface, candidate=candidate, verdict=verdict, model=reviewer_model) return verdict @@ -317,7 +336,7 @@ async def review_read( except json.JSONDecodeError: log.warning("review.parse_failed", preview=result.content[:200]) verdict = Verdict(clean=False, reason="reviewer returned non-JSON", - cost_usd=result.cost_usd, layer="error") + cost_usd=result.cost_usd, layer="error", score=None) await _record_verdict(session, surface=surface, candidate=candidate, verdict=verdict, model=reviewer_model) return verdict @@ -326,13 +345,25 @@ async def review_read( reason = parsed.get("reason") or "" if not isinstance(clean, bool): verdict = Verdict(clean=False, reason="reviewer omitted bool 'clean'", - cost_usd=result.cost_usd, layer="error") + cost_usd=result.cost_usd, layer="error", score=None) await _record_verdict(session, surface=surface, candidate=candidate, verdict=verdict, model=reviewer_model) return verdict + # Score is optional and bounded; the verdict is still valid without it. + raw_score = parsed.get("score") + score: int | None + if isinstance(raw_score, bool): + # bool is a subclass of int — exclude it explicitly to avoid + # silently treating True/False as 1/0. + score = None + elif isinstance(raw_score, (int, float)): + score = max(0, min(10, int(raw_score))) + else: + score = None + verdict = Verdict(clean=clean, reason=str(reason)[:200], - cost_usd=result.cost_usd, layer="llm") + cost_usd=result.cost_usd, layer="llm", score=score) await _record_verdict(session, surface=surface, candidate=candidate, verdict=verdict, model=reviewer_model) return verdict @@ -391,7 +422,7 @@ async def generate_with_review( content=None, verdict=Verdict(clean=False, reason=f"generator error: {str(e)[:80]}", - cost_usd=None, layer="error"), + cost_usd=None, layer="error", score=None), attempts=attempt, ) @@ -411,6 +442,7 @@ async def generate_with_review( return ReviewedGeneration( content=None, verdict=last_verdict or Verdict(clean=False, reason="no attempts", - cost_usd=None, layer="error"), + cost_usd=None, layer="error", + score=None), attempts=max_attempts, ) diff --git a/app/templates/feedback_thanks.html b/app/templates/feedback_thanks.html new file mode 100644 index 0000000..1d1de43 --- /dev/null +++ b/app/templates/feedback_thanks.html @@ -0,0 +1,30 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · Feedback{% endblock %} + +{% block main %} +
+ {% if ok %} +

+ {% if vote == 'up' %}Thanks for the thumbs up.{% else %}Thanks for the thumbs down.{% endif %} +

+

+ Your vote on this strategic log is recorded. + {% if counts %} + Current tally: {{ counts.up }} 👍 · + {{ counts.down }} 👎. + {% endif %} +

+

+ Open the dashboard +

+ {% else %} +

Couldn’t record that vote

+

+ {{ message or "Something went wrong; please try again from the dashboard." }} +

+

+ Open the dashboard +

+ {% endif %} +
+{% endblock %} diff --git a/app/templates/partials/log.html b/app/templates/partials/log.html index 315e42f..105a021 100644 --- a/app/templates/partials/log.html +++ b/app/templates/partials/log.html @@ -9,4 +9,78 @@ title="Last generated {{ log.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}"> {{ log.content_html | safe | glossary(tone) }}
+ +{% if log.feedback %} +{# Anonymous-in-UI thumb up/down. Server stores (user, log, vote) for dedup + so a vote can be flipped; UI shows aggregate counts only. + POST clicks JSON-fetch and swap this partial back in place — no full + reload, the log content stays put. #} +
+ Was this useful? + + + +
+ +{% endif %} {% endif %} diff --git a/tests/test_output_review.py b/tests/test_output_review.py index 9bffb44..4ede525 100644 --- a/tests/test_output_review.py +++ b/tests/test_output_review.py @@ -325,3 +325,109 @@ async def test_review_portfolio_rider_active_when_flag_enabled(monkeypatch): surface="portfolio", ) assert "# Surface: portfolio commentary" in seen_systems[0] + + +# --------------------------------------------------------------------------- +# Reviewer self-score (0-10) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_review_parses_score_from_llm_json(monkeypatch): + _configure(monkeypatch) + def handler(_req): + return httpx.Response(200, json={ + "choices": [{"message": {"content": + '{"clean": true, "reason": "exemplary", "score": 9}'}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 50, "completion_tokens": 12, "cost": 0.00007}, + }) + async with httpx.AsyncClient(transport=_mock_post(handler)) as client: + v = await review_read(client, "Markets pricing tighter policy.") + assert v.clean is True + assert v.score == 9 + assert v.layer == "llm" + + +@pytest.mark.asyncio +async def test_review_score_clamped_to_0_10(monkeypatch): + """A model returning 17 or -3 is buggy but must not blow up — clamp.""" + _configure(monkeypatch) + def handler(_req): + return httpx.Response(200, json={ + "choices": [{"message": {"content": + '{"clean": false, "reason": "x", "score": 17}'}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003}, + }) + async with httpx.AsyncClient(transport=_mock_post(handler)) as client: + v = await review_read(client, "Some content.") + assert v.score == 10 + + +@pytest.mark.asyncio +async def test_review_score_negative_clamped_to_0(monkeypatch): + _configure(monkeypatch) + def handler(_req): + return httpx.Response(200, json={ + "choices": [{"message": {"content": + '{"clean": false, "reason": "x", "score": -3}'}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 50, "completion_tokens": 8, "cost": 0.00003}, + }) + async with httpx.AsyncClient(transport=_mock_post(handler)) as client: + v = await review_read(client, "Some content.") + assert v.score == 0 + + +@pytest.mark.asyncio +async def test_review_missing_score_yields_none(monkeypatch): + """Older mocked responses don't carry score; verdict still valid, + score is None.""" + _configure(monkeypatch) + def handler(_req): + return httpx.Response(200, json={ + "choices": [{"message": {"content": + '{"clean": true, "reason": "ok"}'}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 50, "completion_tokens": 6, "cost": 0.00002}, + }) + async with httpx.AsyncClient(transport=_mock_post(handler)) as client: + v = await review_read(client, "Plain state-level prose.") + assert v.clean is True + assert v.score is None + + +@pytest.mark.asyncio +async def test_review_score_non_numeric_yields_none(monkeypatch): + """Defensive: a string or null in the score field doesn't poison the + verdict; score becomes None.""" + _configure(monkeypatch) + def handler(_req): + return httpx.Response(200, json={ + "choices": [{"message": {"content": + '{"clean": true, "reason": "ok", "score": "high"}'}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 50, "completion_tokens": 6, "cost": 0.00002}, + }) + async with httpx.AsyncClient(transport=_mock_post(handler)) as client: + v = await review_read(client, "Plain state-level prose.") + assert v.clean is True + assert v.score is None + + +@pytest.mark.asyncio +async def test_review_deterministic_layer_score_is_zero(monkeypatch): + """A deterministic-layer hit is a hard reject by rule; the audit row + carries score=0 (no nuance to score).""" + _configure(monkeypatch) + calls = [] + def handler(_req): + calls.append(1) + return httpx.Response(500, json={"error": "should not fire"}) + async with httpx.AsyncClient(transport=_mock_post(handler)) as client: + v = await review_read(client, "You should buy the dip.") + assert v.clean is False + assert v.layer == "deterministic" + assert v.score == 0 + assert calls == [] diff --git a/tests/test_strategic_log_feedback.py b/tests/test_strategic_log_feedback.py new file mode 100644 index 0000000..e1f8b5b --- /dev/null +++ b/tests/test_strategic_log_feedback.py @@ -0,0 +1,186 @@ +"""Strategic-log feedback service + token helpers. + +Covers the pure service path (set_vote, get_counts, clear) plus the +token sign/verify round-trip. The web endpoint POST /api/log/{id}/feedback +is covered in tests/test_api_feedback.py (separate file because it +needs the full FastAPI + auth stack).""" +from __future__ import annotations + +import asyncio + + +def _build_db(tmp_path): + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base + import app.models # noqa: F401 + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/fb.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + async def _seed(): + from app.models import StrategicLog, User + from app.db import utcnow + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + s.add(User(id=1, email="alice@example.com", tier="free", + settings_json={}, created_at=utcnow())) + s.add(User(id=2, email="bob@example.com", tier="free", + settings_json={}, created_at=utcnow())) + s.add(StrategicLog( + id=10, generated_at=utcnow(), + model="m", anchor_date=None, prompt_version=1, + tone="INTERMEDIATE", analysis="DRY", + content="x", prompt_tokens=1, completion_tokens=1, + cost_usd=0.0, + )) + await s.commit() + + asyncio.run(_seed()) + return factory + + +# --------------------------------------------------------------------------- +# Service: set_vote / get_counts +# --------------------------------------------------------------------------- + + +def test_set_vote_up_writes_one_row(tmp_path): + factory = _build_db(tmp_path) + + async def _go(): + from app.services.log_feedback import set_vote + async with factory() as s: + counts = await set_vote(s, log_id=10, user_id=1, vote="up") + return counts + + counts = asyncio.run(_go()) + assert counts.up == 1 + assert counts.down == 0 + assert counts.user_vote == "up" + + +def test_set_vote_flip_replaces_existing(tmp_path): + """Voting again with a different value flips the existing row; + no duplicate is written, the up/down counts swap.""" + factory = _build_db(tmp_path) + + async def _go(): + from app.services.log_feedback import set_vote + async with factory() as s: + await set_vote(s, log_id=10, user_id=1, vote="up") + return await set_vote(s, log_id=10, user_id=1, vote="down") + + counts = asyncio.run(_go()) + assert counts.up == 0 + assert counts.down == 1 + assert counts.user_vote == "down" + + +def test_set_vote_clear_removes_row(tmp_path): + factory = _build_db(tmp_path) + + async def _go(): + from app.services.log_feedback import set_vote, get_counts + async with factory() as s: + await set_vote(s, log_id=10, user_id=1, vote="up") + await set_vote(s, log_id=10, user_id=1, vote="clear") + return await get_counts(s, log_id=10, user_id=1) + + counts = asyncio.run(_go()) + assert counts.up == 0 + assert counts.down == 0 + assert counts.user_vote is None + + +def test_aggregate_counts_across_users(tmp_path): + """Two distinct users vote — counts aggregate; each user's own_vote + field reflects only their own row.""" + factory = _build_db(tmp_path) + + async def _go(): + from app.services.log_feedback import set_vote, get_counts + async with factory() as s: + await set_vote(s, log_id=10, user_id=1, vote="up") + await set_vote(s, log_id=10, user_id=2, vote="down") + alice_view = await get_counts(s, log_id=10, user_id=1) + bob_view = await get_counts(s, log_id=10, user_id=2) + return alice_view, bob_view + + alice, bob = asyncio.run(_go()) + assert alice.up == 1 and alice.down == 1 and alice.user_vote == "up" + assert bob.up == 1 and bob.down == 1 and bob.user_vote == "down" + + +def test_invalid_vote_rejected(tmp_path): + factory = _build_db(tmp_path) + + async def _go(): + from app.services.log_feedback import FeedbackError, set_vote + async with factory() as s: + try: + await set_vote(s, log_id=10, user_id=1, vote="meh") + except FeedbackError as e: + return str(e) + return "no error" + + msg = asyncio.run(_go()) + assert "up" in msg.lower() and "down" in msg.lower() + + +# --------------------------------------------------------------------------- +# Token sign / verify +# --------------------------------------------------------------------------- + + +def test_feedback_token_round_trips(monkeypatch): + """A signed token decodes back to the same (user, log, vote) tuple.""" + monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay") + from app.config import get_settings + get_settings.cache_clear() + from app.services.log_feedback import sign_feedback_token, verify_feedback_token + + tok = sign_feedback_token(user_id=42, log_id=99, vote="up") + payload = verify_feedback_token(tok) + assert payload == {"user_id": 42, "log_id": 99, "vote": "up"} + + +def test_feedback_token_tampered_returns_none(monkeypatch): + monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay") + from app.config import get_settings + get_settings.cache_clear() + from app.services.log_feedback import sign_feedback_token, verify_feedback_token + + tok = sign_feedback_token(user_id=42, log_id=99, vote="up") + tampered = tok[:-1] + ("a" if tok[-1] != "a" else "b") + assert verify_feedback_token(tampered) is None + + +def test_feedback_token_garbage_returns_none(monkeypatch): + monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay") + from app.config import get_settings + get_settings.cache_clear() + from app.services.log_feedback import verify_feedback_token + + assert verify_feedback_token("not.a.real.token") is None + assert verify_feedback_token("") is None + + +def test_feedback_token_clear_is_not_a_valid_email_link_vote(monkeypatch): + """The 'clear' sentinel is a web-only path; the email link can only + apply a positive vote (up or down). Trying to sign 'clear' raises.""" + monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "test-secret-32-chars-long-okay") + from app.config import get_settings + get_settings.cache_clear() + from app.services.log_feedback import FeedbackError, sign_feedback_token + + try: + sign_feedback_token(user_id=1, log_id=10, vote="clear") + except FeedbackError: + return + raise AssertionError("expected FeedbackError") From 411094d7b8a5d507d1c7f3c875d3924e88fdb922 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 1 Jul 2026 16:08:20 +0200 Subject: [PATCH 06/11] admin: internal-only superadmin console (users, payments, DB stats) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New independent `admin` service (admin.main:app) on the same image, reusing app.db/app.models read-only. Never runs migrations or the scheduler; issues SELECTs only. - Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by default when the password is empty. - Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network. - Pages: overview stats, user list + search, per-user history/payment detail, DB usage (information_schema size + row estimates). - Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile bakes admin/ into runtime + test stages. - Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing. Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 + Dockerfile | 5 +- admin/README.md | 50 ++++ admin/__init__.py | 9 + admin/auth.py | 71 ++++++ admin/main.py | 140 ++++++++++++ admin/queries.py | 266 ++++++++++++++++++++++ admin/templates/base.html | 80 +++++++ admin/templates/db.html | 31 +++ admin/templates/login.html | 14 ++ admin/templates/not_found.html | 6 + admin/templates/overview.html | 22 ++ admin/templates/partials_users_table.html | 39 ++++ admin/templates/user_detail.html | 73 ++++++ admin/templates/users.html | 20 ++ app/config.py | 11 + docker-compose.prod.yml | 8 + docker-compose.test.yml | 1 + docker-compose.yml | 27 +++ tests/test_admin_console.py | 264 +++++++++++++++++++++ 20 files changed, 1143 insertions(+), 1 deletion(-) create mode 100644 admin/README.md create mode 100644 admin/__init__.py create mode 100644 admin/auth.py create mode 100644 admin/main.py create mode 100644 admin/queries.py create mode 100644 admin/templates/base.html create mode 100644 admin/templates/db.html create mode 100644 admin/templates/login.html create mode 100644 admin/templates/not_found.html create mode 100644 admin/templates/overview.html create mode 100644 admin/templates/partials_users_table.html create mode 100644 admin/templates/user_detail.html create mode 100644 admin/templates/users.html create mode 100644 tests/test_admin_console.py diff --git a/.env.example b/.env.example index 83f4f24..f2a593b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,13 @@ OPENROUTER_API_KEY= # OpenRouter (AI log generation) # --- App --- CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty CASSANDRA_PORT=8000 + +# --- Superadmin console (internal-only, separate `admin` container) --- +# Password for the read-only operator dashboard at 127.0.0.1:8091 (reach it +# via `ssh -L 8091:localhost:8091 `). Empty = console refuses every +# login (closed by default). Set a strong value in prod. +ADMIN_CONSOLE_PASSWORD= +ADMIN_CONSOLE_SESSION_SECRET= # cookie signing key; falls back to CASSANDRA_SESSION_SECRET/TOKEN if empty CASSANDRA_BASE_CURRENCY=GBP CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs diff --git a/Dockerfile b/Dockerfile index 09c6443..94b8042 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,12 @@ RUN apt-get update \ COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY app ./app +COPY admin ./admin COPY alembic ./alembic COPY alembic.ini ./ -# Default command is the web app; scheduler container overrides via `command:`. +# Default command is the web app; the scheduler and admin-console containers +# override via `command:` (see docker-compose.yml). EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] @@ -57,6 +59,7 @@ COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY pyproject.toml requirements.lock ./ COPY app ./app +COPY admin ./admin COPY alembic ./alembic COPY alembic.ini ./ # tests/ is excluded by .dockerignore (prod-correct: never bake tests into diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..bd1685f --- /dev/null +++ b/admin/README.md @@ -0,0 +1,50 @@ +# Superadmin console + +An **independent, internal-only** web service for the operator: user list with +per-user history + payment status, and database usage stats. Runs in its own +container (`admin` service) off the same image as the main app, reusing +`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`) +that **never** runs migrations or the scheduler and only ever issues `SELECT`s. + +## Access model + +- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on + the `intranet`/NPM network. Reach it over an SSH tunnel: + + ```sh + ssh -L 8091:localhost:8091 + # then open http://localhost:8091 + ``` + +- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`). + Empty password ⇒ every login is refused (closed by default). The login sets + a 12-hour signed cookie (`admin_console_session`). + +## Pages + +- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count, + referral conversions, newest users. +- `/users` — full user table with email search + paging. +- `/users/{id}` — one user: account, computed paid status, Stripe/Polar + linkage, cloud-sync state, referrals sent, feedback votes, digest-email log, + legal acknowledgements. +- `/db` — per-table row estimates and on-disk size (from + `information_schema.tables`), with each table's share of total. + +## Deploy (this host runs prod) + +Adding a **new** service means `up -d` (a plain `restart` won't create it). +Do NOT run a bare `docker compose up` on this host — always pass the prod +overlay: + +```sh +# 1. set ADMIN_CONSOLE_PASSWORD in .env +# 2. build + create just the admin container (leaves app/scheduler/db running) +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin +``` + +## Tests + +```sh +docker compose -f docker-compose.test.yml run --rm test pytest tests/test_admin_console.py -v +``` diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..5f9b1c7 --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,9 @@ +"""Superadmin console — an independent, internal-only web service. + +Runs in its own container (see the `admin` service in docker-compose.yml), +bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the +main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly +separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic +migrations or the app's lifespan. Every DB access is a plain SELECT — the +console never writes. +""" diff --git a/admin/auth.py b/admin/auth.py new file mode 100644 index 0000000..a85067b --- /dev/null +++ b/admin/auth.py @@ -0,0 +1,71 @@ +"""Password gate for the superadmin console. + +A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at +``/login`` for a signed, time-limited session cookie. There is no user +identity here — the console has exactly one principal, "the operator". + +The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET`` +(falling back to the main app's session secret / token), mirroring the scheme +in ``app.auth`` but under a distinct salt so an app session cookie can never +be replayed against the console and vice-versa. +""" +from __future__ import annotations + +import secrets + +from fastapi import HTTPException, Request, status +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from app.config import get_settings + +SESSION_COOKIE_NAME = "admin_console_session" +SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish. +_SALT = "admin-console-session-v1" + + +def _serializer() -> URLSafeTimedSerializer: + s = get_settings() + secret = ( + s.ADMIN_CONSOLE_SESSION_SECRET + or s.CASSANDRA_SESSION_SECRET + or s.CASSANDRA_TOKEN + or "dev-insecure-secret" + ) + return URLSafeTimedSerializer(secret, salt=_SALT) + + +def check_password(provided: str) -> bool: + """Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns + False when the password is unset — the console is closed by default so a + fresh deploy can't be logged into without an explicit password.""" + expected = get_settings().ADMIN_CONSOLE_PASSWORD + if not expected: + return False + return secrets.compare_digest(provided.encode(), expected.encode()) + + +def sign_session() -> str: + """Signed value proving a successful password login. Carries no identity + beyond a version marker — the console has a single principal.""" + return _serializer().dumps({"v": 1}) + + +def verify_session(cookie: str) -> bool: + try: + data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS) + return data.get("v") == 1 + except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError): + return False + + +def require_admin(request: Request) -> None: + """FastAPI dependency guarding every console page. Valid cookie → passes; + otherwise 303 → /login (browser) so the operator lands on the form.""" + cookie = request.cookies.get(SESSION_COOKIE_NAME) + if cookie and verify_session(cookie): + return + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + detail="Login required", + headers={"Location": "/login"}, + ) diff --git a/admin/main.py b/admin/main.py new file mode 100644 index 0000000..324dd72 --- /dev/null +++ b/admin/main.py @@ -0,0 +1,140 @@ +"""Superadmin console FastAPI app. + +Independent of ``app.main``: it wires only its own routes and reuses +``app.db``'s session factory for read-only queries. No lifespan, no +migrations, no scheduler. + +Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000 +""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import Depends, FastAPI, Form, Query, Request +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.ext.asyncio import AsyncSession + +from admin import auth, queries +from app.db import get_session + +TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) + + +def _fmt_bytes(n: int | None) -> str: + if not n: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + size = float(n) + for u in units: + if size < 1024 or u == units[-1]: + return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}" + size /= 1024 + return f"{n} B" + + +def _fmt_dt(dt: datetime | None) -> str: + if dt is None: + return "—" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M UTC") + + +templates.env.filters["bytes"] = _fmt_bytes +templates.env.filters["dt"] = _fmt_dt + +app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None) + + +@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False) +async def healthz() -> str: + return "ok" + + +@app.get("/login", response_class=HTMLResponse, include_in_schema=False) +async def login_form(request: Request): + # Already signed in? Skip the form. + cookie = request.cookies.get(auth.SESSION_COOKIE_NAME) + if cookie and auth.verify_session(cookie): + return RedirectResponse("/", status_code=303) + return templates.TemplateResponse(request, "login.html", {"error": None}) + + +@app.post("/login", response_class=HTMLResponse, include_in_schema=False) +async def login_submit(request: Request, password: str = Form(...)): + if not auth.check_password(password): + return templates.TemplateResponse( + request, "login.html", {"error": "Incorrect password."}, + status_code=401, + ) + resp = RedirectResponse("/", status_code=303) + resp.set_cookie( + auth.SESSION_COOKIE_NAME, + auth.sign_session(), + max_age=auth.SESSION_TTL_SECONDS, + httponly=True, + samesite="lax", + # secure=False: the console is served over http on a localhost SSH + # tunnel, never public TLS. A secure cookie would never be sent. + secure=False, + ) + return resp + + +@app.get("/logout", include_in_schema=False) +async def logout(): + resp = RedirectResponse("/login", status_code=303) + resp.delete_cookie(auth.SESSION_COOKIE_NAME) + return resp + + +@app.get("/", response_class=HTMLResponse, include_in_schema=False, + dependencies=[Depends(auth.require_admin)]) +async def overview(request: Request, session: AsyncSession = Depends(get_session)): + stats = await queries.overview_stats(session) + recent, _ = await queries.list_users(session, limit=10) + return templates.TemplateResponse( + request, "overview.html", {"stats": stats, "recent": recent}, + ) + + +@app.get("/users", response_class=HTMLResponse, include_in_schema=False, + dependencies=[Depends(auth.require_admin)]) +async def users_list( + request: Request, + session: AsyncSession = Depends(get_session), + q: str | None = Query(default=None), + page: int = Query(default=1, ge=1), +): + per_page = 200 + offset = (page - 1) * per_page + rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset) + return templates.TemplateResponse( + request, "users.html", + {"rows": rows, "total": total, "q": q or "", + "page": page, "per_page": per_page}, + ) + + +@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False, + dependencies=[Depends(auth.require_admin)]) +async def user_detail( + request: Request, user_id: int, + session: AsyncSession = Depends(get_session), +): + detail = await queries.user_detail(session, user_id) + if detail is None: + return templates.TemplateResponse( + request, "not_found.html", {"user_id": user_id}, status_code=404, + ) + return templates.TemplateResponse(request, "user_detail.html", {"d": detail}) + + +@app.get("/db", response_class=HTMLResponse, include_in_schema=False, + dependencies=[Depends(auth.require_admin)]) +async def db_page(request: Request, session: AsyncSession = Depends(get_session)): + stats = await queries.db_stats(session) + return templates.TemplateResponse(request, "db.html", {"stats": stats}) diff --git a/admin/queries.py b/admin/queries.py new file mode 100644 index 0000000..d2c9fec --- /dev/null +++ b/admin/queries.py @@ -0,0 +1,266 @@ +"""Read-only queries backing the console. Every function issues SELECTs +only — the console never mutates the database. + +Payment status is computed with the app's own ``paid_status`` so the console +never diverges from what the app considers "paid". +""" +from __future__ import annotations + +from datetime import timedelta + +from sqlalchemy import func, or_, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import utcnow +from app.models import ( + EmailSend, + PortfolioSync, + Referral, + StrategicLogFeedback, + User, + UserAcknowledgement, +) +from app.services.access import paid_status + + +async def overview_stats(session: AsyncSession) -> dict: + """Headline counts for the landing page: totals, tier split, growth, + paid-active, engagement.""" + now = utcnow() + d7 = now - timedelta(days=7) + d30 = now - timedelta(days=30) + + total = await session.scalar(select(func.count(User.id))) or 0 + + tier_rows = (await session.execute( + select(User.tier, func.count(User.id)).group_by(User.tier) + )).all() + tiers = {tier: n for tier, n in tier_rows} + + signups_7d = await session.scalar( + select(func.count(User.id)).where(User.created_at >= d7) + ) or 0 + signups_30d = await session.scalar( + select(func.count(User.id)).where(User.created_at >= d30) + ) or 0 + active_30d = await session.scalar( + select(func.count(User.id)).where(User.last_login_at >= d30) + ) or 0 + + # Paid-active = subscribed tier OR a live credit window. The two are + # counted disjointly (a credit user whose tier is already paid is only + # counted once, under tier) so the sum can't double-count. + paid_by_tier = await session.scalar( + select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise"))) + ) or 0 + credit_active = await session.scalar( + select(func.count(User.id)).where( + User.credit_until.is_not(None), + User.credit_until > now, + User.tier.not_in(("paid", "enterprise")), + ) + ) or 0 + + sync_enabled = await session.scalar( + select(func.count(PortfolioSync.user_id)) + ) or 0 + + referrals_total = await session.scalar(select(func.count(Referral.id))) or 0 + referrals_converted = await session.scalar( + select(func.count(Referral.id)).where(Referral.converted_at.is_not(None)) + ) or 0 + + return { + "total_users": total, + "tiers": tiers, + "free": tiers.get("free", 0), + "paid": tiers.get("paid", 0), + "enterprise": tiers.get("enterprise", 0), + "paid_active": paid_by_tier + credit_active, + "credit_active": credit_active, + "signups_7d": signups_7d, + "signups_30d": signups_30d, + "active_30d": active_30d, + "sync_enabled": sync_enabled, + "referrals_total": referrals_total, + "referrals_converted": referrals_converted, + } + + +async def list_users( + session: AsyncSession, + q: str | None = None, + limit: int = 200, + offset: int = 0, +) -> tuple[list[dict], int]: + """User rows for the table view, newest first. Optional ``q`` filters by + email substring. Returns (rows, total_matching).""" + stmt = select(User) + count_stmt = select(func.count(User.id)) + if q: + like = f"%{q.strip()}%" + stmt = stmt.where(User.email.like(like)) + count_stmt = count_stmt.where(User.email.like(like)) + + total = await session.scalar(count_stmt) or 0 + users = (await session.execute( + stmt.order_by(User.created_at.desc()).limit(limit).offset(offset) + )).scalars().all() + + if not users: + return [], total + + ids = [u.id for u in users] + + # One grouped query each for the per-user badges, merged in Python — + # avoids an N+1 without a wide join fan-out. + sync_ids = set((await session.execute( + select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids)) + )).scalars().all()) + + ref_counts = dict((await session.execute( + select(Referral.referrer_user_id, func.count(Referral.id)) + .where(Referral.referrer_user_id.in_(ids)) + .group_by(Referral.referrer_user_id) + )).all()) + + rows: list[dict] = [] + for u in users: + ps = paid_status(u) + rows.append({ + "id": u.id, + "email": u.email, + "tier": u.tier, + "paid_active": ps.active, + "paid_source": ps.source, + "credit_days": ps.days_remaining, + "created_at": u.created_at, + "last_login_at": u.last_login_at, + "lang": u.lang, + "has_sync": u.id in sync_ids, + "referrals": ref_counts.get(u.id, 0), + "on_stripe": bool(u.stripe_customer_id), + "on_polar": bool(u.polar_customer_id), + "trialing": u.stripe_trial_end_at is not None, + }) + return rows, total + + +async def user_detail(session: AsyncSession, user_id: int) -> dict | None: + """Everything the console shows for one user: the row itself, computed + payment status, billing linkage, and joined activity history.""" + user = await session.get(User, user_id) + if user is None: + return None + + ps = paid_status(user) + + referred_by = None + if user.referred_by_user_id: + ref = await session.get(User, user.referred_by_user_id) + referred_by = {"id": ref.id, "email": ref.email} if ref else None + + # Referrals this user sent (with conversion state). + sent = (await session.execute( + select(Referral, User.email) + .join(User, User.id == Referral.referred_user_id) + .where(Referral.referrer_user_id == user_id) + .order_by(Referral.created_at.desc()) + )).all() + referrals_sent = [{ + "referred_email": email, + "created_at": r.created_at, + "converted_at": r.converted_at, + } for r, email in sent] + + # Digest email delivery log (most recent first, capped). + email_rows = (await session.execute( + select(EmailSend).where(EmailSend.user_id == user_id) + .order_by(EmailSend.sent_at.desc()).limit(25) + )).scalars().all() + emails = [{ + "kind": e.kind, "sent_at": e.sent_at, + "status": e.status, "error": e.error, + } for e in email_rows] + + # Legal acknowledgements (evidentiary audit trail). + ack_rows = (await session.execute( + select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id) + .order_by(UserAcknowledgement.accepted_at.desc()) + )).scalars().all() + acks = [{ + "version": a.version, "lang": a.lang, "accepted_at": a.accepted_at, + } for a in ack_rows] + + # Feedback votes cast on strategic logs. + fb_rows = (await session.execute( + select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id)) + .where(StrategicLogFeedback.user_id == user_id) + .group_by(StrategicLogFeedback.vote) + )).all() + feedback = {vote: n for vote, n in fb_rows} + + sync = await session.get(PortfolioSync, user_id) + + return { + "user": user, + "paid": ps, + "referred_by": referred_by, + "referrals_sent": referrals_sent, + "emails": emails, + "acks": acks, + "feedback": feedback, + "sync": { + "enabled": sync is not None, + "updated_at": sync.updated_at if sync else None, + "version": sync.version if sync else None, + }, + } + + +async def db_stats(session: AsyncSession) -> dict: + """Per-table size and row estimates for the current schema. + + On MariaDB this reads ``information_schema.tables`` (row counts are the + engine's estimate for InnoDB, sizes are exact). On sqlite (the test + backend) there is no such view, so it falls back to real ``COUNT(*)`` + per table with sizes reported as 0. + """ + dialect = session.bind.dialect.name + + if dialect == "sqlite": + from app.db import Base + tables = [] + for tbl in Base.metadata.sorted_tables: + n = await session.scalar(select(func.count()).select_from(tbl)) or 0 + tables.append({ + "name": tbl.name, "rows": n, + "data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0, + }) + tables.sort(key=lambda t: t["rows"], reverse=True) + return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)} + + rows = (await session.execute(text( + "SELECT table_name, table_rows, data_length, index_length " + "FROM information_schema.tables " + "WHERE table_schema = DATABASE() " + "ORDER BY (data_length + index_length) DESC" + ))).all() + + tables = [] + total_bytes = 0 + total_rows = 0 + for name, tr, data_len, idx_len in rows: + data_len = int(data_len or 0) + idx_len = int(idx_len or 0) + tot = data_len + idx_len + total_bytes += tot + total_rows += int(tr or 0) + tables.append({ + "name": name, "rows": int(tr or 0), + "data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot, + }) + for t in tables: + t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0 + + return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows} diff --git a/admin/templates/base.html b/admin/templates/base.html new file mode 100644 index 0000000..003e163 --- /dev/null +++ b/admin/templates/base.html @@ -0,0 +1,80 @@ + + + + + + + {% block title %}superadmin{% endblock %} · read.markets + + + + {% if show_nav|default(true) %} +
+ read.markets · superadmin + + + Log out +
+ {% endif %} +
{% block body %}{% endblock %}
+ + diff --git a/admin/templates/db.html b/admin/templates/db.html new file mode 100644 index 0000000..7ccde4c --- /dev/null +++ b/admin/templates/db.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}database{% endblock %} +{% block body %} +

Database usage

+
+
{{ stats.total_bytes|bytes }}
Total size
+
{{ "{:,}".format(stats.total_rows) }}
Total rows (est.)
+
{{ stats.tables|length }}
Tables
+
+ +

Per table

+ + + + {% for t in stats.tables %} + + + + + + + + + {% endfor %} + +
TableRows (est.)DataIndexTotalShare
{{ t.name }}{{ "{:,}".format(t.rows) }}{{ t.data_bytes|bytes }}{{ t.index_bytes|bytes }}{{ t.total_bytes|bytes }} +
+ {{ t.pct }}% +
+

Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.

+{% endblock %} diff --git a/admin/templates/login.html b/admin/templates/login.html new file mode 100644 index 0000000..aff4c50 --- /dev/null +++ b/admin/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% set show_nav = false %} +{% block title %}login{% endblock %} +{% block body %} + +{% endblock %} diff --git a/admin/templates/not_found.html b/admin/templates/not_found.html new file mode 100644 index 0000000..6e41006 --- /dev/null +++ b/admin/templates/not_found.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}not found{% endblock %} +{% block body %} +

User #{{ user_id }} not found

+

← back to users

+{% endblock %} diff --git a/admin/templates/overview.html b/admin/templates/overview.html new file mode 100644 index 0000000..06f3a0a --- /dev/null +++ b/admin/templates/overview.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}overview{% endblock %} +{% block body %} +

Overview

+
+
{{ stats.total_users }}
Total users
+
{{ stats.paid_active }}
Paid-active
+
{{ stats.free }}
Free tier
+
{{ stats.paid }}
Paid tier
+
{{ stats.enterprise }}
Enterprise
+
{{ stats.credit_active }}
On credit
+
{{ stats.signups_7d }}
Signups · 7d
+
{{ stats.signups_30d }}
Signups · 30d
+
{{ stats.active_30d }}
Active · 30d
+
{{ stats.sync_enabled }}
Cloud-sync on
+
{{ stats.referrals_converted }}/{{ stats.referrals_total }}
Referrals conv.
+
+ +

Newest users

+{% include "partials_users_table.html" %} +

All users →

+{% endblock %} diff --git a/admin/templates/partials_users_table.html b/admin/templates/partials_users_table.html new file mode 100644 index 0000000..5525464 --- /dev/null +++ b/admin/templates/partials_users_table.html @@ -0,0 +1,39 @@ +{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #} +{% set rows = rows if rows is defined else recent %} + + + + + + + + + {% for u in rows %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDEmailTierPaidCreatedLast loginLangSyncRefsBilling
{{ u.id }}{{ u.email }}{{ u.tier }} + {% if u.paid_active %} + yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %} + {% else %} + no + {% endif %} + {% if u.trialing %}trial{% endif %} + {{ u.created_at|dt }}{{ u.last_login_at|dt }}{{ u.lang }}{% if u.has_sync %}on{% else %}{% endif %}{{ u.referrals }} + {% if u.on_stripe %}stripe{% endif %} + {% if u.on_polar %}polar{% endif %} + {% if not u.on_stripe and not u.on_polar %}{% endif %} +
No users.
diff --git a/admin/templates/user_detail.html b/admin/templates/user_detail.html new file mode 100644 index 0000000..715c56f --- /dev/null +++ b/admin/templates/user_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% set u = d.user %} +{% block title %}{{ u.email }}{% endblock %} +{% block body %} +

← users

+

{{ u.email }} #{{ u.id }}

+ +

Account

+
+
Tier
{{ u.tier }}
+
Paid status
+ {% if d.paid.active %} + active via {{ d.paid.source }} + {% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %} + {% else %}inactive{% endif %} +
+
Credit until
{{ u.credit_until|dt }}
+
Created
{{ u.created_at|dt }}
+
Last login
{{ u.last_login_at|dt }}
+
Language
{{ u.lang }}
+
Digest opt-in
{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}
+
Referral code
{{ u.referral_code or '—' }}
+
Referred by
{% if d.referred_by %}{{ d.referred_by.email }}{% else %}—{% endif %}
+
+ +

Billing linkage

+
+
Stripe customer
{{ u.stripe_customer_id or '—' }}
+
Stripe subscription
{{ u.stripe_subscription_id or '—' }}
+
Stripe trial ends
{{ u.stripe_trial_end_at|dt }}
+
Polar customer
{{ u.polar_customer_id or '—' }}
+
Polar subscription
{{ u.polar_subscription_id or '—' }}
+
+ +

Cloud sync

+{% if d.sync.enabled %} +

enabled · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }} + (contents are end-to-end encrypted — not readable here)

+{% else %}

Not enabled.

{% endif %} + +

Referrals sent ({{ d.referrals_sent|length }})

+{% if d.referrals_sent %} + + {% for r in d.referrals_sent %} + + + {% endfor %} +
ReferredSentConverted
{{ r.referred_email }}{{ r.created_at|dt }}{% if r.converted_at %}{{ r.converted_at|dt }}{% else %}pending{% endif %}
+{% else %}

None.

{% endif %} + +

Feedback votes

+

👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}

+ +

Digest emails (last 25)

+{% if d.emails %} + + {% for e in d.emails %} + + + + {% endfor %} +
KindSentStatusError
{{ e.kind }}{{ e.sent_at|dt }}{% if e.status == 'sent' %}sent{% elif e.status == 'error' %}error{% else %}{{ e.status }}{% endif %}{{ e.error or '' }}
+{% else %}

None.

{% endif %} + +

Legal acknowledgements

+{% if d.acks %} + + {% for a in d.acks %} + + {% endfor %} +
VersionLangAccepted
v{{ a.version }}{{ a.lang }}{{ a.accepted_at|dt }}
+{% else %}

None recorded.

{% endif %} +{% endblock %} diff --git a/admin/templates/users.html b/admin/templates/users.html new file mode 100644 index 0000000..72bde22 --- /dev/null +++ b/admin/templates/users.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}users{% endblock %} +{% block body %} +

Users ({{ total }})

+ +{% include "partials_users_table.html" %} + +{% set pages = (total // per_page) + (1 if total % per_page else 0) %} +{% if pages > 1 %} +

+ Page {{ page }} / {{ pages }} + {% if page > 1 %}· prev{% endif %} + {% if page < pages %}· next{% endif %} +

+{% endif %} +{% endblock %} diff --git a/app/config.py b/app/config.py index 975f165..07052d5 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,17 @@ class Settings(BaseSettings): # created. Phase A leaves this open so the operator can self-onboard. CASSANDRA_SIGNUP_ENABLED: bool = True + # Superadmin console (independent container, internal-only — bound to + # 127.0.0.1 on the VPS, reached via SSH tunnel). Read-only operator + # dashboard: user list, per-user history/payment status, DB usage stats. + # A single shared password gates it; empty ADMIN_CONSOLE_PASSWORD makes + # every login attempt fail (the console is closed rather than open by + # default, so a fresh deploy can't accidentally expose it). + ADMIN_CONSOLE_PASSWORD: str = "" + # Signing secret for the console's session cookie. Falls back to + # CASSANDRA_SESSION_SECRET / CASSANDRA_TOKEN like the main app's cookie. + ADMIN_CONSOLE_SESSION_SECRET: str = "" + # SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes # are written to stdout instead of sent — convenient for local dev. SMTP_SERVER: str = "" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0a18178..ac3f6ec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -44,6 +44,14 @@ services: DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} REDIS_URL: redis://readmarkets-redis-1:6379/0 + admin: + # Same DNS-collision reasoning as app/scheduler: use the project-prefixed + # container name for the DB. The console stays OFF the intranet network — + # it is internal-only (127.0.0.1:8091 host port from the base file), so it + # never needs to be reachable by NPM. + environment: + DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} + networks: intranet: external: true diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 219930c..736b23f 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -33,6 +33,7 @@ services: # on the next `run` without rebuilding the image. volumes: - ./app:/app/app + - ./admin:/app/admin - ./tests:/app/tests - ./alembic:/app/alembic - ./alembic.ini:/app/alembic.ini:ro diff --git a/docker-compose.yml b/docker-compose.yml index 8a7e03f..a25b80d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,33 @@ services: redis: condition: service_healthy + # Superadmin console — independent read-only operator dashboard. Same + # image (reuses app.db/app.models) but runs admin.main:app instead of the + # public app, and NEVER runs migrations. Bound to 127.0.0.1 only: it is + # reached over an SSH tunnel, never exposed publicly (no intranet/NPM). + admin: + build: . + restart: unless-stopped + command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] + env_file: .env + environment: + DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra} + volumes: + - ./config:/app/config:ro + - ./app:/app/app + - ./admin:/app/admin + ports: + # Host-loopback only — access via `ssh -L 8091:localhost:8091 `. + - "127.0.0.1:8091:8000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"] + interval: 30s + timeout: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + backup: image: mariadb:11 restart: unless-stopped diff --git a/tests/test_admin_console.py b/tests/test_admin_console.py new file mode 100644 index 0000000..f851fc5 --- /dev/null +++ b/tests/test_admin_console.py @@ -0,0 +1,264 @@ +"""Superadmin console — auth gate, read-only queries, and page wiring. + +Runs against an in-memory-ish sqlite file (same pattern as the other API +tests): seed a handful of users + related rows, rebind app.db to the test +engine, then drive both the query layer directly and the FastAPI app via +TestClient. +""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + + +def _seed(tmp_path): + """Create schema + fixture rows; rebind app.db. Returns the factory.""" + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base, utcnow + from app.models import ( + EmailSend, PortfolioSync, Referral, StrategicLogFeedback, + User, UserAcknowledgement, + ) + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/admin.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + now = utcnow() + + async def _go(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + # 1: free, referrer + s.add(User(id=1, email="alice@x", tier="free", created_at=now, + last_login_at=now, referral_code="ALICE")) + # 2: paid subscriber via Stripe + s.add(User(id=2, email="bob@x", tier="paid", created_at=now, + last_login_at=now, stripe_customer_id="cus_1", + stripe_subscription_id="sub_1")) + # 3: free but credit-active, referred by alice, has sync/history + s.add(User(id=3, email="carol@x", tier="free", created_at=now, + credit_until=now + timedelta(days=30), + referred_by_user_id=1)) + await s.flush() + s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x", + outer_nonce=b"y", version=1, + created_at=now, updated_at=now)) + s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent")) + s.add(UserAcknowledgement(user_id=3, version=2, lang="en", + accepted_at=now)) + s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up", + created_at=now, updated_at=now)) + s.add(Referral(referrer_user_id=1, referred_user_id=3, + created_at=now, converted_at=now)) + await s.commit() + + asyncio.run(_go()) + return factory + + +# --- auth ------------------------------------------------------------------ + +def test_check_password(monkeypatch): + from app.config import get_settings + from admin import auth + + monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "s3cret") + get_settings.cache_clear() + assert auth.check_password("s3cret") is True + assert auth.check_password("wrong") is False + get_settings.cache_clear() + + +def test_check_password_empty_denies(monkeypatch): + from app.config import get_settings + from admin import auth + + monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "") + get_settings.cache_clear() + # Empty password must reject everything — console closed by default. + assert auth.check_password("") is False + assert auth.check_password("anything") is False + get_settings.cache_clear() + + +def test_session_round_trip(monkeypatch): + from app.config import get_settings + from admin import auth + + monkeypatch.setenv("ADMIN_CONSOLE_SESSION_SECRET", "unit-test-secret") + get_settings.cache_clear() + token = auth.sign_session() + assert auth.verify_session(token) is True + assert auth.verify_session("garbage") is False + get_settings.cache_clear() + + +# --- queries --------------------------------------------------------------- + +def test_overview_stats(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.overview_stats(s) + + stats = asyncio.run(_go()) + assert stats["total_users"] == 3 + assert stats["free"] == 2 + assert stats["paid"] == 1 + # bob (tier paid) + carol (credit) both count as paid-active. + assert stats["paid_active"] == 2 + assert stats["credit_active"] == 1 + assert stats["sync_enabled"] == 1 + assert stats["referrals_total"] == 1 + assert stats["referrals_converted"] == 1 + + +def test_list_users(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.list_users(s) + + rows, total = asyncio.run(_go()) + assert total == 3 + by_id = {r["id"]: r for r in rows} + assert by_id[2]["paid_active"] and by_id[2]["paid_source"] == "tier" + assert by_id[2]["on_stripe"] is True + assert by_id[3]["paid_active"] and by_id[3]["paid_source"] == "credit" + assert by_id[3]["has_sync"] is True + assert by_id[1]["referrals"] == 1 + assert by_id[1]["paid_active"] is False + + +def test_list_users_search(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.list_users(s, q="bob") + + rows, total = asyncio.run(_go()) + assert total == 1 and rows[0]["email"] == "bob@x" + + +def test_user_detail(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.user_detail(s, 3) + + d = asyncio.run(_go()) + assert d is not None + assert d["user"].email == "carol@x" + assert d["paid"].active and d["paid"].source == "credit" + assert d["referred_by"]["email"] == "alice@x" + assert d["sync"]["enabled"] is True + assert d["feedback"].get("up") == 1 + assert len(d["emails"]) == 1 + assert len(d["acks"]) == 1 + + +def test_user_detail_missing(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.user_detail(s, 999) + + assert asyncio.run(_go()) is None + + +def test_db_stats_sqlite_fallback(tmp_path): + factory = _seed(tmp_path) + from admin import queries + + async def _go(): + async with factory() as s: + return await queries.db_stats(s) + + stats = asyncio.run(_go()) + names = {t["name"] for t in stats["tables"]} + assert "users" in names + users_row = next(t for t in stats["tables"] if t["name"] == "users") + assert users_row["rows"] == 3 + + +# --- app wiring ------------------------------------------------------------ + +def _client(tmp_path, monkeypatch): + from fastapi.testclient import TestClient + from app.config import get_settings + + _seed(tmp_path) + monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "letmein") + get_settings.cache_clear() + from admin.main import app + return TestClient(app, follow_redirects=False) + + +def test_pages_require_login(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + for path in ("/", "/users", "/users/1", "/db"): + r = client.get(path) + assert r.status_code == 303, path + assert r.headers["location"] == "/login" + get_settings_clear() + + +def test_login_flow(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + + # Wrong password → 401, no cookie. + r = client.post("/login", data={"password": "nope"}) + assert r.status_code == 401 + assert "admin_console_session" not in r.cookies + + # Right password → 303 to / with a session cookie. + r = client.post("/login", data={"password": "letmein"}) + assert r.status_code == 303 + assert r.headers["location"] == "/" + cookie = r.cookies.get("admin_console_session") + assert cookie + + # Authenticated pages now load. + r = client.get("/", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "Overview" in r.text + + r = client.get("/users", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "carol@x" in r.text + + r = client.get("/users/3", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "carol@x" in r.text and "alice@x" in r.text + + r = client.get("/db", cookies={"admin_console_session": cookie}) + assert r.status_code == 200 + assert "users" in r.text + get_settings_clear() + + +def test_healthz(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + r = client.get("/healthz") + assert r.status_code == 200 and r.text == "ok" + get_settings_clear() + + +def get_settings_clear(): + from app.config import get_settings + get_settings.cache_clear() From 83ffa7dbf8b8875c6c82d4761fa22c26c864e5f9 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 27 Jul 2026 18:54:26 +0200 Subject: [PATCH 07/11] admin: front console via NPM + add purge-test-users CLI Networking: the superadmin console now mirrors `app` instead of a loopback-only host port. Base compose drops the host port; the dev override binds 127.0.0.1:8091; the prod overlay joins the `intranet` network and listens on :80 with --proxy-headers so NPM can proxy it. CLI: add `purge-test-users` (dry-run by default, --commit to delete, --keep allow-list defaulting to the real accounts). Deletes child rows explicitly (DB-agnostic) plus email-keyed OTPs, so smoke-test signups that were pointed at prod can be cleaned repeatably instead of via ad-hoc SQL. Covered by 6 new tests. Co-Authored-By: Claude Opus 4.8 --- admin/README.md | 20 ++++--- app/cli.py | 96 ++++++++++++++++++++++++++++++ docker-compose.override.yml | 6 ++ docker-compose.prod.yml | 17 ++++-- docker-compose.yml | 14 ++--- tests/test_cli.py | 115 ++++++++++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 22 deletions(-) diff --git a/admin/README.md b/admin/README.md index bd1685f..3f1213c 100644 --- a/admin/README.md +++ b/admin/README.md @@ -8,18 +8,20 @@ that **never** runs migrations or the scheduler and only ever issues `SELECT`s. ## Access model -- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on - the `intranet`/NPM network. Reach it over an SSH tunnel: - - ```sh - ssh -L 8091:localhost:8091 - # then open http://localhost:8091 - ``` - +- **Dev:** bound to **`127.0.0.1:8091`** on the host (loopback only, from + `docker-compose.override.yml`). Open . +- **Prod:** no host port. The container joins the `intranet` network and + listens on port 80, so **Nginx Proxy Manager** fronts it like the main app + (upstream `readmarkets-admin-1:80`). Point an NPM proxy host at it and, + ideally, add an NPM access list / basic-auth as a second layer. - Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`). Empty password ⇒ every login is refused (closed by default). The login sets a 12-hour signed cookie (`admin_console_session`). +> The console is now reachable on whatever public hostname NPM maps to it — +> it is no longer air-gapped behind an SSH tunnel. Keep `ADMIN_CONSOLE_PASSWORD` +> strong and prefer adding an NPM access rule in front of it. + ## Pages - `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count, @@ -41,6 +43,8 @@ overlay: # 1. set ADMIN_CONSOLE_PASSWORD in .env # 2. build + create just the admin container (leaves app/scheduler/db running) docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin +# 3. in Nginx Proxy Manager: add a proxy host → forward to readmarkets-admin-1 +# port 80 (both containers are on the `intranet` network). ``` ## Tests diff --git a/app/cli.py b/app/cli.py index c780f0b..8e546e7 100644 --- a/app/cli.py +++ b/app/cli.py @@ -146,6 +146,90 @@ async def send_test_digest(email: str, kind: str) -> int: return 0 +# The genuinely-real accounts. Everything else in the users table is a +# leftover from smoke/E2E signup tests that were pointed at the live site +# (OTP delivered to the @gilest.ro catch-all). Kept here so the purge is an +# explicit allow-list — deny by default — rather than pattern-matching on +# test prefixes that a future test run might not follow. +DEFAULT_KEEP_EMAILS = ( + "giorgio@gilest.ro", + "giorgio.gilestro@gmail.com", + "ilariodamato@hotmail.com", +) + + +async def purge_test_users(keep: list[str], commit: bool) -> int: + """Delete every user whose email is not in `keep`, cascading to child + rows. Dry-run by default: prints exactly what would go and changes + nothing unless `commit` is True. + + Deletes child rows explicitly (rather than leaning on DB-level ON DELETE + CASCADE) so behaviour is identical on MariaDB and the sqlite test DB. + """ + from sqlalchemy import delete, or_, update + + from app.models import ( + EmailOTP, EmailSend, PortfolioSync, Referral, + StrategicLogFeedback, User, UserAcknowledgement, + ) + + keep_set = {e.strip().lower() for e in keep if e.strip()} + factory = get_session_factory() + async with factory() as session: + rows = (await session.execute( + select(User.id, User.email, User.tier, User.created_at) + .order_by(User.id) + )).all() + victims = [r for r in rows if (r.email or "").lower() not in keep_set] + kept = [r for r in rows if (r.email or "").lower() in keep_set] + + print(f"users total: {len(rows)} keep: {len(kept)} " + f"to delete: {len(victims)}") + print("\nKEEP:") + for r in kept: + print(f" [{r.id}] {r.email} ({r.tier})") + print("\nDELETE:" if victims else "\nDELETE: (none)") + for r in victims: + print(f" [{r.id}] {r.email} ({r.tier}) {r.created_at}") + + if not victims: + print("\nnothing to do.") + return 0 + if not commit: + print(f"\nDRY RUN — nothing deleted. " + f"Re-run with --commit to remove these {len(victims)} " + f"account(s).") + return 0 + + ids = [r.id for r in victims] + emails = [r.email for r in victims if r.email] + # Child rows first (explicit; order matters without cascade). + await session.execute(delete(StrategicLogFeedback) + .where(StrategicLogFeedback.user_id.in_(ids))) + await session.execute(delete(EmailSend) + .where(EmailSend.user_id.in_(ids))) + await session.execute(delete(PortfolioSync) + .where(PortfolioSync.user_id.in_(ids))) + await session.execute(delete(UserAcknowledgement) + .where(UserAcknowledgement.user_id.in_(ids))) + await session.execute(delete(Referral).where(or_( + Referral.referrer_user_id.in_(ids), + Referral.referred_user_id.in_(ids), + ))) + # Self-referential FK on survivors that pointed at a victim. + await session.execute(update(User) + .where(User.referred_by_user_id.in_(ids)) + .values(referred_by_user_id=None)) + # OTPs are keyed by email, not user_id — no FK to cascade. + if emails: + await session.execute(delete(EmailOTP) + .where(EmailOTP.email.in_(emails))) + result = await session.execute(delete(User).where(User.id.in_(ids))) + await session.commit() + print(f"\ndeleted {result.rowcount} user(s) and their child rows.") + 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) @@ -165,6 +249,15 @@ def build_parser() -> argparse.ArgumentParser: t.add_argument("email") t.add_argument("kind", choices=("daily", "weekly")) + pu = sub.add_parser( + "purge-test-users", + help="Delete all users except an allow-list (dry-run unless --commit)") + pu.add_argument( + "--keep", action="append", metavar="EMAIL", default=None, + help="Email to keep; repeatable. Defaults to the known real accounts.") + pu.add_argument("--commit", action="store_true", + help="Actually delete. Without this it is a dry run.") + return p @@ -181,6 +274,9 @@ async def _dispatch(args) -> int: return await show_status(args.email) if args.cmd == "send-test-digest": return await send_test_digest(args.email, args.kind) + if args.cmd == "purge-test-users": + keep = args.keep if args.keep else list(DEFAULT_KEEP_EMAILS) + return await purge_test_users(keep, args.commit) return 2 finally: await get_engine().dispose() diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d83d0e8..9380856 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,3 +15,9 @@ services: - ./app:/app/app ports: - "${CASSANDRA_PORT:-8000}:8000" + + admin: + # Dev: reach the console at http://localhost:8091 (loopback only). Prod + # drops this and fronts it with NPM instead (docker-compose.prod.yml). + ports: + - "127.0.0.1:${CASSANDRA_ADMIN_PORT:-8091}:8000" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ac3f6ec..c077428 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -45,10 +45,19 @@ services: REDIS_URL: redis://readmarkets-redis-1:6379/0 admin: - # Same DNS-collision reasoning as app/scheduler: use the project-prefixed - # container name for the DB. The console stays OFF the intranet network — - # it is internal-only (127.0.0.1:8091 host port from the base file), so it - # never needs to be reachable by NPM. + # Fronted by NPM like `app`: listen on 80 and join the `intranet` network + # so the proxy can reach it as `readmarkets-admin-1:80`. No host port. + # --proxy-headers so redirect/asset URLs honour X-Forwarded-Proto from NPM. + # Still gated by ADMIN_CONSOLE_PASSWORD; add an NPM access rule in front for + # a second layer. Project-prefixed DB name avoids the shared-network `db` + # DNS collision (same reason as app/scheduler). + command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "80", + "--workers", "1", "--proxy-headers", "--forwarded-allow-ips=*"] + expose: + - "80" + networks: + - default + - intranet environment: DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} diff --git a/docker-compose.yml b/docker-compose.yml index a25b80d..598d704 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,8 +79,10 @@ services: # Superadmin console — independent read-only operator dashboard. Same # image (reuses app.db/app.models) but runs admin.main:app instead of the - # public app, and NEVER runs migrations. Bound to 127.0.0.1 only: it is - # reached over an SSH tunnel, never exposed publicly (no intranet/NPM). + # public app, and NEVER runs migrations. No host port here, mirroring `app`: + # dev adds a loopback port (docker-compose.override.yml) and prod joins the + # `intranet` network so NPM can proxy it (docker-compose.prod.yml). Still + # gated by ADMIN_CONSOLE_PASSWORD. admin: build: . restart: unless-stopped @@ -92,14 +94,6 @@ services: - ./config:/app/config:ro - ./app:/app/app - ./admin:/app/admin - ports: - # Host-loopback only — access via `ssh -L 8091:localhost:8091 `. - - "127.0.0.1:8091:8000" - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"] - interval: 30s - timeout: 5s - retries: 3 depends_on: db: condition: service_healthy diff --git a/tests/test_cli.py b/tests/test_cli.py index 616bed9..b3b9dfe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,8 @@ container. The parser-level tests are enough to catch the common shapes: bad args, missing args, unknown sub-command.""" from __future__ import annotations +import asyncio + import pytest from app.cli import build_parser @@ -47,3 +49,116 @@ def test_unknown_command_rejected(): def test_no_command_rejected(): with pytest.raises(SystemExit): build_parser().parse_args([]) + + +# --- purge-test-users ------------------------------------------------------ + +def test_purge_parses_dry_run_default(): + args = build_parser().parse_args(["purge-test-users"]) + assert args.cmd == "purge-test-users" + assert args.commit is False + assert args.keep is None + + +def test_purge_parses_keep_and_commit(): + args = build_parser().parse_args( + ["purge-test-users", "--keep", "a@x", "--keep", "b@x", "--commit"]) + assert args.keep == ["a@x", "b@x"] + assert args.commit is True + + +def _seed_users(tmp_path): + """Two keepers + one throwaway with child rows across every table.""" + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base, utcnow + from app.models import ( + EmailOTP, EmailSend, PortfolioSync, Referral, + StrategicLogFeedback, User, UserAcknowledgement, + ) + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/cli.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + now = utcnow() + + async def _go(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + s.add(User(id=1, email="keep@real.com", tier="paid", created_at=now)) + s.add(User(id=2, email="also@real.com", tier="free", created_at=now, + referred_by_user_id=3)) # referred by a victim + s.add(User(id=3, email="junk-123@gilest.ro", tier="free", + created_at=now)) + await s.flush() + s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent")) + s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x", + outer_nonce=b"y", version=1, + created_at=now, updated_at=now)) + s.add(UserAcknowledgement(user_id=3, version=1, lang="en", + accepted_at=now)) + s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up", + created_at=now, updated_at=now)) + s.add(Referral(referrer_user_id=1, referred_user_id=3, + created_at=now)) + s.add(EmailOTP(email="junk-123@gilest.ro", code_hash="h", + created_at=now, expires_at=now)) + await s.commit() + + asyncio.run(_go()) + return factory + + +def _count(factory, model): + from sqlalchemy import func, select + + async def _go(): + async with factory() as s: + return (await s.execute(select(func.count()).select_from(model))).scalar() + + return asyncio.run(_go()) + + +def test_purge_dry_run_deletes_nothing(tmp_path): + from app.cli import purge_test_users + from app.models import User + + factory = _seed_users(tmp_path) + rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"], + commit=False)) + assert rc == 0 + assert _count(factory, User) == 3 # untouched + + +def test_purge_commit_removes_victim_and_children(tmp_path): + from sqlalchemy import select + + from app.cli import purge_test_users + from app.models import ( + EmailOTP, EmailSend, PortfolioSync, Referral, + StrategicLogFeedback, User, UserAcknowledgement, + ) + + factory = _seed_users(tmp_path) + rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"], + commit=True)) + assert rc == 0 + # Only the two keepers remain. + async def _remaining(): + async with factory() as s: + emails = (await s.execute(select(User.email).order_by(User.id))).scalars().all() + survivor = (await s.execute( + select(User.referred_by_user_id).where(User.id == 2))).scalar() + return emails, survivor + + emails, survivor = asyncio.run(_remaining()) + assert emails == ["keep@real.com", "also@real.com"] + # Self-ref FK to the deleted user was nulled, not left dangling. + assert survivor is None + # Every child table row for the victim is gone. + for model in (EmailSend, PortfolioSync, UserAcknowledgement, + StrategicLogFeedback, Referral, EmailOTP): + assert _count(factory, model) == 0, model.__name__ From dd953532898d73ecf7dd1b36c11a3bafbb0e8332 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Tue, 28 Jul 2026 18:59:32 +0200 Subject: [PATCH 08/11] gitignore: ignore all of backup/, not just SQL dumps The backup/*.sql* patterns missed pre-change .env copies, which hold live Stripe and SMTP secrets and were committable. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 168165b..e9eb31d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ __pycache__/ .ruff_cache/ .venv/ venv/ -backup/*.sql -backup/*.sql.gz +# Everything under backup/ is operational data, never source: DB dumps and +# pre-change .env copies (which hold live Stripe/SMTP secrets). The earlier +# backup/*.sql* patterns missed the .env copies — ignore the whole directory. +backup/ *.egg-info/ build/ dist/ From 4169a6767b323a1e4413d0be1e1e3ccfac0a311a Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Tue, 28 Jul 2026 18:59:48 +0200 Subject: [PATCH 09/11] stripe: always bill in GBP, never geo-select a currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /pricing renders "£7" and "£70" as static copy, but checkout sniffed CF-IPCountry / Accept-Language and passed a matching currency, so Stripe picked a currency_options rate. A US visitor was shown £7 and charged $9.99; a German one was charged €7. The page's "Prices in GBP" line was therefore untrue, and "two months free" only held in GBP and USD (the EUR annual is €80 against €84, a 4.8% saving). Displaying one price and billing another is what the UK CPRs and the EU price-indication rules prohibit, so drop the currency selection rather than patch the disclosure. Removes _sniff_currency and its country and locale tables, the currency field on CheckoutRequest, and the now-unused request parameter. The currency_options configured on the live Prices are left in place but unused. Reinstating geo-pricing requires making /pricing currency-aware first — copy, buttons and the currency-specific saving claim. Co-Authored-By: Claude Opus 5 --- app/routers/stripe_billing.py | 78 +++++++------------------ tests/test_stripe_billing.py | 107 +++++++++++++++++----------------- 2 files changed, 73 insertions(+), 112 deletions(-) diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index 169e5a1..77edb8c 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -19,7 +19,7 @@ from __future__ import annotations import asyncio import json -from typing import Any, Literal, Optional +from typing import Any, Literal import stripe from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -75,51 +75,21 @@ def _price_for(cadence: str) -> str: raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'") -# Rough country → currency mapping. Covers the markets we have a stated -# rate for; everything else falls back to GBP (the home currency) and -# Stripe handles the FX at checkout. Configure the per-currency -# unit_amount on each Price's `currency_options` in the Stripe Dashboard -# — we just signal which option to use here. -_COUNTRY_CURRENCY: dict[str, str] = { - "US": "usd", "CA": "usd", - "GB": "gbp", "IM": "gbp", "JE": "gbp", "GG": "gbp", - **dict.fromkeys(( - "DE", "FR", "IT", "ES", "PT", "NL", "BE", "IE", "AT", "FI", - "GR", "LU", "MT", "CY", "EE", "LV", "LT", "SI", "SK", "HR", - ), "eur"), -} - -# Accept-Language locale → currency, used when CF-IPCountry is absent. -# Ambiguous locales (e.g. plain "fr" without region) get EUR because -# that's the majority outcome. -_LOCALE_CURRENCY: dict[str, str] = { - "en-gb": "gbp", "en": "gbp", - "en-us": "usd", "en-ca": "usd", - "fr": "eur", "de": "eur", "it": "eur", "es": "eur", - "pt": "eur", "nl": "eur", -} - - -def _sniff_currency(request: Request) -> str: - """Best-effort currency detection for new-customer checkouts. - - Order: explicit Cloudflare country header, then Accept-Language - (exact match then language-only). GBP as the final fallback. Only - consulted when the user has no Stripe customer record yet — Stripe - locks currency at customer creation, so an existing customer's - currency wins regardless of the request locale. - """ - cc = (request.headers.get("cf-ipcountry") or "").upper() - if cc in _COUNTRY_CURRENCY: - return _COUNTRY_CURRENCY[cc] - al = (request.headers.get("accept-language") or "").lower() - first = al.split(",", 1)[0].split(";", 1)[0].strip() - if first in _LOCALE_CURRENCY: - return _LOCALE_CURRENCY[first] - short = first.split("-", 1)[0] - if short in _LOCALE_CURRENCY: - return _LOCALE_CURRENCY[short] - return "gbp" +# NOTE: we deliberately never pass `currency` to Stripe, so every +# checkout bills the Price's base currency — GBP. An earlier version +# sniffed CF-IPCountry / Accept-Language and selected a matching +# `currency_options` entry, but /pricing renders £7 and £70 as static +# copy: a US visitor was shown £7 and charged $9.99. Showing one price +# and billing another is exactly what the UK CPRs and the EU +# price-indication rules prohibit, so the sniffing was removed rather +# than the disclosure patched. The `currency_options` still configured +# on the Prices in the Dashboard are simply unused. +# +# To reinstate geo-pricing, /pricing must render the matching currency +# in its copy, its buttons AND its annual-saving claim first (the claim +# is currency-specific: "two months free" is true at £70/£84 and +# $94.99/$119.88, but not at €80/€84). See git history for the removed +# _sniff_currency helper and its country/locale tables. def _stripe_client() -> stripe.StripeClient: @@ -136,10 +106,6 @@ def _stripe_client() -> stripe.StripeClient: class CheckoutRequest(BaseModel): cadence: Literal["monthly", "annual"] - # Optional override; when omitted we sniff from request headers. - # Honoured only for first-time checkouts (Stripe locks currency - # to the customer at creation). - currency: Optional[Literal["gbp", "usd", "eur"]] = None class CheckoutResponse(BaseModel): @@ -149,7 +115,6 @@ class CheckoutResponse(BaseModel): @router.post("/api/stripe/checkout", response_model=CheckoutResponse) async def create_checkout( body: CheckoutRequest, - request: Request, session: AsyncSession = Depends(get_session), cu: CurrentUser = Depends(require_auth), ) -> CheckoutResponse: @@ -178,13 +143,10 @@ async def create_checkout( # referral redemption flow ships. "allow_promotion_codes": True, } - # Multi-currency: for first-time buyers (no stripe_customer_id yet) - # we pass the detected/requested currency. Stripe picks the matching - # `currency_options` rate configured on the Price in the Dashboard, - # then locks that currency to the new customer record. Existing - # customers keep their original currency regardless. - if not user.stripe_customer_id: - create_kwargs["currency"] = body.currency or _sniff_currency(request) + # No `currency` kwarg — every checkout bills the Price's base + # currency (GBP), matching the static £7 / £70 copy on /pricing. + # See the note above _stripe_client() before reintroducing one. + # # Per-cadence cooling-off treatment: # # - Annual gets a 14-day free trial. No money moves during the diff --git a/tests/test_stripe_billing.py b/tests/test_stripe_billing.py index d231cd2..6feba1a 100644 --- a/tests/test_stripe_billing.py +++ b/tests/test_stripe_billing.py @@ -465,50 +465,69 @@ def test_checkout_endpoint_requires_login(tmp_path): assert r.status_code == 401, r.text -def test_checkout_passes_sniffed_currency_for_new_customer(tmp_path): - """First-time buyer (no stripe_customer_id yet) gets the currency - sniffed from the request. CF-IPCountry=US → 'usd', and Stripe will - look up the USD currency_option on the Price.""" +def test_checkout_never_passes_currency(tmp_path): + """Every checkout bills the Price's base currency (GBP), whatever the + visitor's geo headers say. + + /pricing renders "£7" and "£70" as static copy, so selecting a + `currency_options` rate would show one price and charge another — + a US visitor saw £7 and was billed $9.99. Regression guard: if + geo-pricing is ever reinstated, the pricing page must become + currency-aware in the same change. + """ + client, _, session_cookie = _build_app(tmp_path) + + seen = [] + + def asserter(params): + seen.append(params) + assert "currency" not in params, ( + "no currency may be sent — /pricing advertises GBP only" + ) + + for headers in ( + {"cf-ipcountry": "US"}, + {"cf-ipcountry": "DE"}, + {"accept-language": "en-US,en;q=0.5"}, + {}, + ): + with patch("app.routers.stripe_billing._stripe_client", + return_value=_fake_checkout_client(asserter)): + r = client.post( + "/api/stripe/checkout", + json={"cadence": "monthly"}, + cookies={"cassandra_session": session_cookie}, + headers=headers, + ) + assert r.status_code == 200, r.text + + assert len(seen) == 4 + + +def test_checkout_rejects_currency_in_body(tmp_path): + """The `currency` field is gone from CheckoutRequest. A client that + still sends one must not silently get GBP under a USD label — the + extra key is simply ignored by pydantic, so assert the call is still + currency-free rather than trusting the caller.""" client, _, session_cookie = _build_app(tmp_path) def asserter(params): - assert params["currency"] == "usd" + assert "currency" not in params with patch("app.routers.stripe_billing._stripe_client", return_value=_fake_checkout_client(asserter)): r = client.post( "/api/stripe/checkout", - json={"cadence": "monthly"}, + json={"cadence": "monthly", "currency": "usd"}, cookies={"cassandra_session": session_cookie}, - headers={"cf-ipcountry": "US"}, ) assert r.status_code == 200, r.text -def test_checkout_body_currency_overrides_sniff(tmp_path): - """Explicit `currency` in the request body beats header sniffing — - lets a UK-based buyer choose EUR if they want to.""" - client, _, session_cookie = _build_app(tmp_path) - - def asserter(params): - assert params["currency"] == "eur" - - with patch("app.routers.stripe_billing._stripe_client", - return_value=_fake_checkout_client(asserter)): - r = client.post( - "/api/stripe/checkout", - json={"cadence": "monthly", "currency": "eur"}, - cookies={"cassandra_session": session_cookie}, - headers={"cf-ipcountry": "GB"}, - ) - assert r.status_code == 200, r.text - - -def test_checkout_omits_currency_for_existing_customer(tmp_path): - """Existing customer: Stripe locked their currency at first - checkout, so passing `currency` again would error. Verify we omit - it (and also use the existing `customer` ref instead of - customer_email).""" +def test_checkout_uses_existing_customer_ref(tmp_path): + """Existing customer: use the stored `customer` ref rather than + `customer_email`, so repeat checkouts don't mint duplicate Stripe + customers.""" import asyncio from app.models import User @@ -524,36 +543,16 @@ def test_checkout_omits_currency_for_existing_customer(tmp_path): asyncio.run(_link()) def asserter(params): - assert "currency" not in params, ( - "currency must not be passed once a customer exists — " - "Stripe rejects mismatches against the locked customer currency" - ) + assert "currency" not in params assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx" + assert "customer_email" not in params with patch("app.routers.stripe_billing._stripe_client", return_value=_fake_checkout_client(asserter)): r = client.post( "/api/stripe/checkout", - json={"cadence": "monthly", "currency": "usd"}, + json={"cadence": "monthly"}, cookies={"cassandra_session": session_cookie}, headers={"cf-ipcountry": "US"}, ) assert r.status_code == 200, r.text - - -def test_sniff_currency_fallback_chain(): - """Unit-test the header-sniffing helper: CF country wins, then - Accept-Language exact, then language-only, then GBP default.""" - from types import SimpleNamespace - - from app.routers.stripe_billing import _sniff_currency - - def _req(headers): - return SimpleNamespace(headers=headers) - - assert _sniff_currency(_req({"cf-ipcountry": "DE"})) == "eur" - assert _sniff_currency(_req({"cf-ipcountry": "us"})) == "usd" # case-insensitive - assert _sniff_currency(_req({"accept-language": "fr-FR,fr;q=0.9"})) == "eur" - assert _sniff_currency(_req({"accept-language": "en-US,en;q=0.5"})) == "usd" - assert _sniff_currency(_req({"accept-language": "ja,ja-JP;q=0.5"})) == "gbp" - assert _sniff_currency(_req({})) == "gbp" From 1305aa77ffa99c17560d1a9e6972e3bc6b5c7d91 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 29 Jul 2026 11:05:25 +0200 Subject: [PATCH 10/11] spec: currency-localised pricing design Restores multi-currency pricing that 4169a67 disabled, with the page and the charge reading the same source so they cannot drift apart. Co-Authored-By: Claude Opus 5 --- ...07-29-currency-localised-pricing-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md diff --git a/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md b/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md new file mode 100644 index 0000000..639a4c5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-currency-localised-pricing-design.md @@ -0,0 +1,222 @@ +# Currency-localised pricing — Design Spec + +**Date:** 2026-07-29 +**Status:** Draft — pending implementation plan + +## Context + +`/pricing` hardcodes `£7` and `£70` in its copy and its buttons. Until +commit `4169a67`, checkout sniffed `CF-IPCountry` / `Accept-Language` +and passed a matching `currency` to Stripe, which then selected a +`currency_options` rate off the Price. A US visitor was shown £7 and +billed $9.99; a German visitor was billed €7. The page's "Prices in GBP" +line was untrue for two of the three currencies. + +`4169a67` fixed that by forcing GBP for everyone — correct, but it +gives up genuine multi-currency pricing that is already configured and +paid for on the Stripe side. This spec restores it properly: the page +displays the currency the customer will actually be charged. + +Live Prices today (both `livemode: true`, base currency GBP): + +| Price | Interval | GBP | EUR | USD | +|---|---|---|---|---| +| `price_1TbNshDLpLwvRJpKnuWjdU1x` | month | 700 | 700 | 999 | +| `price_1TbNtWDLpLwvRJpKze87qOJ4` | year | 7000 | 7000 | 9499 | + +The EUR annual was corrected from 8000 to 7000 on 2026-07-29, so the +"two months free" claim now holds in GBP and EUR (16.7%) and understates +USD (20.8%). + +## Goals + +- A visitor sees prices in the currency they will be charged, in the + page copy, the buttons, and the saving claim. +- The visitor can override the detected currency, and the choice sticks. +- The displayed amounts are structurally incapable of disagreeing with + what Stripe charges. +- The monthly cooling-off waiver is effective for non-UK customers. +- `/it/pricing` renders in Italian, matching how the landing page + already works. + +## Non-goals + +- Adding currencies beyond GBP/EUR/USD. Each would need + `currency_options` on both live Prices first. +- VAT calculation or Stripe Tax. `automatic_tax` is currently `false`; + see Open Questions. +- Changing the monthly/annual plan structure. Annual keeps its 14-day + trial, monthly keeps immediate billing with a waiver. +- Localising any public page other than `/pricing`. + +## Design + +### Two axes, both user-switchable + +| Axis | Values | Detection order | Cookie | +|---|---|---|---| +| Language | `en`, `it` | existing `detect_public_lang` | `rtm.lang` | +| Currency | `gbp`, `eur`, `usd` | cookie → country → Accept-Language → `gbp` | `rtm.ccy` | + +An earlier draft added a third, non-switchable `jurisdiction` axis to +select between UK Reg-36 and Italian art. 59 consent wording. It was +dropped: see "Consent wording" below. Nothing legally operative is +derived from IP geolocation. + +### Data flow + +``` +GET /pricing (or /it/pricing) + ├─ lang = detect_public_lang(cookie, accept-language, cf-country, user) + ├─ currency = detect_currency(cookie, cf-country, accept-language) + │ overridden by users.stripe_currency when set + └─ amounts = pricing_catalog.get(currency) + ↓ + render symbol + amounts + computed saving %, in `lang` + ↓ +POST /api/stripe/checkout {cadence, currency} + currency honoured only when the user has no stripe_customer_id +``` + +### Components + +**`app/services/pricing_catalog.py`** (new) + +Reads both Prices with `expand[]=currency_options`, caches the result in +memory for 1 hour, and exposes: + +```python +get(currency: str) -> PriceSet # monthly, annual, symbol, saving_pct +available() -> list[str] # currencies present on BOTH prices +``` + +`saving_pct` is computed as `1 - annual / (12 * monthly)` and rounded +down to a whole percent. It is never written by hand — this is what +structurally prevents a repeat of the €80-vs-€84 drift. + +Knows nothing about HTTP, requests, or templates. Takes a Stripe client +as a constructor argument so tests inject a fake. + +**Currency detection** — added to `app/services/locales.py` next to +`detect_public_lang`, reusing its country tables rather than starting a +parallel module. Pure function, no I/O: + +```python +detect_currency(cookie_ccy, cf_country, accept_language, allowed) -> str +``` + +Priority: an explicit cookie beats everything; then `CF-IPCountry`; +then the first `Accept-Language` tag; then `gbp`. `allowed` is passed in +by the caller from `pricing_catalog.available()` — the function stays +pure and does no I/O of its own; anything not in `allowed` falls through +to the next rule. + +The country table is carried over unchanged from the removed version, +including `CA -> usd`. No CAD price exists, so every choice for Canada is +a proxy; USD is the closest familiar one. Adding a real CAD +`currency_options` entry would be the actual fix, and is out of scope. + +**`/pricing` route** (`app/routers/public.py`) gains the currency in its +context and a sibling `/it/pricing` route. Copy moves into the existing +`app/locales/{en,it}.yaml` under a `pricing.` key, matching the landing +page. A `?ccy=` query parameter sets the cookie and redirects, so the +switcher works without JavaScript. + +**`/api/stripe/checkout`** restores the `currency` field on +`CheckoutRequest`, validated against `pricing_catalog.available()`, and +passes it only when `user.stripe_customer_id` is unset. This reverts the +mechanical part of `4169a67` while keeping its guarantee: the page and +the charge always agree, because both now read the same catalog. + +### Consent wording + +The monthly waiver currently cites *Regulation 36 of the Consumer +Contracts Regulations 2013*. That is UK law; for a customer resident +elsewhere the citation does not apply, and an ineffective waiver means a +monthly subscriber retains the 14-day refund right the checkbox was +meant to remove. + +UK Reg 36 and Italian `Codice del Consumo` art. 59 both implement +Directive 2011/83/EU art. 16(m). The waiver takes effect from its +substance — an express request for immediate performance plus an +acknowledgement that the cancellation right is lost — not from the +citation. Wording that states the substance and cites no statute is +therefore effective under both regimes, whereas citing the wrong one is +worse than citing none. + +New wording, in place of the current sentence: + +> I request that the service starts immediately, and I understand that +> once it has started I lose my right to cancel and get a refund. + +The Terms-of-Service agreement in the same checkbox is unchanged. The +Italian rendering of this sentence is a translation of substance, not of +a statutory reference, so it carries the same weight as the existing +`auth.ack` translations. + +This wording is subject to the legal sign-off already tracked on the +launch blocker list. It is not a lawyer-authored sentence. + +### Locked currency + +Stripe locks currency to the Customer at creation. A returning customer +whose subscription lapsed could otherwise be shown €7 and billed £7. + +Add `users.stripe_currency` (`String(3)`, nullable), populated in +`_grant_paid` from the subscription object. When set, `/pricing` renders +that currency and disables the switcher with a one-line explanation. +Requires a small Alembic migration. + +The simpler alternative — disable the switcher for anyone with a +`stripe_customer_id`, without storing the currency — is rejected because +it still shows a possibly-wrong currency; it only stops the user +changing it. + +### Failure modes + +| Condition | Behaviour | +|---|---| +| Stripe unreachable, warm cache | Serve stale cache indefinitely; log a warning | +| Stripe unreachable, cold cache | Static GBP amounts, switcher hidden — i.e. exactly today's page | +| Requested currency absent from a Price | Excluded from `available()`, so unreachable | +| `?ccy=` with an unknown value | Ignored, cookie untouched | + +The page never returns an error because of a pricing lookup. + +## Testing + +- `detect_currency` — table-driven unit tests over the priority chain, + including values outside `allowed` falling through to the next rule. +- `pricing_catalog` — fake Stripe client: happy path, `saving_pct` + arithmetic, currency missing from one Price but not the other, cold-cache + failure, stale-cache-on-failure. +- Route tests — `/pricing` and `/it/pricing` render expected symbols and + amounts per cookie/header combination; `?ccy=` sets the cookie. +- **Cross-check test:** for each currency, assert the amount rendered in + the page equals the amount Stripe would charge for the currency + checkout sends. This is the regression guard for the original bug and + is the most important test in the set. +- Locked-currency test — a user with `stripe_currency` set sees that + currency regardless of headers or cookie. + +## Open questions + +1. **EU VAT.** `automatic_tax` is `false`, so no VAT is charged. B2C + digital services sold into the EU have no VAT threshold — VAT is due + in the customer's member state from the first sale, normally via a + non-Union OSS registration. Displaying EUR does not create this + obligation, but selling to EU consumers does. Resolve before taking + EUR money. Registration decision, not a code change. +2. **`billing_address_collection`.** Currently unset, so Stripe defaults + to `auto` and may capture only a postal code. Setting it to + `required` puts a country on every Customer record — useful for the + VAT question above and for knowing where customers are. Recommended, + independent of this feature. + +## Out of scope / follow-ups + +- `customer.subscription.paused` and `.resumed` are subscribed at Stripe + but absent from `_HANDLERS`. Harmless while pause is disabled in the + portal configuration, but a live trap if it is ever enabled. +- Localising `/terms` and `/privacy`, which the Italian pricing page + will link to in English. From 78b2c2be288278b6d37f53e613f2b36fc9118f85 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Thu, 30 Jul 2026 12:13:47 +0200 Subject: [PATCH 11/11] stripe: collect billing address, handle paused subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-launch gaps. 1. Checkout collected no address, so Stripe Customers carried no country. EU B2C digital-services VAT is due at the consumer's place of supply and we can't even scope that question without knowing where buyers are; the card's billing country is the evidence a tax authority accepts, an IP guess is not. It also gives AVS data to the fraud checks. Passing `customer` suppresses the write-back to the Customer record, so existing customers also need customer_update.address=auto — without it the country lands on the PaymentIntent and nowhere durable. 2. A paused subscription kept paid features while Stripe billed nothing. Both mechanisms were unhandled: status="paused" (trial ended with no usable card) had no entry in _HANDLERS at all, and `pause_collection` — what the customer portal's pause button uses — leaves status as "active", so the status check waved it through. Revoke on both, keeping stripe_subscription_id since the subscription still exists at Stripe and resumes under the same id. Pause is disabled in our live portal configuration, so (2) is latent rather than live — but it's a one-toggle mistake away from being real. Verified against live Stripe: a Checkout Session with the new params is accepted (gbp 700, billing_address_collection=required, livemode), and the live webhook endpoint already subscribes both paused and resumed. Co-Authored-By: Claude Opus 5 --- app/routers/stripe_billing.py | 50 ++++++++++- tests/test_stripe_billing.py | 153 ++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/app/routers/stripe_billing.py b/app/routers/stripe_billing.py index 77edb8c..30ab079 100644 --- a/app/routers/stripe_billing.py +++ b/app/routers/stripe_billing.py @@ -142,6 +142,14 @@ async def create_checkout( # Lets us paste in a referral coupon at checkout once the # referral redemption flow ships. "allow_promotion_codes": True, + # Collect a billing address on every checkout so each Stripe + # Customer carries a country. Two reasons: card-fraud checks get + # materially better with AVS data, and EU B2C digital-services + # VAT is charged at the *consumer's* place of supply — we can't + # answer the OSS question at all without knowing where buyers + # are. Note this is the address on the card, not an IP guess, + # which is the evidence a tax authority actually accepts. + "billing_address_collection": "required", } # No `currency` kwarg — every checkout bills the Price's base # currency (GBP), matching the static £7 / £70 copy on /pricing. @@ -165,6 +173,12 @@ async def create_checkout( create_kwargs["subscription_data"] = {"trial_period_days": 14} if user.stripe_customer_id: create_kwargs["customer"] = user.stripe_customer_id + # Required for billing_address_collection to actually persist: + # when `customer` is supplied, Stripe collects the address for + # the payment but leaves the Customer record untouched unless + # customer_update.address is "auto". Without this the country + # lands on the PaymentIntent and nowhere durable. + create_kwargs["customer_update"] = {"address": "auto"} else: create_kwargs["customer_email"] = user.email @@ -294,11 +308,15 @@ async def _grant_paid( await convert_referral(session, user) -async def _revoke_paid(user: User) -> None: +async def _revoke_paid(user: User, *, keep_subscription: bool = False) -> None: user.tier = "free" - user.stripe_subscription_id = None + if not keep_subscription: + user.stripe_subscription_id = None user.stripe_trial_end_at = None # Keep stripe_customer_id so a re-subscription matches this row. + # `keep_subscription` is for a pause: the subscription still exists + # at Stripe and will resume under the same id, so nulling our copy + # would lose the link while access is merely suspended. async def _handle_checkout_completed( @@ -336,6 +354,16 @@ async def _handle_subscription_event( customer_id=obj.get("customer")) return status = obj.get("status") + # `pause_collection` is a *different* mechanism from status="paused": + # the subscription stays `active` while Stripe simply stops invoicing. + # Unhandled, that leaves the customer on paid features indefinitely + # without paying, so treat any live pause as not-paid regardless of + # status. Pause is disabled in our live portal configuration, so in + # practice this only fires if someone re-enables it there or pauses + # from the Dashboard — which is exactly when we'd want it to work. + if obj.get("pause_collection"): + await _revoke_paid(user, keep_subscription=True) + return # 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 @@ -362,6 +390,22 @@ async def _handle_subscription_deleted( await _revoke_paid(user) +async def _handle_subscription_paused( + session: AsyncSession, event_type: str, obj: dict[str, Any], +) -> None: + """customer.subscription.paused — status flips to `paused` when a + trial ends with no usable payment method (trial_settings.end_behavior + .missing_payment_method = pause). No money is being collected, so + paid features come off. `.resumed` routes to the normal subscription + handler, which grants again on active/trialing.""" + 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, keep_subscription=True) + + async def _handle_audit_only( session: AsyncSession, event_type: str, obj: dict[str, Any], ) -> None: @@ -376,6 +420,8 @@ _HANDLERS = { "customer.subscription.created": _handle_subscription_event, "customer.subscription.updated": _handle_subscription_event, "customer.subscription.deleted": _handle_subscription_deleted, + "customer.subscription.paused": _handle_subscription_paused, + "customer.subscription.resumed": _handle_subscription_event, "invoice.paid": _handle_audit_only, "invoice.payment_failed": _handle_audit_only, "charge.refunded": _handle_audit_only, diff --git a/tests/test_stripe_billing.py b/tests/test_stripe_billing.py index 6feba1a..f592850 100644 --- a/tests/test_stripe_billing.py +++ b/tests/test_stripe_billing.py @@ -341,6 +341,131 @@ def test_subscription_active_grants_paid(tmp_path): assert asyncio.run(_check_tier()) == "paid" +# --- pause / resume -------------------------------------------------------- + + +def _activate(client, *, customer="cus_p", subscription="sub_p", evt="evt_a"): + """Link user 1 to a Stripe customer and put them on paid.""" + return _post_webhook(client, body={ + "id": evt, + "type": "checkout.session.completed", + "data": {"object": { + "client_reference_id": "1", + "customer": customer, + "subscription": subscription, + }}, + }) + + +def _tier_and_sub(factory): + async def _check(): + from sqlalchemy import select + from app.models import User + async with factory() as session: + u = (await session.execute( + select(User).where(User.id == 1) + )).scalar_one() + return u.tier, u.stripe_subscription_id + return asyncio.run(_check()) + + +def test_subscription_paused_drops_tier(tmp_path): + """status=paused means Stripe has stopped collecting (trial ended + with no usable card). Paid features must come off — otherwise the + customer keeps everything for free.""" + client, factory, _ = _build_app(tmp_path) + _activate(client) + assert _tier_and_sub(factory)[0] == "paid" + + r = _post_webhook(client, body={ + "id": "evt_paused", + "type": "customer.subscription.paused", + "data": {"object": { + "id": "sub_p", "customer": "cus_p", "status": "paused", + }}, + }) + assert r.status_code == 200, r.text + assert r.json()["status"] == "ok", "must not fall through to 'ignored'" + + tier, sub = _tier_and_sub(factory) + assert tier == "free" + # The subscription still exists at Stripe and resumes under the same + # id, so we keep our link to it. + assert sub == "sub_p" + + +def test_subscription_resumed_regrants_tier(tmp_path): + client, factory, _ = _build_app(tmp_path) + _activate(client) + _post_webhook(client, body={ + "id": "evt_paused2", + "type": "customer.subscription.paused", + "data": {"object": { + "id": "sub_p", "customer": "cus_p", "status": "paused", + }}, + }) + assert _tier_and_sub(factory)[0] == "free" + + r = _post_webhook(client, body={ + "id": "evt_resumed", + "type": "customer.subscription.resumed", + "data": {"object": { + "id": "sub_p", "customer": "cus_p", "status": "active", + }}, + }) + assert r.status_code == 200, r.text + assert r.json()["status"] == "ok" + assert _tier_and_sub(factory) == ("paid", "sub_p") + + +def test_pause_collection_drops_tier_despite_active_status(tmp_path): + """The portal's pause uses `pause_collection` and leaves status as + `active`, so the status check alone would keep the user on paid while + Stripe bills them nothing.""" + client, factory, _ = _build_app(tmp_path) + _activate(client) + assert _tier_and_sub(factory)[0] == "paid" + + r = _post_webhook(client, body={ + "id": "evt_pause_coll", + "type": "customer.subscription.updated", + "data": {"object": { + "id": "sub_p", + "customer": "cus_p", + "status": "active", + "pause_collection": {"behavior": "void"}, + }}, + }) + assert r.status_code == 200, r.text + assert _tier_and_sub(factory) == ("free", "sub_p") + + +def test_unpause_collection_regrants_tier(tmp_path): + """Resuming collection sends subscription.updated with + pause_collection cleared to null — that must grant paid back.""" + client, factory, _ = _build_app(tmp_path) + _activate(client) + _post_webhook(client, body={ + "id": "evt_pc_on", + "type": "customer.subscription.updated", + "data": {"object": { + "id": "sub_p", "customer": "cus_p", "status": "active", + "pause_collection": {"behavior": "void"}, + }}, + }) + assert _tier_and_sub(factory)[0] == "free" + + _post_webhook(client, body={ + "id": "evt_pc_off", + "type": "customer.subscription.updated", + "data": {"object": { + "id": "sub_p", "customer": "cus_p", "status": "active", + "pause_collection": None, + }}, + }) + assert _tier_and_sub(factory) == ("paid", "sub_p") + + # --- idempotency + unknown ------------------------------------------------ @@ -524,6 +649,30 @@ def test_checkout_rejects_currency_in_body(tmp_path): assert r.status_code == 200, r.text +def test_checkout_requires_billing_address(tmp_path): + """Every checkout must collect a billing address, so each Stripe + Customer ends up with a country. EU B2C digital-services VAT is due + at the consumer's place of supply, and the card's billing country is + the evidence for that — an IP guess is not.""" + client, _, session_cookie = _build_app(tmp_path) + + def asserter(params): + assert params["billing_address_collection"] == "required" + # New customer (no stored customer id): customer_update is only + # valid alongside `customer`, so it must be absent here. + assert "customer_update" not in params + + for cadence in ("monthly", "annual"): + with patch("app.routers.stripe_billing._stripe_client", + return_value=_fake_checkout_client(asserter)): + r = client.post( + "/api/stripe/checkout", + json={"cadence": cadence}, + cookies={"cassandra_session": session_cookie}, + ) + assert r.status_code == 200, r.text + + def test_checkout_uses_existing_customer_ref(tmp_path): """Existing customer: use the stored `customer` ref rather than `customer_email`, so repeat checkouts don't mint duplicate Stripe @@ -546,6 +695,10 @@ def test_checkout_uses_existing_customer_ref(tmp_path): assert "currency" not in params assert params["customer"] == "cus_existing_xxxxxxxxxxxxxx" assert "customer_email" not in params + # Without customer_update.address the collected address is + # attached to the payment only and the Customer record keeps a + # null address — i.e. still no country for the VAT question. + assert params["customer_update"] == {"address": "auto"} with patch("app.routers.stripe_billing._stripe_client", return_value=_fake_checkout_client(asserter)):