Commit graph

14 commits

Author SHA1 Message Date
daa3f79a52 mobile: cache-bust static assets so browser picks up CSS/JS edits
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.
2026-05-28 19:20:49 +02:00
f4d9c9f2ec settings: extract sync + import widget JS to static files
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.
2026-05-27 20:55:49 +02:00
e807e58629 ui: fix chat pending class, invented CSS vars, .pf-secondary scope
- 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>
2026-05-27 19:19:42 +02:00
50ac6b9366 settings: add language dropdown (IT active, ES/FR/DE WIP) 2026-05-27 17:17:18 +02:00
b8ebba9503 ui: drop remaining T212-only framing from dashboard + import lede
- 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>
2026-05-27 12:41:05 +02:00
b352601228 settings: soften import copy to be broker-agnostic
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>
2026-05-27 12:32:35 +02:00
ce36ce36fd referrals: close D.3 — both parties get 45 days credit on conversion
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>
2026-05-26 23:05:29 +02:00
00211fec02 ui: collapsible settings sections + welcome-email + larger auth inputs
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>
2026-05-26 22:32:59 +02:00
a07fd144ea stripe: per-cadence cooling-off + manage-subscription button
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>
2026-05-26 20:06:19 +02:00
14fe47103f settings: digest opt-in + tone (PATCH /api/settings/digest + UI)
Adds DigestPrefsIn/Out models, PATCH /api/settings/digest endpoint, email
digest section in settings.html, and last_email_send context in pages.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:23:03 +02:00
5c7cc4c6aa sync: detect orphaned blobs (pepper rotation) + fix AESGCM arg order
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>
2026-05-25 12:49:11 +02:00
f326b41a08 sync: encrypted cloud backup for portfolios + settings UX rework
Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:15:54 +02:00
824d849c63 brand: rename product to "Read the Markets" (read.markets)
The product is now "Read the Markets" served at https://read.markets,
with the app at https://app.read.markets. "Cassandra" survives only as
the in-product AI persona (system prompt + "Ask Cassandra" chat label).

Centralised the brand in app/branding.py: BRAND_NAME, BRAND_SHORT,
DOMAIN, SITE_URL, APP_URL, EMAIL_FROM_DEFAULT. Jinja templates pull
{{ BRAND_NAME }} via globals registered in templates_env.py; Python
code reads branding.BRAND_NAME directly. The future-rename surface
is now a one-liner.

Updated: FastAPI app title, every page title (dashboard, news, log,
settings, upload, login, verify), header brand div, auth-card brands,
OTP email subject + HTML + plain-text bodies (incl. uppercase header
tag), OpenRouter X-Title + HTTP-Referer attribution headers, README.
Email tests now assert against branding.BRAND_NAME rather than the
literal string.

Internal identifiers deliberately kept on the legacy "cassandra" name
to avoid invalidating live sessions / advisory locks / configs:
cookies (cassandra_session, cassandra_pending) + itsdangerous salts,
MariaDB GET_LOCK keys, CASSANDRA_TOKEN env var, cassandra.css filename,
pyproject package name, localStorage prefs, outbound User-Agent strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:38 +01:00
9759080134 phase D milestones 1+2: referral system + paid-access gate
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).
2026-05-21 23:25:35 +01:00