feat(billing): Tasks 6-7 — verify + cancel routes

Task 6 — POST /api/subscriptions/verify (UX only):
- HMAC of payment_id|subscription_id (SUBSCRIPTION order, not order_id flavour)
- uses the subscription_id WE stored for the family, not the client's
- grants NOTHING — webhook is source of truth; returns "activating shortly"
- 200 valid / 400 tampered

Task 7 — POST /api/subscriptions/cancel:
- family_id from session (IDOR-safe), cancels family's own live sub only
- RZP cancel with cancel_at_cycle_end=1 — keep premium until period end
- actual downgrade happens later via subscription.cancelled webhook

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 12:20:01 +05:30
parent 2bd45bd4fd
commit 99b5543eb9
2 changed files with 143 additions and 0 deletions

View file

@ -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.",
});
}

View file

@ -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.",
});
}