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:
parent
0c88058a79
commit
e989e6c558
2 changed files with 131 additions and 9 deletions
120
src/app/api/admin/reconcile-subscriptions/route.ts
Normal file
120
src/app/api/admin/reconcile-subscriptions/route.ts
Normal 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],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -83,23 +83,25 @@ export async function POST(req: Request) {
|
||||||
}
|
}
|
||||||
const eventType = event.event ?? "unknown";
|
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 {
|
try {
|
||||||
const inserted = await sql`
|
await sql`
|
||||||
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
|
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
|
||||||
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
|
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
|
||||||
ON CONFLICT (razorpay_event_id) DO NOTHING
|
ON CONFLICT (razorpay_event_id) DO NOTHING
|
||||||
RETURNING id
|
|
||||||
`;
|
`;
|
||||||
if (inserted.length === 0) {
|
|
||||||
return new NextResponse("ok (duplicate)", { status: 200 });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Razorpay webhook: log insert failed", e);
|
// Logging is not critical to entitlement — carry on and still process.
|
||||||
return new NextResponse("log error", { status: 500 }); // retry
|
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 {
|
try {
|
||||||
const sub = event.payload?.subscription?.entity;
|
const sub = event.payload?.subscription?.entity;
|
||||||
const subId = sub?.id as string | undefined;
|
const subId = sub?.id as string | undefined;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue