Files
acmcc/supabase/functions/buildium-sync/index.ts
T
admin 756ebce121 buildium-sync: robustly resolve bill line GL account into expense_account_id
Buildium bill imports left expense_account_id null on all bills because the
importer only read firstLine.GLAccountId, but the bill-line payload exposes the
GL id under GLAccount.Id (nested). chart_of_accounts.account_number already
stores the Buildium GL Id, so resolve the line's GL id from either shape before
mapping it. Re-syncing backfills existing bills via the update path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:17:07 -04:00

2453 lines
114 KiB
TypeScript

// Buildium Sync Edge Function v8 — Unit-centric
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
const BUILDIUM_BASE = "https://api.buildium.com";
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record<string, string>) {
const url = new URL(`${BUILDIUM_BASE}${path}`);
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(url.toString(), {
headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" },
});
if (res.ok) {
return res.json();
}
const text = await res.text();
const shouldRetry = res.status === 429 || res.status >= 500;
if (shouldRetry && attempt < maxAttempts - 1) {
const retryAfterSeconds = Number(res.headers.get("Retry-After") ?? "");
const delayMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? retryAfterSeconds * 1000
: 600 * Math.pow(2, attempt);
console.warn(`Buildium API ${path} throttled/retrying [${res.status}] attempt ${attempt + 1}/${maxAttempts} after ${delayMs}ms`);
await wait(delayMs);
continue;
}
throw new Error(`Buildium API ${path} failed [${res.status}]: ${text}`);
}
throw new Error(`Buildium API ${path} failed after retries`);
}
async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, extraParams?: Record<string, string>) {
const all: any[] = [];
let offset = 0;
const limit = 50;
while (true) {
const params = { ...extraParams, offset: String(offset), limit: String(limit) };
const page = await buildiumFetch(path, clientId, clientSecret, params);
if (!Array.isArray(page) || page.length === 0) break;
all.push(...page);
if (page.length < limit) break;
offset += limit;
}
return all;
}
function norm(value: unknown): string {
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
}
function normalizeAssociationName(value: unknown): string {
return String(value ?? "").toLowerCase()
.replace(/&/g, " and ")
.replace(/\b(homeowners association|homeowners assoc|association|assoc|incorporated|inc|llc|hoa|coa)\b/g, " ")
.replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
}
function normId(value: unknown): string | null {
if (value === null || value === undefined) return null;
const s = String(value).trim();
return s.length > 0 ? s : null;
}
function formatAddress(obj: any): string {
if (!obj) return "";
return [obj.AddressLine1, obj.AddressLine2, obj.AddressLine3, obj.City, obj.State, obj.ZipCode ?? obj.PostalCode]
.map((p) => String(p ?? "").trim()).filter(Boolean).join(", ");
}
function extractUnitNumber(address: string): string | null {
const line1 = String(address || "").split(",")[0]?.trim();
if (!line1) return null;
const explicitMatch = line1.match(/(?:unit|apt|#|suite|ste)\s*([a-z0-9-]+)/i);
if (explicitMatch?.[1]) return explicitMatch[1];
if (line1.length > 0) return line1;
return null;
}
function isPlaceholderUnitNumber(value: unknown): boolean {
const normalized = norm(value);
return !normalized || normalized === "unknown" || normalized === "n a" || normalized === "na";
}
function buildOwnerSyncKey(associationId: string, firstName: string, lastName: string, propertyAddress: string): string {
return `${associationId}|${norm(firstName)}|${norm(lastName)}|${norm(propertyAddress)}`;
}
type OwnerLedgerEntryRow = {
id: string;
owner_id: string;
reference_id: string | null;
reference_type: string | null;
description: string | null;
date: string | null;
created_at: string | null;
debit: number;
credit: number;
transaction_type: string | null;
};
type OwnerLedgerEntryMaps = {
byReferenceId: Map<string, OwnerLedgerEntryRow>;
byLegacyKey: Map<string, OwnerLedgerEntryRow>;
byDateAmount: Map<string, OwnerLedgerEntryRow>;
defaultOwnerId: string | null;
};
function isUniqueViolation(error: { code?: string | null; message?: string | null } | null | undefined) {
if (!error) return false;
return error.code === "23505" || String(error.message || "").toLowerCase().includes("duplicate key");
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const token = authHeader.replace("Bearer ", "");
let userId: string | null = null;
try {
const payload = token.split(".")[1];
if (!payload) throw new Error("Missing JWT payload");
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.replace(/-/g, "+").replace(/_/g, "/").length / 4) * 4, "=");
const decoded = JSON.parse(atob(padded));
userId = typeof decoded?.sub === "string" ? decoded.sub : null;
} catch {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const authClient = createClient(supabaseUrl, supabaseAnonKey, { global: { headers: { Authorization: authHeader } } });
const { data: roles, error: rolesError } = await authClient.from("user_roles").select("role").eq("user_id", userId).limit(10);
const isAdmin = !rolesError && Array.isArray(roles) && roles.some((r: any) => r.role === "admin");
if (!isAdmin) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const supabase = createClient(supabaseUrl, serviceRoleKey);
const { syncType, selectedAssociationIds: rawIds, dateFrom, dateTo, unitId: rawUnitId, documentOffset: rawDocumentOffset, documentLimit: rawDocumentLimit, documentScope: rawDocumentScope } = await req.json();
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;
const ledgerDateTo = typeof dateTo === "string" && dateTo ? dateTo : null;
const unitFilterId = typeof rawUnitId === "string" && rawUnitId.length > 0 ? rawUnitId : null;
const documentOffset = Number.isFinite(Number(rawDocumentOffset)) && Number(rawDocumentOffset) >= 0 ? Math.floor(Number(rawDocumentOffset)) : 0;
const documentLimit = Math.min(10, Math.max(1, Number.isFinite(Number(rawDocumentLimit)) ? Math.floor(Number(rawDocumentLimit)) : 5));
const results: Record<string, any> = {};
// If a single unit was requested, auto-scope selectedAssociationIds to that unit's association
if (unitFilterId && selectedAssociationIds.length === 0) {
const { data: unitRow } = await createClient(supabaseUrl, serviceRoleKey)
.from("units").select("association_id").eq("id", unitFilterId).maybeSingle();
if (unitRow?.association_id) selectedAssociationIds = [unitRow.association_id];
}
if (syncType === "reset_ledgers") {
let deleteQuery = supabase.from("owner_ledger_entries").delete({ count: "exact" });
if (selectedAssociationIds.length > 0) deleteQuery = deleteQuery.in("association_id", selectedAssociationIds);
const { count, error } = await deleteQuery;
if (error) throw error;
let ownerQuery = supabase.from("owners").update({ balance: 0 });
if (selectedAssociationIds.length > 0) ownerQuery = ownerQuery.in("association_id", selectedAssociationIds);
const { error: ownerBalanceError } = await ownerQuery;
if (ownerBalanceError) throw ownerBalanceError;
await supabase.from("company_settings").upsert(
{ key: "buildium_last_sync_reset_ledgers", value: new Date().toISOString() },
{ onConflict: "key" }
);
return new Response(JSON.stringify({ success: true, results: { reset: { deleted: count || 0 } } }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
if (!clientId || !clientSecret) {
return new Response(JSON.stringify({ error: "Buildium API credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
if (syncType === "gl_accounts") {
const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret) as Record<string, unknown>[];
// Flatten Buildium's nested SubAccounts so sub-accounts also appear in the dropdown.
const flat: Record<string, unknown>[] = [];
const seen = new Set<string>();
const walk = (acc: Record<string, unknown>) => {
if (!acc) return;
const id = String((acc as any).Id ?? "");
if (id && !seen.has(id)) {
seen.add(id);
flat.push(acc);
}
const subs = (acc as any).SubAccounts;
if (Array.isArray(subs)) {
for (const s of subs) walk(s as Record<string, unknown>);
}
};
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 || ""),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return new Response(JSON.stringify({ success: true, gl_accounts: chargeable }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
let associationMapsPromise: Promise<any> | null = null;
let unitLookupPromise: Promise<any> | null = null;
let ownerLookupPromise: Promise<any> | null = null;
let buildiumAssociationUnitsPromise: Promise<any> | null = null;
let ownershipAccountsPromise: Promise<any[]> | null = null;
const ownerLedgerCache = new Map<string, OwnerLedgerEntryMaps>();
const isSelected = (id: string | null) => selectedAssociationIds.length === 0 || Boolean(id && selectedAssociationIds.includes(id));
const ownerStatusRank = (status: unknown) => {
const normalized = norm(status);
if (normalized === "active") return 2;
if (normalized.length > 0) return 1;
return 0;
};
const isPreferredOwnerCandidate = (candidate: any, current: any) => {
const candidateRank = ownerStatusRank(candidate?.status);
const currentRank = ownerStatusRank(current?.status);
if (candidateRank !== currentRank) return candidateRank > currentRank;
const candidateBuildiumOwnerId = normId(candidate?.buildium_owner_id) ?? "";
const currentBuildiumOwnerId = normId(current?.buildium_owner_id) ?? "";
const candidateHasBuildiumOwnerId = candidateBuildiumOwnerId.length > 0;
const currentHasBuildiumOwnerId = currentBuildiumOwnerId.length > 0;
if (candidateHasBuildiumOwnerId !== currentHasBuildiumOwnerId) {
return candidateHasBuildiumOwnerId;
}
if (candidateBuildiumOwnerId !== currentBuildiumOwnerId) {
return candidateBuildiumOwnerId < currentBuildiumOwnerId;
}
return String(candidate?.id ?? "") < String(current?.id ?? "");
};
const setPreferredOwnerByUnit = (map: Map<string, any>, owner: any) => {
const unitId = owner?.unit_id;
if (!unitId) return;
const existing = map.get(unitId);
if (!existing || isPreferredOwnerCandidate(owner, existing)) {
map.set(unitId, owner);
}
};
const makeLegacyKey = (description: unknown, date: unknown) => `${String(date ?? "")}|${String(description ?? "")}`;
const makeDateAmountKey = (date: unknown, debit: number, credit: number) => `${String(date ?? "")}|${debit}|${credit}`;
async function getOwnerLedgerEntryMaps(ownerId: string, unitId: string | null = null) {
const cacheKey = unitId ? `unit:${unitId}` : `owner:${ownerId}`;
const cached = ownerLedgerCache.get(cacheKey);
if (cached) return cached;
let query = supabase
.from("owner_ledger_entries")
.select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type");
query = unitId ? query.eq("unit_id", unitId) : query.eq("owner_id", ownerId);
const { data: rows } = await query
.order("created_at", { ascending: true })
.order("id", { ascending: true });
const byReferenceId = new Map<string, OwnerLedgerEntryRow>();
const byLegacyKey = new Map<string, OwnerLedgerEntryRow>();
const byDateAmount = new Map<string, OwnerLedgerEntryRow>();
let defaultOwnerId: string | null = null;
for (const row of rows || []) {
if (!defaultOwnerId && row.reference_type === "buildium" && row.owner_id) {
defaultOwnerId = row.owner_id;
}
if (row.reference_type === "buildium" && row.reference_id && !byReferenceId.has(String(row.reference_id))) {
byReferenceId.set(String(row.reference_id), row);
}
if (!row.reference_id) {
const key = makeLegacyKey(row.description, row.date);
if (!byLegacyKey.has(key)) byLegacyKey.set(key, row);
}
const daKey = makeDateAmountKey(row.date, row.debit, row.credit);
if (!byDateAmount.has(daKey)) byDateAmount.set(daKey, row);
}
const next = { byReferenceId, byLegacyKey, byDateAmount, defaultOwnerId: defaultOwnerId || ownerId };
ownerLedgerCache.set(cacheKey, next);
return next;
}
async function calculateOwnerLedgerBalance(ownerId: string) {
let from = 0;
const pageSize = 1000;
let balance = 0;
while (true) {
const { data, error } = await supabase
.from("owner_ledger_entries")
.select("debit, credit")
.eq("owner_id", ownerId)
.range(from, from + pageSize - 1);
if (error) throw error;
for (const row of data || []) {
balance += Number(row.debit || 0) - Number(row.credit || 0);
}
if (!data || data.length < pageSize) break;
from += pageSize;
}
return Math.round(balance * 100) / 100;
}
async function getAssociationMaps() {
if (associationMapsPromise) return associationMapsPromise;
associationMapsPromise = (async () => {
let query = supabase.from("associations").select("id, name").eq("status", "active");
if (selectedAssociationIds.length > 0) query = query.in("id", selectedAssociationIds);
const { data: rows } = await query;
const exactMap = new Map((rows || []).map((a: any) => [String(a.name).trim(), a.id]));
const normMap = new Map((rows || []).map((a: any) => [normalizeAssociationName(a.name), a.id]));
const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
const bIdToLocalId = new Map<string, string>();
const bIdToName = new Map<string, string>();
for (const ba of buildiumAssocs) {
const bid = normId(ba.Id);
const name = String(ba.Name || "").trim();
if (!bid || !name) continue;
bIdToName.set(bid, name);
const localId = exactMap.get(name) ?? normMap.get(normalizeAssociationName(name)) ?? null;
if (localId) bIdToLocalId.set(bid, localId);
}
return { bIdToLocalId, bIdToName };
})();
return associationMapsPromise;
}
async function getUnitLookup() {
if (unitLookupPromise) return unitLookupPromise;
unitLookupPromise = (async () => {
const data: any[] = [];
let from = 0;
const pageSize = 1000;
while (true) {
let query = supabase.from("units").select("id, association_id, unit_number, buildium_unit_id, account_number, address");
if (selectedAssociationIds.length > 0) query = query.in("association_id", selectedAssociationIds);
const { data: page } = await query.range(from, from + pageSize - 1);
if (page) data.push(...page);
if (!page || page.length < pageSize) break;
from += pageSize;
}
const byBuildiumId = new Map<string, any>();
const byAssocAndNumber = new Map<string, any>();
const byAssocAndAddress = new Map<string, any>();
for (const unit of data || []) {
const buildiumId = normId(unit.buildium_unit_id);
if (buildiumId) byBuildiumId.set(buildiumId, unit);
if (unit.unit_number) byAssocAndNumber.set(`${unit.association_id}|${norm(unit.unit_number)}`, unit);
if (unit.address) byAssocAndAddress.set(`${unit.association_id}|${norm(unit.address)}`, unit);
}
return { units: data || [], byBuildiumId, byAssocAndNumber, byAssocAndAddress };
})();
return unitLookupPromise;
}
async function getOwnerLookup() {
if (ownerLookupPromise) return ownerLookupPromise;
ownerLookupPromise = (async () => {
const data: any[] = [];
let from = 0;
const pageSize = 1000;
while (true) {
let query = supabase.from("owners").select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status");
if (selectedAssociationIds.length > 0) query = query.in("association_id", selectedAssociationIds);
const { data: page } = await query.range(from, from + pageSize - 1);
if (page) data.push(...page);
if (!page || page.length < pageSize) break;
from += pageSize;
}
const byBuildiumId = new Map<string, any>();
const bySyncKey = new Map<string, any>();
const byUnitId = new Map<string, any>();
for (const owner of data || []) {
const buildiumOwnerId = normId(owner.buildium_owner_id);
if (buildiumOwnerId) byBuildiumId.set(buildiumOwnerId, owner);
const propertyAddress = String(owner.property_address || "").trim();
if (propertyAddress) {
bySyncKey.set(buildOwnerSyncKey(owner.association_id, owner.first_name, owner.last_name, propertyAddress), owner);
}
setPreferredOwnerByUnit(byUnitId, owner);
}
return { byBuildiumId, bySyncKey, byUnitId };
})();
return ownerLookupPromise;
}
async function getBuildiumAssociationUnits() {
if (buildiumAssociationUnitsPromise) return buildiumAssociationUnitsPromise;
buildiumAssociationUnitsPromise = (async () => {
const units = await buildiumFetchAll("/v1/associations/units", clientId, clientSecret);
const byId = new Map<string, any>();
for (const unit of units) {
const id = normId(unit.Id);
if (id) byId.set(id, unit);
}
return { units, byId };
})();
return buildiumAssociationUnitsPromise;
}
async function getOwnershipAccounts() {
if (ownershipAccountsPromise) return ownershipAccountsPromise;
ownershipAccountsPromise = buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
return ownershipAccountsPromise;
}
function buildOwnershipAccountsByUnit(ownershipAccounts: any[]) {
const byUnit = new Map<string, any[]>();
for (const account of ownershipAccounts) {
const buildiumUnitId = normId(account?.UnitId);
if (!buildiumUnitId) continue;
if (!byUnit.has(buildiumUnitId)) byUnit.set(buildiumUnitId, []);
byUnit.get(buildiumUnitId)!.push(account);
}
return byUnit;
}
function pickPreferredOwnershipAccount(accounts: any[]) {
if (!Array.isArray(accounts) || accounts.length === 0) return null;
// Prefer active/current accounts, but fall back to any account (including delinquent/collections)
const preferred = accounts.find((account: any) => {
const status = String(account?.Status || "").toLowerCase();
return status === "active" || status === "current" || status === "";
});
return preferred || accounts[0];
}
async function updateUnitAccountNumber(
unitLookup: Awaited<ReturnType<typeof getUnitLookup>>,
unitId: string,
accountNumber: string | null,
) {
if (!accountNumber) return;
const cachedUnit = unitLookup.units.find((unit: any) => unit.id === unitId) || null;
if (cachedUnit?.account_number === accountNumber) return;
const { error } = await supabase.from("units").update({ account_number: accountNumber }).eq("id", unitId);
if (error) {
console.warn(`Failed to update account number for unit ${unitId}: ${error.message}`);
return;
}
if (cachedUnit) cachedUnit.account_number = accountNumber;
for (const unit of unitLookup.byBuildiumId.values()) {
if (unit.id === unitId) unit.account_number = accountNumber;
}
for (const unit of unitLookup.byAssocAndNumber.values()) {
if (unit.id === unitId) unit.account_number = accountNumber;
}
for (const unit of unitLookup.byAssocAndAddress.values()) {
if (unit.id === unitId) unit.account_number = accountNumber;
}
}
async function resolveOrCreateUnit(
buildiumUnit: any,
assocId: string,
unitLookup: Awaited<ReturnType<typeof getUnitLookup>>,
): Promise<{ unitId: string; created: boolean }> {
const buildiumUnitId = normId(buildiumUnit.Id);
const address = formatAddress(buildiumUnit.Address ?? buildiumUnit.PrimaryAddress);
const explicitUnitNumber = normId(buildiumUnit.UnitNumber);
const parsedUnitNumber = extractUnitNumber(address);
const unitNumber = explicitUnitNumber || parsedUnitNumber || "Unknown";
const canMatchByNumber = !isPlaceholderUnitNumber(unitNumber);
const refreshLookup = (previousUnit: any, nextUnit: any) => {
const previousBuildiumId = normId(previousUnit?.buildium_unit_id);
const nextBuildiumId = normId(nextUnit?.buildium_unit_id);
if (previousBuildiumId && previousBuildiumId !== nextBuildiumId) {
unitLookup.byBuildiumId.delete(previousBuildiumId);
}
if (nextBuildiumId) unitLookup.byBuildiumId.set(nextBuildiumId, nextUnit);
if (nextUnit.unit_number) unitLookup.byAssocAndNumber.set(`${assocId}|${norm(nextUnit.unit_number)}`, nextUnit);
if (nextUnit.address) unitLookup.byAssocAndAddress.set(`${assocId}|${norm(nextUnit.address)}`, nextUnit);
};
if (buildiumUnitId) {
const existing = unitLookup.byBuildiumId.get(buildiumUnitId);
if (existing) {
const updates: Record<string, any> = {};
if ((!existing.address || !String(existing.address).trim()) && address) updates.address = address;
if (isPlaceholderUnitNumber(existing.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
if (Object.keys(updates).length > 0) {
await supabase.from("units").update(updates).eq("id", existing.id);
refreshLookup(existing, { ...existing, ...updates });
}
return { unitId: existing.id, created: false };
}
}
const byNumber = canMatchByNumber ? unitLookup.byAssocAndNumber.get(`${assocId}|${norm(unitNumber)}`) : null;
if (byNumber) {
const updates: Record<string, any> = {};
if (buildiumUnitId && byNumber.buildium_unit_id !== buildiumUnitId) updates.buildium_unit_id = buildiumUnitId;
if ((!byNumber.address || !String(byNumber.address).trim()) && address) updates.address = address;
if (isPlaceholderUnitNumber(byNumber.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
if (Object.keys(updates).length > 0) {
await supabase.from("units").update(updates).eq("id", byNumber.id);
refreshLookup(byNumber, { ...byNumber, ...updates });
}
return { unitId: byNumber.id, created: false };
}
if (address) {
const byAddr = unitLookup.byAssocAndAddress.get(`${assocId}|${norm(address)}`);
if (byAddr) {
const updates: Record<string, any> = {};
if (buildiumUnitId && byAddr.buildium_unit_id !== buildiumUnitId) updates.buildium_unit_id = buildiumUnitId;
if (isPlaceholderUnitNumber(byAddr.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
if (Object.keys(updates).length > 0) {
await supabase.from("units").update(updates).eq("id", byAddr.id);
refreshLookup(byAddr, { ...byAddr, ...updates, address: byAddr.address || address });
}
return { unitId: byAddr.id, created: false };
}
}
const { data: newUnit, error } = await supabase.from("units").insert({
association_id: assocId,
unit_number: unitNumber,
address: address || null,
buildium_unit_id: buildiumUnitId,
account_number: null,
status: "active",
}).select("id, association_id, unit_number, buildium_unit_id, account_number, address").single();
if (error) {
if (buildiumUnitId) {
const { data: conflictUnit } = await supabase
.from("units")
.select("id, association_id, unit_number, buildium_unit_id, account_number, address")
.eq("buildium_unit_id", buildiumUnitId)
.maybeSingle();
if (conflictUnit) {
const recoveryUpdates: Record<string, any> = {};
if ((!conflictUnit.address || !String(conflictUnit.address).trim()) && address) recoveryUpdates.address = address;
if (isPlaceholderUnitNumber(conflictUnit.unit_number) && !isPlaceholderUnitNumber(unitNumber)) recoveryUpdates.unit_number = unitNumber;
if (Object.keys(recoveryUpdates).length > 0) {
await supabase.from("units").update(recoveryUpdates).eq("id", conflictUnit.id);
}
refreshLookup(conflictUnit, { ...conflictUnit, ...recoveryUpdates });
return { unitId: conflictUnit.id, created: false };
}
}
console.warn(`Unit create error for Buildium unit ${buildiumUnit?.Id ?? "unknown"}: ${error.message}`);
throw error;
}
refreshLookup(newUnit, newUnit);
return { unitId: newUnit.id, created: true };
}
if (syncType === "all" || syncType === "associations") {
const associations = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
const { data: excludedRows } = await supabase.from("associations").select("name").neq("status", "active");
const excludedNames = new Set((excludedRows || []).map((a: any) => a.name));
let filtered = associations.filter((a: any) => !excludedNames.has(a.Name));
if (selectedAssociationIds.length > 0) {
const { data: selected } = await supabase.from("associations").select("name").in("id", selectedAssociationIds);
const selectedNames = new Set((selected || []).map((s: any) => normalizeAssociationName(s.name)));
filtered = filtered.filter((a: any) => selectedNames.has(normalizeAssociationName(a.Name)));
}
const upserts = filtered.map((a: any) => ({
name: a.Name || "Unknown",
address: a.Address?.AddressLine1 || null,
city: a.Address?.City || null,
state: a.Address?.State || null,
zip: a.Address?.ZipCode || a.Address?.PostalCode || null,
email: a.Email || null,
phone: a.Phone || null,
fiscal_year_start: a.FiscalYearEndMonth || null,
}));
if (upserts.length > 0) {
const { error } = await supabase.from("associations").upsert(upserts, { onConflict: "name", ignoreDuplicates: true });
if (error) throw new Error(`Associations upsert failed: ${error.message}`);
}
// Ensure every active association has an accounting company. Once it exists,
// the existing DB triggers automatically flow units -> customers, owner
// ledger -> A/R + income, and bills -> A/P + expense into Accounting.
// (Opening balances are a separate one-time migration: buildium-opening-balances.)
let accountingCompaniesCreated = 0;
try {
const { data: activeAssocs } = await supabase.from("associations").select("id, name").eq("status", "active");
const acctSchema = (supabase as any).schema("accounting");
const { data: existingCompanies } = await acctSchema.from("companies").select("association_id");
const haveCompany = new Set((existingCompanies || []).map((c: any) => c.association_id));
const toCreate = (activeAssocs || []).filter((a: any) => a.id && !haveCompany.has(a.id));
if (toCreate.length > 0) {
const { error: compErr } = await acctSchema.from("companies").insert(
toCreate.map((a: any) => ({ association_id: a.id, name: a.name || "Association", created_by: userId }))
);
if (compErr) console.warn(`Accounting company provisioning failed: ${compErr.message}`);
else accountingCompaniesCreated = toCreate.length;
}
} catch (e) {
console.warn("Accounting company provisioning error:", (e as Error)?.message);
}
results.associations = { fetched: associations.length, upserted: upserts.length, accounting_companies_created: accountingCompaniesCreated };
}
if (syncType === "all" || syncType === "units") {
const { bIdToLocalId } = await getAssociationMaps();
const unitLookup = await getUnitLookup();
const { units: buildiumUnits } = await getBuildiumAssociationUnits();
const ownershipAccounts = await getOwnershipAccounts();
const ownershipAccountsByUnit = buildOwnershipAccountsByUnit(ownershipAccounts);
let created = 0, linked = 0, skippedUnits = 0;
for (const buildiumUnit of buildiumUnits) {
const assocId = bIdToLocalId.get(normId(buildiumUnit.AssociationId) || "") || null;
if (!assocId || !isSelected(assocId)) {
skippedUnits++;
continue;
}
try {
const result = await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
const buildiumUnitId = normId(buildiumUnit.Id);
const preferredAccount = buildiumUnitId ? pickPreferredOwnershipAccount(ownershipAccountsByUnit.get(buildiumUnitId) || []) : null;
const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
if (leaseId) await updateUnitAccountNumber(unitLookup, result.unitId, leaseId);
if (result.created) created++;
else linked++;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`Unit resolve error for Buildium unit ${buildiumUnit.Id}: ${message}`);
skippedUnits++;
}
}
results.units = { fetched: buildiumUnits.length, created, linked, skipped: skippedUnits };
}
if (syncType === "all" || syncType === "owners") {
const { bIdToLocalId } = await getAssociationMaps();
const unitLookup = await getUnitLookup();
const ownerLookup = await getOwnerLookup();
const { units: buildiumUnits } = await getBuildiumAssociationUnits();
const ownershipAccounts = await getOwnershipAccounts();
const ownershipAccountsByUnit = buildOwnershipAccountsByUnit(ownershipAccounts);
const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret);
let importedOwners = 0;
let skippedOwners = 0;
let unitsCreated = 0;
let unitsLinked = 0;
for (const buildiumUnit of buildiumUnits) {
const assocId = bIdToLocalId.get(normId(buildiumUnit.AssociationId) || "") || null;
if (!assocId || !isSelected(assocId)) continue;
try {
const result = await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
const buildiumUnitId = normId(buildiumUnit.Id);
const preferredAccount = buildiumUnitId ? pickPreferredOwnershipAccount(ownershipAccountsByUnit.get(buildiumUnitId) || []) : null;
const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
if (leaseId) await updateUnitAccountNumber(unitLookup, result.unitId, leaseId);
if (result.created) unitsCreated++;
else unitsLinked++;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`Unit resolve error for Buildium unit ${buildiumUnit.Id}: ${message}`);
}
}
const ownerIdToAccounts = new Map<string, any[]>();
for (const acct of ownershipAccounts) {
const ownerIds: string[] = [];
if (acct.AssociationOwnerIds && Array.isArray(acct.AssociationOwnerIds)) {
for (const oid of acct.AssociationOwnerIds) ownerIds.push(String(oid));
}
if (acct.AssociationOwnerId) ownerIds.push(String(acct.AssociationOwnerId));
for (const oid of ownerIds) {
if (!ownerIdToAccounts.has(oid)) ownerIdToAccounts.set(oid, []);
ownerIdToAccounts.get(oid)!.push(acct);
}
}
console.log(`Owner sync: ${owners.length} owners, ${ownershipAccounts.length} ownership accounts, ownerIdToAccounts size: ${ownerIdToAccounts.size}`);
if (owners.length > 0) {
console.log(`Sample owner keys: ${JSON.stringify(Object.keys(owners[0]))}`);
}
for (const owner of owners) {
const firstName = (owner.FirstName || "").trim() || "Unknown";
const lastName = (owner.LastName || "").trim() || "Owner";
const address = formatAddress(owner.PrimaryAddress);
const mailingAddress = formatAddress(owner.AlternateAddress);
const buildiumOwnerId = normId(owner.Id);
let assocId = bIdToLocalId.get(normId(owner.AssociationId) || "") || null;
const linkedAccountsById = new Map<string, any>();
const linkedAccountsRaw = [
...(Array.isArray(owner.OwnershipAccounts) ? owner.OwnershipAccounts : []),
...(buildiumOwnerId ? (ownerIdToAccounts.get(buildiumOwnerId) || []) : []),
];
for (const acct of linkedAccountsRaw) {
const accountId = normId(acct?.Id ?? acct?.OwnershipAccountId);
if (accountId) linkedAccountsById.set(accountId, acct);
}
const linkedAccounts = Array.from(linkedAccountsById.values());
if (!assocId && linkedAccounts.length > 0) {
for (const acct of linkedAccounts) {
assocId = bIdToLocalId.get(normId(acct.AssociationId) || "") || null;
if (assocId) break;
}
}
if (!assocId || !isSelected(assocId)) {
if (importedOwners === 0 && skippedOwners < 3) {
console.log(`Skipping owner ${buildiumOwnerId} "${firstName} ${lastName}": AssociationId=${owner.AssociationId}, linked accounts=${linkedAccounts.length}`);
}
skippedOwners++;
continue;
}
const propertyAddress = address || "N/A";
let unitId: string | null = null;
const buildiumUnitIds = new Set<string>();
for (const acct of linkedAccounts) {
const linkedUnitId = normId(acct.UnitId);
if (linkedUnitId) buildiumUnitIds.add(linkedUnitId);
}
const directUnitId = normId(owner.UnitId);
if (directUnitId) buildiumUnitIds.add(directUnitId);
for (const buildiumUnitId of buildiumUnitIds) {
const unit = unitLookup.byBuildiumId.get(buildiumUnitId);
if (unit) {
unitId = unit.id;
break;
}
}
if (!unitId && propertyAddress !== "N/A") {
const unitByAddress = unitLookup.byAssocAndAddress.get(`${assocId}|${norm(propertyAddress)}`);
if (unitByAddress) unitId = unitByAddress.id;
}
if (unitId) {
const preferredAccount = pickPreferredOwnershipAccount(linkedAccounts);
const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
if (leaseId) await updateUnitAccountNumber(unitLookup, unitId, leaseId);
}
const isCurrentOwner = linkedAccounts.some((acct: any) => {
const acctStatus = String(acct.Status || "").toLowerCase();
return acctStatus === "active" || acctStatus === "current" || acctStatus === "";
});
const ownerStatus = isCurrentOwner ? "active" : "archived";
const ownerRecord: Record<string, any> = {
association_id: assocId,
first_name: firstName,
last_name: lastName,
property_address: propertyAddress,
mailing_address: mailingAddress || null,
email: owner.Email || null,
phone: owner.PhoneNumbers?.[0]?.Number || null,
buildium_owner_id: buildiumOwnerId,
unit_id: unitId,
status: ownerStatus,
};
const syncKey = buildOwnerSyncKey(assocId, firstName, lastName, propertyAddress);
const existingById = buildiumOwnerId ? ownerLookup.byBuildiumId.get(buildiumOwnerId) : null;
const existingBySyncKey = ownerLookup.bySyncKey.get(syncKey);
const existingOwner = existingById || existingBySyncKey;
if (existingOwner) {
const updates: Record<string, any> = {};
if (!existingOwner.buildium_owner_id && buildiumOwnerId) updates.buildium_owner_id = buildiumOwnerId;
if (unitId && existingOwner.unit_id !== unitId) updates.unit_id = unitId;
if (!existingOwner.email && ownerRecord.email) updates.email = ownerRecord.email;
if (!existingOwner.phone && ownerRecord.phone) updates.phone = ownerRecord.phone;
if (!existingOwner.mailing_address && ownerRecord.mailing_address) updates.mailing_address = ownerRecord.mailing_address;
if ((!existingOwner.property_address || existingOwner.property_address === "N/A") && ownerRecord.property_address && ownerRecord.property_address !== "N/A") updates.property_address = ownerRecord.property_address;
if (existingOwner.status !== ownerStatus) updates.status = ownerStatus;
if (Object.keys(updates).length > 0) {
const { error } = await supabase.from("owners").update(updates).eq("id", existingOwner.id);
if (error) {
console.warn(`Owner update: ${error.message}`);
skippedOwners++;
continue;
}
}
const nextOwner = { ...existingOwner, ...updates, id: existingOwner.id };
if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, nextOwner);
ownerLookup.bySyncKey.set(syncKey, nextOwner);
setPreferredOwnerByUnit(ownerLookup.byUnitId, nextOwner);
importedOwners++;
continue;
}
const { data: dupeCheck } = await supabase.from("owners")
.select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status")
.eq("association_id", assocId)
.eq("first_name", firstName)
.eq("last_name", lastName)
.maybeSingle();
if (dupeCheck) {
const updates: Record<string, any> = {};
if (!dupeCheck.buildium_owner_id && buildiumOwnerId) updates.buildium_owner_id = buildiumOwnerId;
if (unitId && dupeCheck.unit_id !== unitId) updates.unit_id = unitId;
if (!dupeCheck.email && ownerRecord.email) updates.email = ownerRecord.email;
if (!dupeCheck.phone && ownerRecord.phone) updates.phone = ownerRecord.phone;
if (!dupeCheck.mailing_address && ownerRecord.mailing_address) updates.mailing_address = ownerRecord.mailing_address;
if ((!dupeCheck.property_address || dupeCheck.property_address === "N/A") && ownerRecord.property_address && ownerRecord.property_address !== "N/A") updates.property_address = ownerRecord.property_address;
if (dupeCheck.status !== ownerStatus) updates.status = ownerStatus;
if (Object.keys(updates).length > 0) {
await supabase.from("owners").update(updates).eq("id", dupeCheck.id);
}
const nextOwner = { ...dupeCheck, ...updates };
if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, nextOwner);
ownerLookup.bySyncKey.set(syncKey, nextOwner);
setPreferredOwnerByUnit(ownerLookup.byUnitId, nextOwner);
importedOwners++;
continue;
}
const { data: insertedOwner, error } = await supabase
.from("owners")
.insert(ownerRecord)
.select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status")
.single();
if (error) {
console.warn(`Owner insert: ${error.message}`);
skippedOwners++;
continue;
}
if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, insertedOwner);
ownerLookup.bySyncKey.set(syncKey, insertedOwner);
setPreferredOwnerByUnit(ownerLookup.byUnitId, insertedOwner);
importedOwners++;
}
results.owners = {
fetched: owners.length,
imported: importedOwners,
skipped: skippedOwners,
units_created: unitsCreated,
units_linked: unitsLinked,
};
}
if (syncType === "all" || syncType === "financials") {
const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
const { data: assocRows } = await supabase.from("associations").select("id, name");
let totalAccounts = 0;
// Separate parent (top-level) and child (sub) accounts
const parentAccounts = glAccounts.filter((gl: any) => !gl.ParentGLAccountId);
const childAccounts = glAccounts.filter((gl: any) => gl.ParentGLAccountId);
for (const assoc of assocRows || []) {
if (!isSelected(assoc.id)) continue;
// Step 1: Upsert parent accounts first
const parentUpserts = parentAccounts.map((gl: any) => ({
association_id: assoc.id,
account_number: String(gl.Id || gl.AccountNumber || ""),
account_name: gl.Name || "Unknown",
account_type: mapGLAccountType(gl.Type || gl.AccountType),
description: gl.Description || null,
is_active: gl.IsActive !== false,
}));
if (parentUpserts.length > 0) {
const { error } = await supabase.from("chart_of_accounts").upsert(parentUpserts, { onConflict: "association_id, account_number", ignoreDuplicates: false });
if (error) console.warn(`GL parent upsert for ${assoc.name}: ${error.message}`);
else totalAccounts += parentUpserts.length;
}
// Step 2: Build lookup of account_number -> id for this association
const { data: existingAccounts } = await supabase
.from("chart_of_accounts")
.select("id, account_number")
.eq("association_id", assoc.id);
const acctNumToId = new Map<string, string>();
for (const a of existingAccounts || []) {
acctNumToId.set(a.account_number, a.id);
}
// Step 3: Upsert child/subaccounts with parent_account_id linked
if (childAccounts.length > 0) {
const childUpserts = childAccounts.map((gl: any) => {
const parentBuildiumId = String(gl.ParentGLAccountId || "");
const parentDbId = acctNumToId.get(parentBuildiumId) || null;
return {
association_id: assoc.id,
account_number: String(gl.Id || gl.AccountNumber || ""),
account_name: gl.Name || "Unknown",
account_type: mapGLAccountType(gl.Type || gl.AccountType),
description: gl.Description || null,
is_active: gl.IsActive !== false,
parent_account_id: parentDbId,
};
});
const { error } = await supabase.from("chart_of_accounts").upsert(childUpserts, { onConflict: "association_id, account_number", ignoreDuplicates: false });
if (error) console.warn(`GL child upsert for ${assoc.name}: ${error.message}`);
else totalAccounts += childUpserts.length;
}
}
results.financials = { fetched: glAccounts.length, upserted: totalAccounts };
}
if (syncType === "all" || syncType === "charges" || syncType === "payments" || syncType === "ledger") {
const { bIdToLocalId } = await getAssociationMaps();
const unitLookup = await getUnitLookup();
const ownerLookup = await getOwnerLookup();
const { byId: buildiumUnitsById } = await getBuildiumAssociationUnits();
const ownershipAccountsForLedger = await getOwnershipAccounts();
let imported = 0, skipped = 0, updated = 0, totalFetched = 0;
function getEntryLines(entry: any): any[] {
const topLines = Array.isArray(entry.Lines) ? entry.Lines : [];
const journalLines = Array.isArray(entry.Journal?.Lines) ? entry.Journal.Lines : [];
const journalEntryLines = Array.isArray(entry.JournalEntry?.Lines) ? entry.JournalEntry.Lines : [];
return topLines.length > 0 ? topLines : journalLines.length > 0 ? journalLines : journalEntryLines;
}
function getEntryDescription(entry: any): string {
const lines = getEntryLines(entry);
const candidates = [
entry.Description,
entry.Memo,
entry.Journal?.Memo,
entry.Journal?.Description,
entry.JournalEntry?.Memo,
entry.JournalEntry?.Description,
entry.Notes,
entry.PayeeName,
entry.Name,
entry.Title,
entry.ChargeTypeName,
entry.CategoryName,
lines[0]?.Description,
lines[0]?.Memo,
lines[0]?.Name,
lines[0]?.ChargeTypeName,
lines[0]?.GLAccountName,
lines[0]?.GLAccount?.Name,
lines[0]?.GLAccount?.DefaultAccountName,
lines[0]?.GLAccount?.Description,
lines[0]?.GLAccount?.AccountName,
];
for (const c of candidates) {
if (c && String(c).trim()) return String(c).trim();
}
return "";
}
function getEntrySearchText(entry: any): string {
const lines = getEntryLines(entry);
const topLevelValues = [
entry.TransactionType,
entry.TransactionTypeEnum,
entry.Type,
entry.SubType,
entry.Subtype,
entry.Category,
entry.CategoryName,
entry.ChargeType,
entry.ChargeTypeName,
entry.PaymentType,
entry.Description,
entry.Memo,
entry.Journal?.Memo,
entry.Journal?.Description,
entry.Journal?.Type,
entry.Journal?.SubType,
entry.JournalEntry?.Memo,
entry.JournalEntry?.Description,
entry.JournalEntry?.Type,
entry.JournalEntry?.SubType,
entry.Notes,
entry.PayeeName,
entry.ReferenceNumber,
entry.Reference,
entry.Name,
entry.Title,
entry.GLAccountName,
entry.GLAccount?.Name,
entry.GLAccount?.DefaultAccountName,
entry.GLAccount?.Description,
entry.GLAccount?.AccountName,
entry.GLAccount?.Number,
entry.GLAccount?.AccountNumber,
entry.GLAccount?.Type,
entry.GLAccount?.AccountType,
];
const lineLevelValues = lines.flatMap((line: any) => [
line.Description,
line.Memo,
line.Name,
line.Type,
line.SubType,
line.Category,
line.CategoryName,
line.ChargeType,
line.ChargeTypeName,
line.ReferenceNumber,
line.AccountName,
line.AccountNumber,
line.AccountType,
line.GLAccountName,
line.GLAccount?.Name,
line.GLAccount?.DefaultAccountName,
line.GLAccount?.Description,
line.GLAccount?.AccountName,
line.GLAccount?.Number,
line.GLAccount?.AccountNumber,
line.GLAccount?.Type,
line.GLAccount?.AccountType,
]);
return norm([...topLevelValues, ...lineLevelValues].filter(Boolean).join(" "));
}
// Shared classification logic used by both whole-entry and per-line classifiers
function classifyFromText(searchText: string, glAccountText: string, debugInfo?: any): string {
const has = (...needles: string[]) => needles.some((needle) => searchText.includes(needle));
const glHas = (...needles: string[]) => needles.some((n) => glAccountText.includes(n));
if (glHas("interest", "finance charge")) return "interest";
if (glHas("prepayment", "prepaid", "pre payment", "pre paid", "advance payment")) return "Prepayment";
if (has("prepayment charge", "prepayment", "prepay", "pre payment", "pre pay", "advance payment", "advance deposit")) return "Prepayment";
if (has("special assessment", "reserve assessment", "capital improvement assessment")) return "special_assessment";
if (has("interest", "finance charge", "finance charges")) return "interest";
if (has("late fee", "late charge", "late charges", "late payment fee", "late payment charge", "non payment penalty", "delinquency fee", "delinquent fee")) return "late_fee";
if (has("legal fee", "legal fees", "attorney", "attorneys fee", "attorneys fees", "lien", "foreclosure", "court cost", "court costs", "collection cost", "collection costs", "demand letter")) return "legal_fee";
if (has("admin fee", "administrative fee", "administrative fees", "administration fee", "processing fee", "document fee", "notice fee", "statement fee", "mailing fee")) return "admin_fee";
if (has("violation", "violations", "fine", "fines", "covenant", "compliance", "enforcement")) return "violation";
if (has("bank fee", "bank charge", "nsf", "returned check", "returned payment", "return item", "insufficient funds", "chargeback", "ach reject", "rejected ach", "reject fee")) return "bank_fee";
if (has("assessment", "assessment fees", "monthly assessment", "monthly assessments", "monthly dues", "hoa dues", "association dues", "maintenance fee", "maintenance dues", "association fee income", "condo fee")) return "assessment";
if (debugInfo) {
console.warn("[buildium-sync] Unclassified charge defaulted to assessment", debugInfo);
}
return "assessment";
}
function classifyChargeType(entry: any): string {
const searchText = getEntrySearchText(entry);
const lines = getEntryLines(entry);
const glAccountText = norm(
[
...lines.map((l: any) => l.GLAccount?.Name || l.GLAccount?.name || ""),
...lines.map((l: any) => l.GLAccount?.AccountType || l.GLAccount?.accountType || ""),
]
.filter(Boolean)
.join(" ")
);
return classifyFromText(searchText, glAccountText, {
id: entry.Id,
transactionType: entry.TransactionType,
description: entry.Description ?? null,
memo: entry.Memo ?? null,
journalMemo: entry.Journal?.Memo ?? entry.JournalEntry?.Memo ?? null,
glAccountText,
searchText,
});
}
// Classify a single line item from a multi-line charge
function classifyLineChargeType(line: any, entry: any): string {
const lineFields = [
line.Description, line.Memo, line.Name, line.Type, line.SubType,
line.Category, line.CategoryName, line.ChargeType, line.ChargeTypeName,
line.GLAccountName, line.GLAccount?.Name, line.GLAccount?.DefaultAccountName,
line.GLAccount?.Description, line.GLAccount?.AccountName,
line.GLAccount?.Number, line.GLAccount?.AccountNumber,
line.GLAccount?.Type, line.GLAccount?.AccountType,
// Also include entry-level context for fallback matching
entry.Description, entry.Memo, entry.Journal?.Memo, entry.JournalEntry?.Memo,
];
const searchText = norm(lineFields.filter(Boolean).join(" "));
const glAccountText = norm(
[line.GLAccount?.Name || line.GLAccount?.name || "",
line.GLAccount?.AccountType || line.GLAccount?.accountType || ""]
.filter(Boolean).join(" ")
);
return classifyFromText(searchText, glAccountText);
}
// Get description for a single line item
function getLineDescription(line: any, entry: any): string {
const candidates = [
line.Description, line.Memo, line.Name, line.ChargeTypeName,
line.GLAccountName, line.GLAccount?.Name, line.GLAccount?.DefaultAccountName,
line.GLAccount?.Description, line.GLAccount?.AccountName,
entry.Description, entry.Memo,
];
for (const c of candidates) {
if (c && String(c).trim()) return String(c).trim();
}
return "";
}
for (const acct of ownershipAccountsForLedger) {
const assocId = bIdToLocalId.get(normId(acct.AssociationId) || "") || null;
if (!assocId || !isSelected(assocId)) continue;
const buildiumUnitId = normId(acct.UnitId);
let unit = buildiumUnitId ? unitLookup.byBuildiumId.get(buildiumUnitId) : null;
if (!unit && buildiumUnitId) {
const buildiumUnit = buildiumUnitsById.get(buildiumUnitId);
if (buildiumUnit) {
try {
await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
unit = unitLookup.byBuildiumId.get(buildiumUnitId) || null;
} catch (e) {
console.warn(`Ledger unit recovery failed for account ${acct.Id}: ${e}`);
}
}
}
if (!unit) { skipped++; continue; }
if (unitFilterId && unit.id !== unitFilterId) { skipped++; continue; }
const unitOwner = ownerLookup.byUnitId.get(unit.id) || null;
if (!unitOwner) { skipped++; continue; }
const ownershipAccountId = normId(acct.Id);
let ledgerEntries: any[] = [];
try {
const txnParams: Record<string, string> = {};
if (ledgerDateFrom) txnParams.transactiondatefrom = ledgerDateFrom;
if (ledgerDateTo) txnParams.transactiondateto = ledgerDateTo;
ledgerEntries = await buildiumFetchAll(`/v1/associations/ownershipaccounts/${acct.Id}/transactions`, clientId, clientSecret, Object.keys(txnParams).length > 0 ? txnParams : undefined);
} catch (e) {
console.warn(`Ledger fetch for account ${acct.Id}: ${e}`);
skipped++;
continue;
}
totalFetched += ledgerEntries.length;
// DEBUG: Log the raw structure of the first few entries to understand Buildium's response format
if (ledgerEntries.length > 0) {
const sampleEntries = ledgerEntries.slice(0, 3);
for (const sample of sampleEntries) {
console.log("[buildium-sync] RAW ENTRY SAMPLE", JSON.stringify(sample, null, 2));
}
}
// Skip old lump-sum cleanup — we now rely on reference_id deduplication only
// to prevent accidental deletion and re-insertion of entries.
// Invalidate cache for this owner so we get fresh data
ownerLedgerCache.delete(unit.id ? `unit:${unit.id}` : `owner:${unitOwner.id}`);
const ledgerEntryMaps = await getOwnerLedgerEntryMaps(unitOwner.id, unit.id);
const targetOwnerId = ledgerEntryMaps.defaultOwnerId || unitOwner.id;
// Process ALL ledger entries (charges, payments, credits, etc.)
for (const entry of ledgerEntries) {
const buildiumLedgerId = String(entry.Id || "");
if (!buildiumLedgerId) continue;
const amount = Number(entry.TotalAmount ?? entry.Amount ?? 0);
if (amount === 0) continue;
const txnType = String(entry.TransactionType || "");
const txnDate = entry.Date ? entry.Date.split("T")[0] : new Date().toISOString().split("T")[0];
// BLOCK: Skip any prepayment-related entries from Buildium (per user request).
// Exception: keep "SIRS Reserve Prepayment" entries which are intentional reserves.
const rawDescForFilter = String(getEntryDescription(entry) || "");
const isPrepaymentTxn =
txnType === "AppliedPrepayment" ||
txnType === "Applied Prepayment" ||
/prepayment/i.test(txnType) ||
/prepayment/i.test(rawDescForFilter);
const isSirsReserve = /sirs reserve prepayment/i.test(rawDescForFilter);
if (isPrepaymentTxn && !isSirsReserve) {
skipped++;
continue;
}
// Helper: upsert a single ledger entry by reference_id
async function upsertLedgerEntry(
refId: string, txDate: string, txType: string, desc: string,
entryDebit: number, entryCredit: number,
maps: typeof ledgerEntryMaps, assocIdLocal: string, ownerIdLocal: string, unitIdLocal: string,
): Promise<"imported" | "updated" | "skipped"> {
const existingEntry = maps.byReferenceId.get(refId) || null;
if (existingEntry) {
const dChanged = desc && existingEntry.description !== desc;
if (existingEntry.debit !== entryDebit || existingEntry.credit !== entryCredit || existingEntry.transaction_type !== txType || dChanged) {
await supabase.from("owner_ledger_entries").update({
debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType,
}).eq("id", existingEntry.id);
maps.byReferenceId.set(refId, { ...existingEntry, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType });
return "updated";
}
return "skipped";
}
const legacyKey = makeLegacyKey(desc, txDate);
const legacyMatch = maps.byLegacyKey.get(legacyKey) || null;
if (legacyMatch) {
await supabase.from("owner_ledger_entries").update({
reference_id: refId, reference_type: "buildium",
debit: entryDebit, credit: entryCredit, transaction_type: txType, description: desc,
}).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 });
return "skipped";
}
const daKey = makeDateAmountKey(txDate, entryDebit, entryCredit);
const dateAmountMatch = maps.byDateAmount.get(daKey) || null;
if (dateAmountMatch && !dateAmountMatch.reference_id) {
await supabase.from("owner_ledger_entries").update({
reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc,
}).eq("id", dateAmountMatch.id);
maps.byDateAmount.delete(daKey);
maps.byReferenceId.set(refId, { ...dateAmountMatch, reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc });
return "skipped";
}
const insertPayload = {
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",
};
const { data: insertedRow, error: insertErr } = await supabase
.from("owner_ledger_entries")
.insert(insertPayload)
.select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type")
.single();
if (insertErr) {
if (isUniqueViolation(insertErr)) {
const { data: existingRow } = await supabase
.from("owner_ledger_entries")
.select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type")
.eq("unit_id", unitIdLocal)
.eq("reference_type", "buildium")
.eq("reference_id", refId)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle();
if (existingRow) {
const descDiff = desc && existingRow.description !== desc;
let resolvedRow: OwnerLedgerEntryRow = existingRow;
if (existingRow.debit !== entryDebit || existingRow.credit !== entryCredit || existingRow.transaction_type !== txType || descDiff) {
const { error: updateError } = await supabase.from("owner_ledger_entries").update({
debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType,
}).eq("id", existingRow.id);
if (!updateError) {
resolvedRow = { ...existingRow, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType };
}
}
maps.byReferenceId.set(refId, resolvedRow);
maps.byDateAmount.set(daKey, resolvedRow);
return "skipped";
}
}
console.warn(`Ledger insert (${txType}): ${insertErr.message}`);
return "skipped";
} else if (insertedRow) {
maps.byReferenceId.set(refId, insertedRow);
maps.byDateAmount.set(daKey, insertedRow);
return "imported";
}
return "skipped";
}
let transactionType: string;
let debit = 0;
let credit = 0;
let description: string;
const buildiumDesc = getEntryDescription(entry);
// POLICY: From Buildium we only PULL payments. Charges originate locally and are pushed to Buildium.
const isPaymentTxn = ["Payment", "Credit", "Check"].includes(txnType) || amount < 0;
if (!isPaymentTxn) {
skipped++;
continue;
}
if (["Payment", "Credit", "Check"].includes(txnType)) {
transactionType = "payment";
credit = Math.abs(amount);
description = buildiumDesc || `Buildium ${txnType} - ${entry.ReferenceNumber || entry.Id}`;
} else if (txnType === "Charge") {
// Check if this charge has multiple line items linked to different GL accounts
const chargeLines = getEntryLines(entry);
const uniqueGLIds = new Set(
chargeLines.map((l: any) =>
String(l.GLAccount?.Id || l.GLAccountId || l.GLAccount?.AccountNumber || l.GLAccount?.Name || "unknown")
)
);
const isMultiAccount = chargeLines.length > 1 && uniqueGLIds.size > 1;
if (isMultiAccount) {
// Multi-line charge: break into separate ledger entries per GL account line
// Remove legacy single entry if it exists (from before this breakdown logic)
const oldSingleEntry = ledgerEntryMaps.byReferenceId.get(buildiumLedgerId) || null;
if (oldSingleEntry) {
await supabase.from("owner_ledger_entries").delete().eq("id", oldSingleEntry.id);
ledgerEntryMaps.byReferenceId.delete(buildiumLedgerId);
console.log(`[buildium-sync] Replaced single entry ${buildiumLedgerId} with ${chargeLines.length} line items`);
}
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 lineRefId = `${buildiumLedgerId}_L${li}`;
const lineType = classifyLineChargeType(line, entry);
const lineDesc = getLineDescription(line, entry) ||
`Buildium ${lineType.replace(/_/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase())}`;
let lineDebit = 0;
let lineCredit = 0;
if (lineType === "Prepayment") {
lineCredit = Math.abs(lineAmount);
} else {
lineDebit = Math.max(0, lineAmount);
lineCredit = Math.max(0, -lineAmount);
}
const result = await upsertLedgerEntry(
lineRefId, txnDate, lineType, lineDesc,
lineDebit, lineCredit,
ledgerEntryMaps, assocId, targetOwnerId, unit.id,
);
if (result === "imported") imported++;
else if (result === "updated") updated++;
else skipped++;
}
continue; // skip the single-entry upsert below
}
// Single-line charge (or all lines share the same GL account)
transactionType = classifyChargeType(entry);
if (transactionType === "Prepayment") {
credit = Math.abs(amount);
debit = 0;
} else {
debit = Math.max(0, amount);
credit = Math.max(0, -amount);
}
description = buildiumDesc || `Buildium ${transactionType.replace(/_/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase())}`;
} else if (txnType === "AppliedPrepayment" || txnType === "Applied Prepayment") {
transactionType = "applied_prepayment";
credit = Math.abs(amount);
debit = 0;
description = buildiumDesc || `Buildium Applied Prepayment - ${entry.Id}`;
} else {
transactionType = txnType.toLowerCase().replace(/\s+/g, "_") || "other";
if (amount > 0) { debit = amount; } else { credit = Math.abs(amount); }
description = buildiumDesc || `Buildium ${txnType} - ${entry.Id}`;
}
// Single-entry upsert path
const singleResult = await upsertLedgerEntry(
buildiumLedgerId, txnDate, transactionType, description,
debit, credit,
ledgerEntryMaps, assocId, targetOwnerId, unit.id,
);
if (singleResult === "imported") imported++;
else if (singleResult === "updated") updated++;
else skipped++;
}
const recalculatedBalance = await calculateOwnerLedgerBalance(targetOwnerId);
await supabase.from("owners").update({ balance: recalculatedBalance }).eq("id", targetOwnerId);
}
results.ledger = { fetched: totalFetched, imported, updated, skipped };
results.charges = results.ledger;
results.payments = results.ledger;
}
// 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" }
);
// ===== PUSH TO BUILDIUM: Charges & Payments =====
if (syncType === "push_charges" || syncType === "push_payments" || syncType === "push_all") {
const unitLookup = await getUnitLookup();
const ownershipAccounts = await getOwnershipAccounts();
// Build reverse lookup: unit buildium_unit_id -> ownership account ID
const unitToOwnershipAccount = new Map<string, number>();
for (const acct of ownershipAccounts) {
const buildiumUnitId = normId(acct.UnitId);
if (buildiumUnitId) {
const existing = unitToOwnershipAccount.get(buildiumUnitId);
if (!existing) {
unitToOwnershipAccount.set(buildiumUnitId, Number(acct.Id));
}
}
}
// Fetch GL accounts from Buildium for mapping charge types.
// Buildium returns some child accounts nested under SubAccounts, so flatten
// them here too; otherwise mappings saved to sub-account IDs cannot resolve.
const rawGlAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
const glAccounts: any[] = [];
const seenGlAccountIds = new Set<string>();
const walkGlAccount = (gl: any) => {
if (!gl) return;
const id = String(gl.Id ?? "");
if (id && !seenGlAccountIds.has(id)) {
seenGlAccountIds.add(id);
glAccounts.push(gl);
}
if (Array.isArray(gl.SubAccounts)) {
for (const sub of gl.SubAccounts) walkGlAccount(sub);
}
};
for (const gl of rawGlAccounts) walkGlAccount(gl);
interface BuildiumGLAccountMeta { id: number; name: string; normalizedName: string; type: string; isActive: boolean; isChargeable: boolean; }
const glAccountByName = new Map<string, BuildiumGLAccountMeta>();
const glAccountByNumber = new Map<string, BuildiumGLAccountMeta>(); // account number -> Buildium account
const glAccountById = new Map<number, BuildiumGLAccountMeta>(); // Buildium Id -> account
const chargeableGlAccounts: BuildiumGLAccountMeta[] = [];
for (const gl of glAccounts) {
const glType = String(gl.Type || gl.AccountType || "");
const typeKey = glType.toLowerCase();
const meta: BuildiumGLAccountMeta = {
id: Number(gl.Id),
name: String(gl.Name || ""),
normalizedName: norm(gl.Name),
type: glType,
isActive: gl.IsActive !== false,
isChargeable: gl.IsActive !== false && (typeKey === "income" || typeKey === "liability"),
};
if (gl.Name) {
const existingName = glAccountByName.get(meta.normalizedName);
if (!existingName || (!existingName.isChargeable && meta.isChargeable)) glAccountByName.set(meta.normalizedName, meta);
}
if (gl.Id) glAccountById.set(Number(gl.Id), meta);
if (meta.isChargeable) chargeableGlAccounts.push(meta);
// Map by every plausible "account number" field Buildium might return
for (const key of ["AccountNumber", "Number", "GlNumber", "GLNumber", "Code"]) {
const acctNum = String(gl[key] ?? "").trim();
if (acctNum && acctNum !== "undefined" && acctNum !== "null") {
const existing = glAccountByNumber.get(acctNum);
if (!existing || (!existing.isChargeable && meta.isChargeable)) glAccountByNumber.set(acctNum, meta);
}
}
// Also map the Id as a string so "4" -> Id 4 works
const existingIdKey = glAccountByNumber.get(String(gl.Id));
if (!existingIdKey || (!existingIdKey.isChargeable && meta.isChargeable)) glAccountByNumber.set(String(gl.Id), meta);
}
console.log(`[push] Loaded ${glAccounts.length} Buildium GL accounts (${chargeableGlAccounts.length} chargeable income/liability). byNumber keys: ${[...glAccountByNumber.keys()].slice(0, 20).join(", ")}`);
console.log(`[push] Chargeable account names: ${chargeableGlAccounts.map(a => `${a.id}:${a.name}`).slice(0, 60).join(" | ")}`);
if (glAccounts.length > 0) {
console.log(`[push] Sample GL account keys: ${JSON.stringify(Object.keys(glAccounts[0]))}`);
}
// Load per-association GL mappings from the database (with amount thresholds)
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>>();
for (const assocId of (selectedAssociationIds.length > 0 ? selectedAssociationIds : [])) {
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")
.eq("association_id", assocId);
if (mappingRows && mappingRows.length > 0) {
const map = new Map<string, GLMappingRule[]>();
const descMap = new Map<string, string>();
for (const row of mappingRows) {
const rules = map.get(row.charge_type) || [];
rules.push({
glAccountId: row.buildium_gl_account_id,
glAccountName: row.buildium_gl_account_name ?? null,
amountMin: row.amount_min ?? null,
amountMax: row.amount_max ?? null,
customDescription: (row as any).custom_description ?? null,
});
map.set(row.charge_type, rules);
const cd = ((row as any).custom_description ?? "").toString().trim();
if (cd && row.charge_type !== "interest") descMap.set(row.charge_type, cd);
}
glMappingsByAssoc.set(assocId, map);
customDescByAssocType.set(assocId, descMap);
console.log(`[push] Loaded ${mappingRows.length} GL mappings for assoc ${assocId}: ${[...map.keys()].join(", ")}`);
}
}
// Resolve a stored GL account value to a real Buildium API Id.
// The value might already be a valid Buildium Id, or it might be an account number (e.g. "4002").
function resolveGLId(storedValue: string | number, storedName?: string | null): number | null {
const asChargeableId = (meta: BuildiumGLAccountMeta | undefined | null, source: string): number | null => {
if (!meta) return null;
if (meta.isChargeable) return meta.id;
console.warn(`[push] GL ${source} resolved to ${meta.id} (${meta.name}, Type=${meta.type}) but charges require Income or Liability`);
return null;
};
const sv = String(storedValue).trim();
if (!sv) return null;
const num = Number(sv);
// If it's already a known Buildium Id, use it directly
if (!isNaN(num)) {
const byId = asChargeableId(glAccountById.get(num), `id "${sv}"`);
if (byId) return byId;
}
// 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`);
return null;
}
// Find a GL account for a charge type — check DB mappings first (with amount matching), then fuzzy match
function findGLAccountId(transactionType: string, assocId?: string, amount?: number): number | null {
// Priority 1: Per-association mapping from buildium_gl_mappings table
if (assocId) {
const assocMappings = glMappingsByAssoc.get(assocId);
if (assocMappings) {
const rules = assocMappings.get(transactionType);
if (rules && rules.length > 0) {
// Try to find a threshold-specific match first
if (amount !== undefined && amount !== null) {
const thresholdMatch = rules.find((r) => {
if (r.amountMin === null && r.amountMax === null) return false;
const minOk = r.amountMin === null || amount >= r.amountMin;
const maxOk = r.amountMax === null || amount <= r.amountMax;
return minOk && maxOk;
});
if (thresholdMatch) {
const resolved = resolveGLId(thresholdMatch.glAccountId, thresholdMatch.glAccountName);
if (resolved) { console.log(`[push] GL resolved for ${transactionType} (threshold): ${thresholdMatch.glAccountId} -> ${resolved}`); return resolved; }
}
}
const defaultRule = rules.find((r) => r.amountMin === null && r.amountMax === null);
if (defaultRule) {
const resolved = resolveGLId(defaultRule.glAccountId, defaultRule.glAccountName);
if (resolved) { console.log(`[push] GL resolved for ${transactionType} (default rule): ${defaultRule.glAccountId} -> ${resolved}`); return resolved; }
}
const resolved = resolveGLId(rules[0].glAccountId, rules[0].glAccountName);
if (resolved) { console.log(`[push] GL resolved for ${transactionType} (first rule): ${rules[0].glAccountId} -> ${resolved}`); return resolved; }
console.warn(`[push] GL mapping rules found for ${transactionType} but none resolved. Rules: ${JSON.stringify(rules)}`);
}
} else {
console.warn(`[push] No GL mappings loaded for assoc ${assocId}`);
}
}
// 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(", ")}`);
return null;
}
// Get Buildium bank accounts for payment push
let buildiumBankAccounts: any[] = [];
if (syncType === "push_payments" || syncType === "push_all") {
buildiumBankAccounts = await buildiumFetchAll("/v1/bankaccounts", clientId, clientSecret);
}
const defaultBankAccountId = buildiumBankAccounts.length > 0 ? Number(buildiumBankAccounts[0].Id) : null;
let pushed = 0, pushSkipped = 0, pushErrors = 0;
const errorSamples: any[] = [];
const skipSamples: 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 });
};
const recordError = (entry: any, status: number | null, body: string, extra: any = {}) => {
pushErrors++;
if (errorSamples.length < 20) errorSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, debit: entry.debit, credit: entry.credit, status, body: String(body).slice(0, 600), ...extra });
};
// Fetch local ledger entries not yet pushed to Buildium
for (const assocId of (selectedAssociationIds.length > 0 ? selectedAssociationIds : [])) {
// Get entries that are local-only (no buildium reference)
// 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")
.eq("association_id", assocId)
.or("reference_type.is.null,and(reference_type.neq.buildium,reference_type.neq.buildium_pushed)")
.order("date");
if (unitFilterId) entriesQuery = entriesQuery.eq("unit_id", unitFilterId);
const { data: entries } = await entriesQuery;
if (!entries || entries.length === 0) continue;
// Filter based on push type
const filteredEntries = entries.filter((e: any) => {
// Skip already pushed
if (e.reference_type === "buildium" || e.reference_type === "buildium_pushed") return false;
// POLICY: We only PUSH charges to Buildium. Payments originate in Buildium and are pulled.
if (syncType === "push_charges" || syncType === "push_all") {
if (e.debit > 0 && e.credit === 0) return true;
}
return false;
});
for (const entry of filteredEntries) {
// Resolve ownership account ID via unit
if (!entry.unit_id) { recordSkip(entry, "no_unit_id"); continue; }
const unit = unitLookup.units.find((u: any) => u.id === entry.unit_id);
const buildiumUnitId = unit?.buildium_unit_id;
if (!buildiumUnitId) { recordSkip(entry, "unit_not_linked_to_buildium", { unit_id: entry.unit_id }); continue; }
const ownershipAccountId = unitToOwnershipAccount.get(buildiumUnitId);
if (!ownershipAccountId) { recordSkip(entry, "no_ownership_account_for_unit", { buildium_unit_id: buildiumUnitId }); continue; }
try {
if (entry.debit > 0) {
// Push as charge
const glAccountId = findGLAccountId(entry.transaction_type, assocId, entry.debit);
if (!glAccountId) {
recordSkip(entry, "no_gl_account_resolved", {
transaction_type: entry.transaction_type,
available_chargeable: chargeableGlAccounts.map(a => `${a.id}:${a.name}`).slice(0, 30),
});
continue;
}
const customMemo = entry.transaction_type === "interest"
? null
: (customDescByAssocType.get(assocId)?.get(entry.transaction_type) || null);
const chargeMemo = String(customMemo || entry.description || "Charge from management system").slice(0, 65);
const chargeBody = {
Date: entry.date,
Memo: chargeMemo,
Lines: [
{
Amount: entry.debit,
GLAccountId: glAccountId,
Memo: chargeMemo,
},
],
};
// POST charge to Buildium
const postRes = await fetch(`${BUILDIUM_BASE}/v1/associations/ownershipaccounts/${ownershipAccountId}/charges`, {
method: "POST",
headers: {
"x-buildium-client-id": clientId,
"x-buildium-client-secret": clientSecret,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(chargeBody),
});
if (!postRes.ok) {
const errText = await postRes.text();
console.warn(`Push charge failed for entry ${entry.id} (gl=${glAccountId}, type=${entry.transaction_type}): [${postRes.status}] ${errText}`);
recordError(entry, postRes.status, errText, { gl_account_id: glAccountId, ownership_account_id: ownershipAccountId });
continue;
}
const chargeResult = await postRes.json();
const buildiumId = String(chargeResult?.Id || "");
// Mark as pushed
await supabase.from("owner_ledger_entries").update({
reference_type: "buildium_pushed",
reference_id: buildiumId || `pushed_${entry.id}`,
}).eq("id", entry.id);
pushed++;
} else if (entry.credit > 0) {
// Push as payment
if (!defaultBankAccountId) { recordSkip(entry, "no_default_bank_account"); continue; }
const paymentMemo = entry.description || "Payment from management system";
const paymentBody = {
Date: entry.date,
PaymentMethod: "Check",
Memo: paymentMemo,
Lines: [
{
Amount: entry.credit,
GLAccountId: null, // Auto-allocate
Memo: paymentMemo,
},
],
BankAccountId: defaultBankAccountId,
};
const postRes = await fetch(`${BUILDIUM_BASE}/v1/associations/ownershipaccounts/${ownershipAccountId}/payments`, {
method: "POST",
headers: {
"x-buildium-client-id": clientId,
"x-buildium-client-secret": clientSecret,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(paymentBody),
});
if (!postRes.ok) {
const errText = await postRes.text();
console.warn(`Push payment failed for entry ${entry.id}: [${postRes.status}] ${errText}`);
recordError(entry, postRes.status, errText, { ownership_account_id: ownershipAccountId });
continue;
}
const paymentResult = await postRes.json();
const buildiumId = String(paymentResult?.Id || "");
await supabase.from("owner_ledger_entries").update({
reference_type: "buildium_pushed",
reference_id: buildiumId || `pushed_${entry.id}`,
}).eq("id", entry.id);
pushed++;
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn(`Push error for entry ${entry.id}: ${msg}`);
recordError(entry, null, msg);
}
}
}
results.push = { pushed, skipped: pushSkipped, errors: pushErrors, errorSamples, skipSamples };
}
// ===== PULL BUDGETS FROM BUILDIUM =====
if (syncType === "budgets") {
// Use the year of dateFrom if provided, otherwise current year
const yr = ledgerDateFrom ? Number(ledgerDateFrom.slice(0, 4)) : new Date().getFullYear();
const { bIdToLocalId } = await getAssociationMaps();
let totalImported = 0;
let totalUpdated = 0;
let totalFetched = 0;
for (const [buildiumAssocId, localAssocId] of bIdToLocalId.entries()) {
if (!isSelected(localAssocId)) continue;
try {
// Buildium /v1/budgets — filter by AssociationId and fiscal year
const budgets = await buildiumFetchAll("/v1/budgets", clientId, clientSecret, {
associationids: String(buildiumAssocId),
fiscalyear: String(yr),
});
totalFetched += budgets.length;
for (const bud of budgets) {
const details = Array.isArray(bud.Details) ? bud.Details : [];
for (const detail of details) {
const accountName = String(
detail.GLAccountName ||
detail.AccountName ||
detail.Name ||
"Uncategorized"
);
// Buildium budget detail months: Monthly amounts array of 12 entries
const monthly: number[] = Array.isArray(detail.MonthlyAmounts)
? detail.MonthlyAmounts.map((m: any) => Number(m) || 0)
: [];
const annualBudget = monthly.length > 0
? monthly.reduce((s, n) => s + n, 0)
: Number(detail.AnnualAmount || detail.TotalAmount || 0);
if (!annualBudget && !accountName) continue;
const { data: existing } = await supabase
.from("budgets")
.select("id")
.eq("association_id", localAssocId)
.eq("fiscal_year", yr)
.eq("category", accountName)
.maybeSingle();
if (existing?.id) {
await supabase.from("budgets").update({
budgeted_amount: annualBudget,
updated_at: new Date().toISOString(),
}).eq("id", existing.id);
totalUpdated++;
} else {
await supabase.from("budgets").insert({
association_id: localAssocId,
fiscal_year: yr,
category: accountName,
budgeted_amount: annualBudget,
actual_amount: 0,
notes: "Imported from Buildium",
});
totalImported++;
}
}
}
} catch (e) {
console.warn(`Budgets pull for assoc ${localAssocId} failed: ${(e as Error).message}`);
}
}
results.budgets = { fetched: totalFetched, imported: totalImported, updated: totalUpdated, fiscal_year: yr };
}
if (syncType === "bills") {
const { bIdToLocalId } = await getAssociationMaps();
// 1) Sync vendors so bills can be linked
const buildiumVendors = await buildiumFetchAll("/v1/vendors", clientId, clientSecret);
// Paginated fetch so large vendor sets don't get truncated at 1000 rows
const existingVendors: any[] = [];
{
const PAGE = 1000;
for (let from = 0; ; from += PAGE) {
const { data, error } = await supabase
.from("vendors")
.select("id, name, association_id, association_ids, buildium_vendor_id")
.order("created_at", { ascending: true })
.range(from, from + PAGE - 1);
if (error || !data?.length) break;
existingVendors.push(...data);
if (data.length < PAGE) break;
}
}
const vendorByBuildiumId = new Map<string, any>();
const vendorByAssocName = new Map<string, any>();
const vendorByName = new Map<string, any>();
for (const v of existingVendors) {
if (v.buildium_vendor_id) vendorByBuildiumId.set(String(v.buildium_vendor_id), v);
vendorByAssocName.set(`${v.association_id}|${norm(v.name)}`, v);
// Prefer the earliest-created row as the canonical record per name
if (!vendorByName.has(norm(v.name))) vendorByName.set(norm(v.name), v);
}
let vendorsCreated = 0;
let vendorsLinked = 0;
// Helper to ensure a local vendor exists for a buildium vendor + association
async function ensureVendor(bVendor: any, assocLocalId: string): Promise<string | null> {
if (!bVendor) return null;
const bvid = String(bVendor.Id);
const existingByBId = vendorByBuildiumId.get(bvid);
if (existingByBId) return existingByBId.id;
const name = (bVendor.CompanyName || bVendor.Name || `${bVendor.FirstName || ""} ${bVendor.LastName || ""}`).trim() || "Unknown Vendor";
const byName = vendorByAssocName.get(`${assocLocalId}|${norm(name)}`);
if (byName) {
await supabase.from("vendors").update({ buildium_vendor_id: bvid }).eq("id", byName.id);
byName.buildium_vendor_id = bvid;
vendorByBuildiumId.set(bvid, byName);
vendorsLinked++;
return byName.id;
}
// Fall back to a global name match — same vendor across associations should
// be linked, not duplicated. Append the association_id to association_ids.
const globalMatch = vendorByName.get(norm(name));
if (globalMatch) {
const currentIds: string[] = Array.isArray(globalMatch.association_ids) ? globalMatch.association_ids : [];
const mergedIds = currentIds.includes(assocLocalId) ? currentIds : [...currentIds, assocLocalId];
await supabase
.from("vendors")
.update({ buildium_vendor_id: bvid, association_ids: mergedIds })
.eq("id", globalMatch.id);
globalMatch.buildium_vendor_id = bvid;
globalMatch.association_ids = mergedIds;
vendorByBuildiumId.set(bvid, globalMatch);
vendorByAssocName.set(`${assocLocalId}|${norm(name)}`, globalMatch);
vendorsLinked++;
return globalMatch.id;
}
const insertRow = {
association_id: assocLocalId,
association_ids: [assocLocalId],
name,
email: bVendor.PrimaryEmail || bVendor.Email || null,
phone: bVendor.PhoneNumbers?.[0]?.Number || null,
address: formatAddress(bVendor.Address) || null,
buildium_vendor_id: bvid,
is_active: bVendor.IsActive !== false,
};
const { data: inserted, error } = await supabase.from("vendors").insert(insertRow).select("id, association_id, association_ids, name, buildium_vendor_id").single();
if (error || !inserted) {
console.warn(`Vendor insert failed for ${name}: ${error?.message}`);
return null;
}
vendorsCreated++;
vendorByBuildiumId.set(bvid, inserted);
vendorByAssocName.set(`${assocLocalId}|${norm(name)}`, inserted);
vendorByName.set(norm(name), inserted);
return inserted.id;
}
const buildiumVendorById = new Map<string, any>();
for (const bv of buildiumVendors) buildiumVendorById.set(String(bv.Id), bv);
// 2) Fetch bills (both paid and unpaid)
// Buildium requires BOTH FromPaidDate and ToPaidDate when results may include paid bills.
const today = new Date().toISOString().slice(0, 10);
const defaultPaidFrom = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const params: Record<string, string> = {
frompaiddate: ledgerDateFrom || defaultPaidFrom,
topaiddate: ledgerDateTo || today,
};
if (ledgerDateFrom) params.duedatefrom = ledgerDateFrom;
if (ledgerDateTo) params.duedateto = ledgerDateTo;
const buildiumBills = await buildiumFetchAll("/v1/bills", clientId, clientSecret, Object.keys(params).length > 0 ? params : undefined);
// Existing bills by buildium id
const { data: existingBills } = await supabase.from("bills").select("id, buildium_bill_id").not("buildium_bill_id", "is", null);
const billByBuildiumId = new Map<string, string>();
for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id);
// Chart of accounts lookup per association (account_number -> id)
const coaCache = new Map<string, Map<string, string>>();
async function getCoa(assocLocalId: string) {
if (coaCache.has(assocLocalId)) return coaCache.get(assocLocalId)!;
const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocLocalId);
const m = new Map<string, string>();
for (const a of data || []) m.set(String(a.account_number), a.id);
coaCache.set(assocLocalId, m);
return m;
}
let billsCreated = 0;
let billsUpdated = 0;
let billsSkipped = 0;
let billsLinkedDuplicate = 0;
// Cache of existing bills per association for fuzzy duplicate detection
const existingBillsByAssoc = new Map<string, Array<{ id: string; vendor_id: string | null; amount: number | null; invoice_number: string | null; bill_date: string | null; buildium_bill_id: string | null }>>();
async function getAssocBills(assocLocalId: string) {
if (existingBillsByAssoc.has(assocLocalId)) return existingBillsByAssoc.get(assocLocalId)!;
const { data } = await supabase
.from("bills")
.select("id, vendor_id, amount, invoice_number, bill_date, buildium_bill_id")
.eq("association_id", assocLocalId);
const list = (data || []) as any[];
existingBillsByAssoc.set(assocLocalId, list);
return list;
}
const dupKey = (s: any) => String(s || "").trim().toLowerCase().replace(/[^a-z0-9]/g, "");
let billsDebugLogged = 0;
for (const bb of buildiumBills) {
const bbid = String(bb.Id);
// Buildium bills often expose the association/property via AccountingEntity on each line
// (AccountingEntityType = "Association" | "Rental"), not at the top level. Fall back accordingly.
const lineEntity = Array.isArray(bb.Lines)
? bb.Lines.map((l: any) => l?.AccountingEntity).find((e: any) => e && (e.Id || e.AssociationId))
: null;
const buildiumAssocId = String(
bb.AssociationId
|| bb.Association?.Id
|| lineEntity?.Id
|| lineEntity?.AssociationId
|| bb.PropertyId
|| ""
);
const assocLocalId = bIdToLocalId.get(buildiumAssocId) || null;
if (!assocLocalId || !isSelected(assocLocalId)) {
if (billsDebugLogged < 3) {
console.log(`Skipping bill ${bbid}: buildiumAssocId="${buildiumAssocId}", topLevelKeys=${Object.keys(bb).join(",")}, lineEntity=${JSON.stringify(lineEntity)}`);
billsDebugLogged++;
}
billsSkipped++;
continue;
}
const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
const vendorId = await ensureVendor(buildiumVendor, assocLocalId);
// Pick first line's GL account as expense account if available.
// chart_of_accounts.account_number stores the Buildium GL Id (see the
// glaccounts upsert: account_number = String(gl.Id ...)), so resolve the
// line's GL Id from whichever shape Buildium returns it in.
const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null;
let expenseAccountId: string | null = null;
const lineGlId = firstLine
? (firstLine.GLAccountId
?? firstLine.GLAccount?.Id
?? firstLine.GLAccount?.GLAccountId
?? null)
: null;
if (lineGlId !== null && lineGlId !== undefined && String(lineGlId) !== "") {
const coa = await getCoa(assocLocalId);
expenseAccountId = coa.get(String(lineGlId)) || null;
}
let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0);
if ((!amount || amount === 0) && Array.isArray(bb.Lines)) {
amount = bb.Lines.reduce((sum: number, l: any) => sum + Number(l?.Amount ?? l?.TotalAmount ?? 0), 0);
}
const billDate = (bb.Date || bb.BillDate || new Date().toISOString().slice(0, 10)).slice(0, 10);
const dueDate = bb.DueDate ? String(bb.DueDate).slice(0, 10) : null;
const invoiceNumber = bb.ReferenceNumber || bb.InvoiceNumber || null;
const description = bb.Memo || bb.Description || null;
const isPaid = !!bb.PaidDate
|| String(bb.Status || "").toLowerCase() === "paid"
|| String(bb.ApprovalStatus || "").toLowerCase() === "paid"
|| (bb.PaidAmount && Number(bb.PaidAmount) >= amount && amount > 0);
// Route unpaid imports through Bill Approvals so staff can review before posting.
const status = isPaid ? "paid" : "pending";
const payload: Record<string, any> = {
association_id: assocLocalId,
vendor_id: vendorId,
invoice_number: invoiceNumber,
bill_date: billDate,
due_date: dueDate,
amount,
amount_paid: Number(bb.PaidAmount || (isPaid ? amount : 0)),
expense_account_id: expenseAccountId,
description,
status,
buildium_bill_id: bbid,
};
if (isPaid) payload.paid_date = (bb.PaidDate ? String(bb.PaidDate).slice(0, 10) : billDate);
let existingId = billByBuildiumId.get(bbid);
// Fuzzy duplicate detection: match a previously-created bill (e.g. manually entered)
// by association + vendor + amount + (invoice# OR bill_date) when no Buildium ID match exists.
if (!existingId) {
const assocBills = await getAssocBills(assocLocalId);
const invKey = dupKey(invoiceNumber);
const dup = assocBills.find((b) => {
if (b.buildium_bill_id) return false; // already linked to a different Buildium bill
if (b.vendor_id !== vendorId) return false;
const amtMatch = Math.abs(Number(b.amount || 0) - amount) < 0.01;
if (!amtMatch) return false;
const invMatch = invKey && dupKey(b.invoice_number) === invKey;
const dateMatch = b.bill_date && String(b.bill_date).slice(0, 10) === billDate;
return invMatch || dateMatch;
});
if (dup) {
existingId = dup.id;
dup.buildium_bill_id = bbid;
billByBuildiumId.set(bbid, dup.id);
billsLinkedDuplicate++;
}
}
let finalBillId: string | null = null;
if (existingId) {
const { error } = await supabase.from("bills").update(payload).eq("id", existingId);
if (error) console.warn(`Bill update ${bbid} failed: ${error.message}`);
else { billsUpdated++; finalBillId = existingId; }
} else {
const { data: inserted, error } = await supabase.from("bills").insert(payload).select("id, vendor_id, amount, invoice_number, bill_date, buildium_bill_id").single();
if (error) console.warn(`Bill insert ${bbid} failed: ${error.message}`);
else {
billsCreated++;
if (inserted) {
const list = await getAssocBills(assocLocalId);
list.push(inserted as any);
billByBuildiumId.set(bbid, (inserted as any).id);
finalBillId = (inserted as any).id;
}
}
}
// Ensure a pending bill_approvals row exists so unpaid imports show up in Bill Approvals
if (finalBillId && !isPaid) {
const { data: existingApproval } = await supabase
.from("bill_approvals")
.select("id")
.eq("bill_id", finalBillId)
.limit(1)
.maybeSingle();
if (!existingApproval) {
const { error: aErr } = await supabase.from("bill_approvals").insert({
bill_id: finalBillId,
association_id: assocLocalId,
vendor_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor",
amount,
status: "pending",
});
if (aErr) console.warn(`Bill approval insert for ${bbid} failed: ${aErr.message}`);
}
}
// Download bill attachments from Buildium and link the first one to the bill
if (finalBillId) {
try {
const { data: existingBill } = await supabase
.from("bills")
.select("attachment_url")
.eq("id", finalBillId)
.maybeSingle();
if (!existingBill?.attachment_url) {
const filesRes = await fetch(`${BUILDIUM_BASE}/v1/bills/${bbid}/files`, {
headers: {
"x-buildium-client-id": clientId,
"x-buildium-client-secret": clientSecret,
Accept: "application/json",
},
});
if (filesRes.ok) {
const billFiles = await filesRes.json();
let firstUrl: string | null = null;
for (const bf of (Array.isArray(billFiles) ? billFiles : [])) {
const fileId = bf.Id;
if (!fileId) continue;
const dlReq = await fetch(`${BUILDIUM_BASE}/v1/bills/${bbid}/files/${fileId}/downloadrequest`, {
method: "POST",
headers: {
"x-buildium-client-id": clientId,
"x-buildium-client-secret": clientSecret,
Accept: "application/json",
},
});
if (!dlReq.ok) continue;
const dl = await dlReq.json();
const downloadUrl: string | undefined = dl.DownloadUrl || dl.Url;
if (!downloadUrl) continue;
const fileRes = await fetch(downloadUrl);
if (!fileRes.ok) continue;
const blob = await fileRes.blob();
const contentType = fileRes.headers.get("content-type") || "application/octet-stream";
const fileName: string = bf.PhysicalFileName || bf.Title || `bill-${bbid}-${fileId}`;
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
const storagePath = `${assocLocalId}/bills/${bbid}-${fileId}-${safeName}`;
const { error: upErr } = await supabase.storage.from("files").upload(storagePath, blob, {
upsert: true, contentType,
});
if (upErr) { console.warn(`Bill file upload ${bbid}/${fileId}: ${upErr.message}`); continue; }
const { data: urlData } = supabase.storage.from("files").getPublicUrl(storagePath);
if (!firstUrl) firstUrl = urlData.publicUrl;
}
if (firstUrl) {
await supabase.from("bills").update({ attachment_url: firstUrl }).eq("id", finalBillId);
}
}
}
} catch (e) {
console.warn(`Bill attachments for ${bbid} failed: ${(e as Error).message}`);
}
}
}
results.bills = {
fetched: buildiumBills.length,
created: billsCreated,
updated: billsUpdated,
skipped: billsSkipped,
linked_duplicates: billsLinkedDuplicate,
vendors_created: vendorsCreated,
vendors_linked: vendorsLinked,
};
await supabase.from("company_settings").upsert(
{ key: "buildium_last_sync_bills", value: new Date().toISOString() },
{ onConflict: "key" }
);
}
if (syncType === "documents") {
let totalFetched = 0;
let totalCreated = 0;
let totalSkipped = 0;
const errors: string[] = [];
const { data: existingDocs } = await supabase
.from("documents")
.select("id, file_name, association_id")
.eq("category", "Buildium");
const existingKey = new Set<string>(
(existingDocs || []).map((d: any) => `${d.association_id ?? "company"}|${String(d.file_name || "").toLowerCase()}`)
);
// Build list of (buildiumEntityType, buildiumEntityId, localAssocId|null, label) targets
type Target = { entityType: string | null; entityId: string | null; localAssocId: string | null; label: string };
const targets: Target[] = [];
if (documentScope === "company") {
// Company-level files: not associated with any property
targets.push({ entityType: null, entityId: null, localAssocId: null, label: "company" });
} else {
const { bIdToLocalId } = await getAssociationMaps();
for (const [buildiumAssocId, localAssocId] of bIdToLocalId.entries()) {
if (!isSelected(localAssocId)) continue;
targets.push({ entityType: "Association", entityId: String(buildiumAssocId), localAssocId, label: `assoc ${localAssocId}` });
}
}
for (const tgt of targets) {
let files: any[] = [];
try {
const params: Record<string, string> = {};
if (tgt.entityType && tgt.entityId) {
params.entitytype = tgt.entityType;
params.entityid = tgt.entityId;
}
// For company scope, fetch unfiltered and filter client-side (Buildium /v1/files requires entityid when entitytype is set)
files = await buildiumFetchAll("/v1/files", clientId, clientSecret, Object.keys(params).length ? params : undefined);
// For company scope, keep only files attached to the Company entity (or no entity at all)
if (documentScope === "company") {
files = files.filter((f: any) => {
const et = f?.EntityType ?? f?.PhysicalFileEntity?.EntityType ?? null;
if (!et) return true;
const norm = String(et).toLowerCase();
return norm === "company" || norm === "none" || norm === "general";
});
}
} catch (e) {
errors.push(`${tgt.label}: list failed: ${(e as Error).message}`);
continue;
}
totalFetched += files.length;
const filesToProcess = files.slice(documentOffset, documentOffset + documentLimit);
const dedupAssocKey = tgt.localAssocId ?? "company";
for (const f of filesToProcess) {
const fileId = f.Id;
const fileName: string = f.PhysicalFileName || f.Title || `buildium-${fileId}`;
const title: string = f.Title || fileName;
const dedupKey = `${dedupAssocKey}|${fileName.toLowerCase()}`;
if (existingKey.has(dedupKey)) { totalSkipped++; continue; }
try {
const dlReq = await fetch(`${BUILDIUM_BASE}/v1/files/${fileId}/downloadrequest`, {
method: "POST",
headers: {
"x-buildium-client-id": clientId,
"x-buildium-client-secret": clientSecret,
Accept: "application/json",
},
});
if (!dlReq.ok) { errors.push(`file ${fileId}: downloadrequest [${dlReq.status}]`); continue; }
const dl = await dlReq.json();
const downloadUrl: string | undefined = dl.DownloadUrl || dl.Url;
if (!downloadUrl) { errors.push(`file ${fileId}: no DownloadUrl`); continue; }
const fileRes = await fetch(downloadUrl);
if (!fileRes.ok) { errors.push(`file ${fileId}: download [${fileRes.status}]`); continue; }
const blob = await fileRes.blob();
const contentType = fileRes.headers.get("content-type") || "application/octet-stream";
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
const folderPrefix = tgt.localAssocId ?? "company";
const storagePath = `${folderPrefix}/Buildium/${Date.now()}-${fileId}-${safeName}`;
const { error: upErr } = await supabase.storage.from("files").upload(storagePath, blob, {
upsert: false, contentType,
});
if (upErr) { errors.push(`file ${fileId}: upload ${upErr.message}`); continue; }
const { data: urlData } = supabase.storage.from("files").getPublicUrl(storagePath);
const { error: insErr } = await supabase.from("documents").insert({
title: title.replace(/\.[^/.]+$/, ""),
file_name: fileName,
file_url: urlData.publicUrl,
file_size: Number(f.FileSize || blob.size || 0),
category: "Buildium",
association_id: tgt.localAssocId,
});
if (insErr) { errors.push(`file ${fileId}: insert ${insErr.message}`); continue; }
existingKey.add(dedupKey);
totalCreated++;
} catch (e) {
errors.push(`file ${fileId}: ${(e as Error).message}`);
}
}
results.documents = {
fetched: totalFetched,
created: totalCreated,
skipped: totalSkipped,
processed: totalCreated + totalSkipped + errors.length,
nextOffset: documentOffset + documentLimit < files.length ? documentOffset + documentLimit : null,
errors: errors.slice(0, 20),
};
}
if (!results.documents) {
results.documents = { fetched: 0, created: 0, skipped: 0, processed: 0, nextOffset: null, errors };
}
await supabase.from("company_settings").upsert(
{ key: "buildium_last_sync_documents", value: new Date().toISOString() },
{ onConflict: "key" }
);
}
return new Response(JSON.stringify({ success: true, results }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
} catch (error: unknown) {
console.error("Buildium sync error:", error);
const msg = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
});
function mapGLAccountType(buildiumType: string): string {
const typeMap: Record<string, string> = { Asset: "asset", Liability: "liability", Equity: "equity", Income: "income", Expense: "expense", Revenue: "income" };
return typeMap[buildiumType] || "expense";
}