mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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." },
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user