mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user