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:
parent
2bd45bd4fd
commit
99b5543eb9
2 changed files with 143 additions and 0 deletions
67
src/app/api/subscriptions/cancel/route.ts
Normal file
67
src/app/api/subscriptions/cancel/route.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
76
src/app/api/subscriptions/verify/route.ts
Normal file
76
src/app/api/subscriptions/verify/route.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue