diff --git a/routes/api/payments/webhook.ts b/routes/api/payments/webhook.ts new file mode 100644 index 0000000..1fb1112 --- /dev/null +++ b/routes/api/payments/webhook.ts @@ -0,0 +1,123 @@ +import { define } from "../../../utils.ts"; +import { + createPaymentTransaction, + getTransactionByStripeId, +} from "../../../services/paymentService.ts"; + +async function hmacSHA256Hex(key: string, message: string): Promise { + const enc = new TextEncoder(); + const cryptoKey = await crypto.subtle.importKey( + "raw", + enc.encode(key), + { name: "HMAC", hash: { name: "SHA-256" } }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message)); + const bytes = new Uint8Array(sig); + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function parseStripeSignature(header: string | null) { + if (!header) return null; + const parts = header.split(",").map((p) => p.trim()); + const out: Record = {}; + for (const part of parts) { + const [k, v] = part.split("="); + if (k && v) out[k] = v; + } + if (!out["t"] || !out["v1"]) return null; + return { timestamp: out["t"], signature: out["v1"] }; +} + +export const handler = define.handlers({ + async POST(ctx) { + try { + const raw = await ctx.req.text(); + const sandbox = Deno.env.get("DISABLE_EXTERNAL_APIS") === "true" || + Deno.env.get("SANDBOX_MODE") === "true"; + + if (sandbox) { + // Accept simulated event: { type, data: { id, amount, lease_id, payment_method } } + const { type, data } = JSON.parse(raw || "{}"); + if (type === "payment_intent.succeeded" && data?.id) { + const existing = await getTransactionByStripeId(data.id); + if (!existing) { + const amountNumber = typeof data.amount === "number" + ? data.amount + : Number(data.amount); + const amount = Number.isFinite(amountNumber) ? amountNumber : 0; + await createPaymentTransaction( + data.lease_id, + amount, + Date.now(), + data.payment_method || "ach", + "completed", + data.id, + ); + } + } + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Production Stripe verification + const secret = Deno.env.get("STRIPE_WEBHOOK_SECRET"); + const sigHeader = ctx.req.headers.get("stripe-signature"); + if (!secret || !sigHeader) { + return new Response(JSON.stringify({ error: "Missing signature" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + const parsed = parseStripeSignature(sigHeader); + if (!parsed) { + return new Response(JSON.stringify({ error: "Bad signature header" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + const signedPayload = `${parsed.timestamp}.${raw}`; + const expected = await hmacSHA256Hex(secret, signedPayload); + if (expected !== parsed.signature) { + return new Response(JSON.stringify({ error: "Invalid signature" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const event = JSON.parse(raw || "{}"); + if (event?.type === "payment_intent.succeeded") { + const obj = event.data?.object ?? {}; + const stripeId = obj.id as string; + const amountCents = obj.amount_received ?? obj.amount ?? 0; + const amount = Math.round(Number(amountCents)) / 100; + const leaseId = obj.metadata?.lease_id ?? ""; + const method = obj.payment_method_types?.[0] ?? "card"; + const existing = await getTransactionByStripeId(stripeId); + if (!existing) { + await createPaymentTransaction( + leaseId, + amount, + Date.now(), + method, + "completed", + stripeId, + ); + } + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return new Response(JSON.stringify({ error: String(err) }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, +});