import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, content-type, stripe-signature", }; const encoder = new TextEncoder(); function timingSafeEqual(a: Uint8Array, b: Uint8Array) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } function hexToBytes(hex: string) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return bytes; } async function verifyStripeSignature(rawBody: string, signatureHeader: string, secret: string) { const parts = signatureHeader.split(",").reduce>((acc, part) => { const [key, value] = part.split("="); if (!key || !value) return acc; acc[key] = [...(acc[key] || []), value]; return acc; }, {}); const timestamp = parts.t?.[0]; const signatures = parts.v1 || []; if (!timestamp || signatures.length === 0) return false; const payload = `${timestamp}.${rawBody}`; const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const expected = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload))); return signatures.some((sig) => timingSafeEqual(expected, hexToBytes(sig))); } function stripeHeaders(secretKey: string, accountId?: string | null) { return { Authorization: `Bearer ${secretKey}`, ...(accountId?.startsWith("acct_") ? { "Stripe-Account": accountId } : {}), }; } serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, ); const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET"); const sig = req.headers.get("stripe-signature"); const rawBody = await req.text(); if (webhookSecret && sig) { const valid = await verifyStripeSignature(rawBody, sig, webhookSecret); if (!valid) { console.error("signature verification failed"); return new Response("signature error", { status: 400, headers: corsHeaders }); } } else { console.warn("STRIPE_WEBHOOK_SECRET not set — accepting unverified event"); } let event: any; try { event = JSON.parse(rawBody); } catch { return new Response("invalid body", { status: 400, headers: corsHeaders }); } try { const type = event.type as string; const obj = event.data?.object || {}; const piId: string | undefined = obj.object === "payment_intent" ? obj.id : obj.payment_intent || obj.charge?.payment_intent; if (!piId) { console.log("webhook: no payment_intent on event", type); return new Response(JSON.stringify({ ok: true, ignored: "no PI" }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } let { data: payment } = await supabase .from("stripe_payments") .select("*") .eq("stripe_payment_intent_id", piId) .maybeSingle(); const eventAccount = event.account || obj.account || null; const metadata = obj.metadata || {}; let mappingQuery = supabase .from("stripe_account_mappings") .select("stripe_secret_key, stripe_account_id, association_id") .eq("is_active", true); if (payment?.association_id) { mappingQuery = mappingQuery.eq("association_id", payment.association_id); } else if (metadata.association_id) { mappingQuery = mappingQuery.eq("association_id", metadata.association_id); } else if (eventAccount) { mappingQuery = mappingQuery.eq("stripe_account_id", eventAccount); } else { mappingQuery = mappingQuery.limit(1); } const { data: mapping } = await mappingQuery.maybeSingle(); if (!mapping?.stripe_secret_key) { console.error("webhook: no stripe key for", { piId, eventAccount, associationId: payment?.association_id || metadata.association_id }); return new Response("no stripe key", { status: 400, headers: corsHeaders }); } const stripeFetch = async (path: string) => { const r = await fetch(`https://api.stripe.com/v1${path}`, { headers: stripeHeaders(mapping.stripe_secret_key, mapping.stripe_account_id), }); if (!r.ok) console.error("Stripe fetch failed", path, await r.text()); return r.ok ? await r.json() : null; }; const pi = await stripeFetch(`/payment_intents/${piId}`); const piMetadata = pi?.metadata || metadata; if (!payment && pi && (piMetadata.association_id || mapping.association_id)) { const amountCents = Number(piMetadata.net_amount_cents || pi.amount || 0); const feeCents = Number(piMetadata.fee_cents || Math.max(Number(pi.amount || 0) - amountCents, 0)); const { data: insertedPayment, error: paymentInsertError } = await supabase .from("stripe_payments") .insert({ association_id: piMetadata.association_id || mapping.association_id, owner_id: piMetadata.owner_id || null, unit_id: piMetadata.unit_id || null, stripe_payment_intent_id: piId, amount_cents: amountCents, fee_cents: feeCents, total_cents: Number(pi.amount || amountCents + feeCents), payment_method_type: pi.payment_method_types?.includes("us_bank_account") ? "us_bank_account" : "card", status: pi.status || "pending", description: pi.description || obj.description || "HOA Assessment Payment", }) .select("*") .single(); if (paymentInsertError) throw paymentInsertError; payment = insertedPayment; } if (!payment) { console.log("webhook: no stripe_payments row for", piId); return new Response(JSON.stringify({ ok: true, ignored: "unknown PI" }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const today = new Date().toISOString().split("T")[0]; const postPaymentToLedger = async (newStatus: string) => { await supabase.from("stripe_payments") .update({ status: newStatus, updated_at: new Date().toISOString() }) .eq("id", payment.id); const { data: existing } = await supabase .from("owner_ledger_entries") .select("id") .eq("reference_type", "stripe_payment") .eq("reference_id", piId) .maybeSingle(); if (existing) return { skipped: true }; if (!payment.owner_id) return { skipped: "no owner" }; const netAmount = Number(payment.amount_cents) / 100; const methodLabel = payment.payment_method_type === "us_bank_account" ? "ACH" : "Card"; const { error } = await supabase.from("owner_ledger_entries").insert({ owner_id: payment.owner_id, association_id: payment.association_id, unit_id: payment.unit_id, date: today, transaction_type: "payment", description: `Online Payment (${methodLabel}) — ${payment.description || "Assessment"}`, debit: 0, credit: netAmount, reference_type: "stripe_payment", reference_id: piId, }); if (error) throw error; return { recorded: true }; }; const reverseLedger = async (refId: string, amount: number, description: string, newStatus: string) => { const { data: existing } = await supabase .from("owner_ledger_entries") .select("id") .eq("reference_type", "stripe_payment_reversal") .eq("reference_id", refId) .maybeSingle(); if (existing) return { skipped: true }; if (!payment.owner_id) return { skipped: "no owner" }; const { error } = await supabase.from("owner_ledger_entries").insert({ owner_id: payment.owner_id, association_id: payment.association_id, unit_id: payment.unit_id, date: today, transaction_type: "adjustment", description, debit: amount, credit: 0, reference_type: "stripe_payment_reversal", reference_id: refId, }); if (error) throw error; await supabase.from("stripe_payments") .update({ status: newStatus, updated_at: new Date().toISOString() }) .eq("id", payment.id); return { recorded: true }; }; if (type === "payment_intent.succeeded" || type === "payment_intent.processing") { if (!pi) return new Response("PI fetch failed", { status: 502, headers: corsHeaders }); if (pi.status === "succeeded" || pi.status === "processing") await postPaymentToLedger(pi.status); } else if (type === "charge.refunded") { const charge = await stripeFetch(`/charges/${obj.id}?expand[]=refunds`); if (!charge) return new Response("charge fetch failed", { status: 502, headers: corsHeaders }); for (const r of charge.refunds?.data || []) { if (r.status !== "succeeded") continue; await reverseLedger( `${piId}:refund:${r.id}`, Number(r.amount) / 100, `Refund — Stripe payment (${r.id})`, charge.amount_refunded >= charge.amount ? "refunded" : "partially_refunded", ); } } else if (type === "payment_intent.payment_failed" || type === "charge.failed") { if (!pi) return new Response("PI fetch failed", { status: 502, headers: corsHeaders }); const { data: priorCredit } = await supabase .from("owner_ledger_entries") .select("id, credit") .eq("reference_type", "stripe_payment") .eq("reference_id", piId) .maybeSingle(); if (priorCredit) { await reverseLedger( `${piId}:failed`, Number(priorCredit.credit) || (Number(payment.amount_cents) / 100), `Returned/Failed Payment — ${pi.last_payment_error?.message || obj.failure_message || "Payment failed / returned"}`, "failed", ); } else { await supabase.from("stripe_payments") .update({ status: "failed", updated_at: new Date().toISOString() }) .eq("id", payment.id); } } else if (type === "charge.dispute.created") { await reverseLedger( `${piId}:dispute:${obj.id}`, Number(obj.amount || 0) / 100, `Chargeback dispute opened — ${obj.reason || "unspecified"}`, "disputed", ); } return new Response(JSON.stringify({ ok: true, type }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (err) { console.error("stripe-webhook error:", err); return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } });