/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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Replaces the passive "by signing in you agree…" paragraph on /login with
an active un-pre-ticked checkbox over three short statements that the
user actively accepts. Each acceptance is recorded against a code-level
version pointer, so a future wording revision bumps the version and
prompts every user again.
- New app/legal.py::ACKNOWLEDGEMENT_VERSION (currently 1).
- New UserAcknowledgement model + migration 0027 (user_id FK CASCADE,
version, lang, accepted_at, composite index on user_id+version).
- auth_service: has_acknowledged_current() and record_acknowledgement()
helpers; POST /login validates the checkbox, falls through to a 400
with the localised error otherwise, and writes a row iff the user has
no current-version row (so existing-already-accepted users don't
produce duplicates).
- GET /login: language detection mirrors the landing's
detect_public_lang(); ?lang=en|it overrides; stamps the rtm.lang
cookie; passes the locale dict + version into the template.
- login.html: EN/IT pill, localised lede/banner/legal footer, required
checkbox in an acknowledgement block, hidden lang+ack_version fields.
Submit disabled until the box is ticked (UX polish; the server check
is what carries weight).
- locales/{en,it}.yaml: new auth.ack.* section with TODO(legal) marker.
Wording matches the brief's substance pending solicitor sign-off.
- tests/test_signup_acknowledgement.py: 10 tests (EN + IT rejection,
one row per acceptance, displayed-lang recorded, idempotent on
current version, version-bump writes new row, helper unit tests).
The acknowledgement strengthens the user-civil-claim vector — combined
with the liability cap and the Ltd, it makes "I was misled into thinking
this was advice" much harder to argue. It does NOT move the regulatory
perimeter, which is governed by the content discipline shipped in
47dce1a. Belt-and-braces, not a substitute.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements docs/read-markets-compliance-changes.md as flag-gated changes
(no deletions) so paused features stay in the tree for future re-enable.
All four flags default False so a fresh deploy is compliance-safe.
- New env flags: PORTFOLIO_AI_ENABLED, PORTFOLIO_SYNC_ENABLED,
TICKER_UNIVERSE_AGGREGATE_ENABLED, SUBSCRIPTIONS_ENABLED.
- Gates: /api/analyze, /api/portfolio/sync*, /api/stripe/*, /pricing,
ticker_universe writes, portfolio_analysis.analyse(). is_paid_active()
returns True for any auth'd user when subscriptions are paused.
- Prompts (PROMPT_VERSION 10): universal _COMPLIANCE_RIDER prepended to
every system prompt; watch list removed; price-target / close-above-below
/ trigger / forward-state-as-description rules added; SPECULATIVE
pivoted to regime-only scenarios; daily + weekly digests tightened.
- Reviewer: deterministic regex/lexicon pre-check fail-closed under the
Haiku call; portfolio rider gated by PORTFOLIO_AI_ENABLED; base prompt
sharpened for forward-state and MAR forward-opinion patterns;
ReviewerVerdict audit table; generate_with_review retry helper.
- Migration 0026: purge portfolio_sync + ticker_universe; create
reviewer_verdicts.
- Copy: MAR cite fixed to Art 3(1)(35) + Art 20 + Del Reg 2016/958;
portfolio reframed as browser-only viewer in disclaimer / privacy /
terms / about / pricing / landing (en + it). TODO(legal) marker for
lawyer sign-off on disclaimer.
- Tests: 13 lexicon + 6 reviewer compliance regressions; conftest enables
all flags so existing 402 tests still cover their code paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 /<lang>/.
* 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/<lang>.yaml — flat-ish nested copy files. YAML chosen
so a future translator can edit without touching Python. Strings
containing inline HTML (<strong>, <em>, <a>) 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.<key> }} references.
* public_base.html gets <html lang="…"> + 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 <noreply@anthropic.com>
The /api/analyze flow previously read principal.user.lang from the
DB on every request and ignored anything the client might send. That
races the language toggle's PATCH: a user can flip the toggle and
click Generate/Regenerate before the PATCH /api/settings/language
hits the DB, so the analysis is sent with the OLD persisted lang
while the toggle visually reads as the new one. From the user's POV
the analysis comes back in the wrong language.
Frontend portfolio.js now reads the live #lang-toggle data-lang
attribute (the same source the UI itself uses) and includes it in
the /api/analyze body. The dataset attribute is updated optimistically
by cassandraSetLang() before the PATCH fires, so it always reflects
what the user is looking at.
Backend universe.py prefers payload["lang"] when present and falls
back to user.lang otherwise — older clients (scripts, direct curl)
that don't send anything still get the DB-stored preference. The
resolution path is logged so we can confirm in prod which lang
actually drove a given request.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reports that portfolio AI analysis was coming back in English even
for IT-toggled users. Traced the chain (DB user.lang IS set to it,
router passes it into the payload, parse_request reads it, build_prompt
appends respond_in_clause), so the wiring is correct end-to-end. The
model was simply ignoring the single-sentence tail nudge: when the
system prompt is hundreds of lines of English and the user message
adds more English context, "Respond in Italian." at the end is easy
to drop on the floor.
Add a new services/i18n.language_directive_lead() that returns a
strong, explicit top-of-prompt block — "# LANGUAGE — write everything
in <X>" plus the verbatim-tickers-and-numbers carve-out — meant to
be PREPENDED so the model anchors on the target language before it
reads the bulk of the instructions. Combined with the existing tail
clause it's belt-and-suspenders: top + bottom of the prompt both
say "in this language".
Applied to portfolio_analysis.build_prompt() and chat.py — the two
surfaces that generate user-facing prose in real time (the strategic
log + indicator summaries get post-hoc translation via translate(),
so the directive isn't needed there).
Empty-string return for en / unknown lang means callers can wire
it in unconditionally; no extra plumbing in i18n callsites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends the reviewer agent — previously only protecting indicator
summaries — to every AI-generated surface that reaches a user. The
reviewer's prompt already rejects scratchpad, truncation,
meta-commentary, and (since a6e476b) financial advice; wiring it in
turns those rules from prompt-level "asks" into structural gates.
Four call sites updated:
- ai_log_job.run() : after each tone/analysis variant is generated,
pass through review_read. On reject, log the reason and skip the
StrategicLog insert; the API's existing "latest StrategicLog" lookup
falls back to the previous clean log.
- services/portfolio_analysis.analyse() : on reject, raise a clean
RuntimeError that the /api/analyze router already maps to HTTP 502
with a retry-able message. Portfolio analysis isn't cached server-
side, so the user retries; the reviewer's verdict reason goes into
the AICall ledger as the leaked-status row's error column.
- routers/chat.chat() : on reject, instead of returning the raw
assistant content we return a short refusal explaining the limit
and inviting a rephrase. Adds ~1-2 s of latency per turn (one extra
LLM call to Haiku) — the only user-facing latency tax.
- jobs/email_digest_job._generate_variants() : on reject, the variant
is dropped for the cycle. Recipients on the rejected tone get no
digest email this run, which is better than delivering inbox copy
that drifts into advice (emails are unrecallable once sent).
In every case the AICall ledger row records the reviewer cost so
month_spend stays accurate across all paths.
The reviewer system prompt is slightly generalised to cover both the
indicator-summary case and the longer-form log/digest/chat case:
- removes "short interpretive read" framing
- softens the "any question" rule so genuine rhetorical structure in
a long-form log doesn't trigger a reject
tests/conftest.py grows an autouse fixture that stubs review_read to
clean=True in every consumer module. Tests that mock the generator
shouldn't have to also mock the safety gate behind it; tests that
specifically want the reject branch can override with their own
monkeypatch. test_output_review.py is unaffected — it imports
review_read directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Strategic Log Archive panel header used to show two engineery
badges sourced from server config:
new logs use: tone intermediate analysis speculative
Both were misleading:
- The tone badge described the SERVER's generator setting, not the
user's reading preference — confusingly disconnected from the
Novice | Pro toggle in the topbar that actually controls what AI
panels render.
- The analysis flag is always SPECULATIVE in production, so the badge
carried no information.
Drop the "new logs use:" prefix and the analysis badge. The tone badge
now mirrors the user's toggle: NOVICE → "novice", INTERMEDIATE → "pro"
(same data values; just the display label flips, matching the header
relabel from 3e1a14f).
Wiring lives in base.html: a new cassandraSyncToneBadge(tone) helper
updates the #tone-badge element when present. Called from
DOMContentLoaded (so the initial badge picks up the localStorage tone)
and from cassandraSetTone (so toggling the header updates the badge
live, without a page refresh).
current_tone / current_analysis are removed from _log_page_context —
log.html was the only consumer and neither key is referenced now.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three connected fixes after the user spotted the 2026-05-28 IT log
cutting off mid-sentence:
1. translation: bump max_tokens 4000 → 8000.
call_llm()'s default cap was 4000, which is what the English log
generator itself uses as its ceiling. Italian expands roughly 15-25 %
over English in tokens, so any near-cap English source produced an
IT translation that hit finish_reason=length and returned a
truncated body — silently, because _call_provider() only raises when
content is fully empty. The strategic_log_translations table has
dozens of rows where completion_tokens landed at exactly 4000 with
content well under half the source length. 8000 gives ample
headroom for any of the five LANGUAGES we ship (en/it/es/fr/de).
2. log.html: localise the chat sidebar strings.
user_lang was already passed into the template by pages.py, so an
inline {% if user_lang == 'it' %} keeps it simple. Covers the
"Ask Cassandra" title, the "grounded on…" hint, the helper lede,
the textarea placeholder, and the Send button label.
3. chat endpoint: append respond_in_clause(user.lang) to the system
prompt. The chat conversation can now happen in IT — the model's
first reply lands in the right language even when the user's first
turn is short.
scripts/backfill_truncated_translations.py: one-off cleanup utility.
Scans strategic_log_translations for rows whose translated content is
< 70 % of the English source (the truncation signal — IT *expands*
beyond English, so a shorter translation is always suspect), deletes
them, and re-translates via the now-uncapped service. Supports --date,
--since, --all and --dry-run. The 2026-05-28 fan-out has already been
re-translated (13/13 rows). Other historical dates still hold older
truncations; the user can decide whether to backfill those (the script
is idempotent).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pass `currency` to Stripe checkout for first-time buyers so Stripe
picks the matching `currency_options` rate configured on the Price
in the Dashboard (multi-currency Prices: one Price, per-currency
unit_amount). Operator configures the rates on existing Prices
prod_UaZ0xCpCboUGCN/price_*; this commit is the application-side
signal.
Currency precedence: explicit request body > Cloudflare cf-ipcountry
header > Accept-Language locale > GBP fallback. Only honoured when
the user has no stripe_customer_id yet — Stripe locks currency to
the customer record at first checkout, so existing customers keep
their original currency (they can switch via the portal).
Adds 4 tests: sniffed currency on new customer, body override beats
sniff, currency omitted for existing customer, and unit-tests for
the sniffing fallback chain.
Splits the 2571-line cassandra.css into ten focused stylesheets:
tokens (palette + fonts), layout (chrome), panels, dashboard,
portfolio, log-chat, auth, settings, news, public. base.html and
public_base.html load only what they need; auth pages (login,
verify, unsubscribe confirm) load tokens + layout + auth.
Brand drift-detection test repointed at tokens.css (where the
palette now lives). 291 tests still pass.
api.py was 933 lines mixing four distinct concerns: indicators +
news + strategic log (the JSON/HTMX API proper), the chat endpoint
+ its three private helpers (~200 lines), and the two HTML-only ops
endpoints /markets-bar + /health (~150 lines).
Extracted:
- app/routers/chat.py — POST /api/chat + _latest_quotes_by_group_chat,
_thesis_headlines_for_chat, _month_spend
- app/routers/ops.py — GET /api/markets-bar + GET /api/health +
_fmt_price helper
Both new routers use the same dependencies=[Depends(require_token)]
as api.py and are mounted at the /api prefix in app/main.py.
URL surface is byte-identical with no externally-visible change.
api.py shrinks to ~620 lines focused on indicators+news+log+settings.
Helpers shared with the original api.py (_md_to_html, _resolve_tone_param)
are imported from app.routers.api where needed in chat.py to avoid
duplication.
Also updated tests/test_chat_and_log_gates.py to mount chat_router
in its local test app, since /api/chat now lives there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
openrouter.py was 790 lines mixing two orthogonal concerns:
- Prompt engineering (build_system_prompt, build_summary_*,
build_chat_*, build_daily_digest_*, etc.) — ~400 lines, changes
weekly as PROMPT_VERSION bumps
- LLM transport (call_llm, _provider_chain, _call_provider, retry
+ fallback machinery) — ~250 lines, rarely changes
Extracted the prompt-engineering surface to app/services/llm_prompts.py.
Transport stays in openrouter.py (consistent with the filename — the
OpenRouter URL is the transport's anchor).
All import sites (jobs, routers, services, tests) split their
multi-import lines into two: prompt-things from llm_prompts, transport
from openrouter. PROMPT_VERSION constant, _TONE_ALIASES, _resolve_tone,
and SYSTEM_PROMPT moved with the prompt functions.
No behaviour change — pure relocation. Function signatures, body, and
naming all preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three recently-added tables (strategic_log_translations,
indicator_summary_translations, csv_format_templates) drifted from
the codebase's existing naming convention:
- llm_model -> model
- llm_cost_usd -> cost_usd
- content_md -> content (on the two translation tables; csv_format
doesn't have a content field)
Also added prompt_tokens and completion_tokens to the three tables;
they were silently dropped at write time despite LogResult exposing
them.
All writer call sites (ai_log_job, indicator_summary_job,
llm_csv_parser) and reader call sites (api.py localized helpers)
updated to match. Tests realigned.
Migration 0025 uses batch_alter_table for SQLite compatibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The HTMX log endpoints in api.py do their own localization via
_localized_content; the pages.py helper was added during the
initial localization wiring but was bypassed once HTMX rendering
landed. No call sites remain.
The /log page renders its content asynchronously by hitting
/api/log/latest?as=html and /api/log/by-date/{day}?as=html via HTMX.
Both endpoints returned StrategicLog.content (English) verbatim,
ignoring the new StrategicLogTranslation table entirely. The
_resolve_log_content helper I added to pages.py earlier was wired
into the page handlers themselves but never reached for HTMX swaps,
so Italian users only ever saw English content despite their
lang='it' preference being persisted and translations being
generated correctly.
Fix: add a _localized_content helper in api.py that looks up the
matching translation row for the requesting principal's lang.
_log_partial_payload gains a content_override arg; both HTMX
endpoints (log_latest, log_by_date) compute the override and pass
it through. JSON paths (?as= other than html) remain English to
avoid changing the public API contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- indicator_summary_job.py imported its own copies of _month_spend and
_latest_quotes_by_group; _market_context.py already exposes these.
Switched to the canonical imports. Also fixed _market_context's
latest_quotes_by_group to actually filter null prices (it claimed to
in its docstring but lacked the WHERE clause).
- api.py duplicated REFERENCE_LINE as CHAT_REFERENCE_LINE — same string,
two sources of truth. Now imports REFERENCE_LINE.
- Chat endpoint used the deprecated `call_openrouter` alias and passed
an explicit `model=` that bypassed the provider chain. Switched to
`call_llm` with default model selection, then removed the alias.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stale comments referencing completed migrations:
- universe.py "remain live until step 10 of Phase G" — endpoints gone
- api.py "Portfolio endpoints moved to universe.py" — empty block
- csv_import.py "persist_pie removed in Phase G" — historical context
Dead Settings fields (all confirmed unreferenced by app code):
- CASSANDRA_PORT — port is hardcoded in docker-compose / uvicorn cmd
- POLAR_API_KEY — Polar was replaced by Stripe
- CASSANDRA_MOCK — env var still set by tests as a sentinel; the
Settings field itself was never read
- CASSANDRA_BASE_CURRENCY — "GBP" hardcoded inline elsewhere
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds module-level _resolve_log_content(session, log_id, lang) helper
to app/routers/pages.py: looks up StrategicLogTranslation by (log_id,
lang) when lang != 'en'; falls back silently to the English original
when no translation row exists yet (the expected case for the first
hour after a new language activates, or when translation fails for a
specific log).
log_page / log_page_day pull cu.user.lang and thread it through
_log_page_context so the template renders the right variant.
Two tests cover both branches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The route's resolve-slice loop is T212-specific — it looks tickers up
against the InstrumentMap, which only has T212's universe. For the LLM
path the ticker is already Yahoo-ready (e.g. VOD.L, ASML.AS), so
sending it through resolve_slice produced spurious "could not be
resolved" warnings and dropped the positions.
Fix: ParsedPie gains a ``tickers_resolved`` flag (default False for
T212 backward-compat); _apply_mapping in the LLM path sets it True
and also extracts currency from the LLM-mapped currency_col into a
new ``ParsedPosition.currency`` field. The route branches on the flag:
LLM-path positions are kept verbatim with a best-effort InstrumentMap
lookup for nicer name/currency overrides, never dropped.
Integration test tightened to assert all 5 IBKR fixture positions
round-trip with the right currencies (USD / GBP / EUR).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "Phase D.1/D.2/D.3" comment scaffolding and the "Paddle webhook
will fill this in" references became actively misleading after D.3
landed — anyone reading the code would think referral conversion was
still pending. Also corrects a stale "Paddle" reference to "Stripe"
(we never shipped Paddle; ended up on Stripe after the Paddle → Polar
→ Stripe MoR onboarding pivot).
Pure docstring sweep, no behaviour change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.
Closing the loop:
- New `convert_referral(session, user)` in referral_service.py looks
up the user's Referral row, stamps `converted_at` + `credited_at`,
and extends `credit_until` by 45 days on BOTH the buyer and the
referrer. Idempotent — replayed webhooks and renewals are no-ops.
Stacks correctly when the user already has a credit window running
(anchors at max(now, current_credit_until) like cli.grant_credit).
- Stripe webhook wires this into `_grant_paid`. A captured
`first_paid_transition = user.tier != "paid"` gate avoids the DB
lookup on every renewal event; convert_referral's own idempotency
is the second line of defence.
- `_grant_paid` now takes `session` as its first positional arg so
the conversion runs inside the same transaction as the tier flip
and audit-row write. A mid-flight failure rolls everything back
together — no partial state.
- Settings page replaces the "— (D.3)" placeholder with the live
count of conversions still inside their 45-day credit window, plus
a "+N days on your account" hint when the user has any credit of
their own (referrer bonus, admin grant, or future refund-as-credit).
- Marketing copy on pricing.html + settings.html switches from "50%
off for 3 months" to "45 days of paid access" — same economic value,
honest about the actual mechanism (full free access rather than
discounted billing).
Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.
7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.
Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Settings page tidy-up driven by user feedback that it had grown too busy:
- Each section (Import, Invite, Email digests, Cloud sync) is now a
native <details>/<summary> accordion. Import stays open by default
because /settings#import is the deep-link target from the dashboard
CTA; the others collapse so the page lands quiet.
- Manage subscription is a right-aligned gear-icon button instead of
a rectangular text button — the descriptive copy moves into the
tooltip. Frees up the Tier row of visual weight.
Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.
The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.
Tests rewritten to cover the new welcome-email path:
- first login sends exactly one welcome email
- returning user gets none
- SMTP failure does not break the redirect
- regression guard: returning user who opted out stays opted out
Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bundles three related pieces that came out of the operator's first
end-to-end test of the paid flow:
1. Manage subscription button on /settings (paid users with a real
Stripe sub — i.e. not credit-granted access). POSTs to the existing
/api/stripe/portal endpoint; Stripe-hosted customer portal handles
card updates, cancellation, monthly↔annual switch, invoice history.
Replaces the stale "Paid features unlock with Paddle (D.3) or
invite credits" hint for free users with a live link to /pricing.
2. Per-cadence cooling-off treatment:
- **Annual £70**: 14-day free trial via
subscription_data.trial_period_days=14. No money moves during
the trial, so the CCR 2013 14-day refund question doesn't arise
(nothing paid = nothing to refund). Card is still required at
checkout so Stripe can charge on day 15.
- **Monthly £7**: bills immediately. A 14-day trial there would
give away ~50% of cycle one. Instead, /pricing now carries a
required tick-box above the Subscribe buttons (subscribe stays
disabled until checked) — by ticking, the user expressly
consents to begin performance immediately and acknowledges that
this extinguishes their statutory 14-day right under Reg 36
CCR 2013. Consent collected on our own page (not via Stripe's
account-wide consent_collection.terms_of_service) so each
product can keep its own Terms URL as we add more.
3. T&C §6 clause 1 split into 1a (annual / trial substitute) +
1b (monthly / Reg 36 waiver via on-page tick-box). Clause 2
(post-cooling-off cancellation) unchanged.
Settings page shows "Free trial — N days remaining" while the
sub is in `trialing` status, falling back to "Paid subscription
active." once it transitions to active. Countdown is computed
server-side from User.stripe_trial_end_at (new column, migration
0020) populated by the subscription.created/updated webhook from
the Stripe trial_end timestamp; cleared on the trialing→active
transition and on revoke.
Drive-by: fixed a structlog kwarg-name collision on
`log.warning(..., event=event_type, ...)` in both polar_webhook.py
and stripe_billing.py — `event` is structlog's positional event
name and "got multiple values" crashed the user-not-found log
path. Renamed to `event_type=` everywhere it appeared. Caught by
the new trialing-stores-trial-end test.
Tests
- 4 new in test_stripe_billing.py covering monthly (no trial, no
consent_collection), annual (trial, no consent), trialing stores
trial_end, trialing→active clears trial_end.
- 1 existing test renamed + reworked for the consent split.
- Full suite: 224 passed, 5 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: the per-browser pie was stored under a single global key
(`cassandra.pie`) with no per-user scope. If User A uploaded a
portfolio and User B then signed in on the same browser, User B saw
User A's holdings — portfolio.js read straight from localStorage on
hydration with no check that the data belonged to the current session.
This was not a server-side leak: the session cookie was correct, no
API returned User A's data to User B. The stale browser state was the
sole vector. Reported by the operator while testing the paid-checkout
flow with a second account on the same browser.
Fix — defense in depth, two layers:
1. base.html now stamps cu.user.id into localStorage as
`cassandra.user_id` on every authenticated page load. If the
previous stamp doesn't match the current user, wipe localStorage
(preserving only `cassandra.theme`, which is cosmetic) and
sessionStorage before any other script runs. This catches:
- the reported scenario (User A logs out, User B logs in)
- any case where logout missed the wipe (JS disabled, browser
killed before the redirect ran)
- cookie-revocation / session-rotation edge cases where the
server-side identity changes without an explicit logout
2. /logout no longer returns a bare 303; it returns a small HTML
page that actively wipes per-user localStorage + sessionStorage
client-side (theme preserved), then redirects to /login. A
meta-refresh covers the no-JS case (the cookie deletion is
still server-side, so security is preserved either way).
Behaviour for the legitimate case (same user logs out + back in)
is unchanged: their localStorage data survives because the
mismatch check sees the same user_id and doesn't fire — the
logout wipe runs but they re-stamp + re-upload only the
cassandra.user_id and a fresh pie cycle if they choose to upload.
Suite: 221 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stripe is the merchant-on-record for read.markets after Polar/Paddle
both declined the financial-media category. This commit lands the
full subscription flow: an "Upgrade" button on /pricing now opens a
real Stripe-hosted Checkout, completes the subscription, and the
webhook flips user.tier to "paid" idempotently.
Endpoints
- POST /api/stripe/checkout (require_auth) — creates a hosted
Checkout Session in subscription mode, passes user.id as
client_reference_id + email as customer_email, returns the URL
for the page-side JS to redirect to. Reuses an existing
stripe_customer_id to avoid duplicate Stripe customers on repeat
checkouts. allow_promotion_codes=True so the referral-credit
redemption can attach a coupon at checkout once that flow ships.
- POST /api/stripe/portal (require_auth) — mints a Stripe Customer
Portal session. Used by /settings; returns 404 until the user has
a stripe_customer_id (i.e. completed at least one checkout).
- POST /api/stripe/webhook — signature-verified via
stripe.Webhook.construct_event. Idempotent via UNIQUE on
stripe_events.event_id. Event dispatch:
checkout.session.completed → grant paid, store IDs
customer.subscription.created → grant paid (active/trialing)
customer.subscription.updated → grant paid (active/trialing)
customer.subscription.deleted → drop to free, clear sub id
invoice.paid / failed → audit only
charge.refunded → audit only
Stripe-SDK objects don't expose dict.get(); we use the SDK for
signature verification then re-parse the JSON body for handler
dispatch — cleaner than reaching into StripeObject internals.
Schema (migration 0019)
- users.stripe_customer_id, users.stripe_subscription_id (nullable
String(64), UNIQUE on customer_id).
- stripe_events table mirroring polar_events: event_id (unique),
event_type, received_at, processed_at, error, raw payload
(truncated to 16 KiB).
Settings (.env)
- STRIPE_API_KEY (rk_test_… for dev, rk_live_… for GA)
- STRIPE_WEBHOOK_SECRET (whsec_… from the dashboard endpoint)
- STRIPE_PRICE_MONTHLY (price_xxx for £7/month)
- STRIPE_PRICE_ANNUAL (price_xxx for £70/year)
Pricing page
- Free tier CTA unchanged.
- Paid CTA branches three ways: paid → "Manage subscription" to
/settings; logged-in free → two buttons (£7/mo, £70/yr) that POST
to /api/stripe/checkout and redirect; anonymous → /login?next=/pricing.
- Inline JS intercepts the button click, calls the checkout
endpoint, redirects on success, surfaces errors via alert(). No
Stripe.js dep — we use the hosted-checkout URL directly.
Polar handler stays in place for berengar.io / flyroom.net which
still ship through Polar. polar_* and stripe_* columns coexist
independently on the User row.
Tests
- 9 in tests/test_stripe_billing.py covering: bad signature → 401,
missing signature → 400, checkout.session.completed flips tier +
stores IDs, subscription.updated active grants paid,
subscription.deleted drops to free with customer id preserved,
replayed event id is no-op (one row in stripe_events),
unknown event acked 200, checkout endpoint mocks the SDK and
returns the hosted URL, checkout requires login.
- Full suite: 221 passed, 5 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Standalone router for inbound Polar (merchant-of-record) deliveries.
No bearer-token dep — authenticity comes from the Standard Webhooks
HMAC instead. Wired up so it's safe to deploy dark: empty
POLAR_WEBHOOK_SECRET makes the endpoint return 503 (loud) rather than
accept unsigned events.
Behaviour
- Standard Webhooks signature verification: HMAC-SHA256 over
`{webhook-id}.{webhook-timestamp}.{body}`, base64 secret prefixed
whsec_, ±5min replay window, constant-time compare against any of
the space-separated v1 tokens.
- Idempotency via UNIQUE on polar_events.event_id — a replayed
webhook-id short-circuits to 200 "duplicate" without re-running.
- Event dispatch table covers the 10 events we subscribed to:
subscription.{created,active,updated,uncanceled} -> tier=paid +
persist polar_customer_id / polar_subscription_id.
subscription.revoked -> tier=free (customer id kept so a resub
matches the same User row).
canceled / past_due / order.* / refund.created -> audit only.
- Unknown event types are acked 200 + recorded; we don't want to 4xx
on something Polar adds in the future and trigger their retry loop.
Schema (migration 0018)
- users.polar_customer_id, users.polar_subscription_id (both nullable
String(64)); UNIQUE on polar_customer_id so two users can't claim
the same Polar identity.
- polar_events table: event_id (unique), event_type, received_at,
processed_at, error, raw payload (truncated to 16 KiB).
Tests
- 7 in tests/test_polar_webhook.py: bad signature -> 401, stale
timestamp -> 401, missing headers -> 400, subscription.active flips
tier to paid + stores IDs, subscription.revoked drops to free while
keeping customer link, replayed webhook-id is no-op, unknown event
is acked.
- Full suite: 212 passed, 5 skipped.
Operator next steps before saving the webhook in Polar
1. Pull this branch to prod and apply migration 0018.
2. Save the webhook in Polar pointing at
https://read.markets/api/polar/webhook — Polar will accept the
save even though our endpoint still 503s (no secret yet).
3. Copy the secret Polar reveals into the prod .env as
POLAR_WEBHOOK_SECRET=whsec_... and restart the app.
4. Trigger a test event from Polar's dashboard to confirm 200 OK.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Marketing + behaviour pass to get the site ready for Paddle approval.
Pricing page
- £7/month, £70/year headline (was "Coming soon").
- Bigger tier names (was 11px uppercase mono — looked like chips).
- Real CTAs (button base styles were only scoped to .hero__ctas).
- "Best value" badge + drop-shadow on the Paid card; full-width
block CTAs that align across both cards.
- "Free vs Paid at a glance" comparison table beneath the cards.
- Compact "Invite a friend — both get 50% off for 3 months"
callout with the detail explanation behind a <dialog> popup.
Tier copy + behaviour now consistent
- Free strategic-log refresh is every 6 hours, not hourly. New
read-side filter on /api/log/{latest,by-date} restricts free
users to logs at boundary hours (00/06/12/18 UTC); paid users
still see the most recent.
- Follow-up chat is paid-only. /api/chat returns 402 for free;
the chat sidebar on /log is replaced with a locked aside and
chat.js no longer loads at all for free users.
- Dashboard meta lines + landing copy softened so they no longer
promise hourly to everyone.
Future-proofing copy on public pages
- Dropped "free forever" wording (we may close the free tier).
- "Trading 212 CSV" became "broker CSV (Trading 212 today; more
planned)" on pricing + landing; the actual import UIs stay
T212-specific.
Terms
- Renamed Terms of Service -> Terms and Conditions (Paddle
expectation), bumped last-updated to 2026-05-26.
- New §6 Refunds covering the 14-day cooling off, post-window
cancellation, termination-by-us refunds, statutory rights, and
how to request a refund.
- Renumbered §7-§14 and fixed the disclaimer link labels.
Tests
- 6 new tests in tests/test_chat_and_log_gates.py cover the
chat 402 + the boundary-hour filter on both log endpoints.
- Full suite: 205 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five fixes uncovered by actually running the suite in docker-compose.test.yml:
1. (real prod bug) PATCH /api/settings/digest mutated principal.user which
require_token had loaded in a now-closed session — the commit on the
handler's session persisted nothing. Re-fetch the user via the active
session before writing.
2. Portable PK type. SQLite only auto-fills `INTEGER PRIMARY KEY`; plain
BIGINT requires explicit values. Define a `_PK` alias of
`BigInteger().with_variant(Integer(), "sqlite")` and use it for all 10
autoincrement primary keys in app/models.py. No prod-schema change
(MariaDB still gets BIGINT).
3. job_lifecycle's MariaDB GET_LOCK / RELEASE_LOCK is now gated behind
`dialect.name == "mysql"`, so the test SQLite engine doesn't trip on
the missing function. Single-process test runs can't race themselves.
4. tests/test_news_window.py seeded Headline rows without `fingerprint`,
which is NOT NULL — added an `fp-{title}` value per row.
5. tests/test_email_digest_job.py now also patches `llm_configured` to
True so the job doesn't short-circuit on the missing API key.
6. (test container hygiene) Drop `COPY tests ./tests` from the test stage
in the Dockerfile — .dockerignore excludes `tests/` (correct: prod
image must not bake tests), and docker-compose.test.yml bind-mounts
./tests at run time anyway.
Suite now: 198 passed, 5 skipped, 1 pre-existing failure
(test_default_groups_present — Phase G dropped the "pie" group from
config/default.toml but the assertion wasn't updated; unrelated to this
branch).
- verify_submit now applies the subscribe checkbox only at first sign-up.
Returning users keep whatever they set via Settings or the one-click
unsubscribe link — previously, every login silently re-enrolled them.
- JOB_NAMES gains email_digest_job so the ops footer reflects its health.
Adds tests/test_verify_subscribe.py::test_returning_user_login_preserves_unsubscribe.
Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync
rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine
GCM corruption → 500), and the UI silently cleans up the dead row and
shows a soft "please re-import" notice instead of a confusing PIN
re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and
backfilled on success.
Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped
(ct, nonce instead of nonce, ct), so restore-from-cloud always failed
even when the pepper was correct.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the unauthenticated surface that's needed to invite outsiders:
- Landing (/) — dual-purpose root: dashboard for logged-in users,
landing for everyone else. New maybe_current_user soft-auth helper
in app/auth.py supports it without disturbing the per-route
require_token deps on /news, /log, /upload, /settings.
- About, Pricing, Disclaimer, Terms, Privacy — own router
(app/routers/public.py), no auth dep, shared public_base layout
(brand link, thin nav, footer with legal links + ICO ref + date).
- Editorial positioning: news aggregator with a macro brain; tagline
"Understand markets. Don't gamble on them."; anti-trading-as-gambling
stance carried through About and Landing.
Legal pass following an independent lawyer-style review:
- Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22
automated-decision line; explicit consent for sessionStorage sync
key (PECR); 30-day IP-log retention; Art. 21 objection right;
Children clause; Art. 33/34 breach-notification clause;
international-transfer mechanism (IDTA + UK Addendum). ICO
registration ZC098928 surfaced at the top.
- Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove
advice-shaped wording ("what would invalidate the posture" gone);
added italic carve-out citing FSMA / FCA COBS.
- Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34)
commentator safe-harbour; "qualifies the Terms" line; hallucination
wording fixed.
- Terms: cl.4 explicit AI-training prohibition + harassment line;
cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright
claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension +
pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights
carve-out from the liability cap; cl.11 right to close account on
material change; cl.12 non-exclusive jurisdiction + UK consumer
local courts.
Code-side enforcement of the Privacy claim:
- openrouter.py: outbound OpenRouter calls now carry
X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request
flag; the Privacy page discloses this caveat verbatim.
Apex domain prep:
- branding.APP_URL flipped to https://read.markets (was app.). DNS for
the apex already resolves; pending operator NPM step is a cert that
covers the bare apex + a 301 from app.read.markets. No hard-coded
subdomain references remain in code (verified with grep).
Nav + chrome:
- app dropdown gains Pricing / Terms / Privacy / Disclaimer links.
- login.html gains a small legal-links footer for the
highest-leverage moment to surface them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lays the billing-prep spine before Paddle lands in D.3.
D.1 — referrals
- users.referral_code: unique 8-char URL-safe code (alphabet excludes the
ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing
accounts pick one up without a backfill migration.
- users.referred_by_user_id + new referrals audit table (referrer,
referred, created_at, converted_at, credited_at). converted_at /
credited_at stay null until D.3 fills them via the Paddle webhook.
- POST /login accepts ?ref=<code>; the code rides on the signed
pending-verify cookie so it survives the GET → POST → /verify hop.
- /settings page: email, tier badge, referral code chip + invite link
with one-click copy, pending/converted/active-credits stats grid.
Settings nav link added to the top bar.
Reward shape: when the referred user makes their first paid Paddle
subscription, both they and the referrer get 50% off for 3 months.
(D.3 wires the actual credit application via the Paddle webhook.)
D.2 — paid-access gate
- users.credit_until: timestamp until which a free-tier account has
paid-tier access. Null = no credit. Populated by admin CLI now and the
D.3 webhook later.
- app.services.access exposes paid_status(user) → PaidStatus dataclass
(active / source / expires_at / days_remaining), is_paid_active() with
admin-bearer-token bypass, and a require_paid FastAPI dependency that
raises 402 Payment Required for free-tier callers.
- POST /api/analyze (portfolio AI commentary) gated behind require_paid.
- Settings page surfaces credit window when active ("free · credit · N
day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not.
- Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}.
grant-credit is idempotent — extends from max(now, current expiry) so
re-running the command never erodes an existing grant.
Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the
paid-status truth table, code generation + normalisation, CLI argument
parsing, and the pending-cookie ref roundtrip (29 new tests).
- Move news_job from hourly to 3x/hour (cron 10,30,50), with a CadencePolicy
gate that throttles to active hours (07-21 UTC weekdays at 20 min), off-hours
(3 h), weekends (6 h). Keeps the daytime feed fresh without spamming RSS
sources overnight.
- Tag each headline on ingestion via DeepSeek (BATCH_SIZE=25, max_tokens=4000,
json.JSONDecoder().raw_decode + per-row regex recovery for resilient parsing).
Vocabulary: 16 tags including new EU / USA / AI / Conflict. NULL tags are
picked up automatically on the next news_job run, so back-tagging is implicit
rather than a separate migration step.
- Tag UI: pill bar above the feed with off → include → exclude cycle on click;
shift-click jumps straight to exclude. State persists in localStorage and is
injected into /api/news requests via htmx:configRequest. Per-row chips sit to
the right of the headline (new 5-column grid: age | source | title | tags |
UTC) so vertical density stays high.
- Strategic log header bug: model was hallucinating "(Updated 21:30 UTC)" in
future tense. Bumped PROMPT_VERSION 6→7, added explicit ban on time-of-day
clauses, and supply the actual current UTC time in the user prompt so the
model has no need to invent one.
Migration 0012 adds headlines.tags (JSON, nullable). Tests cover vocabulary
integrity, validation/normalisation, and the JSON-recovery parser (17 tests).
Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.
Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.
LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.
Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>