diff --git a/src/components/settings/BuildiumGLAccountMapCard.tsx b/src/components/settings/BuildiumGLAccountMapCard.tsx new file mode 100644 index 0000000..4c9a361 --- /dev/null +++ b/src/components/settings/BuildiumGLAccountMapCard.tsx @@ -0,0 +1,338 @@ +import { useEffect, useMemo, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { invokeEdgeFunction } from "@/lib/edgeFunctionUtils"; +import { fetchChartOfAccounts, NormalizedAccount } from "@/lib/chartOfAccountsSource"; +import { useToast } from "@/hooks/use-toast"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Save, ArrowRight, AlertTriangle, Wand2 } from "lucide-react"; + +interface BuildiumGLAccount { + id: string; + name: string; + account_number: string | null; + type: string; + is_active: boolean; +} + +interface FlagRow { + buildium_gl_id: string; + buildium_name: string | null; + buildium_number: string | null; + context: string | null; +} + +interface Props { + associations: { id: string; name: string }[]; +} + +const normalize = (v: string | null | undefined) => + String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); +// Dashboard names sometimes carry a leading code ("2010 Prepayments") +const normalizeName = (v: string | null | undefined) => + normalize(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, "")); + +/** + * Precise Buildium GL account ↔ dashboard accounting account mapping. + * Both the nightly GL pull and charge push/pull resolve accounts ONLY through + * these links — unmapped accounts hold their transactions until linked here. + */ +export default function BuildiumGLAccountMapCard({ associations }: Props) { + const { toast } = useToast(); + const [selectedAssociation, setSelectedAssociation] = useState(""); + const [buildiumAccounts, setBuildiumAccounts] = useState([]); + const [dashboardAccounts, setDashboardAccounts] = useState([]); + // buildium_gl_id -> dashboard account id ("" = unmapped) + const [selection, setSelection] = useState>({}); + // dashboard account id -> buildium_gl_id chosen as push target (when several map to it) + const [pushTargets, setPushTargets] = useState>({}); + const [flags, setFlags] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!selectedAssociation) { + setBuildiumAccounts([]); setDashboardAccounts([]); setSelection({}); setPushTargets({}); setFlags([]); + return; + } + loadAll(selectedAssociation); + }, [selectedAssociation]); + + const loadAll = async (assocId: string) => { + setLoading(true); + try { + const [glRes, dashAccounts, linksRes, flagsRes] = await Promise.all([ + invokeEdgeFunction("buildium-sync", { body: { syncType: "gl_accounts", includeAll: true, selectedAssociationIds: [assocId] } }), + fetchChartOfAccounts(assocId, "platform"), + supabase.from("buildium_gl_account_links").select("buildium_gl_id, account_id, is_push_target").eq("association_id", assocId), + supabase.from("buildium_unmapped_gl_accounts").select("buildium_gl_id, buildium_name, buildium_number, context").eq("association_id", assocId), + ]); + if (glRes.error) throw glRes.error; + if (linksRes.error) throw linksRes.error; + + setBuildiumAccounts((glRes.data?.gl_accounts || []) as BuildiumGLAccount[]); + setDashboardAccounts(dashAccounts); + + const sel: Record = {}; + const targets: Record = {}; + for (const row of linksRes.data || []) { + sel[row.buildium_gl_id] = row.account_id; + if (row.is_push_target) targets[row.account_id] = row.buildium_gl_id; + } + setSelection(sel); + setPushTargets(targets); + setFlags((flagsRes.data || []) as FlagRow[]); + } catch (err: any) { + toast({ variant: "destructive", title: "Error loading GL account map", description: err.message }); + } finally { + setLoading(false); + } + }; + + const flaggedIds = useMemo(() => new Set(flags.map((f) => f.buildium_gl_id)), [flags]); + + const sortedAccounts = useMemo(() => { + const rank = (a: BuildiumGLAccount) => (flaggedIds.has(a.id) ? 0 : selection[a.id] ? 2 : 1); + return [...buildiumAccounts].sort((a, b) => { + const r = rank(a) - rank(b); + if (r !== 0) return r; + const an = a.account_number || ""; + const bn = b.account_number || ""; + if (an !== bn) return an.localeCompare(bn, undefined, { numeric: true }); + return a.name.localeCompare(b.name); + }); + }, [buildiumAccounts, selection, flaggedIds]); + + // dashboard account id -> buildium ids currently mapped to it (for push-target radios) + const duplicateGroups = useMemo(() => { + const groups = new Map(); + for (const [glId, acctId] of Object.entries(selection)) { + if (!acctId) continue; + groups.set(acctId, [...(groups.get(acctId) || []), glId]); + } + return groups; + }, [selection]); + + const mappedCount = Object.values(selection).filter(Boolean).length; + const unmappedCount = buildiumAccounts.length - buildiumAccounts.filter((a) => selection[a.id]).length; + + const setMapping = (glId: string, accountId: string) => { + setSelection((prev) => { + const next = { ...prev }; + if (accountId) next[glId] = accountId; else delete next[glId]; + return next; + }); + }; + + const suggestMatches = () => { + const byCode = new Map(); + const byName = new Map(); + for (const a of dashboardAccounts) { + if (a.account_number) byCode.set(normalize(a.account_number), a); + byName.set(normalizeName(a.account_name), a); + } + let suggested = 0; + setSelection((prev) => { + const next = { ...prev }; + for (const gl of buildiumAccounts) { + if (next[gl.id]) continue; // never overwrite an existing choice + const codeMatch = gl.account_number ? byCode.get(normalize(gl.account_number)) : undefined; + const nameMatch = byName.get(normalizeName(gl.name)); + const match = codeMatch || nameMatch; + if (match) { next[gl.id] = match.id; suggested++; } + } + return next; + }); + toast({ + title: suggested > 0 ? `${suggested} match(es) suggested` : "No new matches found", + description: suggested > 0 ? "Review the prefilled rows, then Save to apply." : "Remaining accounts need to be mapped by hand.", + }); + }; + + const saveLinks = async () => { + if (!selectedAssociation) return; + setSaving(true); + try { + const glById = new Map(buildiumAccounts.map((a) => [a.id, a])); + // One push target per dashboard account: honor the chosen radio, default first. + const pushTargetByGl = new Set(); + for (const [acctId, glIds] of duplicateGroups.entries()) { + const chosen = pushTargets[acctId] && glIds.includes(pushTargets[acctId]) ? pushTargets[acctId] : glIds[0]; + pushTargetByGl.add(chosen); + } + + const rows = Object.entries(selection) + .filter(([, acctId]) => acctId) + .map(([glId, acctId]) => ({ + association_id: selectedAssociation, + buildium_gl_id: glId, + buildium_name: glById.get(glId)?.name ?? null, + buildium_number: glById.get(glId)?.account_number ?? null, + buildium_type: glById.get(glId)?.type ?? null, + account_id: acctId, + is_push_target: pushTargetByGl.has(glId), + updated_at: new Date().toISOString(), + })); + + // Replace the association's links wholesale — simplest way to honor the + // unique push-target index across re-mappings. + const { error: delErr } = await supabase.from("buildium_gl_account_links").delete().eq("association_id", selectedAssociation); + if (delErr) throw delErr; + if (rows.length > 0) { + const { error: insErr } = await supabase.from("buildium_gl_account_links").insert(rows); + if (insErr) throw insErr; + } + + // Clear "needs mapping" flags that are now resolved. + const mappedIds = rows.map((r) => r.buildium_gl_id); + if (mappedIds.length > 0) { + await supabase.from("buildium_unmapped_gl_accounts").delete() + .eq("association_id", selectedAssociation) + .in("buildium_gl_id", mappedIds); + } + setFlags((prev) => prev.filter((f) => !mappedIds.includes(f.buildium_gl_id))); + + toast({ title: "GL account map saved", description: `${rows.length} link(s) saved.` }); + } catch (err: any) { + toast({ variant: "destructive", title: "Save failed", description: err.message }); + } finally { + setSaving(false); + } + }; + + return ( + + + Buildium ↔ Dashboard GL Account Map + + Link each Buildium GL account to the exact dashboard accounting account it represents. + Pull and push syncs resolve accounts only through these links — transactions touching an + unmapped Buildium account are held until you map it here. + + + +
+ + {selectedAssociation && !loading && ( + <> + {mappedCount} mapped + {unmappedCount > 0 && ( + {unmappedCount} unmapped + )} + + )} +
+ + {selectedAssociation && ( + loading ? ( +
+ Loading accounts and links... +
+ ) : ( +
+ {dashboardAccounts.length === 0 && ( +

+ No platform accounting accounts found for this association — it may not be on the accounting platform yet. +

+ )} + + {flags.length > 0 && ( +
+

+ {flags.length} account(s) blocked a recent sync +

+
    + {flags.slice(0, 8).map((f) => ( +
  • + {[f.buildium_number, f.buildium_name || `Buildium GL ${f.buildium_gl_id}`].filter(Boolean).join(" - ")} + {f.context ? ` (${f.context.replace(/_/g, " ")})` : ""} +
  • + ))} + {flags.length > 8 &&
  • …and {flags.length - 8} more
  • } +
+

They're sorted to the top below. Map and save, then re-run the sync.

+
+ )} + +
+ +
+ +
+ {sortedAccounts.map((gl) => { + const acctId = selection[gl.id] || ""; + const group = acctId ? duplicateGroups.get(acctId) || [] : []; + const showPushRadio = group.length > 1; + const isPushTarget = showPushRadio + ? (pushTargets[acctId] && group.includes(pushTargets[acctId]) ? pushTargets[acctId] : group[0]) === gl.id + : false; + return ( +
+
+

+ {[gl.account_number, gl.name].filter(Boolean).join(" - ")} +

+

+ {gl.type}{!gl.is_active && " · inactive"} + {flaggedIds.has(gl.id) && · blocked a sync} +

+
+ + + {showPushRadio && ( + + )} +
+ ); + })} + {sortedAccounts.length === 0 && ( +

No Buildium GL accounts returned. Check the Buildium connection.

+ )} +
+ +
+ +
+
+ ) + )} +
+
+ ); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 164f2b0..d70b474 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2526,6 +2526,53 @@ export type Database = { }, ] } + buildium_gl_account_links: { + Row: { + account_id: string + association_id: string + buildium_gl_id: string + buildium_name: string | null + buildium_number: string | null + buildium_type: string | null + created_at: string + id: string + is_push_target: boolean + updated_at: string + } + Insert: { + account_id: string + association_id: string + buildium_gl_id: string + buildium_name?: string | null + buildium_number?: string | null + buildium_type?: string | null + created_at?: string + id?: string + is_push_target?: boolean + updated_at?: string + } + Update: { + account_id?: string + association_id?: string + buildium_gl_id?: string + buildium_name?: string | null + buildium_number?: string | null + buildium_type?: string | null + created_at?: string + id?: string + is_push_target?: boolean + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "buildium_gl_account_links_association_id_fkey" + columns: ["association_id"] + isOneToOne: false + referencedRelation: "associations" + referencedColumns: ["id"] + }, + ] + } buildium_gl_mappings: { Row: { amount_max: number | null @@ -2646,6 +2693,47 @@ export type Database = { } Relationships: [] } + buildium_unmapped_gl_accounts: { + Row: { + association_id: string + buildium_gl_id: string + buildium_name: string | null + buildium_number: string | null + buildium_type: string | null + context: string | null + id: string + last_seen_at: string + } + Insert: { + association_id: string + buildium_gl_id: string + buildium_name?: string | null + buildium_number?: string | null + buildium_type?: string | null + context?: string | null + id?: string + last_seen_at?: string + } + Update: { + association_id?: string + buildium_gl_id?: string + buildium_name?: string | null + buildium_number?: string | null + buildium_type?: string | null + context?: string | null + id?: string + last_seen_at?: string + } + Relationships: [ + { + foreignKeyName: "buildium_unmapped_gl_accounts_association_id_fkey" + columns: ["association_id"] + isOneToOne: false + referencedRelation: "associations" + referencedColumns: ["id"] + }, + ] + } bundle_expenses: { Row: { added_at: string diff --git a/src/pages/settings/BuildiumSettingsPage.tsx b/src/pages/settings/BuildiumSettingsPage.tsx index 51f1eb0..aa31e75 100644 --- a/src/pages/settings/BuildiumSettingsPage.tsx +++ b/src/pages/settings/BuildiumSettingsPage.tsx @@ -16,13 +16,14 @@ import { import { cn } from "@/lib/utils"; import BuildiumGLMappingCard from "@/components/settings/BuildiumGLMappingCard"; import BuildiumUnitMappingCard from "@/components/settings/BuildiumUnitMappingCard"; +import BuildiumGLAccountMapCard from "@/components/settings/BuildiumGLAccountMapCard"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -type SyncType = "associations" | "units" | "owners" | "financials" | "ledger" | "push_charges" | "push_payments" | "push_all" | "reset_ledgers" | "all"; +type SyncType = "associations" | "units" | "owners" | "financials" | "ledger" | "charges" | "push_charges" | "push_payments" | "push_all" | "reset_ledgers" | "all"; type DeleteType = "charges" | "payments" | "owners" | "units" | "financials" | "all"; interface SyncResults { @@ -31,8 +32,10 @@ interface SyncResults { owners?: { fetched: number; imported: number; skipped: number }; financials?: { fetched: number; upserted: number }; ledger?: { fetched: number; imported: number; skipped: number; updated?: number }; - push?: { pushed: number; skipped: number; errors: number; skipSamples?: any[]; errorSamples?: any[] }; + push?: { pushed: number; skipped: number; errors: number; skipSamples?: any[]; errorSamples?: any[]; dryRun?: boolean; dryRunSamples?: any[] }; reset?: { deleted: number }; + unmapped?: { buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; count: number }[]; + charges_missing_gl_info?: number; } interface Association { @@ -167,7 +170,7 @@ export default function BuildiumSettingsPage() { body: { syncType: type, selectedAssociationIds: selectedIds, - ...(type === "ledger" || type === "all" ? { + ...(type === "ledger" || type === "charges" || type === "all" ? { dateFrom: dateFrom ? format(dateFrom, "yyyy-MM-dd") : undefined, dateTo: dateTo ? format(dateTo, "yyyy-MM-dd") : undefined, } : {}), @@ -179,8 +182,11 @@ export default function BuildiumSettingsPage() { // Update last sync dates setLastSyncDates(prev => ({ ...prev, [type]: new Date().toISOString() })); const pushResult = data.results?.push; + const unmappedCount = Array.isArray(data.results?.unmapped) ? data.results.unmapped.length : 0; if (pushResult?.errors > 0) { toast({ variant: "destructive", title: "Push completed with errors", description: `${pushResult.pushed} pushed, ${pushResult.errors} failed. See Sync Results below.` }); + } else if (unmappedCount > 0) { + toast({ variant: "destructive", title: "Some charges were held", description: `${unmappedCount} Buildium GL account(s) have no dashboard mapping. Map them in the GL Account Map tab, then re-run.` }); } else { toast({ title: type === "reset_ledgers" ? "Ledgers cleared successfully" : "Sync completed successfully" }); } @@ -284,6 +290,7 @@ export default function BuildiumSettingsPage() { { type: "owners", title: "Owners", desc: "Import property owners, emails, and phone numbers", icon: Users, deleteType: "owners" }, { type: "financials", title: "GL Accounts", desc: "Import chart of accounts and GL structure", icon: BookOpen, deleteType: "financials" }, { type: "ledger", title: "Pull Payments", desc: "Pull payments from Buildium into unit ledgers (charges are not pulled)", icon: DollarSign, deleteType: "charges" }, + { type: "charges", title: "Pull Charges", desc: "Pull charges from Buildium into unit ledgers (payments are not pulled). Requires the GL Account Map — charges on unmapped accounts are held.", icon: BookOpen }, ]; const pushCards: { type: SyncType; title: string; desc: string; icon: any }[] = [ @@ -373,11 +380,15 @@ export default function BuildiumSettingsPage() { {/* GL Mapping Tabs */} - + + GL Account Map Association Mapping Unit Mapping + + + @@ -677,6 +688,27 @@ export default function BuildiumSettingsPage() { )} )} + {Array.isArray(results.unmapped) && results.unmapped.length > 0 && ( +
+
+ Held — Buildium GL accounts without a dashboard mapping: +
+
    + {results.unmapped.map((u) => ( +
  • + {[u.buildium_number, u.buildium_name || `Buildium GL ${u.buildium_gl_id}`].filter(Boolean).join(" - ")} + {" "}— {u.count} charge(s) held +
  • + ))} +
+

+ Map these in the GL Account Map tab above, then run Pull Charges again. +

+ {(results.charges_missing_gl_info ?? 0) > 0 && ( +

{results.charges_missing_gl_info} charge(s) had no GL account info from Buildium and were skipped.

+ )} +
+ )} {results.push && (
diff --git a/supabase/functions/buildium-gl-sync/index.ts b/supabase/functions/buildium-gl-sync/index.ts index 914e0e4..c4ec18f 100644 --- a/supabase/functions/buildium-gl-sync/index.ts +++ b/supabase/functions/buildium-gl-sync/index.ts @@ -10,6 +10,11 @@ // // Pull-only by design: nothing is written back to Buildium, and transactions // edited or deleted in Buildium after they were pulled are NOT reconciled. +// +// Account resolution is STRICT: Buildium GL accounts must be explicitly linked +// to local accounts via public.buildium_gl_account_links (Buildium settings → +// GL Account Map). Unmapped accounts hold their transactions (watermark is not +// advanced) and are flagged in public.buildium_unmapped_gl_accounts. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; const corsHeaders = { @@ -64,9 +69,6 @@ async function buildiumFetchAll(path: string, clientId: string, clientSecret: st } const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); -// Local account names sometimes carry a leading code ("2010 Prepayments"); -// strip it so they match Buildium's bare names. -const normName = (v: unknown) => norm(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, "")); function mapGLAccountType(type: string | null | undefined): string { const t = String(type || "").toLowerCase(); @@ -176,7 +178,7 @@ Deno.serve(async (req) => { const bAssocIdByName = new Map(); for (const ba of buildiumAssocs) bAssocIdByName.set(norm(ba.Name), String(ba.Id)); - // ---- Buildium chart of accounts (account-id resolution + auto-create) ---- + // ---- Buildium chart of accounts (account metadata for line signing) ---- // /v1/glaccounts returns only top-level accounts as list items; children // are nested in each item's SubAccounts array. Flatten recursively. const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret); @@ -286,64 +288,33 @@ Deno.serve(async (req) => { if ((rows || []).length < 1000) break; } - // ---- Local account resolution maps ---- + // ---- Local account resolution: explicit links only ---- + // STRICT mapping: Buildium GL accounts resolve exclusively through + // public.buildium_gl_account_links (seeded from the historical + // accounts.external_id backfills). No code/name matching and no + // auto-create — transactions touching an unmapped account are held, + // flagged for the GL Account Map UI, and re-pulled once mapped. const { data: localAccounts, error: aErr } = await supabase .from("accounts") - .select("id, code, name, type, external_source, external_id") + .select("id, type") .eq("company_id", company.id); if (aErr) throw aErr; - const byExternal = new Map(); - const byCode = new Map(); - const byName = new Map(); - for (const a of localAccounts || []) { - if (a.external_id) byExternal.set(String(a.external_id), a); - if (a.code) byCode.set(norm(a.code), a); - byName.set(normName(a.name), a); - } + const localById = new Map(); + for (const a of localAccounts || []) localById.set(a.id, a); - async function resolveAccount(bGlId: string): Promise<{ id: string } | null> { - const direct = byExternal.get(bGlId); - if (direct) return direct; - const meta = bGlById.get(bGlId); - if (!meta) return null; - const codeMatch = meta.AccountNumber ? byCode.get(norm(meta.AccountNumber)) : null; - const nameMatch = byName.get(normName(meta.Name)); - const match = codeMatch || nameMatch || null; - if (match) { - // Backfill the Buildium id so future syncs resolve deterministically. - if (!match.external_id && !dryRun) { - await supabase.from("accounts").update({ external_source: "buildium", external_id: bGlId }).eq("id", match.id); - } - match.external_id = match.external_id || bGlId; - byExternal.set(bGlId, match); - return match; - } - if (dryRun) { - // Would be auto-created in a real run; stub it so the dry run - // reports the transaction as insertable rather than unmapped. - const stub = { id: `dryrun-${bGlId}`, external_id: bGlId }; - byExternal.set(bGlId, stub); - companyResult.accounts_created = (companyResult.accounts_created || 0) + 1; - return stub; - } - // New account in Buildium — mirror it locally, like the import would. - const { data: created, error: createErr } = await supabase - .from("accounts") - .insert({ - company_id: company.id, - code: meta.AccountNumber ? String(meta.AccountNumber) : null, - name: meta.Name || `Buildium account ${bGlId}`, - type: mapGLAccountType(meta.Type || meta.AccountType), - description: meta.Description || null, - external_source: "buildium", - external_id: bGlId, - }) - .select("id, code, name, external_id") - .single(); - if (createErr) throw createErr; - byExternal.set(bGlId, created); - companyResult.accounts_created = (companyResult.accounts_created || 0) + 1; - return created; + const { data: linkRows, error: lkErr } = await pub + .from("buildium_gl_account_links") + .select("buildium_gl_id, account_id") + .eq("association_id", company.association_id); + if (lkErr) throw lkErr; + const linkByGlId = new Map(); + for (const r of linkRows || []) linkByGlId.set(String(r.buildium_gl_id), r.account_id); + + const unmappedGl = new Map(); // buildium_gl_id -> Buildium meta + function resolveAccount(bGlId: string): { id: string; type?: string } | null { + const accountId = linkByGlId.get(bGlId); + if (!accountId) return null; + return localById.get(accountId) || { id: accountId }; } // ---- Insert new transactions as journal entries ---- @@ -357,12 +328,15 @@ Deno.serve(async (req) => { const lineRows: { account_id: string; debit: number; credit: number; description: string | null }[] = []; let resolved = true; for (const l of tx.lines) { - const acct = await resolveAccount(l.bGlId); + const acct = resolveAccount(l.bGlId); if (!acct) { const meta = bGlById.get(l.bGlId); - companyResult.errors.push( - `tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"})`, - ); + unmappedGl.set(String(l.bGlId), meta || null); + if (companyResult.errors.length < 50) { + companyResult.errors.push( + `tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"}) — map it in Buildium settings → GL Account Map`, + ); + } resolved = false; break; } @@ -432,8 +406,29 @@ Deno.serve(async (req) => { } } + // ---- Flag unmapped Buildium accounts for the GL Account Map UI ---- + if (unmappedGl.size > 0) { + const flagRows = [...unmappedGl.entries()].map(([glId, meta]) => ({ + association_id: company.association_id, + buildium_gl_id: glId, + buildium_name: meta?.Name ?? null, + buildium_number: meta?.AccountNumber != null ? String(meta.AccountNumber) : null, + buildium_type: meta?.Type || meta?.AccountType || null, + context: "gl_sync", + last_seen_at: new Date().toISOString(), + })); + const { error: flagErr } = await pub + .from("buildium_unmapped_gl_accounts") + .upsert(flagRows, { onConflict: "association_id,buildium_gl_id" }); + if (flagErr) companyResult.errors.push(`flagging unmapped accounts failed: ${flagErr.message}`); + companyResult.unmapped = flagRows.map((r) => ({ buildium_gl_id: r.buildium_gl_id, name: r.buildium_name, number: r.buildium_number })); + } + // ---- Advance the watermark ---- - if (!dryRun) { + // STRICT mode: unmapped accounts hold their transactions, so keep the + // old watermark until they're mapped — the next run re-pulls the same + // window and the external_id dedupe skips whatever already landed. + if (!dryRun && unmappedGl.size === 0) { const nextCfg = { ...cfg, buildium_gl: { @@ -447,6 +442,8 @@ Deno.serve(async (req) => { }, }; await supabase.from("companies").update({ acmacc_sync_config: nextCfg }).eq("id", company.id); + } else if (unmappedGl.size > 0) { + companyResult.watermark_held = true; } companyResult.window = { since, until }; } catch (e: any) { diff --git a/supabase/functions/buildium-sync/index.ts b/supabase/functions/buildium-sync/index.ts index 2d360cf..f91b833 100644 --- a/supabase/functions/buildium-sync/index.ts +++ b/supabase/functions/buildium-sync/index.ts @@ -118,6 +118,7 @@ type OwnerLedgerEntryRow = { debit: number; credit: number; transaction_type: string | null; + gl_account_id?: string | null; }; type OwnerLedgerEntryMaps = { @@ -166,7 +167,9 @@ Deno.serve(async (req) => { const supabase = createClient(supabaseUrl, serviceRoleKey); - const { syncType, selectedAssociationIds: rawIds, dateFrom, dateTo, unitId: rawUnitId, documentOffset: rawDocumentOffset, documentLimit: rawDocumentLimit, documentScope: rawDocumentScope } = await req.json(); + const { syncType, selectedAssociationIds: rawIds, dateFrom, dateTo, unitId: rawUnitId, documentOffset: rawDocumentOffset, documentLimit: rawDocumentLimit, documentScope: rawDocumentScope, includeAll: rawIncludeAll, dryRun: rawDryRun } = await req.json(); + const includeAll = rawIncludeAll === true; + const dryRun = rawDryRun === true; const documentScope: "association" | "company" = rawDocumentScope === "company" ? "company" : "association"; let selectedAssociationIds = Array.isArray(rawIds) ? rawIds.filter((v): v is string => typeof v === "string" && v.length > 0) : []; const ledgerDateFrom = typeof dateFrom === "string" && dateFrom ? dateFrom : null; @@ -232,17 +235,25 @@ Deno.serve(async (req) => { }; for (const gl of glAccounts) walk(gl); - const chargeable = flat - .filter((gl) => (gl as any).IsActive !== false) - .map((gl) => ({ - id: String((gl as any).Id), - name: String((gl as any).Name || `Buildium GL ${(gl as any).Id}`), - account_number: String((gl as any).AccountNumber ?? (gl as any).Number ?? (gl as any).GlNumber ?? (gl as any).GLNumber ?? (gl as any).Code ?? (gl as any).Id ?? "").trim() || null, - type: String((gl as any).Type || (gl as any).AccountType || ""), - })) + // includeAll: full chart (for the GL Account Map UI); default: active accounts + // only (legacy charge-type mapping dropdown). + const accounts = flat + .filter((gl) => includeAll || (gl as any).IsActive !== false) + .map((gl) => { + const type = String((gl as any).Type || (gl as any).AccountType || ""); + const isActive = (gl as any).IsActive !== false; + return { + id: String((gl as any).Id), + name: String((gl as any).Name || `Buildium GL ${(gl as any).Id}`), + account_number: String((gl as any).AccountNumber ?? (gl as any).Number ?? (gl as any).GlNumber ?? (gl as any).GLNumber ?? (gl as any).Code ?? (gl as any).Id ?? "").trim() || null, + type, + is_active: isActive, + chargeable: isActive && ["income", "liability"].includes(type.toLowerCase()), + }; + }) .sort((a, b) => a.name.localeCompare(b.name)); - return new Response(JSON.stringify({ success: true, gl_accounts: chargeable }), { + return new Response(JSON.stringify({ success: true, gl_accounts: accounts }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); @@ -306,7 +317,7 @@ Deno.serve(async (req) => { let query = supabase .from("owner_ledger_entries") - .select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type"); + .select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type, gl_account_id"); query = unitId ? query.eq("unit_id", unitId) : query.eq("owner_id", ownerId); @@ -324,7 +335,9 @@ Deno.serve(async (req) => { defaultOwnerId = row.owner_id; } - if (row.reference_type === "buildium" && row.reference_id && !byReferenceId.has(String(row.reference_id))) { + // Include rows we pushed to Buildium ("buildium_pushed") so pulling the + // same transaction back (e.g. a pushed charge) doesn't duplicate it. + if ((row.reference_type === "buildium" || row.reference_type === "buildium_pushed") && row.reference_id && !byReferenceId.has(String(row.reference_id))) { byReferenceId.set(String(row.reference_id), row); } @@ -1006,6 +1019,48 @@ Deno.serve(async (req) => { let imported = 0, skipped = 0, updated = 0, totalFetched = 0; + // "charges" pulls charge transactions only; "ledger"/"payments"/"all" keep + // the historical payments-only policy. + const pullCharges = syncType === "charges"; + + // Buildium GL account -> dashboard accounting account, per association. + // Strict mapping: a charge line whose GL account has no link is held and + // flagged instead of being imported. + const glLinksByAssoc = new Map>(); + async function getGlLinks(assocId: string): Promise> { + const cached = glLinksByAssoc.get(assocId); + if (cached) return cached; + const { data } = await supabase + .from("buildium_gl_account_links") + .select("buildium_gl_id, account_id") + .eq("association_id", assocId); + const m = new Map(); + for (const row of data || []) m.set(String(row.buildium_gl_id), row.account_id); + glLinksByAssoc.set(assocId, m); + return m; + } + + type UnmappedGL = { association_id: string; buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; buildium_type: string | null; count: number }; + const unmappedGl = new Map(); + let missingGlInfo = 0; + function flagUnmapped(assocId: string, glId: string, glMeta: any) { + const key = `${assocId}|${glId}`; + const existing = unmappedGl.get(key); + if (existing) { existing.count++; return; } + unmappedGl.set(key, { + association_id: assocId, + buildium_gl_id: glId, + buildium_name: glMeta?.Name ? String(glMeta.Name) : null, + buildium_number: glMeta?.AccountNumber != null ? String(glMeta.AccountNumber) : null, + buildium_type: glMeta?.Type || glMeta?.AccountType ? String(glMeta?.Type || glMeta?.AccountType) : null, + count: 1, + }); + } + function getLineGlId(line: any): string { + const raw = line?.GLAccount?.Id ?? line?.GLAccountId ?? null; + return raw === null || raw === undefined ? "" : String(raw); + } + function getEntryLines(entry: any): any[] { const topLines = Array.isArray(entry.Lines) ? entry.Lines : []; const journalLines = Array.isArray(entry.Journal?.Lines) ? entry.Journal.Lines : []; @@ -1282,15 +1337,29 @@ Deno.serve(async (req) => { refId: string, txDate: string, txType: string, desc: string, entryDebit: number, entryCredit: number, maps: typeof ledgerEntryMaps, assocIdLocal: string, ownerIdLocal: string, unitIdLocal: string, + localGlAccountId: string | null = null, ): Promise<"imported" | "updated" | "skipped"> { const existingEntry = maps.byReferenceId.get(refId) || null; if (existingEntry) { + const glChanged = Boolean(localGlAccountId) && existingEntry.gl_account_id !== localGlAccountId; + // Entries we pushed keep their locally-authored type/description — + // re-classifying our own memo on the way back would only degrade + // them. Just backfill the GL account link if it's missing. + if (existingEntry.reference_type === "buildium_pushed") { + if (glChanged) { + await supabase.from("owner_ledger_entries").update({ gl_account_id: localGlAccountId }).eq("id", existingEntry.id); + maps.byReferenceId.set(refId, { ...existingEntry, gl_account_id: localGlAccountId }); + return "updated"; + } + return "skipped"; + } const dChanged = desc && existingEntry.description !== desc; - if (existingEntry.debit !== entryDebit || existingEntry.credit !== entryCredit || existingEntry.transaction_type !== txType || dChanged) { + if (existingEntry.debit !== entryDebit || existingEntry.credit !== entryCredit || existingEntry.transaction_type !== txType || dChanged || glChanged) { await supabase.from("owner_ledger_entries").update({ debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType, + ...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}), }).eq("id", existingEntry.id); - maps.byReferenceId.set(refId, { ...existingEntry, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType }); + maps.byReferenceId.set(refId, { ...existingEntry, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType, gl_account_id: localGlAccountId ?? existingEntry.gl_account_id }); return "updated"; } return "skipped"; @@ -1302,6 +1371,7 @@ Deno.serve(async (req) => { await supabase.from("owner_ledger_entries").update({ reference_id: refId, reference_type: "buildium", debit: entryDebit, credit: entryCredit, transaction_type: txType, description: desc, + ...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}), }).eq("id", legacyMatch.id); maps.byLegacyKey.delete(legacyKey); maps.byReferenceId.set(refId, { ...legacyMatch, reference_id: refId, reference_type: "buildium", debit: entryDebit, credit: entryCredit, transaction_type: txType, description: desc }); @@ -1313,6 +1383,7 @@ Deno.serve(async (req) => { if (dateAmountMatch && !dateAmountMatch.reference_id) { await supabase.from("owner_ledger_entries").update({ reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc, + ...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}), }).eq("id", dateAmountMatch.id); maps.byDateAmount.delete(daKey); maps.byReferenceId.set(refId, { ...dateAmountMatch, reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc }); @@ -1325,6 +1396,7 @@ Deno.serve(async (req) => { association_id: assocIdLocal, owner_id: ownerIdLocal, unit_id: unitIdLocal, date: txDate, transaction_type: txType, description: desc, debit: entryDebit, credit: entryCredit, reference_id: refId, reference_type: "buildium", + ...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}), }; const { data: insertedRow, error: insertErr } = await supabase @@ -1374,12 +1446,20 @@ Deno.serve(async (req) => { let debit = 0; let credit = 0; let description: string; + let entryGlAccountId: string | null = null; const buildiumDesc = getEntryDescription(entry); - // POLICY: From Buildium we only PULL payments. Charges originate locally and are pushed to Buildium. + // POLICY: "ledger"/"payments"/"all" only PULL payments. The explicit + // "charges" sync pulls charge transactions (and only those), resolving + // GL accounts strictly through buildium_gl_account_links. const isPaymentTxn = ["Payment", "Credit", "Check"].includes(txnType) || amount < 0; - if (!isPaymentTxn) { + if (pullCharges) { + if (txnType !== "Charge") { + skipped++; + continue; + } + } else if (!isPaymentTxn) { skipped++; continue; } @@ -1399,7 +1479,27 @@ Deno.serve(async (req) => { const isMultiAccount = chargeLines.length > 1 && uniqueGLIds.size > 1; if (isMultiAccount) { - // Multi-line charge: break into separate ledger entries per GL account line + // Multi-line charge: break into separate ledger entries per GL account line. + // Strict GL mapping (charges pull only): resolve every line BEFORE + // touching the books — if any line's Buildium GL account is + // unmapped, hold the whole charge rather than importing it partially. + const lineResolutions: (string | null)[] = new Array(chargeLines.length).fill(null); + if (pullCharges) { + const links = await getGlLinks(assocId); + let allResolved = true; + for (let li = 0; li < chargeLines.length; li++) { + const line = chargeLines[li]; + const lineAmount = Number(line.Amount ?? line.TotalAmount ?? 0); + if (lineAmount === 0) continue; + const lineGlId = getLineGlId(line); + if (!lineGlId) { missingGlInfo++; allResolved = false; continue; } + const resolved = links.get(lineGlId) || null; + if (!resolved) { flagUnmapped(assocId, lineGlId, line.GLAccount); allResolved = false; continue; } + lineResolutions[li] = resolved; + } + if (!allResolved) { skipped++; continue; } + } + // Remove legacy single entry if it exists (from before this breakdown logic) const oldSingleEntry = ledgerEntryMaps.byReferenceId.get(buildiumLedgerId) || null; if (oldSingleEntry) { @@ -1413,6 +1513,7 @@ Deno.serve(async (req) => { const lineAmount = Number(line.Amount ?? line.TotalAmount ?? 0); if (lineAmount === 0) continue; + const lineGlAccountId = lineResolutions[li]; const lineRefId = `${buildiumLedgerId}_L${li}`; const lineType = classifyLineChargeType(line, entry); const lineDesc = getLineDescription(line, entry) || @@ -1431,6 +1532,7 @@ Deno.serve(async (req) => { lineRefId, txnDate, lineType, lineDesc, lineDebit, lineCredit, ledgerEntryMaps, assocId, targetOwnerId, unit.id, + lineGlAccountId, ); if (result === "imported") imported++; else if (result === "updated") updated++; @@ -1440,6 +1542,16 @@ Deno.serve(async (req) => { } // Single-line charge (or all lines share the same GL account) + if (pullCharges) { + const glId = chargeLines.length > 0 ? getLineGlId(chargeLines[0]) : ""; + if (!glId) { missingGlInfo++; skipped++; continue; } + entryGlAccountId = (await getGlLinks(assocId)).get(glId) || null; + if (!entryGlAccountId) { + flagUnmapped(assocId, glId, chargeLines[0]?.GLAccount); + skipped++; + continue; + } + } transactionType = classifyChargeType(entry); if (transactionType === "Prepayment") { credit = Math.abs(amount); @@ -1465,6 +1577,7 @@ Deno.serve(async (req) => { buildiumLedgerId, txnDate, transactionType, description, debit, credit, ledgerEntryMaps, assocId, targetOwnerId, unit.id, + entryGlAccountId, ); if (singleResult === "imported") imported++; else if (singleResult === "updated") updated++; @@ -1478,15 +1591,39 @@ Deno.serve(async (req) => { results.ledger = { fetched: totalFetched, imported, updated, skipped }; results.charges = results.ledger; results.payments = results.ledger; + + // Surface held charges: persist the unmapped Buildium GL accounts so the + // GL Account Map UI can flag them, and report them in the response. + if (pullCharges) { + if (unmappedGl.size > 0) { + const flagRows = [...unmappedGl.values()].map((u) => ({ + association_id: u.association_id, + buildium_gl_id: u.buildium_gl_id, + buildium_name: u.buildium_name, + buildium_number: u.buildium_number, + buildium_type: u.buildium_type, + context: "pull_charges", + last_seen_at: new Date().toISOString(), + })); + const { error: flagErr } = await supabase + .from("buildium_unmapped_gl_accounts") + .upsert(flagRows, { onConflict: "association_id,buildium_gl_id" }); + if (flagErr) console.warn(`[buildium-sync] Failed to flag unmapped GL accounts: ${flagErr.message}`); + } + results.unmapped = [...unmappedGl.values()]; + if (missingGlInfo > 0) results.charges_missing_gl_info = missingGlInfo; + } } - // Save last sync timestamp per syncType - const syncTimestamp = new Date().toISOString(); - const settingsKey = `buildium_last_sync_${syncType}`; - await supabase.from("company_settings").upsert( - { key: settingsKey, value: syncTimestamp }, - { onConflict: "key" } - ); + // Save last sync timestamp per syncType (not for dry runs) + if (!dryRun) { + const syncTimestamp = new Date().toISOString(); + const settingsKey = `buildium_last_sync_${syncType}`; + await supabase.from("company_settings").upsert( + { key: settingsKey, value: syncTimestamp }, + { onConflict: "key" } + ); + } // ===== PUSH TO BUILDIUM: Charges & Payments ===== if (syncType === "push_charges" || syncType === "push_payments" || syncType === "push_all") { @@ -1567,7 +1704,20 @@ Deno.serve(async (req) => { interface GLMappingRule { glAccountId: string; glAccountName: string | null; amountMin: number | null; amountMax: number | null; customDescription: string | null; } const glMappingsByAssoc = new Map>(); const customDescByAssocType = new Map>(); + // Account-level links: local accounting account -> Buildium GL account. + // Takes priority over charge-type rules when the entry carries gl_account_id. + const accountLinksByAssoc = new Map>(); for (const assocId of (selectedAssociationIds.length > 0 ? selectedAssociationIds : [])) { + const { data: linkRows } = await supabase + .from("buildium_gl_account_links") + .select("buildium_gl_id, buildium_name, account_id, is_push_target") + .eq("association_id", assocId); + const linkMap = new Map(); + for (const row of linkRows || []) { + if (row.is_push_target === false) continue; + linkMap.set(row.account_id, { glId: String(row.buildium_gl_id), name: row.buildium_name ?? null }); + } + if (linkMap.size > 0) accountLinksByAssoc.set(assocId, linkMap); const { data: mappingRows } = await supabase .from("buildium_gl_mappings") .select("charge_type, buildium_gl_account_id, buildium_gl_account_name, amount_min, amount_max, custom_description") @@ -1614,21 +1764,14 @@ Deno.serve(async (req) => { // Try to look it up as an account number string const byNumber = asChargeableId(glAccountByNumber.get(sv), `number "${sv}"`); if (byNumber) return byNumber; - // Try normalized name match (for values like "4000 - Assessment Fees") and saved display names. - const nameCandidates = [sv, storedName || ""].filter(Boolean); - for (const candidate of nameCandidates) { - const normalized = norm(candidate); - const withoutLeadingNumber = norm(String(candidate).replace(/^\s*\d+\s*-\s*/, "")); - for (const [name, meta] of glAccountByName) { - if (!meta.isChargeable) continue; - if (name === normalized || name === withoutLeadingNumber || normalized.includes(name) || name.includes(normalized) || (withoutLeadingNumber && name.includes(withoutLeadingNumber))) return meta.id; - } - } - console.warn(`[push] resolveGLId: could not resolve "${sv}" — not found in ${glAccountById.size} IDs or ${glAccountByNumber.size} account numbers`); + // STRICT: no name-based fuzzy matching — only exact Buildium Ids or + // account numbers resolve. Anything else must be mapped explicitly. + console.warn(`[push] resolveGLId: could not resolve "${sv}"${storedName ? ` (${storedName})` : ""} — not found in ${glAccountById.size} IDs or ${glAccountByNumber.size} account numbers`); return null; } - // Find a GL account for a charge type — check DB mappings first (with amount matching), then fuzzy match + // Find a GL account for a charge type via the explicit buildium_gl_mappings + // rules (with amount thresholds). STRICT: no fuzzy fallback. function findGLAccountId(transactionType: string, assocId?: string, amount?: number): number | null { // Priority 1: Per-association mapping from buildium_gl_mappings table if (assocId) { @@ -1663,47 +1806,10 @@ Deno.serve(async (req) => { } } - // Priority 2: Fuzzy name matching against Buildium GL accounts. - // Order matters — earlier terms are tried first. Each term is matched as a - // whole-word substring against chargeable account names. - const typeToSearchTerms: Record = { - assessment: ["assessment fee", "assessment", "hoa dues", "association fee", "condo fee", "maintenance fee", "monthly dues", "dues"], - late_fee: ["late fee", "late fees", "late charge", "late charges", "late payment fee", "late payment charge", "delinquency fee", "delinquent fee", "non payment penalty"], - interest: ["interest income", "interest", "finance charge", "finance charges", "ar interest", "interest charge"], - legal_fee: ["legal fee", "legal fees", "attorney fee", "attorney fees", "legal", "attorney"], - admin_fee: ["administrative fee", "administrative fees", "administration fee", "admin fee", "admin fees", "processing fee", "document fee", "notice fee", "statement fee", "mailing fee", "administrative", "admin"], - violation: ["violation fee", "violation fine", "violation", "fine", "fines"], - bank_fee: ["nsf fee", "nsf", "returned check", "returned payment", "bank fee", "bank charge"], - special_assessment: ["special assessment", "capital contribution", "reserve contribution", "reserve"], - }; - const terms = typeToSearchTerms[transactionType] || [transactionType.replace(/_/g, " ")]; - // First pass: exact normalized match on full term - for (const term of terms) { - const normTerm = norm(term); - for (const [name, meta] of glAccountByName) { - if (!meta.isChargeable) continue; - if (name === normTerm) { - console.log(`[push] GL exact-matched ${transactionType}: "${term}" === "${name}" -> ${meta.id}`); - return meta.id; - } - } - } - // Second pass: substring match (term inside account name) - for (const term of terms) { - const normTerm = norm(term); - for (const [name, meta] of glAccountByName) { - if (!meta.isChargeable) continue; - if (name.includes(normTerm)) { - console.log(`[push] GL fuzzy-matched ${transactionType}: "${term}" found in "${name}" -> ${meta.id}`); - return meta.id; - } - } - } - // No match — DO NOT silently fall back to a generic income account, that - // routes admin/late/interest charges into the wrong account in Buildium. - // Instead return null so the caller skips the entry with a clear reason - // and the user can fix the mapping. - console.warn(`[push] No GL account found for ${transactionType}. Available chargeable: ${chargeableGlAccounts.map(a => a.name).join(", ")}`); + // STRICT: no fuzzy name matching. Without an explicit account link or a + // charge-type rule the entry is skipped with a clear reason so the user + // can fix the mapping — never silently routed to a guessed account. + console.warn(`[push] No GL mapping found for ${transactionType} (assoc ${assocId ?? "?"}).`); return null; } @@ -1717,6 +1823,7 @@ Deno.serve(async (req) => { let pushed = 0, pushSkipped = 0, pushErrors = 0; const errorSamples: any[] = []; const skipSamples: any[] = []; + const dryRunSamples: any[] = []; const recordSkip = (entry: any, reason: string, extra: any = {}) => { pushSkipped++; if (skipSamples.length < 20) skipSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, debit: entry.debit, credit: entry.credit, reason, ...extra }); @@ -1732,7 +1839,7 @@ Deno.serve(async (req) => { // Only fetch entries that haven't been synced from/to Buildium let entriesQuery = supabase .from("owner_ledger_entries") - .select("id, owner_id, unit_id, date, transaction_type, description, debit, credit, reference_type, reference_id") + .select("id, owner_id, unit_id, date, transaction_type, description, debit, credit, reference_type, reference_id, gl_account_id") .eq("association_id", assocId) .or("reference_type.is.null,and(reference_type.neq.buildium,reference_type.neq.buildium_pushed)") .order("date"); @@ -1766,8 +1873,27 @@ Deno.serve(async (req) => { try { if (entry.debit > 0) { - // Push as charge - const glAccountId = findGLAccountId(entry.transaction_type, assocId, entry.debit); + // Push as charge. + // Priority 1: the entry's own GL account via the explicit + // account-to-account link. If the entry names an account but no + // link exists, hold it — falling back to charge-type rules could + // route it to the wrong Buildium account. + let glAccountId: number | null = null; + if (entry.gl_account_id) { + const link = accountLinksByAssoc.get(assocId)?.get(entry.gl_account_id) || null; + if (!link) { + recordSkip(entry, "no_account_link", { gl_account_id: entry.gl_account_id }); + continue; + } + glAccountId = resolveGLId(link.glId, link.name); + if (!glAccountId) { + recordSkip(entry, "account_link_unresolved_in_buildium", { gl_account_id: entry.gl_account_id, buildium_gl_id: link.glId, buildium_name: link.name }); + continue; + } + } else { + // Priority 2: explicit charge-type rules (buildium_gl_mappings). + glAccountId = findGLAccountId(entry.transaction_type, assocId, entry.debit); + } if (!glAccountId) { recordSkip(entry, "no_gl_account_resolved", { transaction_type: entry.transaction_type, @@ -1780,6 +1906,13 @@ Deno.serve(async (req) => { ? null : (customDescByAssocType.get(assocId)?.get(entry.transaction_type) || null); const chargeMemo = String(customMemo || entry.description || "Charge from management system").slice(0, 65); + + if (dryRun) { + pushed++; + if (dryRunSamples.length < 50) dryRunSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, amount: entry.debit, gl_account_id: entry.gl_account_id || null, buildium_gl_account: glAccountId, memo: chargeMemo }); + continue; + } + const chargeBody = { Date: entry.date, Memo: chargeMemo, @@ -1825,6 +1958,12 @@ Deno.serve(async (req) => { // Push as payment if (!defaultBankAccountId) { recordSkip(entry, "no_default_bank_account"); continue; } + if (dryRun) { + pushed++; + if (dryRunSamples.length < 50) dryRunSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, amount: entry.credit, kind: "payment" }); + continue; + } + const paymentMemo = entry.description || "Payment from management system"; const paymentBody = { Date: entry.date, @@ -1876,7 +2015,7 @@ Deno.serve(async (req) => { } } - results.push = { pushed, skipped: pushSkipped, errors: pushErrors, errorSamples, skipSamples }; + results.push = { pushed, skipped: pushSkipped, errors: pushErrors, errorSamples, skipSamples, ...(dryRun ? { dryRun: true, dryRunSamples } : {}) }; } // ===== PULL BUDGETS FROM BUILDIUM =====