Commit graph

2 commits

Author SHA1 Message Date
082956adea fix(billing): abandoned-checkout lockout + stuck "Opening checkout…"
Issue 1 — "An active subscription already exists" lockout:
Clicking Upgrade creates a 'created' row + Razorpay sub BEFORE payment. If the
user closes checkout without paying, that row persisted forever and the partial
unique index blocked all future upgrade attempts (Razorpay also refuses to
cancel a 'created' sub — "no billing cycle"). Real users hit this on every
abandoned checkout.
Fix: create route now inspects existing non-terminal sub:
  - active/authenticated/pending -> block (genuinely subscribed)
  - created -> REUSE it (return same sub_id so checkout reopens) instead of lock
  - halted -> retire to 'expired' so a fresh sub can be created

Issue 2 — iOS PWA stuck on "Opening checkout…":
- loadCheckout() could hang forever if a script tag existed but its load event
  already fired (listeners never run). Rewrote with a polling fallback +10s
  timeout so it always resolves.
- Button stayed loading until dismiss; now clears loading right after rzp.open()
  so it never sticks in an iOS standalone PWA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:24:09 +05:30
2bd45bd4fd feat(billing): Tasks 4-5 — create-subscription + webhook routes
Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client

Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
    authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
    halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)

middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).

Verified locally: HMAC valid/tampered, unix->date, event routing maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:00 +05:30