feat(billing): Tasks 4-5 — create-subscription + webhook routes
Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client
Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)
middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).
Verified locally: HMAC valid/tampered, unix->date, event routing maps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6a1aaa38a2
commit
2bd45bd4fd
3 changed files with 276 additions and 0 deletions
108
src/app/api/subscriptions/create/route.ts
Normal file
108
src/app/api/subscriptions/create/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
165
src/app/api/webhooks/razorpay/route.ts
Normal file
165
src/app/api/webhooks/razorpay/route.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
"subscription.authenticated": "authenticated",
|
||||||
|
"subscription.activated": "active",
|
||||||
|
"subscription.charged": "active",
|
||||||
|
"subscription.resumed": "active",
|
||||||
|
"subscription.pending": "pending", // grace — keep entitled
|
||||||
|
};
|
||||||
|
const REVOKE_EVENTS: Record<string, string> = {
|
||||||
|
"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<string, unknown> } };
|
||||||
|
};
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,9 @@ const protectedApiRoutes = [
|
||||||
"/api/chat",
|
"/api/chat",
|
||||||
"/api/history",
|
"/api/history",
|
||||||
"/api/family/members",
|
"/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) {
|
export function middleware(request: NextRequest) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue