diff --git a/src/app/api/subscriptions/create/route.ts b/src/app/api/subscriptions/create/route.ts new file mode 100644 index 0000000..35f4fe9 --- /dev/null +++ b/src/app/api/subscriptions/create/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; +import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config"; + +/** + * POST /api/subscriptions/create + * + * Creates a Razorpay subscription for the caller's family and returns the + * subscription_id + key_id for Razorpay Checkout. Grants NOTHING — entitlement + * is applied only by the webhook (subscription.charged/activated). + * + * Security: + * - family_id comes from the session (requireFamily), never from the request + * body — so a user can only create a sub for their own family (IDOR-safe). + * - key_secret never leaves the server; only key_id is returned. + */ +export async function POST() { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const userId = auth.session!.userId; + + let cfg; + try { + cfg = getRazorpayConfig(); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } + + // Resolve the plan row (must be seeded — see /api/admin/seed-plan). + const planRows = await sql` + SELECT id, razorpay_plan_id FROM subscription_plans + WHERE razorpay_plan_id = ${cfg.planId} AND is_active = true + LIMIT 1 + `; + if (!planRows[0]) { + return NextResponse.json( + { error: "Plan not seeded. Run POST /api/admin/seed-plan first." }, + { status: 500 }, + ); + } + const planRowId = planRows[0].id as string; + + // Reject if a live (non-terminal) subscription already exists for this family. + // The partial unique index also enforces this at the DB level. + const live = await sql` + SELECT id, razorpay_subscription_id, status FROM family_subscriptions + WHERE family_id = ${familyId} + AND status IN ('created','authenticated','active','pending','halted') + LIMIT 1 + `; + if (live[0]) { + return NextResponse.json( + { error: "An active subscription already exists for this family.", status: live[0].status }, + { status: 409 }, + ); + } + + // Create the subscription at Razorpay. + let rzpSub: { id?: string; status?: string; error?: { description?: string } }; + try { + const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions`, { + method: "POST", + headers: { + Authorization: razorpayAuthHeader(cfg), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + plan_id: cfg.planId, + total_count: 120, // ~10 yrs of monthly cycles; avoids auto-complete + customer_notify: 1, + quantity: 1, + notes: { family_id: familyId, user_id: userId }, // correlation backup for webhook + }), + }); + rzpSub = await res.json(); + if (!res.ok || !rzpSub.id) { + console.error("Razorpay create sub failed:", rzpSub); + return NextResponse.json( + { error: rzpSub?.error?.description || "Failed to create subscription" }, + { status: 502 }, + ); + } + } catch (e) { + console.error("Razorpay create sub error:", e); + return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 }); + } + + // Record our side as 'created'. The webhook drives all later state. + try { + await sql` + INSERT INTO family_subscriptions + (family_id, plan_id, razorpay_subscription_id, status) + VALUES (${familyId}, ${planRowId}, ${rzpSub.id}, 'created') + `; + } catch (e) { + // Unique index race (double-click) — treat as the existing-sub case. + console.error("Insert family_subscriptions failed:", e); + return NextResponse.json( + { error: "Subscription already in progress for this family." }, + { status: 409 }, + ); + } + + return NextResponse.json({ subscriptionId: rzpSub.id, keyId: cfg.keyId }); +} diff --git a/src/app/api/webhooks/razorpay/route.ts b/src/app/api/webhooks/razorpay/route.ts new file mode 100644 index 0000000..63d0a3b --- /dev/null +++ b/src/app/api/webhooks/razorpay/route.ts @@ -0,0 +1,165 @@ +import { NextResponse } from "next/server"; +import crypto from "crypto"; +import { sql } from "@/db"; +import { getRazorpayConfig } from "@/lib/billing/config"; +import { grantPremium, revokeToFree } from "@/lib/billing/entitlements"; + +/** + * POST /api/webhooks/razorpay — THE source of truth for entitlement. + * + * Razorpay calls this on every subscription lifecycle change. We: + * 1. Verify the HMAC signature (timing-safe) over the RAW body. + * 2. Idempotency: unique-insert on x-razorpay-event-id. Duplicate → 200 & bail. + * 3. Update family_subscriptions by razorpay_subscription_id. + * 4. Sync entitlement onto families.tier (grant/revoke) so the existing + * quota.ts guards enforce the new limits. + * + * Status codes (Razorpay retries on non-2xx): + * 400 — bad signature (do NOT retry) + * 200 — processed OK, or duplicate (already processed) + * 500 — processing error (Razorpay WILL retry — that's what we want) + */ + +// Razorpay event → how it affects entitlement. +const GRANT_EVENTS: Record = { + "subscription.authenticated": "authenticated", + "subscription.activated": "active", + "subscription.charged": "active", + "subscription.resumed": "active", + "subscription.pending": "pending", // grace — keep entitled +}; +const REVOKE_EVENTS: Record = { + "subscription.halted": "halted", + "subscription.cancelled": "cancelled", + "subscription.completed": "completed", + "subscription.expired": "expired", + "subscription.paused": "paused", +}; + +function unixToDate(v: unknown): Date | null { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null; +} + +export async function POST(req: Request) { + // RAW body — never req.json() here; the signature is over the exact bytes. + const rawBody = await req.text(); + const signature = req.headers.get("x-razorpay-signature") ?? ""; + const eventId = req.headers.get("x-razorpay-event-id") ?? ""; + + let cfg; + try { + cfg = getRazorpayConfig(); + } catch (e) { + console.error("Razorpay webhook: not configured", e); + return new NextResponse("not configured", { status: 500 }); + } + + // 1. Verify signature (timing-safe). + const expected = crypto + .createHmac("sha256", cfg.webhookSecret) + .update(rawBody) + .digest("hex"); + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + const validSig = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf); + if (!validSig) { + return new NextResponse("bad signature", { status: 400 }); + } + + if (!eventId) { + // No event id to dedupe on — refuse so Razorpay retries with headers. + return new NextResponse("missing event id", { status: 400 }); + } + + let event: { + event?: string; + payload?: { subscription?: { entity?: Record } }; + }; + try { + event = JSON.parse(rawBody); + } catch { + return new NextResponse("bad json", { status: 400 }); + } + const eventType = event.event ?? "unknown"; + + // 2. Idempotency — unique insert on event id. If it already exists, bail 200. + try { + const inserted = 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 + } + + // 3 + 4. Process the event. Errors here → 500 so Razorpay retries. + try { + const sub = event.payload?.subscription?.entity; + const subId = sub?.id as string | undefined; + + // Events without a subscription entity (e.g. payment.* if ever subscribed) + // are logged above; nothing to sync. + if (!subId) return new NextResponse("ok (no subscription entity)", { status: 200 }); + + // Find our row for this Razorpay subscription. + const rows = await sql` + SELECT id, family_id FROM family_subscriptions + WHERE razorpay_subscription_id = ${subId} + LIMIT 1 + `; + const row = rows[0] as { id: string; family_id: string } | undefined; + if (!row) { + // Unknown subscription (e.g. created outside this app). Logged; ack so + // Razorpay stops retrying — there's nothing for us to update. + console.warn("Razorpay webhook: no family_subscriptions row for", subId); + return new NextResponse("ok (unknown subscription)", { status: 200 }); + } + + const currentStart = unixToDate(sub?.current_start); + const currentEnd = unixToDate(sub?.current_end); + const customerId = (sub?.customer_id as string) ?? null; + + const grantStatus = GRANT_EVENTS[eventType]; + const revokeStatus = REVOKE_EVENTS[eventType]; + + if (grantStatus) { + await sql` + UPDATE family_subscriptions SET + status = ${grantStatus}::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 = ${row.id} + `; + // paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active. + await grantPremium(row.family_id, grantStatus); + } else if (revokeStatus) { + const endedAt = revokeStatus === "paused" ? null : new Date(); + await sql` + UPDATE family_subscriptions SET + status = ${revokeStatus}::subscription_status_enum, + cancelled_at = ${revokeStatus === "cancelled" ? new Date() : null}, + ended_at = COALESCE(${endedAt}, ended_at), + updated_at = NOW() + WHERE id = ${row.id} + `; + await revokeToFree(row.family_id, revokeStatus); + } else { + // Unhandled event type — already logged, ack it. + return new NextResponse("ok (unhandled event)", { status: 200 }); + } + + return new NextResponse("ok", { status: 200 }); + } catch (e) { + console.error("Razorpay webhook: processing error", e); + return new NextResponse("processing error", { status: 500 }); // retry + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 78227c3..8e8cb1c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -52,6 +52,9 @@ const protectedApiRoutes = [ "/api/chat", "/api/history", "/api/family/members", + "/api/subscriptions", + // NOTE: /api/webhooks/razorpay is intentionally NOT here — Razorpay calls it + // with no session cookie; it authenticates via HMAC signature instead. ]; export function middleware(request: NextRequest) {