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