Commit graph

2 commits

Author SHA1 Message Date
78ce8c8b0d alembic: make migration chain SQLite-compatible (fresh upgrade)
Five existing migrations used op.alter_column / op.create_unique_constraint /
op.drop_constraint / op.create_foreign_key directly on the users + quotes +
quotes_daily tables. SQLite has no native support for those operations and
requires Alembic's batch_alter_table copy-and-rename workaround.

This wasn't noticed until now because the test suite uses
Base.metadata.create_all to materialise schema, not the migration chain
itself; and prod is MariaDB. But running `alembic upgrade head` against
a fresh SQLite database (developer onboarding, CI smoke tests, the
test container's own bootstrap) would fail at 0005.

Fixes:
- alembic/env.py: set render_as_batch=True when the dialect is SQLite.
  This auto-wraps any future autogenerated migration but doesn't
  retroactively rewrite existing op.* calls.
- 0005 (widen quotes.symbol), 0013 (referrals), 0018 (polar webhook),
  0019 (stripe), 0023 (users.lang index + qd_symbol widen) explicitly
  wrap their problematic ops in `with op.batch_alter_table(...) as bop`.

Now `alembic upgrade head` + `alembic downgrade base` round-trip cleanly
on a fresh SQLite database. MariaDB prod behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:16:09 +02:00
6c13f855e9 polar: build /api/polar/webhook handler
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>
2026-05-26 17:42:41 +02:00