// 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, 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, 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 = { 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, params?: Record) { 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); } });