Commit graph

12 commits

Author SHA1 Message Date
eedd32b885 log: remove dead _resolve_log_content helper from pages.py
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.
2026-05-27 20:39:28 +02:00
1ea71bc160 log: serve translated content when available; English fallback
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>
2026-05-27 17:13:57 +02:00
1be0c5a436 docs: drop Phase D.x markers now that the referral loop is closed
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>
2026-05-26 23:09:39 +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
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
2297f9b2ed pricing: land £7/£70 paid tier and make behaviour match
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>
2026-05-26 11:34:37 +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
f1903e1e61 public: landing + pricing + legal pages, apex-ready, lawyer-reviewed
Adds the unauthenticated surface that's needed to invite outsiders:

  - Landing (/) — dual-purpose root: dashboard for logged-in users,
    landing for everyone else. New maybe_current_user soft-auth helper
    in app/auth.py supports it without disturbing the per-route
    require_token deps on /news, /log, /upload, /settings.
  - About, Pricing, Disclaimer, Terms, Privacy — own router
    (app/routers/public.py), no auth dep, shared public_base layout
    (brand link, thin nav, footer with legal links + ICO ref + date).
  - Editorial positioning: news aggregator with a macro brain; tagline
    "Understand markets. Don't gamble on them."; anti-trading-as-gambling
    stance carried through About and Landing.

Legal pass following an independent lawyer-style review:

  - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22
    automated-decision line; explicit consent for sessionStorage sync
    key (PECR); 30-day IP-log retention; Art. 21 objection right;
    Children clause; Art. 33/34 breach-notification clause;
    international-transfer mechanism (IDTA + UK Addendum). ICO
    registration ZC098928 surfaced at the top.
  - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove
    advice-shaped wording ("what would invalidate the posture" gone);
    added italic carve-out citing FSMA / FCA COBS.
  - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34)
    commentator safe-harbour; "qualifies the Terms" line; hallucination
    wording fixed.
  - Terms: cl.4 explicit AI-training prohibition + harassment line;
    cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright
    claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension +
    pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights
    carve-out from the liability cap; cl.11 right to close account on
    material change; cl.12 non-exclusive jurisdiction + UK consumer
    local courts.

Code-side enforcement of the Privacy claim:

  - openrouter.py: outbound OpenRouter calls now carry
    X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request
    flag; the Privacy page discloses this caveat verbatim.

Apex domain prep:

  - branding.APP_URL flipped to https://read.markets (was app.). DNS for
    the apex already resolves; pending operator NPM step is a cert that
    covers the bare apex + a 301 from app.read.markets. No hard-coded
    subdomain references remain in code (verified with grep).

Nav + chrome:

  - app dropdown gains Pricing / Terms / Privacy / Disclaimer links.
  - login.html gains a small legal-links footer for the
    highest-leverage moment to surface them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:08:02 +02:00
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
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
8a155ef157 phase B (2/2): CSV upload endpoint + drag-drop UI
Completes Phase B. The full alternative-onboarding flow is now end-to-end:
drop a T212 pie CSV → parser → InstrumentMap resolver → PortfolioSnapshot
+ Position rows, all without ever asking the user for broker credentials.

- persist_pie() in app/services/csv_import.py: takes a ParsedPie, resolves
  each Slice via InstrumentMap, writes Portfolio + Snapshot + Position
  rows. Unmapped slices are still persisted using their CSV values and
  surfaced in the response for the UI to warn about.
- POST /api/portfolios/upload: multipart endpoint accepting CSV file +
  optional portfolio_name + currency. 2 MiB cap. Returns import summary.
- /upload page with drag-drop dropzone, file input fallback, and inline
  result panel showing invested/value/result + unmapped-slice warnings.
- New "Import" link in the header nav.

Verified end-to-end against the real T212 export: all 13 positions land
with correct T212 tickers (incl. FPp_EQ for the Paris TotalEnergies
listing the heuristic resolver picks), zero unmapped slices, totals
reconcile to the penny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:00:42 +01:00
a10409c02b initial commit — cassandra v0.1
Containerised macro-strategy dashboard: 4-panel web UI (indicators,
portfolio, flash news, AI strategic log), MariaDB store, hourly
ingestion jobs, OpenRouter-backed AI analysis.

Ports the four prototype scripts in the parent dir (market_pulse,
flash_news, trading212, strategic_log) into async services backed by a
persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs
as a separate compose service for crash-safety and easier restarts.

Portfolio composition + position names come live from Trading 212;
news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/
PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and
stored on each log row so historical entries show what produced them.

Default model is deepseek/deepseek-v4-flash (overridable via env).
Light/dark theme toggle, sans-serif for prose surfaces, monospace for
data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto-
disabled on consecutive failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:56:10 +01:00