Buildium GL account map: strict account links, separate pull/push charges

- buildium_gl_account_links + buildium_unmapped_gl_accounts tables (strict, hold-and-flag)
- buildium-sync: pull charges syncType, account-first push resolution, no fuzzy matching, push dryRun
- buildium-gl-sync: links-only resolution, watermark held while unmapped accounts exist
- GL Account Map settings tab + Pull Charges card + unmapped results panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:07:18 -04:00
parent abd46bcb2b
commit ff65c8a656
5 changed files with 741 additions and 147 deletions
@@ -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<BuildiumGLAccount[]>([]);
const [dashboardAccounts, setDashboardAccounts] = useState<NormalizedAccount[]>([]);
// buildium_gl_id -> dashboard account id ("" = unmapped)
const [selection, setSelection] = useState<Record<string, string>>({});
// dashboard account id -> buildium_gl_id chosen as push target (when several map to it)
const [pushTargets, setPushTargets] = useState<Record<string, string>>({});
const [flags, setFlags] = useState<FlagRow[]>([]);
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<string, string> = {};
const targets: Record<string, string> = {};
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<string, string[]>();
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<string, NormalizedAccount>();
const byName = new Map<string, NormalizedAccount>();
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<string>();
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 (
<Card>
<CardHeader>
<CardTitle className="text-lg">Buildium Dashboard GL Account Map</CardTitle>
<CardDescription>
Link each Buildium GL account to the exact dashboard accounting account it represents.
Pull and push syncs resolve accounts <strong>only</strong> through these links transactions touching an
unmapped Buildium account are held until you map it here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={selectedAssociation} onValueChange={setSelectedAssociation}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select association..." />
</SelectTrigger>
<SelectContent>
{associations.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
{selectedAssociation && !loading && (
<>
<Badge variant="outline" className="text-xs">{mappedCount} mapped</Badge>
{unmappedCount > 0 && (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-200 bg-amber-50">{unmappedCount} unmapped</Badge>
)}
</>
)}
</div>
{selectedAssociation && (
loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts and links...
</div>
) : (
<div className="space-y-4">
{dashboardAccounts.length === 0 && (
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg">
No platform accounting accounts found for this association it may not be on the accounting platform yet.
</p>
)}
{flags.length > 0 && (
<div className="border border-amber-200 bg-amber-50 rounded-lg p-3 text-sm">
<p className="font-medium text-amber-800 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> {flags.length} account(s) blocked a recent sync
</p>
<ul className="mt-1 text-xs text-amber-700 list-disc pl-5">
{flags.slice(0, 8).map((f) => (
<li key={f.buildium_gl_id}>
{[f.buildium_number, f.buildium_name || `Buildium GL ${f.buildium_gl_id}`].filter(Boolean).join(" - ")}
{f.context ? ` (${f.context.replace(/_/g, " ")})` : ""}
</li>
))}
{flags.length > 8 && <li>and {flags.length - 8} more</li>}
</ul>
<p className="text-xs text-amber-700 mt-1">They're sorted to the top below. Map and save, then re-run the sync.</p>
</div>
)}
<div className="flex justify-end">
<Button variant="outline" size="sm" className="gap-2" onClick={suggestMatches} disabled={dashboardAccounts.length === 0}>
<Wand2 className="h-3.5 w-3.5" /> Suggest matches
</Button>
</div>
<div className="border rounded-lg divide-y max-h-[480px] overflow-y-auto">
{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 (
<div key={gl.id} className="flex items-center gap-2 px-3 py-2">
<div className="w-[40%] min-w-0">
<p className="text-sm truncate">
{[gl.account_number, gl.name].filter(Boolean).join(" - ")}
</p>
<p className="text-[11px] text-muted-foreground">
{gl.type}{!gl.is_active && " · inactive"}
{flaggedIds.has(gl.id) && <span className="text-amber-600"> · blocked a sync</span>}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
<Select value={acctId || "__none__"} onValueChange={(v) => setMapping(gl.id, v === "__none__" ? "" : v)}>
<SelectTrigger className="flex-1 h-8 text-xs">
<SelectValue placeholder="Select dashboard account..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Not mapped </SelectItem>
{dashboardAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{[a.account_number, a.account_name].filter(Boolean).join(" - ")} ({a.account_type})
</SelectItem>
))}
</SelectContent>
</Select>
{showPushRadio && (
<label className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 cursor-pointer" title="Several Buildium accounts map to this dashboard account — pick which one charges are pushed to.">
<input
type="radio"
name={`push-${acctId}`}
checked={isPushTarget}
onChange={() => setPushTargets((prev) => ({ ...prev, [acctId]: gl.id }))}
/>
push
</label>
)}
</div>
);
})}
{sortedAccounts.length === 0 && (
<p className="text-sm text-muted-foreground p-4">No Buildium GL accounts returned. Check the Buildium connection.</p>
)}
</div>
<div className="flex justify-end pt-1">
<Button onClick={saveLinks} disabled={saving} className="gap-2">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save GL Account Map
</Button>
</div>
</div>
)
)}
</CardContent>
</Card>
);
}
+88
View File
@@ -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
+36 -4
View File
@@ -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() {
</Card>
{/* GL Mapping Tabs */}
<Tabs defaultValue="association" className="w-full">
<Tabs defaultValue="account_map" className="w-full">
<TabsList>
<TabsTrigger value="account_map">GL Account Map</TabsTrigger>
<TabsTrigger value="association">Association Mapping</TabsTrigger>
<TabsTrigger value="unit">Unit Mapping</TabsTrigger>
</TabsList>
<TabsContent value="account_map">
<BuildiumGLAccountMapCard associations={associations} />
</TabsContent>
<TabsContent value="association">
<BuildiumGLMappingCard associations={associations} />
</TabsContent>
@@ -677,6 +688,27 @@ export default function BuildiumSettingsPage() {
)}
</div>
)}
{Array.isArray(results.unmapped) && results.unmapped.length > 0 && (
<div className="bg-background p-4 rounded-lg border border-amber-200 col-span-full">
<div className="text-xs font-semibold text-amber-600 mb-1 flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" /> Held Buildium GL accounts without a dashboard mapping:
</div>
<ul className="text-xs text-muted-foreground list-disc pl-5 space-y-0.5">
{results.unmapped.map((u) => (
<li key={u.buildium_gl_id}>
{[u.buildium_number, u.buildium_name || `Buildium GL ${u.buildium_gl_id}`].filter(Boolean).join(" - ")}
{" "} {u.count} charge(s) held
</li>
))}
</ul>
<p className="text-[11px] text-muted-foreground mt-2">
Map these in the <strong>GL Account Map</strong> tab above, then run Pull Charges again.
</p>
{(results.charges_missing_gl_info ?? 0) > 0 && (
<p className="text-[11px] text-amber-600 mt-1">{results.charges_missing_gl_info} charge(s) had no GL account info from Buildium and were skipped.</p>
)}
</div>
)}
{results.push && (
<div className="bg-background p-4 rounded-lg border col-span-full">
<div className="flex flex-wrap gap-6 justify-center text-center">