mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<string, any>();
|
||||
const byCode = new Map<string, any>();
|
||||
const byName = new Map<string, any>();
|
||||
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<string, any>();
|
||||
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<string, string>();
|
||||
for (const r of linkRows || []) linkByGlId.set(String(r.buildium_gl_id), r.account_id);
|
||||
|
||||
const unmappedGl = new Map<string, any>(); // 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);
|
||||
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"})`,
|
||||
`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) {
|
||||
|
||||
@@ -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) => ({
|
||||
// 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: String((gl as any).Type || (gl as any).AccountType || ""),
|
||||
}))
|
||||
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<string, Map<string, string>>();
|
||||
async function getGlLinks(assocId: string): Promise<Map<string, string>> {
|
||||
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<string, string>();
|
||||
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<string, UnmappedGL>();
|
||||
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
|
||||
// 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<string, Map<string, GLMappingRule[]>>();
|
||||
const customDescByAssocType = new Map<string, Map<string, string>>();
|
||||
// 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<string, Map<string, { glId: string; name: string | null }>>();
|
||||
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<string, { glId: string; name: string | null }>();
|
||||
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<string, string[]> = {
|
||||
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 =====
|
||||
|
||||
Reference in New Issue
Block a user