diff --git a/src/app/api/subscriptions/cancel/route.ts b/src/app/api/subscriptions/cancel/route.ts new file mode 100644 index 0000000..d251fb2 --- /dev/null +++ b/src/app/api/subscriptions/cancel/route.ts @@ -0,0 +1,67 @@ +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/cancel + * + * Initiates cancellation at Razorpay with cancel_at_cycle_end=1 — the family + * keeps premium until the paid period ends. The actual state change happens + * later via the subscription.cancelled webhook; this route only initiates. + * + * family_id from session (IDOR-safe). Cancels the family's own live sub only. + */ +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!; + + let cfg; + try { + cfg = getRazorpayConfig(); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } + + // Find the family's live subscription. + const rows = await sql` + SELECT razorpay_subscription_id, status FROM family_subscriptions + WHERE family_id = ${familyId} + AND status IN ('created','authenticated','active','pending') + ORDER BY created_at DESC + LIMIT 1 + `; + const subId = rows[0]?.razorpay_subscription_id as string | undefined; + if (!subId) { + return NextResponse.json({ error: "No active subscription to cancel" }, { status: 404 }); + } + + try { + const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions/${subId}/cancel`, { + method: "POST", + headers: { + Authorization: razorpayAuthHeader(cfg), + "Content-Type": "application/json", + }, + body: JSON.stringify({ cancel_at_cycle_end: 1 }), // keep premium until period end + }); + const data = await res.json(); + if (!res.ok) { + console.error("Razorpay cancel failed:", data); + return NextResponse.json( + { error: data?.error?.description || "Failed to cancel subscription" }, + { status: 502 }, + ); + } + } catch (e) { + console.error("Razorpay cancel error:", e); + return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 }); + } + + return NextResponse.json({ + success: true, + message: "Subscription will cancel at the end of your current billing period. You keep premium until then.", + }); +} diff --git a/src/app/api/subscriptions/verify/route.ts b/src/app/api/subscriptions/verify/route.ts new file mode 100644 index 0000000..66c265b --- /dev/null +++ b/src/app/api/subscriptions/verify/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import crypto from "crypto"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; +import { getRazorpayConfig } from "@/lib/billing/config"; + +/** + * POST /api/subscriptions/verify — UX feedback ONLY. + * + * Called by the Checkout success handler so we can show "you're in!". It verifies + * the checkout signature but GRANTS NOTHING — entitlement is applied solely by + * the webhook (/api/webhooks/razorpay). This route just confirms the handshake + * looks authentic so the UI can show an optimistic "activating shortly" screen. + * + * Subscription signature order is payment_id|subscription_id (NOT the order_id + * flavour). We HMAC against the subscription_id WE stored, not whatever the + * client sends, so a client can't verify against an arbitrary subscription. + */ +export async function POST(req: Request) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + + let body: { razorpay_payment_id?: string; razorpay_signature?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const paymentId = body.razorpay_payment_id; + const signature = body.razorpay_signature; + if (!paymentId || !signature) { + return NextResponse.json({ error: "Missing payment id or signature" }, { status: 400 }); + } + + let cfg; + try { + cfg = getRazorpayConfig(); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } + + // Use the subscription_id we recorded for this family — never the client's. + const rows = await sql` + SELECT razorpay_subscription_id FROM family_subscriptions + WHERE family_id = ${familyId} + AND status IN ('created','authenticated','active','pending') + ORDER BY created_at DESC + LIMIT 1 + `; + const subId = rows[0]?.razorpay_subscription_id as string | undefined; + if (!subId) { + return NextResponse.json({ error: "No pending subscription found" }, { status: 404 }); + } + + // SUBSCRIPTION order: payment_id|subscription_id + const expected = crypto + .createHmac("sha256", cfg.keySecret) + .update(`${paymentId}|${subId}`) + .digest("hex"); + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + const valid = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf); + + if (!valid) { + return NextResponse.json({ error: "Signature verification failed" }, { status: 400 }); + } + + // Looks authentic. Do NOT grant here — the webhook is the source of truth. + return NextResponse.json({ + success: true, + message: "Payment received — your premium is activating shortly.", + }); +}