mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
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" },
|
|
});
|
|
}
|
|
});
|