Commit graph

4 commits

Author SHA1 Message Date
756f5d6cfb feat(admin/billing): grant-logic tier change, churn alerts, webhook health
#1 Admin tier change uses real grant logic:
- families PATCH calls grantPremium()/revokeToFree() on tier change instead of
  hardcoded maxMembers:10. Manual/comp upgrades now match real grant (50GB/6/3).
  subscription_status records 'admin_comp'/'admin_downgrade'. Explicit
  maxChildren/maxMembers overrides still honored. Client sends tier only.

#2 Failed-payment / churn Telegram alerts (webhook):
- subscription.pending  -> warn "Payment failing (grace)" — reach out pre-churn
- subscription.halted   -> error "Subscription HALTED (churn)"
- subscription.cancelled-> warn; activated -> "New subscriber"; charged -> silent
  All include family name + sub id.

#3 Webhook freshness in admin health:
- New "Razorpay Webhooks" check: last event age (Xm/Xh/Xd ago). warn if >35d
  silence while subs exist (renewals should keep it fresh). Also added a
  "Razorpay" config-presence check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:28:23 +05:30
a69546977c fix(billing): webhook crashed binding Date to postgres.js — use ISO strings
THE actual root cause of "charged but still free". reconcile surfaced it:
  TypeError: The "string" argument must be of type string... Received Date

postgres.js in this repo binds timestamp params via a custom string serializer
— passing a raw JS Date object throws. The webhook built currentStart/
currentEnd/endedAt/cancelledAt as Date objects and bound them into the
UPDATE family_subscriptions query, so EVERY charged/activated/authenticated
event crashed in processing → 500 → (with the now-fixed idempotency) retries
also failed → entitlement never applied.

Fix: all timestamp params are now ISO strings (.toISOString()):
- webhook: unixToDate -> unixToISO; revoke branch ended_at/cancelled_at as ISO
- reconcile endpoint: same toISO conversion

Combined with the previous idempotency fix, live charges now grant correctly
and a transient failure can be retried successfully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:00:29 +05:30
e989e6c558 fix(billing): webhook idempotency trap + add reconcile recovery endpoint
ROOT CAUSE of "charged but still free": the webhook logged each event BEFORE
processing, then early-returned 200 on duplicate. So when processing threw
after the log insert, every Razorpay retry hit the duplicate guard and skipped
processing forever — entitlement never applied. Diagnostic confirmed: 6 events
logged with correct sub_ids + status=active, but family_subscriptions still
'created' and no paid families.

Fix:
- Webhook no longer early-returns on duplicate. Log is best-effort (never blocks
  or fails the request); processing always runs. All processing ops are
  idempotent (status UPDATE + grantPremium/revokeToFree upserts) so reprocessing
  a redelivered event is safe. Now a transient error → 500 → retry actually
  reprocesses and lands the grant.

- NEW POST /api/admin/reconcile-subscriptions: admin recovery. For each
  subscription, replays its latest logged webhook event, reapplies grant/revoke
  with per-sub error capture, returns resulting paid families. Recovers the two
  families already stuck in 'created' despite successful charges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:55:33 +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