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>
This commit is contained in:
Giorgio Gilestro 2026-05-23 16:15:54 +02:00
parent 89632e9937
commit f326b41a08
23 changed files with 1637 additions and 95 deletions

View file

@ -130,6 +130,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
positions: list[ParsedPosition] = []
total: ParsedPosition | None = None
pie_name: str | None = None
zero_qty_slices = 0 # real slice rows skipped for missing/zero quantity
for row_num, row in enumerate(reader, start=2):
if not row or not any(cell.strip() for cell in row):
@ -167,6 +168,8 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
qty = record.get("quantity")
if qty is None or qty == 0:
# Position row with no usable quantity — skip rather than fail.
# Counted so an all-zero (unfunded) pie yields a precise error.
zero_qty_slices += 1
continue
positions.append(ParsedPosition(
@ -182,6 +185,16 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
))
if not positions:
# Distinguish an unfunded pie (slices present, all 0 quantity)
# from a genuinely unreadable file — the two need very different
# user action, and the generic message misleads people into
# debugging the file format.
if zero_qty_slices:
raise CSVImportError(
f"This pie holds no shares — all {zero_qty_slices} "
f"slice(s) have an Owned quantity of 0. Export the pie from "
f"Trading 212 after it has been funded."
)
raise CSVImportError(
"CSV contained no parseable position rows. "
"Expected at least one row with a Slice code and quantity."