diff --git a/src/app/api/admin/reconcile-subscriptions/route.ts b/src/app/api/admin/reconcile-subscriptions/route.ts new file mode 100644 index 0000000..47af079 --- /dev/null +++ b/src/app/api/admin/reconcile-subscriptions/route.ts @@ -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 = { + "subscription.authenticated": "authenticated", + "subscription.activated": "active", + "subscription.charged": "active", + "subscription.resumed": "active", + "subscription.pending": "pending", +}; +const REVOKE_FROM_EVENT: Record = { + "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[]) { + 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 } | 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)?.payload as Record) + ?.subscription as Record | undefined; + const ent = (entity as { entity?: Record })?.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], + }); +} diff --git a/src/app/api/webhooks/razorpay/route.ts b/src/app/api/webhooks/razorpay/route.ts index 63d0a3b..407da59 100644 --- a/src/app/api/webhooks/razorpay/route.ts +++ b/src/app/api/webhooks/razorpay/route.ts @@ -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;