#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>
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>
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>
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>