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>
This commit is contained in:
Manohar Gupta 2026-06-06 14:55:33 +05:30
parent 0c88058a79
commit e989e6c558
2 changed files with 131 additions and 9 deletions

View file

@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import { grantPremium, revokeToFree, ENTITLED_STATUSES, TERMINAL_STATUSES } from "@/lib/billing/entitlements";
/**
* POST /api/admin/reconcile-subscriptions admin recovery + diagnostic.
*
* For each family_subscription, looks at the most recent webhook event we
* logged for its Razorpay subscription id, derives the intended status, and
* (re)applies the grant/revoke + row update catching and RETURNING any error.
*
* Why this exists: the webhook logs an event before processing it, so a
* processing error left the event logged-but-unprocessed and the idempotency
* guard blocked retries. This endpoint reprocesses those stuck events safely
* (all grant/revoke ops are idempotent) and surfaces the real error per sub.
*/
const GRANT_FROM_EVENT: Record<string, string> = {
"subscription.authenticated": "authenticated",
"subscription.activated": "active",
"subscription.charged": "active",
"subscription.resumed": "active",
"subscription.pending": "pending",
};
const REVOKE_FROM_EVENT: Record<string, string> = {
"subscription.halted": "halted",
"subscription.cancelled": "cancelled",
"subscription.completed": "completed",
"subscription.expired": "expired",
"subscription.paused": "paused",
};
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const subs = await sql`
SELECT id, family_id, razorpay_subscription_id, status FROM family_subscriptions
`;
const results: unknown[] = [];
for (const s of subs as Record<string, unknown>[]) {
const subId = s.razorpay_subscription_id as string;
const rowId = s.id as string;
const familyId = s.family_id as string;
// Most recent webhook event we have for this subscription.
const ev = await sql`
SELECT event_type, payload FROM razorpay_webhook_events
WHERE payload->'payload'->'subscription'->'entity'->>'id' = ${subId}
ORDER BY received_at DESC
LIMIT 1
`;
const event = ev[0] as { event_type: string; payload: Record<string, unknown> } | undefined;
if (!event) {
results.push({ subId, action: "skipped", reason: "no webhook event logged yet" });
continue;
}
const eventType = event.event_type;
const grant = GRANT_FROM_EVENT[eventType];
const revoke = REVOKE_FROM_EVENT[eventType];
try {
// Pull current_start/current_end from the stored payload.
const entity = ((event.payload as Record<string, unknown>)?.payload as Record<string, unknown>)
?.subscription as Record<string, unknown> | undefined;
const ent = (entity as { entity?: Record<string, unknown> })?.entity;
const toDate = (v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null;
};
const currentStart = toDate(ent?.current_start);
const currentEnd = toDate(ent?.current_end);
const customerId = (ent?.customer_id as string) ?? null;
if (grant) {
await sql`
UPDATE family_subscriptions SET
status = ${grant}::subscription_status_enum,
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
current_start = COALESCE(${currentStart}, current_start),
current_end = COALESCE(${currentEnd}, current_end),
updated_at = NOW()
WHERE id = ${rowId}
`;
await grantPremium(familyId, grant);
results.push({ subId, familyId, eventType, applied: "grant", status: grant, ok: true });
} else if (revoke) {
await sql`
UPDATE family_subscriptions SET
status = ${revoke}::subscription_status_enum,
updated_at = NOW()
WHERE id = ${rowId}
`;
await revokeToFree(familyId, revoke);
results.push({ subId, familyId, eventType, applied: "revoke", status: revoke, ok: true });
} else {
results.push({ subId, eventType, action: "skipped", reason: "unhandled event type" });
}
} catch (e) {
results.push({ subId, familyId, eventType, ok: false, error: String(e) });
}
}
// Show resulting family tiers so we can confirm the grant landed.
const families = await sql`
SELECT id, tier, subscription_status, max_members, max_children
FROM families WHERE tier != 'free' OR subscription_status IS NOT NULL
`;
return NextResponse.json({
success: true,
reconciled: results,
paidFamilies: families,
entitledStatuses: [...ENTITLED_STATUSES],
terminalStatuses: [...TERMINAL_STATUSES],
});
}

View file

@ -83,23 +83,25 @@ export async function POST(req: Request) {
}
const eventType = event.event ?? "unknown";
// 2. Idempotency — unique insert on event id. If it already exists, bail 200.
// 2. Audit log (best-effort, never blocks processing). We intentionally do
// NOT early-return on duplicate here: processing is idempotent (status
// UPDATE + grant/revoke are all upserts), so re-running a redelivered event
// is safe. Early-returning on duplicate BEFORE processing was a trap — a
// processing error left the event logged-but-unapplied and every retry hit
// the duplicate guard and skipped processing forever.
try {
const inserted = await sql`
await sql`
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
ON CONFLICT (razorpay_event_id) DO NOTHING
RETURNING id
`;
if (inserted.length === 0) {
return new NextResponse("ok (duplicate)", { status: 200 });
}
} catch (e) {
console.error("Razorpay webhook: log insert failed", e);
return new NextResponse("log error", { status: 500 }); // retry
// Logging is not critical to entitlement — carry on and still process.
console.error("Razorpay webhook: log insert failed (continuing)", e);
}
// 3 + 4. Process the event. Errors here → 500 so Razorpay retries.
// 3 + 4. Process the event. Errors here → 500 so Razorpay retries (and the
// retry WILL reprocess, because we no longer short-circuit on duplicate).
try {
const sub = event.payload?.subscription?.entity;
const subId = sub?.id as string | undefined;