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>
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.
Removed "Trading 212 is recognised natively and other formats (IBKR,
Fidelity, Schwab…) are auto-detected" from the import section's
lede paragraph — internal/marketing noise that doesn't help the user
once they're already on the import screen with a file picker in
front of them. Kept the surrounding sentence ("Drop a portfolio CSV
from any broker. We'll parse it…") and the T212 export-path hint
since the latter is concrete instructional content for T212 users.
The two <script src="{{ url_for(...) }}"> lines for the sync scripts
had Unicode smart-quotes (' / ') instead of ASCII apostrophes —
left over from a copy-paste at some point. Jinja's tokenizer hit the
first one and raised TemplateSyntaxError, so /settings returned a
500. Replaced with ASCII quotes and added the missing ?v=ASSET_VERSION
cache-buster the other static URLs already use.
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>
The previous commit's i18n explanatory comment included the snippet
{% if user_lang == 'it' %} as illustration — but Jinja parses the
whole template, including content inside JS // comments, so that
literal got picked up as a real (unclosed) tag and every page rendered
with a TemplateSyntaxError. Rewrite the comment without the literal
Jinja syntax.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The strategic log content already refreshes via HTMX on lang-changed
(server-side translation lookup), but the chat sidebar's static labels
— title, hint, helper lede, textarea placeholder, Send button — were
baked into the HTML by Jinja at page render and only updated after a
full reload.
Add a tiny client-side i18n dictionary (CASSANDRA_I18N) plus
applyI18n(lang) in base.html. cassandraSetLang() now calls
applyI18n(newLang) right after the language PATCH succeeds and before
firing the HTMX triggers, so labels swap in step with the AI content.
Convention: <element data-i18n="key">…</element> sets textContent;
<input data-i18n-placeholder="key" …> sets .placeholder. Initial
render still goes through the existing {% if user_lang == 'it' %}
Jinja blocks so there's no flash of English on page load for IT users
— applyI18n is a no-op until the toggle is clicked.
Only the chat sidebar has bindings today. Adding more labels later is
a matter of dropping a key into the dict and tagging the element.
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>
Reverses the polarity of 71155a6 to match the actual semantics:
- "Novice" stays labelled "Novice" → glossary tooltips, plainer prose.
- "Intermediate" is relabelled "Pro" → terse, assumes fluency, no
hand-holding. This is the mode an expert reader wants, so the "Pro"
badge actually fits.
Backend tone values (NOVICE, INTERMEDIATE) are unchanged — no API,
prompt, or stored-preference impact. Only the display strings flip.
Also drops the .tone-toggle button min-width: 10em override added in
71155a6. With "Intermediate" gone from the visible label, the longest
remaining label is "Novice" (6 chars), which fits the shared 5.5em
just like the theme and language toggles.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-visible relabel only. Backend tone value stays NOVICE — no API
contract change, no migration on stored user.digest_tone, the
glossary/plain-prose depth of analysis is unchanged. The marketing
intent is that "Pro" reads better than "Novice" on the dashboard
header; landing/pricing/privacy copy still uses the word "Novice" in
flowing prose, so leaving those alone keeps the existing explanations
coherent until they get a copy pass.
Toggle width: the popup expansion (positioned left:0/right:0) is
sized by the container, which previously sized to the active button.
When "Pro" was active the popup was too narrow to fit "Intermediate".
Bumped .tone-toggle button min-width to 10em so both buttons reserve
enough room for the longest label regardless of which one is active.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header layout was visibly broken on desktop after the mobile-drawer
change: flex space-between distributed brand, BETA, tone-toggle, nav
and header-right across the bar, so BETA drifted away from the brand
wordmark and the tone-toggle landed in the middle of the row.
Markup: brand + BETA are now wrapped in .header-left so they ride
together. The tone-toggle moves back inside .header-right next to
theme + lang where it logically belongs. CSS: the header switches to
grid (1fr auto 1fr) on desktop, which truly centres the nav regardless
of side-group widths. The mobile @media block reverts to flex so the
hamburger + slide-out drawer still work.
Toggle redesign (tone, theme, language):
- The single-button theme widget becomes a Light | Dark segmented
control matching the other two so all three read as one cluster.
cassandraToggleTheme is replaced by cassandraSetTheme(theme), the
toggle's data-theme attribute is synced on page load.
- All three share one CSS rule set: same padding, font, border, and
a min-width so the active-only width matches the expanded width
(no layout jump on hover).
- On hover-capable devices each toggle collapses to just the active
option; hovering (or keyboard focus-within) reveals both. Touch
devices keep both visible — the @media (hover: hover) gate handles
that and the mobile-drawer block overrides it explicitly so the
drawer-stacked controls remain full-width with both options shown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reported phone still showing old behaviour (Qty/Avg portfolio
columns visible) even though the server-side JS had been updated.
Root cause: every <link>/<script> URL was a plain
/static/css/foo.css with no query string, so mobile Chrome served
the file from its HTTP cache rather than refetching it.
Adds a process-startup timestamp to the Jinja environment as
ASSET_VERSION (computed once when templates_env is imported). Every
<link>/<script> reference now appends `?v={{ ASSET_VERSION }}` so a
container restart bumps the URL and the browser refetches. 38 URLs
across 8 templates updated via sed; tests still pass.
Side benefit: future CSS/JS edits no longer require users to hard-
refresh.
Three pieces of phone-side feedback:
1. Indicator group tabs wrap onto multiple rows instead of
horizontal-scrolling — every group is visible at a glance. Each
button keeps its own bottom border so wrapped rows stay
visually delimited; the container's bottom border is removed.
2. Portfolio holdings table hides Qty and Avg columns on mobile via
the mobile-hide class (same mechanism as the indicator table).
Remaining columns are the actionable ones: Ticker, Name, Last,
P/L, %.
3. Markets bar at the bottom compacts to one row per chip —
dot + code + change% only. The state word ("open" / "closed")
is implied by the dot colour; the index label, price, and
until-time are dropped on mobile. Grid columns drop their 220px
floor so the full set fits the viewport without horizontal
scroll (previously the bar scrolled within itself).
Adds the @media (max-width: 480px) blocks specified in the design:
- dashboard.css: indicator table hides the 'mobile-hide'-tagged
columns (Label, Ccy, 1y, anchor, as-of), keeping Symbol / Price /
1d / 1m. Cell padding + font shrink. Group-tab buttons get a
bigger touch target.
- panels.css: header padding tightens, scroll-body max-height drops
to 60vh so log/news stay above the fold in the stacked layout.
- portfolio.css: overall grid keeps 2 cols (already at 640px) with
tighter gap; action buttons wrap; composer input goes full-width.
- log-chat.css: chat bubbles edge-to-edge, input row stacks, font-
size:14px on form fields to avoid iOS Safari zoom-on-focus.
- news.css: row collapses to age | (title / source) — source moves
under the title. Tag filter strip wraps.
- settings.css: form rows stack (label above input). Import picker
becomes single-column. Buttons full-width.
- auth.css: card padding tightens to free up vertical space when the
iOS keyboard is up. font-size:14px on inputs.
- public.css: hero headline clamp() lower bound drops to 22px; CTAs
stack full-width; pricing tier-grid stacks.
indicators.html: tagged the secondary cells with .mobile-hide rather
than relying on positional nth-child — the anchor column is
conditional and would have shifted positions.
336 tests still pass.
≤480px gets a hamburger button in the topbar and a fixed slide-out
panel from the right edge (width min(82vw, 320px)). The topbar keeps
only brand + tone toggle + hamburger visible; nav and the
header-right widgets (theme, lang, user menu, version meta) move
into the drawer.
Markup change: nav and .header-right are now wrapped in
.mobile-drawer, which is display:contents on desktop (no layout
effect) and a fixed translateX panel on mobile. The user-menu
dropdown chip hides on mobile and its links surface flat inside the
drawer.
JS: ~50 lines of vanilla. Tap hamburger / backdrop / ESC / swipe-
right-on-drawer all close. Clicking a nav link inside the drawer
closes it after the navigation kicks off so the panel doesn't linger
on the next page.
CSS: per-file @media block at the bottom of layout.css per the
agreed-upon organisation.
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.
The two largest inline <script> blocks in settings.html — the cloud
sync modal/management UI (~145 lines) and the import widget wiring
(~245 lines) — moved to app/static/js/settings-sync.js and
settings-import.js respectively, included via <script src="..."
defer> at the bottom of the template.
Where the inline code referenced Jinja vars or {% if %} guards,
those values are now passed via data-* attributes on the relevant
DOM elements (or via window.cassandra* config objects for structured
data) and read in the static JS.
Smaller blocks (Stripe portal, digest prefs, language select,
invite copy) stay inline — each <40 lines and easier to follow
next to their markup. settings.html drops from 758 lines to roughly
half that.
The theme toggle's onclick attribute held a 140-character inline
IIFE that was hard to read amongst the other named-function
handlers in the same header. Promoted it to cassandraToggleTheme()
alongside cassandraSetTone / cassandraSetLang.
- chat.js: pending indicator class was wrong (.pending instead of
chat-msg--pending) so the … waiting message never got italic/dim
- settings.html + cassandra.css: three invented CSS vars (--panel-bg,
--ok, --surface-1) had hardcoded fallbacks that broke dark mode;
replaced with real tokens (--surface, --positive)
- cassandra.css: .pf-secondary was scoped to .pf-actions but used
standalone in 4 places (sync modal, disable-sync, import cancel,
forget-pie button) — hoisted to a top-level selector
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues addressed:
1. The /settings language <select> was unstyled — .settings-select and
.settings-status classes didn't exist, so the dropdown rendered
with full native browser chrome and clashed visually with the rest
of the panel. Added a terminal-aesthetic select: transparent
background, 1px var(--border), custom chevron via crossed
linear-gradients, accent border on focus/hover. Disabled options
(ES/FR/DE 'coming soon') render in --dim.
2. Added a compact EN | IT pill in the topbar next to the theme
toggle, mirroring the .tone-toggle visual rhythm. Shown only when
a user is signed in (admins skipped). Optimistic UI: clicking
flips the pill immediately, PATCHes /api/settings/language, and
reverts on failure. On /log specifically the page reloads so the
user sees the localized version of the strategic log right away.
The /settings dropdown still surfaces all five languages (with ES/FR/DE
disabled) for visibility; the topbar pill keeps to the two active
languages to stay compact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Delete app/templates/upload.html. The /upload route redirected to
/settings#import (302) and never rendered this template; the file
was carrying stale Trading-212-only copy.
- Landing + pricing pages: replace "Trading 212 today, more brokers
planned" with "Trading 212 natively, other formats auto-detected"
to reflect the LLM-fallback parser that's been live for a few days.
The /upload redirect route in app/routers/pages.py stays — it remains
a useful bookmark-forwarder for users with old links.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A small italic muted line beneath the form explaining the controls:
"Type a symbol, then quantity and cost — or use the calendar to fill
cost from a buy date — then [+] to add. [×] next to an existing row
removes it."
Only renders while the composer itself is visible (i.e. in edit mode),
so it doesn't clutter the dashboard at rest.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related polishes:
- The add form was auto-shown by the empty-state path so brand-new
users would see something to act on. That conflicts with the user's
preference for "Edit always toggles the form, no other path." The
empty state now shows guidance copy ("click edit to add one")
instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
+ glyph (26×26). Matches the visual weight of the calendar ghost
next to it but stays in the accent colour so it reads as primary.
Adds a tiny active-state scale tick for tactile feedback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subtitle was technical noise that didn't earn its space in the header.
Title alone reads cleaner. Kept the scoped panel-header layout override
in cassandra.css since it's harmless and future-proof against re-adding
header children.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous CSS used invented variable names (--neu-dim, --err, --ok)
that don't exist in the project's design system; the form fell back to
hardcoded hex values and looked disconnected from the rest of the site.
Rebuilt against the real tokens (--border, --dim, --muted, --positive,
--negative, --warning, --accent) and the mono-first 'geopolitical-
terminal aesthetic' the rest of the dashboard uses:
$ ticker ✓ 172.40 USD │ qty @ cost USD 📅 add
────
- No boxed-form chrome. A dashed bottom rule separates the composer
from the table below.
- Inputs lose their card-style boxes; they're underline-only with a
faint accent wash on focus — feels like editing a command line.
- '$' prompt marker, '│' divider, '@' between qty and cost give the
row a terminal grammar without being twee.
- Submit is a ghost pill in the accent colour; lights to solid only
when enabled.
- All controls now respond correctly to the light/dark theme toggle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the multi-row wizard-style form (Ticker / Qty on row 1, mode
radios on row 2, Date+Cost on row 3) with a single horizontal strip
that sits unobtrusively above the portfolio table. The radio toggle is
gone; a small calendar icon next to the Cost input pops out a date
picker that auto-fills cost on selection and then hides itself.
Same input IDs, so the existing validate/Add/× handlers work unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- portfolio.js empty-state CTA: "Import a T212 CSV" → "Import a portfolio CSV"
- settings.html lede: lead with broker-agnostic copy; relegate the T212
export path to a smaller secondary line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Section heading drops "Trading 212"; drop-zone label and hint mention
the auto-detect path; the help-paragraph opens conditionally with
"If you use Trading 212" so non-T212 users don't feel like outsiders.
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>
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>
Audit against the live feature set surfaced one missing entry and a few
soft phrasings:
- Free now lists "Ask follow-up questions on any past log" — the /api/chat
endpoint has no paid gate (router-level require_token only), so it's
available to every signed-in user. The landing-page screenshot already
showed it; the pricing page didn't mention it.
- "Per-group cross-asset summaries" → "Cross-asset indicator panels
(equities, rates, FX, …) with a one-paragraph AI read on each tab" —
more concrete about what the user actually sees.
- "Novice / Intermediate reading levels" → spelled out what each does
(Novice defines jargon; Intermediate is terse).
- Free's exclusion list explicitly includes "Daily email digest" so the
paid/free distinction reads cleanly without back-and-forth.
- Paid's daily-digest bullet now leads with the word count target
(~600 words) so the value is concrete, not abstract.
- Encrypted cloud sync bullet now names the actual security model
(PIN-derived in-browser + server-side outer wrap).
Added a small "Invite a friend" footnote — the credit ledger and invite
link both ship today; the rate kicks in with the payments rollout. Honest
phrasing keeps it from looking like vaporware.
Intro paragraph rewritten to lead with what's free (most of the editorial)
rather than what paid extends, since the free tier is the entry point.
Five PNGs at app/static/images/ (renamed from screenshot dumps):
- dashboard.png — full dashboard hero shot, sits below the hero CTAs
- news-feed.png — feature-card thumbnail: auto-tagged news feed
- indicators-read.png — feature-card thumbnail: per-group AI commentary
- strategic-log.png — feature-card thumbnail: hourly strategic log text
- chat-with-log.png — "More views" gallery: ask follow-ups against a log
Every screenshot is a <button class="shot"> with data-full + data-caption;
click opens an HTML5 <dialog>-based lightbox. <dialog> handles focus trap,
ESC-to-close, inert background; the backdrop click closes too. Images use
loading="lazy" so the lightbox-only ones don't block first paint.
CSS appended to cassandra.css: .shot, .shot-hero, .shots-grid, .shot__caption,
and .shot-modal (+ ::backdrop). All colours pull from the existing palette
vars so light and dark themes stay coherent.
Total image weight: ~950 KB across all five — acceptable for a marketing
landing page with lazy-loaded thumbnails.
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>