// 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) { 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) { 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; byLegacyKey: Map; byDateAmount: Map; 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 = {}; // 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[]; // Flatten Buildium's nested SubAccounts so sub-accounts also appear in the dropdown. const flat: Record[] = []; const seen = new Set(); const walk = (acc: Record) => { 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); } }; 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 | null = null; let unitLookupPromise: Promise | null = null; let ownerLookupPromise: Promise | null = null; let buildiumAssociationUnitsPromise: Promise | null = null; let ownershipAccountsPromise: Promise | null = null; const ownerLedgerCache = new Map(); 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, 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(); const byLegacyKey = new Map(); const byDateAmount = new Map(); 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(); const bIdToName = new Map(); 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(); const byAssocAndNumber = new Map(); const byAssocAndAddress = new Map(); 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(); const bySyncKey = new Map(); const byUnitId = new Map(); 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(); 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(); 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>, 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>, ): 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 = {}; 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 = {}; 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 = {}; 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 = {}; 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}`); } results.associations = { fetched: associations.length, upserted: upserts.length }; } 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(); 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(); 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(); 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 = { 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 = {}; 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 = {}; 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(); 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 = {}; 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(); 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(); 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(); const glAccountByNumber = new Map(); // account number -> Buildium account const glAccountById = new Map(); // 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>(); const customDescByAssocType = new Map>(); 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(); const descMap = new Map(); 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 = { 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(); const vendorByAssocName = new Map(); const vendorByName = new Map(); 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 { 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(); 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 = { 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(); 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>(); 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(); 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>(); 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 const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null; let expenseAccountId: string | null = null; if (firstLine?.GLAccountId) { const coa = await getCoa(assocLocalId); expenseAccountId = coa.get(String(firstLine.GLAccountId)) || 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 = { 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( (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 = {}; 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 = { Asset: "asset", Liability: "liability", Equity: "equity", Income: "income", Expense: "expense", Revenue: "income" }; return typeMap[buildiumType] || "expense"; }