mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
10cd24e738
Swap the bank-linking + transaction-download integration from Plaid to Stripe Financial Connections (you're already on Stripe for payments). - accounting.stripe_bank_connections table (mirrors plaid_connections) - stripe-financial-connections edge function: create_session / save_account / sync / disconnect, using per-association keys from stripe_account_mappings (handles connected accounts via Stripe-Account + stripeAccount on the client) - lib/stripeBank.ts: client wrappers + the Stripe.js collectFinancialConnections Accounts flow - AccountingBankingPage: Connect/Sync/Disconnect now drive Stripe FC; removed react-plaid-link usage and the PlaidLinkButton - Integrations page wording updated - Imported transactions land uncategorised in the bank feed (credit/debit by amount sign) for matching, same as before Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
9.8 KiB
TypeScript
184 lines
9.8 KiB
TypeScript
// Stripe Financial Connections bank feed (replaces Plaid).
|
|
// Actions (mirrors the old `plaid` function contract):
|
|
// create_session { companyId } -> { client_secret, publishable_key }
|
|
// save_account { companyId, accountId, fc_account_id, fc_customer_id, institution_name, last4 }
|
|
// sync { companyId, accountId? } -> { added, skipped, errors }
|
|
// disconnect { accountId } -> { success }
|
|
// Per-association Stripe keys come from public.stripe_account_mappings, same as the
|
|
// payment functions. Imported transactions land uncategorised in accounting.transactions
|
|
// (no coa yet), so they appear in the bank feed for matching/categorisation.
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers":
|
|
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
};
|
|
const json = (body: unknown, status = 200) =>
|
|
new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
|
|
const STRIPE = "https://api.stripe.com";
|
|
|
|
// Encode nested params the way Stripe expects (a[b]=c, arr[]=x).
|
|
function encodeForm(obj: Record<string, unknown>, prefix = ""): string {
|
|
const parts: string[] = [];
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (v === undefined || v === null) continue;
|
|
const key = prefix ? `${prefix}[${k}]` : k;
|
|
if (Array.isArray(v)) {
|
|
for (const item of v) parts.push(`${encodeURIComponent(`${key}[]`)}=${encodeURIComponent(String(item))}`);
|
|
} else if (typeof v === "object") {
|
|
parts.push(encodeForm(v as Record<string, unknown>, key));
|
|
} else {
|
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
|
|
}
|
|
}
|
|
return parts.filter(Boolean).join("&");
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
|
try {
|
|
const authHeader = req.headers.get("Authorization");
|
|
if (!authHeader) return json({ error: "Missing authorization" }, 401);
|
|
|
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
|
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
|
|
const token = authHeader.replace("Bearer ", "");
|
|
const authClient = createClient(supabaseUrl, anonKey);
|
|
const { data: claims, error: authErr } = await authClient.auth.getClaims(token);
|
|
if (authErr || !claims?.claims?.sub) return json({ error: "Unauthorized" }, 401);
|
|
|
|
const svc = createClient(supabaseUrl, serviceKey);
|
|
const acct = svc.schema("accounting");
|
|
|
|
const body = await req.json().catch(() => ({}));
|
|
const action = body.action as string;
|
|
|
|
// ---- resolve the company's Stripe config (per association) ----
|
|
async function stripeConfigFor(companyId: string) {
|
|
const { data: company } = await acct.from("companies").select("id, name, association_id").eq("id", companyId).maybeSingle();
|
|
if (!company?.association_id) throw new Error("Company or association not found.");
|
|
const { data: mapping } = await svc.from("stripe_account_mappings")
|
|
.select("stripe_secret_key, stripe_public_key, stripe_account_id")
|
|
.eq("association_id", company.association_id).eq("is_active", true).maybeSingle();
|
|
if (!mapping?.stripe_secret_key) throw new Error("No active Stripe configuration for this association.");
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${mapping.stripe_secret_key}`,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
};
|
|
if (mapping.stripe_account_id?.startsWith("acct_")) headers["Stripe-Account"] = mapping.stripe_account_id;
|
|
return { company, mapping, headers };
|
|
}
|
|
async function stripeReq(path: string, method: "GET" | "POST", headers: Record<string, string>, params?: Record<string, unknown>) {
|
|
const url = method === "GET" && params ? `${STRIPE}${path}?${encodeForm(params)}` : `${STRIPE}${path}`;
|
|
const res = await fetch(url, { method, headers, body: method === "POST" && params ? encodeForm(params) : undefined });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data?.error?.message || `Stripe ${path} failed`);
|
|
return data;
|
|
}
|
|
|
|
if (action === "create_session") {
|
|
const { headers, company, mapping } = await stripeConfigFor(body.companyId);
|
|
// The HOA's own bank is linked under a Stripe customer acting as the account holder.
|
|
const customer = await stripeReq("/v1/customers", "POST", headers, {
|
|
name: company.name, metadata: { association_id: company.association_id, purpose: "bank_feed" },
|
|
});
|
|
const session = await stripeReq("/v1/financial_connections/sessions", "POST", headers, {
|
|
account_holder: { type: "customer", customer: customer.id },
|
|
permissions: ["transactions", "balances"],
|
|
filters: { countries: ["US"] },
|
|
});
|
|
const { data: mapping2 } = await svc.from("stripe_account_mappings")
|
|
.select("stripe_public_key").eq("association_id", company.association_id).eq("is_active", true).maybeSingle();
|
|
return json({
|
|
client_secret: session.client_secret, fc_customer_id: customer.id,
|
|
publishable_key: mapping2?.stripe_public_key ?? null,
|
|
stripe_account: mapping.stripe_account_id?.startsWith("acct_") ? mapping.stripe_account_id : null,
|
|
});
|
|
}
|
|
|
|
if (action === "save_account") {
|
|
const { headers } = await stripeConfigFor(body.companyId);
|
|
// Subscribe to ongoing transaction refreshes (best-effort; some accounts lack support).
|
|
try { await stripeReq(`/v1/financial_connections/accounts/${body.fc_account_id}/subscribe`, "POST", headers, { features: ["transactions"] }); } catch (_) { /* ok */ }
|
|
const { error } = await acct.from("stripe_bank_connections").upsert({
|
|
company_id: body.companyId, account_id: body.accountId, fc_account_id: body.fc_account_id,
|
|
fc_customer_id: body.fc_customer_id ?? null, institution_name: body.institution_name ?? null,
|
|
last4: body.last4 ?? null, status: "active",
|
|
}, { onConflict: "company_id,account_id" });
|
|
if (error) throw new Error(error.message);
|
|
return json({ success: true });
|
|
}
|
|
|
|
if (action === "sync") {
|
|
const { headers } = await stripeConfigFor(body.companyId);
|
|
let q = acct.from("stripe_bank_connections").select("*").eq("company_id", body.companyId).eq("status", "active");
|
|
if (body.accountId) q = q.eq("account_id", body.accountId);
|
|
const { data: conns } = await q;
|
|
let added = 0, skipped = 0; const errors: string[] = [];
|
|
for (const conn of (conns ?? [])) {
|
|
try {
|
|
// Kick a refresh so next sync has fresh data, then pull what Stripe has now.
|
|
try { await stripeReq(`/v1/financial_connections/accounts/${conn.fc_account_id}/refresh`, "POST", headers, { features: ["transactions"] }); } catch (_) { /* ok */ }
|
|
|
|
// Already-imported references for this bank account (dedupe).
|
|
const { data: existing } = await acct.from("transactions").select("reference").eq("account_id", conn.account_id).not("reference", "is", null);
|
|
const seen = new Set((existing ?? []).map((r: any) => r.reference));
|
|
|
|
let startingAfter: string | undefined; let more = true; const rows: any[] = [];
|
|
while (more) {
|
|
const page = await stripeReq("/v1/financial_connections/transactions", "GET", headers, {
|
|
account: conn.fc_account_id, limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}),
|
|
});
|
|
for (const t of page.data ?? []) {
|
|
if (t.status === "void") continue;
|
|
if (seen.has(t.id)) continue;
|
|
const cents = Number(t.amount ?? 0);
|
|
const when = t.transacted_at ?? t?.status_transitions?.posted_at ?? null;
|
|
rows.push({
|
|
company_id: body.companyId, account_id: conn.account_id,
|
|
date: when ? new Date(when * 1000).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
|
|
description: t.description ?? "Bank transaction",
|
|
amount: Math.abs(cents) / 100,
|
|
type: cents >= 0 ? "credit" : "debit",
|
|
reference: t.id, cleared: t.status === "posted",
|
|
});
|
|
}
|
|
more = !!page.has_more && (page.data?.length ?? 0) > 0;
|
|
if (more) startingAfter = page.data[page.data.length - 1].id;
|
|
}
|
|
if (rows.length) {
|
|
const { error } = await acct.from("transactions").insert(rows);
|
|
if (error) throw new Error(error.message);
|
|
added += rows.length;
|
|
}
|
|
skipped += seen.size;
|
|
await acct.from("stripe_bank_connections").update({ last_sync_at: new Date().toISOString() }).eq("id", conn.id);
|
|
} catch (e) {
|
|
errors.push(`${conn.institution_name ?? conn.fc_account_id}: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
return json({ added, skipped, errors });
|
|
}
|
|
|
|
if (action === "disconnect") {
|
|
const { data: conn } = await acct.from("stripe_bank_connections").select("*").eq("account_id", body.accountId).maybeSingle();
|
|
if (conn) {
|
|
try {
|
|
const { headers } = await stripeConfigFor(conn.company_id);
|
|
await stripeReq(`/v1/financial_connections/accounts/${conn.fc_account_id}/disconnect`, "POST", headers, {});
|
|
} catch (_) { /* ignore — still remove our link */ }
|
|
await acct.from("stripe_bank_connections").delete().eq("id", conn.id);
|
|
}
|
|
return json({ success: true });
|
|
}
|
|
|
|
return json({ error: `Unknown action: ${action}` }, 400);
|
|
} catch (e) {
|
|
return json({ error: (e as Error).message ?? "Request failed" }, 400);
|
|
}
|
|
});
|