diff --git a/src/pages/accounting/AccountingBankingPage.tsx b/src/pages/accounting/AccountingBankingPage.tsx index 7e3985a..c937d82 100644 --- a/src/pages/accounting/AccountingBankingPage.tsx +++ b/src/pages/accounting/AccountingBankingPage.tsx @@ -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(null); - const [plaidTargetAcct, setPlaidTargetAcct] = useState(""); + const [linkingAcctId, setLinkingAcctId] = useState(null); const [syncingAcctId, setSyncingAcctId] = useState(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() {
{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 ( e.stopPropagation()}>
)} @@ -1157,60 +1164,7 @@ export default function AccountingBankingPage() { - {/* Plaid Link — mounts only when a link token is ready */} - {plaidLinkToken && ( - { - setPlaidLinkToken(null); - qc.invalidateQueries({ queryKey: ["plaid-connections", cid] }); - }} - /> - )} - ); } -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; -} diff --git a/src/pages/accounting/AccountingIntegrationsPage.tsx b/src/pages/accounting/AccountingIntegrationsPage.tsx index fa6e453..f30de79 100644 --- a/src/pages/accounting/AccountingIntegrationsPage.tsx +++ b/src/pages/accounting/AccountingIntegrationsPage.tsx @@ -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." }, ]; diff --git a/src/pages/accounting/lib/stripeBank.ts b/src/pages/accounting/lib/stripeBank.ts new file mode 100644 index 0000000..2a7b7ca --- /dev/null +++ b/src/pages/accounting/lib/stripeBank.ts @@ -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(body: Record): Promise { + 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 }; +} diff --git a/supabase/functions/stripe-financial-connections/index.ts b/supabase/functions/stripe-financial-connections/index.ts new file mode 100644 index 0000000..acf1e86 --- /dev/null +++ b/supabase/functions/stripe-financial-connections/index.ts @@ -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, 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); + } +}); diff --git a/supabase/migrations/20260615120000_stripe_bank_connections.sql b/supabase/migrations/20260615120000_stripe_bank_connections.sql new file mode 100644 index 0000000..ff15ef8 --- /dev/null +++ b/supabase/migrations/20260615120000_stripe_bank_connections.sql @@ -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;