Files
acmcc/supabase/functions/stripe-webhook/index.ts
T
2026-06-01 20:19:26 -04:00

296 lines
11 KiB
TypeScript

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<Record<string, string[]>>((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" },
});
}
});