Bank feeds: replace Plaid with Stripe Financial Connections

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>
This commit is contained in:
2026-06-15 00:27:05 -04:00
parent 266a99d4b2
commit 10cd24e738
5 changed files with 312 additions and 79 deletions
+28 -74
View File
@@ -19,8 +19,7 @@ import { money, fmtDate } from "./lib/format";
import { PeriodPicker, periodRange, type PeriodPreset } from "./components/PeriodPicker";
import { generateCheckPDF } from "./lib/checkPdf";
import { parseCsv, pick, parseDateStr } from "./lib/csv";
import { usePlaidLink } from "react-plaid-link";
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
import { linkBankAccount, syncStripeTransactions, disconnectStripe } from "./lib/stripeBank";
import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill";
type TxForm = {
@@ -72,37 +71,44 @@ export default function AccountingBankingPage() {
const cur = "USD";
const qc = useQueryClient();
const [plaidLinkToken, setPlaidLinkToken] = useState<string | null>(null);
const [plaidTargetAcct, setPlaidTargetAcct] = useState<string>("");
const [linkingAcctId, setLinkingAcctId] = useState<string | null>(null);
const [syncingAcctId, setSyncingAcctId] = useState<string | null>(null);
const { data: plaidConnections = [] } = useQuery({
queryKey: ["plaid-connections", cid],
const { data: bankConnections = [] } = useQuery({
queryKey: ["stripe-bank-connections", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("plaid_connections").select("*").eq("company_id", cid)).data ?? [],
(await accounting.from("stripe_bank_connections").select("*").eq("company_id", cid)).data ?? [],
});
const plaidByAcct = new Map((plaidConnections as any[]).map((c) => [c.account_id, c]));
const connByAcct = new Map((bankConnections as any[]).map((c) => [c.account_id, c]));
const openPlaidLink = async (accountId: string) => {
const openStripeLink = async (accountId: string) => {
if (!user?.id) return toast.error("Must be logged in");
setPlaidTargetAcct(accountId);
setLinkingAcctId(accountId);
try {
const { link_token } = await createLinkToken(user.id, cid);
setPlaidLinkToken(link_token);
const { linked, institution } = await linkBankAccount(cid, accountId);
if (linked > 0) {
toast.success(`Connected to ${institution ?? "bank"} — click Sync now to import transactions`);
qc.invalidateQueries({ queryKey: ["stripe-bank-connections", cid] });
} else {
toast.message("No account was linked.");
}
} catch (e: any) {
toast.error(e?.message ?? "Failed to start Plaid Link");
toast.error(e?.message ?? "Failed to connect bank");
} finally {
setLinkingAcctId(null);
}
};
const syncAccount = async (accountId: string) => {
setSyncingAcctId(accountId);
try {
const result = await syncPlaidTransactions(cid, accountId);
toast.success(`Synced: +${result.added} new, ${result.modified} updated, ${result.removed} removed`);
const result = await syncStripeTransactions(cid, accountId);
toast.success(result.added ? `Imported ${result.added} new transaction${result.added === 1 ? "" : "s"}` : "No new transactions");
if (result.errors?.length) toast.error(result.errors[0]);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
qc.invalidateQueries({ queryKey: ["stripe-bank-connections", cid] });
} catch (e: any) {
toast.error(e?.message ?? "Sync failed");
} finally {
@@ -113,9 +119,9 @@ export default function AccountingBankingPage() {
const disconnectAccount = async (accountId: string) => {
if (!confirm("Disconnect this bank feed? Existing transactions are kept.")) return;
try {
await disconnectPlaid(accountId);
await disconnectStripe(accountId);
toast.success("Bank feed disconnected");
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
qc.invalidateQueries({ queryKey: ["stripe-bank-connections", cid] });
} catch (e: any) {
toast.error(e?.message ?? "Disconnect failed");
}
@@ -691,8 +697,9 @@ export default function AccountingBankingPage() {
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{bankAccounts.map((acc: any) => {
const conn = plaidByAcct.get(acc.id);
const conn = connByAcct.get(acc.id);
const isSyncing = syncingAcctId === acc.id;
const isLinking = linkingAcctId === acc.id;
return (
<Card
key={acc.id}
@@ -728,8 +735,8 @@ export default function AccountingBankingPage() {
) : (
<div onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="outline" className="h-7 text-xs w-full"
onClick={() => openPlaidLink(acc.id)}>
<Link2 className="h-3 w-3 mr-1" /> Connect bank feed
onClick={() => openStripeLink(acc.id)} disabled={isLinking}>
<Link2 className="h-3 w-3 mr-1" /> {isLinking ? "Connecting…" : "Connect bank feed"}
</Button>
</div>
)}
@@ -1157,60 +1164,7 @@ export default function AccountingBankingPage() {
</DialogContent>
</Dialog>
{/* Plaid Link — mounts only when a link token is ready */}
{plaidLinkToken && (
<PlaidLinkButton
linkToken={plaidLinkToken}
accountId={plaidTargetAcct}
companyId={cid}
onDone={() => {
setPlaidLinkToken(null);
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
}}
/>
)}
</div>
);
}
function PlaidLinkButton({
linkToken, accountId, companyId, onDone,
}: {
linkToken: string;
accountId: string;
companyId: string;
onDone: () => void;
}) {
const { open, ready } = usePlaidLink({
token: linkToken,
onSuccess: async (publicToken, metadata) => {
try {
const account = metadata.accounts?.[0];
await exchangePlaidToken({
publicToken,
companyId,
accountId,
plaidAccountId: account?.id ?? "",
institutionName: metadata.institution?.name ?? undefined,
institutionId: metadata.institution?.institution_id ?? undefined,
mask: account?.mask ?? undefined,
});
toast.success(`Connected to ${metadata.institution?.name ?? "bank"} — click Sync now to import transactions`);
} catch (e: any) {
toast.error(e?.message ?? "Connection failed");
}
onDone();
},
onExit: (err) => {
if (err) toast.error(`Plaid: ${err.display_message ?? err.error_message ?? "Closed"}`);
onDone();
},
});
useEffect(() => {
if (ready) open();
}, [ready, open]);
return null;
}
@@ -2,14 +2,13 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Landmark, RefreshCw, Sparkles } from "lucide-react";
/**
* Integrations hub. The bank-feed (Plaid), ledger-sync and ACMACC-sync panels
* are powered by Supabase Edge Functions on ACMACC. This page is a stub until
* those functions are deployed; the credentials are configured in .env /
* Supabase secrets (PLAID_*, ANTHROPIC_API_KEY).
* Integrations hub. The bank-feed (Stripe Financial Connections), ledger-sync
* and ACMACC-sync panels are powered by Supabase Edge Functions on ACMACC.
* Stripe keys are configured per-association in stripe_account_mappings.
*/
export default function AccountingIntegrationsPage() {
const items = [
{ icon: Landmark, title: "Bank Feeds (Plaid)", desc: "Connect bank & credit-card accounts to auto-import transactions for reconciliation." },
{ icon: Landmark, title: "Bank Feeds (Stripe)", desc: "Connect bank & credit-card accounts via Stripe Financial Connections to auto-import transactions for reconciliation." },
{ icon: RefreshCw, title: "Ledger Sync", desc: "Sync the general ledger with your external accounting source." },
{ icon: Sparkles, title: "AI Bill Parsing", desc: "Auto-extract vendor, amount and line items from uploaded bills." },
];
+58
View File
@@ -0,0 +1,58 @@
import { supabase } from "@/integrations/supabase/client";
import { loadStripe } from "@stripe/stripe-js";
/**
* Client wrappers around the `stripe-financial-connections` Edge Function.
* Replaces the old Plaid bank-feed integration with Stripe Financial Connections.
* The function holds no secrets itself — it reads the association's Stripe keys
* from public.stripe_account_mappings (same as the payment functions).
*/
async function invoke<T>(body: Record<string, unknown>): Promise<T> {
const { data, error } = await supabase.functions.invoke("stripe-financial-connections", { body });
if (error) {
const ctx: any = (error as any).context;
let msg = error.message;
try { const b = ctx && (await ctx.json?.()); if (b?.error) msg = b.error; } catch { /* ignore */ }
throw new Error(msg || "Stripe request failed.");
}
if ((data as any)?.error) throw new Error((data as any).error);
return data as T;
}
export function createFcSession(companyId: string) {
return invoke<{ client_secret: string; publishable_key: string | null; fc_customer_id: string; stripe_account: string | null }>({ action: "create_session", companyId });
}
export function saveFcAccount(input: {
companyId: string; accountId: string; fc_account_id: string; fc_customer_id?: string;
institution_name?: string; last4?: string;
}) {
return invoke<{ success: boolean }>({ action: "save_account", ...input });
}
export function syncStripeTransactions(companyId: string, accountId?: string) {
return invoke<{ added: number; skipped: number; errors: string[] }>({ action: "sync", companyId, accountId });
}
export function disconnectStripe(accountId: string) {
return invoke<{ success: boolean }>({ action: "disconnect", accountId });
}
/**
* Runs the full Stripe Financial Connections linking flow for one ledger bank
* account: create a session server-side, open Stripe's account picker, then
* persist the chosen account so it can be synced.
*/
export async function linkBankAccount(companyId: string, accountId: string): Promise<{ linked: number; institution?: string }> {
const { client_secret, publishable_key, fc_customer_id, stripe_account } = await createFcSession(companyId);
if (!publishable_key) throw new Error("Stripe publishable key isn't configured for this association.");
const stripe = await loadStripe(publishable_key, stripe_account ? { stripeAccount: stripe_account } : undefined);
if (!stripe) throw new Error("Failed to load Stripe.js.");
const result = await (stripe as any).collectFinancialConnectionsAccounts({ clientSecret: client_secret });
if (result.error) throw new Error(result.error.message);
const accounts = result.financialConnectionsSession?.accounts ?? [];
if (!accounts.length) return { linked: 0 };
const a = accounts[0];
await saveFcAccount({
companyId, accountId, fc_account_id: a.id, fc_customer_id,
institution_name: a.institution_name, last4: a.last4,
});
return { linked: accounts.length, institution: a.institution_name };
}
@@ -0,0 +1,183 @@
// 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);
}
});
@@ -0,0 +1,39 @@
-- Stripe Financial Connections bank links (replaces Plaid).
-- One row per accounting bank account linked through Stripe Financial Connections.
create table if not exists accounting.stripe_bank_connections (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references accounting.companies(id) on delete cascade,
account_id uuid not null references accounting.accounts(id) on delete cascade,
fc_account_id text not null, -- Stripe Financial Connections account id (fca_...)
fc_customer_id text, -- Stripe customer used as the account holder (cus_...)
institution_name text,
last4 text,
status text not null default 'active',
last_sync_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (company_id, account_id)
);
create index if not exists idx_stripe_bank_conn_company on accounting.stripe_bank_connections(company_id);
alter table accounting.stripe_bank_connections enable row level security;
drop policy if exists "Accounting staff full access" on accounting.stripe_bank_connections;
create policy "Accounting staff full access" on accounting.stripe_bank_connections
for all using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff());
drop policy if exists "Members CRUD stripe_bank_connections" on accounting.stripe_bank_connections;
create policy "Members CRUD stripe_bank_connections" on accounting.stripe_bank_connections
for all using (accounting.is_company_member(company_id, auth.uid()))
with check (accounting.is_company_member(company_id, auth.uid()));
drop policy if exists "Board view stripe_bank_connections" on accounting.stripe_bank_connections;
create policy "Board view stripe_bank_connections" on accounting.stripe_bank_connections
for select using (accounting.is_company_board_member(company_id));
drop trigger if exists trg_stripe_bank_conn_updated on accounting.stripe_bank_connections;
create trigger trg_stripe_bank_conn_updated
before update on accounting.stripe_bank_connections
for each row execute function public.update_updated_at_column();
grant select, insert, update, delete on accounting.stripe_bank_connections to authenticated;
grant all on accounting.stripe_bank_connections to service_role;