mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
cc5f70bc5b
Anything on/before buildium_gl.last_synced_date is already in the books as buildium_gl entries; ap_cutover_date freezes that boundary so direct buildium_bill/billpay postings never double-count history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2923 lines
139 KiB
TypeScript
2923 lines
139 KiB
TypeScript
// Buildium Sync Edge Function v8 — Unit-centric
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers":
|
|
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
};
|
|
|
|
const BUILDIUM_BASE = "https://api.buildium.com";
|
|
|
|
function wait(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record<string, string>) {
|
|
const url = new URL(`${BUILDIUM_BASE}${path}`);
|
|
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
|
|
const maxAttempts = 4;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const res = await fetch(url.toString(), {
|
|
headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" },
|
|
});
|
|
|
|
if (res.ok) {
|
|
return res.json();
|
|
}
|
|
|
|
const text = await res.text();
|
|
const shouldRetry = res.status === 429 || res.status >= 500;
|
|
|
|
if (shouldRetry && attempt < maxAttempts - 1) {
|
|
const retryAfterSeconds = Number(res.headers.get("Retry-After") ?? "");
|
|
const delayMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
? retryAfterSeconds * 1000
|
|
: 600 * Math.pow(2, attempt);
|
|
|
|
console.warn(`Buildium API ${path} throttled/retrying [${res.status}] attempt ${attempt + 1}/${maxAttempts} after ${delayMs}ms`);
|
|
await wait(delayMs);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Buildium API ${path} failed [${res.status}]: ${text}`);
|
|
}
|
|
|
|
throw new Error(`Buildium API ${path} failed after retries`);
|
|
}
|
|
|
|
async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, extraParams?: Record<string, string>) {
|
|
const all: any[] = [];
|
|
let offset = 0;
|
|
const limit = 50;
|
|
while (true) {
|
|
const params = { ...extraParams, offset: String(offset), limit: String(limit) };
|
|
const page = await buildiumFetch(path, clientId, clientSecret, params);
|
|
if (!Array.isArray(page) || page.length === 0) break;
|
|
all.push(...page);
|
|
if (page.length < limit) break;
|
|
offset += limit;
|
|
}
|
|
return all;
|
|
}
|
|
|
|
function norm(value: unknown): string {
|
|
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function normalizeAssociationName(value: unknown): string {
|
|
return String(value ?? "").toLowerCase()
|
|
.replace(/&/g, " and ")
|
|
.replace(/\b(homeowners association|homeowners assoc|association|assoc|incorporated|inc|llc|hoa|coa)\b/g, " ")
|
|
.replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function normId(value: unknown): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
const s = String(value).trim();
|
|
return s.length > 0 ? s : null;
|
|
}
|
|
|
|
function formatAddress(obj: any): string {
|
|
if (!obj) return "";
|
|
return [obj.AddressLine1, obj.AddressLine2, obj.AddressLine3, obj.City, obj.State, obj.ZipCode ?? obj.PostalCode]
|
|
.map((p) => String(p ?? "").trim()).filter(Boolean).join(", ");
|
|
}
|
|
|
|
function extractUnitNumber(address: string): string | null {
|
|
const line1 = String(address || "").split(",")[0]?.trim();
|
|
if (!line1) return null;
|
|
|
|
const explicitMatch = line1.match(/(?:unit|apt|#|suite|ste)\s*([a-z0-9-]+)/i);
|
|
if (explicitMatch?.[1]) return explicitMatch[1];
|
|
|
|
if (line1.length > 0) return line1;
|
|
|
|
return null;
|
|
}
|
|
|
|
function isPlaceholderUnitNumber(value: unknown): boolean {
|
|
const normalized = norm(value);
|
|
return !normalized || normalized === "unknown" || normalized === "n a" || normalized === "na";
|
|
}
|
|
|
|
function buildOwnerSyncKey(associationId: string, firstName: string, lastName: string, propertyAddress: string): string {
|
|
return `${associationId}|${norm(firstName)}|${norm(lastName)}|${norm(propertyAddress)}`;
|
|
}
|
|
|
|
type OwnerLedgerEntryRow = {
|
|
id: string;
|
|
owner_id: string;
|
|
reference_id: string | null;
|
|
reference_type: string | null;
|
|
description: string | null;
|
|
date: string | null;
|
|
created_at: string | null;
|
|
debit: number;
|
|
credit: number;
|
|
transaction_type: string | null;
|
|
gl_account_id?: string | null;
|
|
};
|
|
|
|
type OwnerLedgerEntryMaps = {
|
|
byReferenceId: Map<string, OwnerLedgerEntryRow>;
|
|
byLegacyKey: Map<string, OwnerLedgerEntryRow>;
|
|
byDateAmount: Map<string, OwnerLedgerEntryRow>;
|
|
defaultOwnerId: string | null;
|
|
};
|
|
|
|
function isUniqueViolation(error: { code?: string | null; message?: string | null } | null | undefined) {
|
|
if (!error) return false;
|
|
return error.code === "23505" || String(error.message || "").toLowerCase().includes("duplicate key");
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
|
|
|
try {
|
|
const authHeader = req.headers.get("Authorization");
|
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
|
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
|
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
const token = authHeader.replace("Bearer ", "");
|
|
|
|
let userId: string | null = null;
|
|
try {
|
|
const payload = token.split(".")[1];
|
|
if (!payload) throw new Error("Missing JWT payload");
|
|
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.replace(/-/g, "+").replace(/_/g, "/").length / 4) * 4, "=");
|
|
const decoded = JSON.parse(atob(padded));
|
|
userId = typeof decoded?.sub === "string" ? decoded.sub : null;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
const authClient = createClient(supabaseUrl, supabaseAnonKey, { global: { headers: { Authorization: authHeader } } });
|
|
const { data: roles, error: rolesError } = await authClient.from("user_roles").select("role").eq("user_id", userId).limit(10);
|
|
const isAdmin = !rolesError && Array.isArray(roles) && roles.some((r: any) => r.role === "admin");
|
|
if (!isAdmin) {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceRoleKey);
|
|
|
|
const { syncType, selectedAssociationIds: rawIds, dateFrom, dateTo, unitId: rawUnitId, documentOffset: rawDocumentOffset, documentLimit: rawDocumentLimit, documentScope: rawDocumentScope, includeAll: rawIncludeAll, dryRun: rawDryRun } = await req.json();
|
|
const includeAll = rawIncludeAll === true;
|
|
const dryRun = rawDryRun === true;
|
|
const documentScope: "association" | "company" = rawDocumentScope === "company" ? "company" : "association";
|
|
let selectedAssociationIds = Array.isArray(rawIds) ? rawIds.filter((v): v is string => typeof v === "string" && v.length > 0) : [];
|
|
const ledgerDateFrom = typeof dateFrom === "string" && dateFrom ? dateFrom : null;
|
|
const ledgerDateTo = typeof dateTo === "string" && dateTo ? dateTo : null;
|
|
const unitFilterId = typeof rawUnitId === "string" && rawUnitId.length > 0 ? rawUnitId : null;
|
|
const documentOffset = Number.isFinite(Number(rawDocumentOffset)) && Number(rawDocumentOffset) >= 0 ? Math.floor(Number(rawDocumentOffset)) : 0;
|
|
const documentLimit = Math.min(10, Math.max(1, Number.isFinite(Number(rawDocumentLimit)) ? Math.floor(Number(rawDocumentLimit)) : 5));
|
|
const results: Record<string, any> = {};
|
|
|
|
// If a single unit was requested, auto-scope selectedAssociationIds to that unit's association
|
|
if (unitFilterId && selectedAssociationIds.length === 0) {
|
|
const { data: unitRow } = await createClient(supabaseUrl, serviceRoleKey)
|
|
.from("units").select("association_id").eq("id", unitFilterId).maybeSingle();
|
|
if (unitRow?.association_id) selectedAssociationIds = [unitRow.association_id];
|
|
}
|
|
|
|
if (syncType === "reset_ledgers") {
|
|
let deleteQuery = supabase.from("owner_ledger_entries").delete({ count: "exact" });
|
|
if (selectedAssociationIds.length > 0) deleteQuery = deleteQuery.in("association_id", selectedAssociationIds);
|
|
|
|
const { count, error } = await deleteQuery;
|
|
if (error) throw error;
|
|
|
|
let ownerQuery = supabase.from("owners").update({ balance: 0 });
|
|
if (selectedAssociationIds.length > 0) ownerQuery = ownerQuery.in("association_id", selectedAssociationIds);
|
|
const { error: ownerBalanceError } = await ownerQuery;
|
|
if (ownerBalanceError) throw ownerBalanceError;
|
|
|
|
await supabase.from("company_settings").upsert(
|
|
{ key: "buildium_last_sync_reset_ledgers", value: new Date().toISOString() },
|
|
{ onConflict: "key" }
|
|
);
|
|
|
|
return new Response(JSON.stringify({ success: true, results: { reset: { deleted: count || 0 } } }), {
|
|
status: 200,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
|
|
const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
|
|
if (!clientId || !clientSecret) {
|
|
return new Response(JSON.stringify({ error: "Buildium API credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (syncType === "gl_accounts") {
|
|
const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret) as Record<string, unknown>[];
|
|
|
|
// Flatten Buildium's nested SubAccounts so sub-accounts also appear in the dropdown.
|
|
const flat: Record<string, unknown>[] = [];
|
|
const seen = new Set<string>();
|
|
const walk = (acc: Record<string, unknown>) => {
|
|
if (!acc) return;
|
|
const id = String((acc as any).Id ?? "");
|
|
if (id && !seen.has(id)) {
|
|
seen.add(id);
|
|
flat.push(acc);
|
|
}
|
|
const subs = (acc as any).SubAccounts;
|
|
if (Array.isArray(subs)) {
|
|
for (const s of subs) walk(s as Record<string, unknown>);
|
|
}
|
|
};
|
|
for (const gl of glAccounts) walk(gl);
|
|
|
|
// includeAll: full chart (for the GL Account Map UI); default: active accounts
|
|
// only (legacy charge-type mapping dropdown).
|
|
const accounts = flat
|
|
.filter((gl) => includeAll || (gl as any).IsActive !== false)
|
|
.map((gl) => {
|
|
const type = String((gl as any).Type || (gl as any).AccountType || "");
|
|
const isActive = (gl as any).IsActive !== false;
|
|
return {
|
|
id: String((gl as any).Id),
|
|
name: String((gl as any).Name || `Buildium GL ${(gl as any).Id}`),
|
|
account_number: String((gl as any).AccountNumber ?? (gl as any).Number ?? (gl as any).GlNumber ?? (gl as any).GLNumber ?? (gl as any).Code ?? (gl as any).Id ?? "").trim() || null,
|
|
type,
|
|
is_active: isActive,
|
|
chargeable: isActive && ["income", "liability"].includes(type.toLowerCase()),
|
|
};
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return new Response(JSON.stringify({ success: true, gl_accounts: accounts }), {
|
|
status: 200,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let associationMapsPromise: Promise<any> | null = null;
|
|
let unitLookupPromise: Promise<any> | null = null;
|
|
let ownerLookupPromise: Promise<any> | null = null;
|
|
let buildiumAssociationUnitsPromise: Promise<any> | null = null;
|
|
let ownershipAccountsPromise: Promise<any[]> | null = null;
|
|
const ownerLedgerCache = new Map<string, OwnerLedgerEntryMaps>();
|
|
|
|
const isSelected = (id: string | null) => selectedAssociationIds.length === 0 || Boolean(id && selectedAssociationIds.includes(id));
|
|
|
|
const ownerStatusRank = (status: unknown) => {
|
|
const normalized = norm(status);
|
|
if (normalized === "active") return 2;
|
|
if (normalized.length > 0) return 1;
|
|
return 0;
|
|
};
|
|
|
|
const isPreferredOwnerCandidate = (candidate: any, current: any) => {
|
|
const candidateRank = ownerStatusRank(candidate?.status);
|
|
const currentRank = ownerStatusRank(current?.status);
|
|
|
|
if (candidateRank !== currentRank) return candidateRank > currentRank;
|
|
|
|
const candidateBuildiumOwnerId = normId(candidate?.buildium_owner_id) ?? "";
|
|
const currentBuildiumOwnerId = normId(current?.buildium_owner_id) ?? "";
|
|
const candidateHasBuildiumOwnerId = candidateBuildiumOwnerId.length > 0;
|
|
const currentHasBuildiumOwnerId = currentBuildiumOwnerId.length > 0;
|
|
|
|
if (candidateHasBuildiumOwnerId !== currentHasBuildiumOwnerId) {
|
|
return candidateHasBuildiumOwnerId;
|
|
}
|
|
|
|
if (candidateBuildiumOwnerId !== currentBuildiumOwnerId) {
|
|
return candidateBuildiumOwnerId < currentBuildiumOwnerId;
|
|
}
|
|
|
|
return String(candidate?.id ?? "") < String(current?.id ?? "");
|
|
};
|
|
|
|
const setPreferredOwnerByUnit = (map: Map<string, any>, owner: any) => {
|
|
const unitId = owner?.unit_id;
|
|
if (!unitId) return;
|
|
|
|
const existing = map.get(unitId);
|
|
if (!existing || isPreferredOwnerCandidate(owner, existing)) {
|
|
map.set(unitId, owner);
|
|
}
|
|
};
|
|
|
|
const makeLegacyKey = (description: unknown, date: unknown) => `${String(date ?? "")}|${String(description ?? "")}`;
|
|
const makeDateAmountKey = (date: unknown, debit: number, credit: number) => `${String(date ?? "")}|${debit}|${credit}`;
|
|
|
|
async function getOwnerLedgerEntryMaps(ownerId: string, unitId: string | null = null) {
|
|
const cacheKey = unitId ? `unit:${unitId}` : `owner:${ownerId}`;
|
|
const cached = ownerLedgerCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
let query = supabase
|
|
.from("owner_ledger_entries")
|
|
.select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type, gl_account_id");
|
|
|
|
query = unitId ? query.eq("unit_id", unitId) : query.eq("owner_id", ownerId);
|
|
|
|
const { data: rows } = await query
|
|
.order("created_at", { ascending: true })
|
|
.order("id", { ascending: true });
|
|
|
|
const byReferenceId = new Map<string, OwnerLedgerEntryRow>();
|
|
const byLegacyKey = new Map<string, OwnerLedgerEntryRow>();
|
|
const byDateAmount = new Map<string, OwnerLedgerEntryRow>();
|
|
let defaultOwnerId: string | null = null;
|
|
|
|
for (const row of rows || []) {
|
|
if (!defaultOwnerId && row.reference_type === "buildium" && row.owner_id) {
|
|
defaultOwnerId = row.owner_id;
|
|
}
|
|
|
|
// Include rows we pushed to Buildium ("buildium_pushed") so pulling the
|
|
// same transaction back (e.g. a pushed charge) doesn't duplicate it.
|
|
if ((row.reference_type === "buildium" || row.reference_type === "buildium_pushed") && row.reference_id && !byReferenceId.has(String(row.reference_id))) {
|
|
byReferenceId.set(String(row.reference_id), row);
|
|
}
|
|
|
|
if (!row.reference_id) {
|
|
const key = makeLegacyKey(row.description, row.date);
|
|
if (!byLegacyKey.has(key)) byLegacyKey.set(key, row);
|
|
}
|
|
|
|
const daKey = makeDateAmountKey(row.date, row.debit, row.credit);
|
|
if (!byDateAmount.has(daKey)) byDateAmount.set(daKey, row);
|
|
}
|
|
|
|
const next = { byReferenceId, byLegacyKey, byDateAmount, defaultOwnerId: defaultOwnerId || ownerId };
|
|
ownerLedgerCache.set(cacheKey, next);
|
|
return next;
|
|
}
|
|
|
|
async function calculateOwnerLedgerBalance(ownerId: string) {
|
|
let from = 0;
|
|
const pageSize = 1000;
|
|
let balance = 0;
|
|
|
|
while (true) {
|
|
const { data, error } = await supabase
|
|
.from("owner_ledger_entries")
|
|
.select("debit, credit")
|
|
.eq("owner_id", ownerId)
|
|
.range(from, from + pageSize - 1);
|
|
|
|
if (error) throw error;
|
|
for (const row of data || []) {
|
|
balance += Number(row.debit || 0) - Number(row.credit || 0);
|
|
}
|
|
if (!data || data.length < pageSize) break;
|
|
from += pageSize;
|
|
}
|
|
|
|
return Math.round(balance * 100) / 100;
|
|
}
|
|
|
|
async function getAssociationMaps() {
|
|
if (associationMapsPromise) return associationMapsPromise;
|
|
|
|
associationMapsPromise = (async () => {
|
|
let query = supabase.from("associations").select("id, name").eq("status", "active");
|
|
if (selectedAssociationIds.length > 0) query = query.in("id", selectedAssociationIds);
|
|
const { data: rows } = await query;
|
|
const exactMap = new Map((rows || []).map((a: any) => [String(a.name).trim(), a.id]));
|
|
const normMap = new Map((rows || []).map((a: any) => [normalizeAssociationName(a.name), a.id]));
|
|
|
|
const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
|
|
const bIdToLocalId = new Map<string, string>();
|
|
const bIdToName = new Map<string, string>();
|
|
for (const ba of buildiumAssocs) {
|
|
const bid = normId(ba.Id);
|
|
const name = String(ba.Name || "").trim();
|
|
if (!bid || !name) continue;
|
|
bIdToName.set(bid, name);
|
|
const localId = exactMap.get(name) ?? normMap.get(normalizeAssociationName(name)) ?? null;
|
|
if (localId) bIdToLocalId.set(bid, localId);
|
|
}
|
|
return { bIdToLocalId, bIdToName };
|
|
})();
|
|
|
|
return associationMapsPromise;
|
|
}
|
|
|
|
async function getUnitLookup() {
|
|
if (unitLookupPromise) return unitLookupPromise;
|
|
|
|
unitLookupPromise = (async () => {
|
|
const data: any[] = [];
|
|
let from = 0;
|
|
const pageSize = 1000;
|
|
while (true) {
|
|
let query = supabase.from("units").select("id, association_id, unit_number, buildium_unit_id, account_number, address");
|
|
if (selectedAssociationIds.length > 0) query = query.in("association_id", selectedAssociationIds);
|
|
const { data: page } = await query.range(from, from + pageSize - 1);
|
|
if (page) data.push(...page);
|
|
if (!page || page.length < pageSize) break;
|
|
from += pageSize;
|
|
}
|
|
const byBuildiumId = new Map<string, any>();
|
|
const byAssocAndNumber = new Map<string, any>();
|
|
const byAssocAndAddress = new Map<string, any>();
|
|
|
|
for (const unit of data || []) {
|
|
const buildiumId = normId(unit.buildium_unit_id);
|
|
if (buildiumId) byBuildiumId.set(buildiumId, unit);
|
|
if (unit.unit_number) byAssocAndNumber.set(`${unit.association_id}|${norm(unit.unit_number)}`, unit);
|
|
if (unit.address) byAssocAndAddress.set(`${unit.association_id}|${norm(unit.address)}`, unit);
|
|
}
|
|
|
|
return { units: data || [], byBuildiumId, byAssocAndNumber, byAssocAndAddress };
|
|
})();
|
|
|
|
return unitLookupPromise;
|
|
}
|
|
|
|
async function getOwnerLookup() {
|
|
if (ownerLookupPromise) return ownerLookupPromise;
|
|
|
|
ownerLookupPromise = (async () => {
|
|
const data: any[] = [];
|
|
let from = 0;
|
|
const pageSize = 1000;
|
|
while (true) {
|
|
let query = supabase.from("owners").select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status");
|
|
if (selectedAssociationIds.length > 0) query = query.in("association_id", selectedAssociationIds);
|
|
const { data: page } = await query.range(from, from + pageSize - 1);
|
|
if (page) data.push(...page);
|
|
if (!page || page.length < pageSize) break;
|
|
from += pageSize;
|
|
}
|
|
const byBuildiumId = new Map<string, any>();
|
|
const bySyncKey = new Map<string, any>();
|
|
const byUnitId = new Map<string, any>();
|
|
|
|
for (const owner of data || []) {
|
|
const buildiumOwnerId = normId(owner.buildium_owner_id);
|
|
if (buildiumOwnerId) byBuildiumId.set(buildiumOwnerId, owner);
|
|
const propertyAddress = String(owner.property_address || "").trim();
|
|
if (propertyAddress) {
|
|
bySyncKey.set(buildOwnerSyncKey(owner.association_id, owner.first_name, owner.last_name, propertyAddress), owner);
|
|
}
|
|
setPreferredOwnerByUnit(byUnitId, owner);
|
|
}
|
|
|
|
return { byBuildiumId, bySyncKey, byUnitId };
|
|
})();
|
|
|
|
return ownerLookupPromise;
|
|
}
|
|
|
|
async function getBuildiumAssociationUnits() {
|
|
if (buildiumAssociationUnitsPromise) return buildiumAssociationUnitsPromise;
|
|
|
|
buildiumAssociationUnitsPromise = (async () => {
|
|
const units = await buildiumFetchAll("/v1/associations/units", clientId, clientSecret);
|
|
const byId = new Map<string, any>();
|
|
for (const unit of units) {
|
|
const id = normId(unit.Id);
|
|
if (id) byId.set(id, unit);
|
|
}
|
|
return { units, byId };
|
|
})();
|
|
|
|
return buildiumAssociationUnitsPromise;
|
|
}
|
|
|
|
async function getOwnershipAccounts() {
|
|
if (ownershipAccountsPromise) return ownershipAccountsPromise;
|
|
|
|
ownershipAccountsPromise = buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
|
|
return ownershipAccountsPromise;
|
|
}
|
|
|
|
function buildOwnershipAccountsByUnit(ownershipAccounts: any[]) {
|
|
const byUnit = new Map<string, any[]>();
|
|
for (const account of ownershipAccounts) {
|
|
const buildiumUnitId = normId(account?.UnitId);
|
|
if (!buildiumUnitId) continue;
|
|
if (!byUnit.has(buildiumUnitId)) byUnit.set(buildiumUnitId, []);
|
|
byUnit.get(buildiumUnitId)!.push(account);
|
|
}
|
|
return byUnit;
|
|
}
|
|
|
|
function pickPreferredOwnershipAccount(accounts: any[]) {
|
|
if (!Array.isArray(accounts) || accounts.length === 0) return null;
|
|
// Prefer active/current accounts, but fall back to any account (including delinquent/collections)
|
|
const preferred = accounts.find((account: any) => {
|
|
const status = String(account?.Status || "").toLowerCase();
|
|
return status === "active" || status === "current" || status === "";
|
|
});
|
|
return preferred || accounts[0];
|
|
}
|
|
|
|
async function updateUnitAccountNumber(
|
|
unitLookup: Awaited<ReturnType<typeof getUnitLookup>>,
|
|
unitId: string,
|
|
accountNumber: string | null,
|
|
) {
|
|
if (!accountNumber) return;
|
|
|
|
const cachedUnit = unitLookup.units.find((unit: any) => unit.id === unitId) || null;
|
|
if (cachedUnit?.account_number === accountNumber) return;
|
|
|
|
const { error } = await supabase.from("units").update({ account_number: accountNumber }).eq("id", unitId);
|
|
if (error) {
|
|
console.warn(`Failed to update account number for unit ${unitId}: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
if (cachedUnit) cachedUnit.account_number = accountNumber;
|
|
for (const unit of unitLookup.byBuildiumId.values()) {
|
|
if (unit.id === unitId) unit.account_number = accountNumber;
|
|
}
|
|
for (const unit of unitLookup.byAssocAndNumber.values()) {
|
|
if (unit.id === unitId) unit.account_number = accountNumber;
|
|
}
|
|
for (const unit of unitLookup.byAssocAndAddress.values()) {
|
|
if (unit.id === unitId) unit.account_number = accountNumber;
|
|
}
|
|
}
|
|
|
|
async function resolveOrCreateUnit(
|
|
buildiumUnit: any,
|
|
assocId: string,
|
|
unitLookup: Awaited<ReturnType<typeof getUnitLookup>>,
|
|
): Promise<{ unitId: string; created: boolean }> {
|
|
const buildiumUnitId = normId(buildiumUnit.Id);
|
|
const address = formatAddress(buildiumUnit.Address ?? buildiumUnit.PrimaryAddress);
|
|
const explicitUnitNumber = normId(buildiumUnit.UnitNumber);
|
|
const parsedUnitNumber = extractUnitNumber(address);
|
|
const unitNumber = explicitUnitNumber || parsedUnitNumber || "Unknown";
|
|
const canMatchByNumber = !isPlaceholderUnitNumber(unitNumber);
|
|
|
|
const refreshLookup = (previousUnit: any, nextUnit: any) => {
|
|
const previousBuildiumId = normId(previousUnit?.buildium_unit_id);
|
|
const nextBuildiumId = normId(nextUnit?.buildium_unit_id);
|
|
if (previousBuildiumId && previousBuildiumId !== nextBuildiumId) {
|
|
unitLookup.byBuildiumId.delete(previousBuildiumId);
|
|
}
|
|
if (nextBuildiumId) unitLookup.byBuildiumId.set(nextBuildiumId, nextUnit);
|
|
if (nextUnit.unit_number) unitLookup.byAssocAndNumber.set(`${assocId}|${norm(nextUnit.unit_number)}`, nextUnit);
|
|
if (nextUnit.address) unitLookup.byAssocAndAddress.set(`${assocId}|${norm(nextUnit.address)}`, nextUnit);
|
|
};
|
|
|
|
if (buildiumUnitId) {
|
|
const existing = unitLookup.byBuildiumId.get(buildiumUnitId);
|
|
if (existing) {
|
|
const updates: Record<string, any> = {};
|
|
if ((!existing.address || !String(existing.address).trim()) && address) updates.address = address;
|
|
if (isPlaceholderUnitNumber(existing.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
|
|
if (Object.keys(updates).length > 0) {
|
|
await supabase.from("units").update(updates).eq("id", existing.id);
|
|
refreshLookup(existing, { ...existing, ...updates });
|
|
}
|
|
return { unitId: existing.id, created: false };
|
|
}
|
|
}
|
|
|
|
const byNumber = canMatchByNumber ? unitLookup.byAssocAndNumber.get(`${assocId}|${norm(unitNumber)}`) : null;
|
|
if (byNumber) {
|
|
const updates: Record<string, any> = {};
|
|
if (buildiumUnitId && byNumber.buildium_unit_id !== buildiumUnitId) updates.buildium_unit_id = buildiumUnitId;
|
|
if ((!byNumber.address || !String(byNumber.address).trim()) && address) updates.address = address;
|
|
if (isPlaceholderUnitNumber(byNumber.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
|
|
if (Object.keys(updates).length > 0) {
|
|
await supabase.from("units").update(updates).eq("id", byNumber.id);
|
|
refreshLookup(byNumber, { ...byNumber, ...updates });
|
|
}
|
|
return { unitId: byNumber.id, created: false };
|
|
}
|
|
|
|
if (address) {
|
|
const byAddr = unitLookup.byAssocAndAddress.get(`${assocId}|${norm(address)}`);
|
|
if (byAddr) {
|
|
const updates: Record<string, any> = {};
|
|
if (buildiumUnitId && byAddr.buildium_unit_id !== buildiumUnitId) updates.buildium_unit_id = buildiumUnitId;
|
|
if (isPlaceholderUnitNumber(byAddr.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
|
|
if (Object.keys(updates).length > 0) {
|
|
await supabase.from("units").update(updates).eq("id", byAddr.id);
|
|
refreshLookup(byAddr, { ...byAddr, ...updates, address: byAddr.address || address });
|
|
}
|
|
return { unitId: byAddr.id, created: false };
|
|
}
|
|
}
|
|
|
|
const { data: newUnit, error } = await supabase.from("units").insert({
|
|
association_id: assocId,
|
|
unit_number: unitNumber,
|
|
address: address || null,
|
|
buildium_unit_id: buildiumUnitId,
|
|
account_number: null,
|
|
status: "active",
|
|
}).select("id, association_id, unit_number, buildium_unit_id, account_number, address").single();
|
|
|
|
if (error) {
|
|
if (buildiumUnitId) {
|
|
const { data: conflictUnit } = await supabase
|
|
.from("units")
|
|
.select("id, association_id, unit_number, buildium_unit_id, account_number, address")
|
|
.eq("buildium_unit_id", buildiumUnitId)
|
|
.maybeSingle();
|
|
|
|
if (conflictUnit) {
|
|
const recoveryUpdates: Record<string, any> = {};
|
|
if ((!conflictUnit.address || !String(conflictUnit.address).trim()) && address) recoveryUpdates.address = address;
|
|
if (isPlaceholderUnitNumber(conflictUnit.unit_number) && !isPlaceholderUnitNumber(unitNumber)) recoveryUpdates.unit_number = unitNumber;
|
|
if (Object.keys(recoveryUpdates).length > 0) {
|
|
await supabase.from("units").update(recoveryUpdates).eq("id", conflictUnit.id);
|
|
}
|
|
refreshLookup(conflictUnit, { ...conflictUnit, ...recoveryUpdates });
|
|
return { unitId: conflictUnit.id, created: false };
|
|
}
|
|
}
|
|
|
|
console.warn(`Unit create error for Buildium unit ${buildiumUnit?.Id ?? "unknown"}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
|
|
refreshLookup(newUnit, newUnit);
|
|
return { unitId: newUnit.id, created: true };
|
|
}
|
|
|
|
if (syncType === "all" || syncType === "associations") {
|
|
const associations = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
|
|
const { data: excludedRows } = await supabase.from("associations").select("name").neq("status", "active");
|
|
const excludedNames = new Set((excludedRows || []).map((a: any) => a.name));
|
|
let filtered = associations.filter((a: any) => !excludedNames.has(a.Name));
|
|
if (selectedAssociationIds.length > 0) {
|
|
const { data: selected } = await supabase.from("associations").select("name").in("id", selectedAssociationIds);
|
|
const selectedNames = new Set((selected || []).map((s: any) => normalizeAssociationName(s.name)));
|
|
filtered = filtered.filter((a: any) => selectedNames.has(normalizeAssociationName(a.Name)));
|
|
}
|
|
const upserts = filtered.map((a: any) => ({
|
|
name: a.Name || "Unknown",
|
|
address: a.Address?.AddressLine1 || null,
|
|
city: a.Address?.City || null,
|
|
state: a.Address?.State || null,
|
|
zip: a.Address?.ZipCode || a.Address?.PostalCode || null,
|
|
email: a.Email || null,
|
|
phone: a.Phone || null,
|
|
fiscal_year_start: a.FiscalYearEndMonth || null,
|
|
}));
|
|
if (upserts.length > 0) {
|
|
const { error } = await supabase.from("associations").upsert(upserts, { onConflict: "name", ignoreDuplicates: true });
|
|
if (error) throw new Error(`Associations upsert failed: ${error.message}`);
|
|
}
|
|
|
|
// Ensure every active association has an accounting company. Once it exists,
|
|
// the existing DB triggers automatically flow units -> customers, owner
|
|
// ledger -> A/R + income, and bills -> A/P + expense into Accounting.
|
|
// (Opening balances are a separate one-time migration: buildium-opening-balances.)
|
|
let accountingCompaniesCreated = 0;
|
|
try {
|
|
const { data: activeAssocs } = await supabase.from("associations").select("id, name").eq("status", "active");
|
|
const acctSchema = (supabase as any).schema("accounting");
|
|
const { data: existingCompanies } = await acctSchema.from("companies").select("association_id");
|
|
const haveCompany = new Set((existingCompanies || []).map((c: any) => c.association_id));
|
|
const toCreate = (activeAssocs || []).filter((a: any) => a.id && !haveCompany.has(a.id));
|
|
if (toCreate.length > 0) {
|
|
const { error: compErr } = await acctSchema.from("companies").insert(
|
|
toCreate.map((a: any) => ({ association_id: a.id, name: a.name || "Association", created_by: userId }))
|
|
);
|
|
if (compErr) console.warn(`Accounting company provisioning failed: ${compErr.message}`);
|
|
else accountingCompaniesCreated = toCreate.length;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Accounting company provisioning error:", (e as Error)?.message);
|
|
}
|
|
|
|
results.associations = { fetched: associations.length, upserted: upserts.length, accounting_companies_created: accountingCompaniesCreated };
|
|
}
|
|
|
|
if (syncType === "all" || syncType === "units") {
|
|
const { bIdToLocalId } = await getAssociationMaps();
|
|
const unitLookup = await getUnitLookup();
|
|
const { units: buildiumUnits } = await getBuildiumAssociationUnits();
|
|
const ownershipAccounts = await getOwnershipAccounts();
|
|
const ownershipAccountsByUnit = buildOwnershipAccountsByUnit(ownershipAccounts);
|
|
|
|
let created = 0, linked = 0, skippedUnits = 0;
|
|
|
|
for (const buildiumUnit of buildiumUnits) {
|
|
const assocId = bIdToLocalId.get(normId(buildiumUnit.AssociationId) || "") || null;
|
|
if (!assocId || !isSelected(assocId)) {
|
|
skippedUnits++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const result = await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
|
|
const buildiumUnitId = normId(buildiumUnit.Id);
|
|
const preferredAccount = buildiumUnitId ? pickPreferredOwnershipAccount(ownershipAccountsByUnit.get(buildiumUnitId) || []) : null;
|
|
const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
|
|
if (leaseId) await updateUnitAccountNumber(unitLookup, result.unitId, leaseId);
|
|
|
|
if (result.created) created++;
|
|
else linked++;
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
console.warn(`Unit resolve error for Buildium unit ${buildiumUnit.Id}: ${message}`);
|
|
skippedUnits++;
|
|
}
|
|
}
|
|
|
|
results.units = { fetched: buildiumUnits.length, created, linked, skipped: skippedUnits };
|
|
}
|
|
|
|
if (syncType === "all" || syncType === "owners") {
|
|
const { bIdToLocalId } = await getAssociationMaps();
|
|
const unitLookup = await getUnitLookup();
|
|
const ownerLookup = await getOwnerLookup();
|
|
const { units: buildiumUnits } = await getBuildiumAssociationUnits();
|
|
const ownershipAccounts = await getOwnershipAccounts();
|
|
const ownershipAccountsByUnit = buildOwnershipAccountsByUnit(ownershipAccounts);
|
|
const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret);
|
|
|
|
let importedOwners = 0;
|
|
let skippedOwners = 0;
|
|
let unitsCreated = 0;
|
|
let unitsLinked = 0;
|
|
|
|
for (const buildiumUnit of buildiumUnits) {
|
|
const assocId = bIdToLocalId.get(normId(buildiumUnit.AssociationId) || "") || null;
|
|
if (!assocId || !isSelected(assocId)) continue;
|
|
|
|
try {
|
|
const result = await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
|
|
const buildiumUnitId = normId(buildiumUnit.Id);
|
|
const preferredAccount = buildiumUnitId ? pickPreferredOwnershipAccount(ownershipAccountsByUnit.get(buildiumUnitId) || []) : null;
|
|
const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
|
|
if (leaseId) await updateUnitAccountNumber(unitLookup, result.unitId, leaseId);
|
|
|
|
if (result.created) unitsCreated++;
|
|
else unitsLinked++;
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
console.warn(`Unit resolve error for Buildium unit ${buildiumUnit.Id}: ${message}`);
|
|
}
|
|
}
|
|
|
|
const ownerIdToAccounts = new Map<string, any[]>();
|
|
for (const acct of ownershipAccounts) {
|
|
const ownerIds: string[] = [];
|
|
if (acct.AssociationOwnerIds && Array.isArray(acct.AssociationOwnerIds)) {
|
|
for (const oid of acct.AssociationOwnerIds) ownerIds.push(String(oid));
|
|
}
|
|
if (acct.AssociationOwnerId) ownerIds.push(String(acct.AssociationOwnerId));
|
|
for (const oid of ownerIds) {
|
|
if (!ownerIdToAccounts.has(oid)) ownerIdToAccounts.set(oid, []);
|
|
ownerIdToAccounts.get(oid)!.push(acct);
|
|
}
|
|
}
|
|
|
|
console.log(`Owner sync: ${owners.length} owners, ${ownershipAccounts.length} ownership accounts, ownerIdToAccounts size: ${ownerIdToAccounts.size}`);
|
|
if (owners.length > 0) {
|
|
console.log(`Sample owner keys: ${JSON.stringify(Object.keys(owners[0]))}`);
|
|
}
|
|
|
|
for (const owner of owners) {
|
|
const firstName = (owner.FirstName || "").trim() || "Unknown";
|
|
const lastName = (owner.LastName || "").trim() || "Owner";
|
|
const address = formatAddress(owner.PrimaryAddress);
|
|
const mailingAddress = formatAddress(owner.AlternateAddress);
|
|
const buildiumOwnerId = normId(owner.Id);
|
|
|
|
let assocId = bIdToLocalId.get(normId(owner.AssociationId) || "") || null;
|
|
const linkedAccountsById = new Map<string, any>();
|
|
const linkedAccountsRaw = [
|
|
...(Array.isArray(owner.OwnershipAccounts) ? owner.OwnershipAccounts : []),
|
|
...(buildiumOwnerId ? (ownerIdToAccounts.get(buildiumOwnerId) || []) : []),
|
|
];
|
|
for (const acct of linkedAccountsRaw) {
|
|
const accountId = normId(acct?.Id ?? acct?.OwnershipAccountId);
|
|
if (accountId) linkedAccountsById.set(accountId, acct);
|
|
}
|
|
const linkedAccounts = Array.from(linkedAccountsById.values());
|
|
|
|
if (!assocId && linkedAccounts.length > 0) {
|
|
for (const acct of linkedAccounts) {
|
|
assocId = bIdToLocalId.get(normId(acct.AssociationId) || "") || null;
|
|
if (assocId) break;
|
|
}
|
|
}
|
|
|
|
if (!assocId || !isSelected(assocId)) {
|
|
if (importedOwners === 0 && skippedOwners < 3) {
|
|
console.log(`Skipping owner ${buildiumOwnerId} "${firstName} ${lastName}": AssociationId=${owner.AssociationId}, linked accounts=${linkedAccounts.length}`);
|
|
}
|
|
skippedOwners++;
|
|
continue;
|
|
}
|
|
|
|
const propertyAddress = address || "N/A";
|
|
let unitId: string | null = null;
|
|
const buildiumUnitIds = new Set<string>();
|
|
|
|
for (const acct of linkedAccounts) {
|
|
const linkedUnitId = normId(acct.UnitId);
|
|
if (linkedUnitId) buildiumUnitIds.add(linkedUnitId);
|
|
}
|
|
const directUnitId = normId(owner.UnitId);
|
|
if (directUnitId) buildiumUnitIds.add(directUnitId);
|
|
|
|
for (const buildiumUnitId of buildiumUnitIds) {
|
|
const unit = unitLookup.byBuildiumId.get(buildiumUnitId);
|
|
if (unit) {
|
|
unitId = unit.id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!unitId && propertyAddress !== "N/A") {
|
|
const unitByAddress = unitLookup.byAssocAndAddress.get(`${assocId}|${norm(propertyAddress)}`);
|
|
if (unitByAddress) unitId = unitByAddress.id;
|
|
}
|
|
|
|
if (unitId) {
|
|
const preferredAccount = pickPreferredOwnershipAccount(linkedAccounts);
|
|
const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
|
|
if (leaseId) await updateUnitAccountNumber(unitLookup, unitId, leaseId);
|
|
}
|
|
|
|
const isCurrentOwner = linkedAccounts.some((acct: any) => {
|
|
const acctStatus = String(acct.Status || "").toLowerCase();
|
|
return acctStatus === "active" || acctStatus === "current" || acctStatus === "";
|
|
});
|
|
const ownerStatus = isCurrentOwner ? "active" : "archived";
|
|
|
|
const ownerRecord: Record<string, any> = {
|
|
association_id: assocId,
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
property_address: propertyAddress,
|
|
mailing_address: mailingAddress || null,
|
|
email: owner.Email || null,
|
|
phone: owner.PhoneNumbers?.[0]?.Number || null,
|
|
buildium_owner_id: buildiumOwnerId,
|
|
unit_id: unitId,
|
|
status: ownerStatus,
|
|
};
|
|
|
|
const syncKey = buildOwnerSyncKey(assocId, firstName, lastName, propertyAddress);
|
|
const existingById = buildiumOwnerId ? ownerLookup.byBuildiumId.get(buildiumOwnerId) : null;
|
|
const existingBySyncKey = ownerLookup.bySyncKey.get(syncKey);
|
|
const existingOwner = existingById || existingBySyncKey;
|
|
|
|
if (existingOwner) {
|
|
const updates: Record<string, any> = {};
|
|
if (!existingOwner.buildium_owner_id && buildiumOwnerId) updates.buildium_owner_id = buildiumOwnerId;
|
|
if (unitId && existingOwner.unit_id !== unitId) updates.unit_id = unitId;
|
|
if (!existingOwner.email && ownerRecord.email) updates.email = ownerRecord.email;
|
|
if (!existingOwner.phone && ownerRecord.phone) updates.phone = ownerRecord.phone;
|
|
if (!existingOwner.mailing_address && ownerRecord.mailing_address) updates.mailing_address = ownerRecord.mailing_address;
|
|
if ((!existingOwner.property_address || existingOwner.property_address === "N/A") && ownerRecord.property_address && ownerRecord.property_address !== "N/A") updates.property_address = ownerRecord.property_address;
|
|
if (existingOwner.status !== ownerStatus) updates.status = ownerStatus;
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
const { error } = await supabase.from("owners").update(updates).eq("id", existingOwner.id);
|
|
if (error) {
|
|
console.warn(`Owner update: ${error.message}`);
|
|
skippedOwners++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const nextOwner = { ...existingOwner, ...updates, id: existingOwner.id };
|
|
if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, nextOwner);
|
|
ownerLookup.bySyncKey.set(syncKey, nextOwner);
|
|
setPreferredOwnerByUnit(ownerLookup.byUnitId, nextOwner);
|
|
importedOwners++;
|
|
continue;
|
|
}
|
|
|
|
const { data: dupeCheck } = await supabase.from("owners")
|
|
.select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status")
|
|
.eq("association_id", assocId)
|
|
.eq("first_name", firstName)
|
|
.eq("last_name", lastName)
|
|
.maybeSingle();
|
|
|
|
if (dupeCheck) {
|
|
const updates: Record<string, any> = {};
|
|
if (!dupeCheck.buildium_owner_id && buildiumOwnerId) updates.buildium_owner_id = buildiumOwnerId;
|
|
if (unitId && dupeCheck.unit_id !== unitId) updates.unit_id = unitId;
|
|
if (!dupeCheck.email && ownerRecord.email) updates.email = ownerRecord.email;
|
|
if (!dupeCheck.phone && ownerRecord.phone) updates.phone = ownerRecord.phone;
|
|
if (!dupeCheck.mailing_address && ownerRecord.mailing_address) updates.mailing_address = ownerRecord.mailing_address;
|
|
if ((!dupeCheck.property_address || dupeCheck.property_address === "N/A") && ownerRecord.property_address && ownerRecord.property_address !== "N/A") updates.property_address = ownerRecord.property_address;
|
|
if (dupeCheck.status !== ownerStatus) updates.status = ownerStatus;
|
|
if (Object.keys(updates).length > 0) {
|
|
await supabase.from("owners").update(updates).eq("id", dupeCheck.id);
|
|
}
|
|
const nextOwner = { ...dupeCheck, ...updates };
|
|
if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, nextOwner);
|
|
ownerLookup.bySyncKey.set(syncKey, nextOwner);
|
|
setPreferredOwnerByUnit(ownerLookup.byUnitId, nextOwner);
|
|
importedOwners++;
|
|
continue;
|
|
}
|
|
|
|
const { data: insertedOwner, error } = await supabase
|
|
.from("owners")
|
|
.insert(ownerRecord)
|
|
.select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status")
|
|
.single();
|
|
|
|
if (error) {
|
|
console.warn(`Owner insert: ${error.message}`);
|
|
skippedOwners++;
|
|
continue;
|
|
}
|
|
|
|
if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, insertedOwner);
|
|
ownerLookup.bySyncKey.set(syncKey, insertedOwner);
|
|
setPreferredOwnerByUnit(ownerLookup.byUnitId, insertedOwner);
|
|
importedOwners++;
|
|
}
|
|
|
|
results.owners = {
|
|
fetched: owners.length,
|
|
imported: importedOwners,
|
|
skipped: skippedOwners,
|
|
units_created: unitsCreated,
|
|
units_linked: unitsLinked,
|
|
};
|
|
}
|
|
|
|
if (syncType === "all" || syncType === "financials") {
|
|
const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
|
const { data: assocRows } = await supabase.from("associations").select("id, name");
|
|
let totalAccounts = 0;
|
|
|
|
// Separate parent (top-level) and child (sub) accounts
|
|
const parentAccounts = glAccounts.filter((gl: any) => !gl.ParentGLAccountId);
|
|
const childAccounts = glAccounts.filter((gl: any) => gl.ParentGLAccountId);
|
|
|
|
for (const assoc of assocRows || []) {
|
|
if (!isSelected(assoc.id)) continue;
|
|
|
|
// Step 1: Upsert parent accounts first
|
|
const parentUpserts = parentAccounts.map((gl: any) => ({
|
|
association_id: assoc.id,
|
|
account_number: String(gl.Id || gl.AccountNumber || ""),
|
|
account_name: gl.Name || "Unknown",
|
|
account_type: mapGLAccountType(gl.Type || gl.AccountType),
|
|
description: gl.Description || null,
|
|
is_active: gl.IsActive !== false,
|
|
}));
|
|
if (parentUpserts.length > 0) {
|
|
const { error } = await supabase.from("chart_of_accounts").upsert(parentUpserts, { onConflict: "association_id, account_number", ignoreDuplicates: false });
|
|
if (error) console.warn(`GL parent upsert for ${assoc.name}: ${error.message}`);
|
|
else totalAccounts += parentUpserts.length;
|
|
}
|
|
|
|
// Step 2: Build lookup of account_number -> id for this association
|
|
const { data: existingAccounts } = await supabase
|
|
.from("chart_of_accounts")
|
|
.select("id, account_number")
|
|
.eq("association_id", assoc.id);
|
|
const acctNumToId = new Map<string, string>();
|
|
for (const a of existingAccounts || []) {
|
|
acctNumToId.set(a.account_number, a.id);
|
|
}
|
|
|
|
// Step 3: Upsert child/subaccounts with parent_account_id linked
|
|
if (childAccounts.length > 0) {
|
|
const childUpserts = childAccounts.map((gl: any) => {
|
|
const parentBuildiumId = String(gl.ParentGLAccountId || "");
|
|
const parentDbId = acctNumToId.get(parentBuildiumId) || null;
|
|
return {
|
|
association_id: assoc.id,
|
|
account_number: String(gl.Id || gl.AccountNumber || ""),
|
|
account_name: gl.Name || "Unknown",
|
|
account_type: mapGLAccountType(gl.Type || gl.AccountType),
|
|
description: gl.Description || null,
|
|
is_active: gl.IsActive !== false,
|
|
parent_account_id: parentDbId,
|
|
};
|
|
});
|
|
const { error } = await supabase.from("chart_of_accounts").upsert(childUpserts, { onConflict: "association_id, account_number", ignoreDuplicates: false });
|
|
if (error) console.warn(`GL child upsert for ${assoc.name}: ${error.message}`);
|
|
else totalAccounts += childUpserts.length;
|
|
}
|
|
}
|
|
results.financials = { fetched: glAccounts.length, upserted: totalAccounts };
|
|
}
|
|
|
|
if (syncType === "all" || syncType === "charges" || syncType === "payments" || syncType === "ledger") {
|
|
const { bIdToLocalId } = await getAssociationMaps();
|
|
const unitLookup = await getUnitLookup();
|
|
const ownerLookup = await getOwnerLookup();
|
|
const { byId: buildiumUnitsById } = await getBuildiumAssociationUnits();
|
|
const ownershipAccountsForLedger = await getOwnershipAccounts();
|
|
|
|
let imported = 0, skipped = 0, updated = 0, totalFetched = 0;
|
|
|
|
// "charges" pulls charge transactions only; "ledger"/"payments"/"all" keep
|
|
// the historical payments-only policy.
|
|
const pullCharges = syncType === "charges";
|
|
|
|
// Buildium GL account -> dashboard accounting account, per association.
|
|
// Strict mapping: a charge line whose GL account has no link is held and
|
|
// flagged instead of being imported.
|
|
const glLinksByAssoc = new Map<string, Map<string, string>>();
|
|
async function getGlLinks(assocId: string): Promise<Map<string, string>> {
|
|
const cached = glLinksByAssoc.get(assocId);
|
|
if (cached) return cached;
|
|
const { data } = await supabase
|
|
.from("buildium_gl_account_links")
|
|
.select("buildium_gl_id, account_id")
|
|
.eq("association_id", assocId);
|
|
const m = new Map<string, string>();
|
|
for (const row of data || []) m.set(String(row.buildium_gl_id), row.account_id);
|
|
glLinksByAssoc.set(assocId, m);
|
|
return m;
|
|
}
|
|
|
|
type UnmappedGL = { association_id: string; buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; buildium_type: string | null; count: number };
|
|
const unmappedGl = new Map<string, UnmappedGL>();
|
|
let missingGlInfo = 0;
|
|
function flagUnmapped(assocId: string, glId: string, glMeta: any) {
|
|
const key = `${assocId}|${glId}`;
|
|
const existing = unmappedGl.get(key);
|
|
if (existing) { existing.count++; return; }
|
|
unmappedGl.set(key, {
|
|
association_id: assocId,
|
|
buildium_gl_id: glId,
|
|
buildium_name: glMeta?.Name ? String(glMeta.Name) : null,
|
|
buildium_number: glMeta?.AccountNumber != null ? String(glMeta.AccountNumber) : null,
|
|
buildium_type: glMeta?.Type || glMeta?.AccountType ? String(glMeta?.Type || glMeta?.AccountType) : null,
|
|
count: 1,
|
|
});
|
|
}
|
|
function getLineGlId(line: any): string {
|
|
const raw = line?.GLAccount?.Id ?? line?.GLAccountId ?? null;
|
|
return raw === null || raw === undefined ? "" : String(raw);
|
|
}
|
|
|
|
function getEntryLines(entry: any): any[] {
|
|
const topLines = Array.isArray(entry.Lines) ? entry.Lines : [];
|
|
const journalLines = Array.isArray(entry.Journal?.Lines) ? entry.Journal.Lines : [];
|
|
const journalEntryLines = Array.isArray(entry.JournalEntry?.Lines) ? entry.JournalEntry.Lines : [];
|
|
return topLines.length > 0 ? topLines : journalLines.length > 0 ? journalLines : journalEntryLines;
|
|
}
|
|
|
|
function getEntryDescription(entry: any): string {
|
|
const lines = getEntryLines(entry);
|
|
|
|
const candidates = [
|
|
entry.Description,
|
|
entry.Memo,
|
|
entry.Journal?.Memo,
|
|
entry.Journal?.Description,
|
|
entry.JournalEntry?.Memo,
|
|
entry.JournalEntry?.Description,
|
|
entry.Notes,
|
|
entry.PayeeName,
|
|
entry.Name,
|
|
entry.Title,
|
|
entry.ChargeTypeName,
|
|
entry.CategoryName,
|
|
lines[0]?.Description,
|
|
lines[0]?.Memo,
|
|
lines[0]?.Name,
|
|
lines[0]?.ChargeTypeName,
|
|
lines[0]?.GLAccountName,
|
|
lines[0]?.GLAccount?.Name,
|
|
lines[0]?.GLAccount?.DefaultAccountName,
|
|
lines[0]?.GLAccount?.Description,
|
|
lines[0]?.GLAccount?.AccountName,
|
|
];
|
|
|
|
for (const c of candidates) {
|
|
if (c && String(c).trim()) return String(c).trim();
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getEntrySearchText(entry: any): string {
|
|
const lines = getEntryLines(entry);
|
|
|
|
const topLevelValues = [
|
|
entry.TransactionType,
|
|
entry.TransactionTypeEnum,
|
|
entry.Type,
|
|
entry.SubType,
|
|
entry.Subtype,
|
|
entry.Category,
|
|
entry.CategoryName,
|
|
entry.ChargeType,
|
|
entry.ChargeTypeName,
|
|
entry.PaymentType,
|
|
entry.Description,
|
|
entry.Memo,
|
|
entry.Journal?.Memo,
|
|
entry.Journal?.Description,
|
|
entry.Journal?.Type,
|
|
entry.Journal?.SubType,
|
|
entry.JournalEntry?.Memo,
|
|
entry.JournalEntry?.Description,
|
|
entry.JournalEntry?.Type,
|
|
entry.JournalEntry?.SubType,
|
|
entry.Notes,
|
|
entry.PayeeName,
|
|
entry.ReferenceNumber,
|
|
entry.Reference,
|
|
entry.Name,
|
|
entry.Title,
|
|
entry.GLAccountName,
|
|
entry.GLAccount?.Name,
|
|
entry.GLAccount?.DefaultAccountName,
|
|
entry.GLAccount?.Description,
|
|
entry.GLAccount?.AccountName,
|
|
entry.GLAccount?.Number,
|
|
entry.GLAccount?.AccountNumber,
|
|
entry.GLAccount?.Type,
|
|
entry.GLAccount?.AccountType,
|
|
];
|
|
|
|
const lineLevelValues = lines.flatMap((line: any) => [
|
|
line.Description,
|
|
line.Memo,
|
|
line.Name,
|
|
line.Type,
|
|
line.SubType,
|
|
line.Category,
|
|
line.CategoryName,
|
|
line.ChargeType,
|
|
line.ChargeTypeName,
|
|
line.ReferenceNumber,
|
|
line.AccountName,
|
|
line.AccountNumber,
|
|
line.AccountType,
|
|
line.GLAccountName,
|
|
line.GLAccount?.Name,
|
|
line.GLAccount?.DefaultAccountName,
|
|
line.GLAccount?.Description,
|
|
line.GLAccount?.AccountName,
|
|
line.GLAccount?.Number,
|
|
line.GLAccount?.AccountNumber,
|
|
line.GLAccount?.Type,
|
|
line.GLAccount?.AccountType,
|
|
]);
|
|
|
|
return norm([...topLevelValues, ...lineLevelValues].filter(Boolean).join(" "));
|
|
}
|
|
|
|
// Shared classification logic used by both whole-entry and per-line classifiers
|
|
function classifyFromText(searchText: string, glAccountText: string, debugInfo?: any): string {
|
|
const has = (...needles: string[]) => needles.some((needle) => searchText.includes(needle));
|
|
const glHas = (...needles: string[]) => needles.some((n) => glAccountText.includes(n));
|
|
|
|
if (glHas("interest", "finance charge")) return "interest";
|
|
if (glHas("prepayment", "prepaid", "pre payment", "pre paid", "advance payment")) return "Prepayment";
|
|
if (has("prepayment charge", "prepayment", "prepay", "pre payment", "pre pay", "advance payment", "advance deposit")) return "Prepayment";
|
|
if (has("special assessment", "reserve assessment", "capital improvement assessment")) return "special_assessment";
|
|
if (has("interest", "finance charge", "finance charges")) return "interest";
|
|
if (has("late fee", "late charge", "late charges", "late payment fee", "late payment charge", "non payment penalty", "delinquency fee", "delinquent fee")) return "late_fee";
|
|
if (has("legal fee", "legal fees", "attorney", "attorneys fee", "attorneys fees", "lien", "foreclosure", "court cost", "court costs", "collection cost", "collection costs", "demand letter")) return "legal_fee";
|
|
if (has("admin fee", "administrative fee", "administrative fees", "administration fee", "processing fee", "document fee", "notice fee", "statement fee", "mailing fee")) return "admin_fee";
|
|
if (has("violation", "violations", "fine", "fines", "covenant", "compliance", "enforcement")) return "violation";
|
|
if (has("bank fee", "bank charge", "nsf", "returned check", "returned payment", "return item", "insufficient funds", "chargeback", "ach reject", "rejected ach", "reject fee")) return "bank_fee";
|
|
if (has("assessment", "assessment fees", "monthly assessment", "monthly assessments", "monthly dues", "hoa dues", "association dues", "maintenance fee", "maintenance dues", "association fee income", "condo fee")) return "assessment";
|
|
|
|
if (debugInfo) {
|
|
console.warn("[buildium-sync] Unclassified charge defaulted to assessment", debugInfo);
|
|
}
|
|
return "assessment";
|
|
}
|
|
|
|
function classifyChargeType(entry: any): string {
|
|
const searchText = getEntrySearchText(entry);
|
|
const lines = getEntryLines(entry);
|
|
const glAccountText = norm(
|
|
[
|
|
...lines.map((l: any) => l.GLAccount?.Name || l.GLAccount?.name || ""),
|
|
...lines.map((l: any) => l.GLAccount?.AccountType || l.GLAccount?.accountType || ""),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
);
|
|
return classifyFromText(searchText, glAccountText, {
|
|
id: entry.Id,
|
|
transactionType: entry.TransactionType,
|
|
description: entry.Description ?? null,
|
|
memo: entry.Memo ?? null,
|
|
journalMemo: entry.Journal?.Memo ?? entry.JournalEntry?.Memo ?? null,
|
|
glAccountText,
|
|
searchText,
|
|
});
|
|
}
|
|
|
|
// Classify a single line item from a multi-line charge
|
|
function classifyLineChargeType(line: any, entry: any): string {
|
|
const lineFields = [
|
|
line.Description, line.Memo, line.Name, line.Type, line.SubType,
|
|
line.Category, line.CategoryName, line.ChargeType, line.ChargeTypeName,
|
|
line.GLAccountName, line.GLAccount?.Name, line.GLAccount?.DefaultAccountName,
|
|
line.GLAccount?.Description, line.GLAccount?.AccountName,
|
|
line.GLAccount?.Number, line.GLAccount?.AccountNumber,
|
|
line.GLAccount?.Type, line.GLAccount?.AccountType,
|
|
// Also include entry-level context for fallback matching
|
|
entry.Description, entry.Memo, entry.Journal?.Memo, entry.JournalEntry?.Memo,
|
|
];
|
|
const searchText = norm(lineFields.filter(Boolean).join(" "));
|
|
|
|
const glAccountText = norm(
|
|
[line.GLAccount?.Name || line.GLAccount?.name || "",
|
|
line.GLAccount?.AccountType || line.GLAccount?.accountType || ""]
|
|
.filter(Boolean).join(" ")
|
|
);
|
|
return classifyFromText(searchText, glAccountText);
|
|
}
|
|
|
|
// Get description for a single line item
|
|
function getLineDescription(line: any, entry: any): string {
|
|
const candidates = [
|
|
line.Description, line.Memo, line.Name, line.ChargeTypeName,
|
|
line.GLAccountName, line.GLAccount?.Name, line.GLAccount?.DefaultAccountName,
|
|
line.GLAccount?.Description, line.GLAccount?.AccountName,
|
|
entry.Description, entry.Memo,
|
|
];
|
|
for (const c of candidates) {
|
|
if (c && String(c).trim()) return String(c).trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
for (const acct of ownershipAccountsForLedger) {
|
|
const assocId = bIdToLocalId.get(normId(acct.AssociationId) || "") || null;
|
|
if (!assocId || !isSelected(assocId)) continue;
|
|
|
|
const buildiumUnitId = normId(acct.UnitId);
|
|
let unit = buildiumUnitId ? unitLookup.byBuildiumId.get(buildiumUnitId) : null;
|
|
if (!unit && buildiumUnitId) {
|
|
const buildiumUnit = buildiumUnitsById.get(buildiumUnitId);
|
|
if (buildiumUnit) {
|
|
try {
|
|
await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
|
|
unit = unitLookup.byBuildiumId.get(buildiumUnitId) || null;
|
|
} catch (e) {
|
|
console.warn(`Ledger unit recovery failed for account ${acct.Id}: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
if (!unit) { skipped++; continue; }
|
|
if (unitFilterId && unit.id !== unitFilterId) { skipped++; continue; }
|
|
|
|
const unitOwner = ownerLookup.byUnitId.get(unit.id) || null;
|
|
if (!unitOwner) { skipped++; continue; }
|
|
|
|
const ownershipAccountId = normId(acct.Id);
|
|
|
|
let ledgerEntries: any[] = [];
|
|
try {
|
|
const txnParams: Record<string, string> = {};
|
|
if (ledgerDateFrom) txnParams.transactiondatefrom = ledgerDateFrom;
|
|
if (ledgerDateTo) txnParams.transactiondateto = ledgerDateTo;
|
|
ledgerEntries = await buildiumFetchAll(`/v1/associations/ownershipaccounts/${acct.Id}/transactions`, clientId, clientSecret, Object.keys(txnParams).length > 0 ? txnParams : undefined);
|
|
} catch (e) {
|
|
console.warn(`Ledger fetch for account ${acct.Id}: ${e}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
totalFetched += ledgerEntries.length;
|
|
|
|
// DEBUG: Log the raw structure of the first few entries to understand Buildium's response format
|
|
if (ledgerEntries.length > 0) {
|
|
const sampleEntries = ledgerEntries.slice(0, 3);
|
|
for (const sample of sampleEntries) {
|
|
console.log("[buildium-sync] RAW ENTRY SAMPLE", JSON.stringify(sample, null, 2));
|
|
}
|
|
}
|
|
|
|
// Skip old lump-sum cleanup — we now rely on reference_id deduplication only
|
|
// to prevent accidental deletion and re-insertion of entries.
|
|
|
|
// Invalidate cache for this owner so we get fresh data
|
|
ownerLedgerCache.delete(unit.id ? `unit:${unit.id}` : `owner:${unitOwner.id}`);
|
|
const ledgerEntryMaps = await getOwnerLedgerEntryMaps(unitOwner.id, unit.id);
|
|
const targetOwnerId = ledgerEntryMaps.defaultOwnerId || unitOwner.id;
|
|
|
|
// Process ALL ledger entries (charges, payments, credits, etc.)
|
|
for (const entry of ledgerEntries) {
|
|
const buildiumLedgerId = String(entry.Id || "");
|
|
if (!buildiumLedgerId) continue;
|
|
|
|
const amount = Number(entry.TotalAmount ?? entry.Amount ?? 0);
|
|
if (amount === 0) continue;
|
|
|
|
const txnType = String(entry.TransactionType || "");
|
|
const txnDate = entry.Date ? entry.Date.split("T")[0] : new Date().toISOString().split("T")[0];
|
|
|
|
// BLOCK: Skip any prepayment-related entries from Buildium (per user request).
|
|
// Exception: keep "SIRS Reserve Prepayment" entries which are intentional reserves.
|
|
const rawDescForFilter = String(getEntryDescription(entry) || "");
|
|
const isPrepaymentTxn =
|
|
txnType === "AppliedPrepayment" ||
|
|
txnType === "Applied Prepayment" ||
|
|
/prepayment/i.test(txnType) ||
|
|
/prepayment/i.test(rawDescForFilter);
|
|
const isSirsReserve = /sirs reserve prepayment/i.test(rawDescForFilter);
|
|
if (isPrepaymentTxn && !isSirsReserve) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Helper: upsert a single ledger entry by reference_id
|
|
async function upsertLedgerEntry(
|
|
refId: string, txDate: string, txType: string, desc: string,
|
|
entryDebit: number, entryCredit: number,
|
|
maps: typeof ledgerEntryMaps, assocIdLocal: string, ownerIdLocal: string, unitIdLocal: string,
|
|
localGlAccountId: string | null = null,
|
|
): Promise<"imported" | "updated" | "skipped"> {
|
|
const existingEntry = maps.byReferenceId.get(refId) || null;
|
|
if (existingEntry) {
|
|
const glChanged = Boolean(localGlAccountId) && existingEntry.gl_account_id !== localGlAccountId;
|
|
// Entries we pushed keep their locally-authored type/description —
|
|
// re-classifying our own memo on the way back would only degrade
|
|
// them. Just backfill the GL account link if it's missing.
|
|
if (existingEntry.reference_type === "buildium_pushed") {
|
|
if (glChanged) {
|
|
await supabase.from("owner_ledger_entries").update({ gl_account_id: localGlAccountId }).eq("id", existingEntry.id);
|
|
maps.byReferenceId.set(refId, { ...existingEntry, gl_account_id: localGlAccountId });
|
|
return "updated";
|
|
}
|
|
return "skipped";
|
|
}
|
|
const dChanged = desc && existingEntry.description !== desc;
|
|
if (existingEntry.debit !== entryDebit || existingEntry.credit !== entryCredit || existingEntry.transaction_type !== txType || dChanged || glChanged) {
|
|
await supabase.from("owner_ledger_entries").update({
|
|
debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType,
|
|
...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}),
|
|
}).eq("id", existingEntry.id);
|
|
maps.byReferenceId.set(refId, { ...existingEntry, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType, gl_account_id: localGlAccountId ?? existingEntry.gl_account_id });
|
|
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,
|
|
...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}),
|
|
}).eq("id", legacyMatch.id);
|
|
maps.byLegacyKey.delete(legacyKey);
|
|
maps.byReferenceId.set(refId, { ...legacyMatch, reference_id: refId, reference_type: "buildium", debit: entryDebit, credit: entryCredit, transaction_type: txType, description: desc });
|
|
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,
|
|
...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}),
|
|
}).eq("id", dateAmountMatch.id);
|
|
maps.byDateAmount.delete(daKey);
|
|
maps.byReferenceId.set(refId, { ...dateAmountMatch, reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc });
|
|
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",
|
|
...(localGlAccountId ? { gl_account_id: localGlAccountId } : {}),
|
|
};
|
|
|
|
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;
|
|
let entryGlAccountId: string | null = null;
|
|
|
|
const buildiumDesc = getEntryDescription(entry);
|
|
|
|
// POLICY: "ledger"/"payments"/"all" only PULL payments. The explicit
|
|
// "charges" sync pulls charge transactions (and only those), resolving
|
|
// GL accounts strictly through buildium_gl_account_links.
|
|
const isPaymentTxn = ["Payment", "Credit", "Check"].includes(txnType) || amount < 0;
|
|
if (pullCharges) {
|
|
if (txnType !== "Charge") {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
} else 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.
|
|
// Strict GL mapping (charges pull only): resolve every line BEFORE
|
|
// touching the books — if any line's Buildium GL account is
|
|
// unmapped, hold the whole charge rather than importing it partially.
|
|
const lineResolutions: (string | null)[] = new Array(chargeLines.length).fill(null);
|
|
if (pullCharges) {
|
|
const links = await getGlLinks(assocId);
|
|
let allResolved = true;
|
|
for (let li = 0; li < chargeLines.length; li++) {
|
|
const line = chargeLines[li];
|
|
const lineAmount = Number(line.Amount ?? line.TotalAmount ?? 0);
|
|
if (lineAmount === 0) continue;
|
|
const lineGlId = getLineGlId(line);
|
|
if (!lineGlId) { missingGlInfo++; allResolved = false; continue; }
|
|
const resolved = links.get(lineGlId) || null;
|
|
if (!resolved) { flagUnmapped(assocId, lineGlId, line.GLAccount); allResolved = false; continue; }
|
|
lineResolutions[li] = resolved;
|
|
}
|
|
if (!allResolved) { skipped++; continue; }
|
|
}
|
|
|
|
// Remove legacy single entry if it exists (from before this breakdown logic)
|
|
const oldSingleEntry = ledgerEntryMaps.byReferenceId.get(buildiumLedgerId) || null;
|
|
if (oldSingleEntry) {
|
|
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 lineGlAccountId = lineResolutions[li];
|
|
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,
|
|
lineGlAccountId,
|
|
);
|
|
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)
|
|
if (pullCharges) {
|
|
const glId = chargeLines.length > 0 ? getLineGlId(chargeLines[0]) : "";
|
|
if (!glId) { missingGlInfo++; skipped++; continue; }
|
|
entryGlAccountId = (await getGlLinks(assocId)).get(glId) || null;
|
|
if (!entryGlAccountId) {
|
|
flagUnmapped(assocId, glId, chargeLines[0]?.GLAccount);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
}
|
|
transactionType = classifyChargeType(entry);
|
|
if (transactionType === "Prepayment") {
|
|
credit = Math.abs(amount);
|
|
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,
|
|
entryGlAccountId,
|
|
);
|
|
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;
|
|
|
|
// Surface held charges: persist the unmapped Buildium GL accounts so the
|
|
// GL Account Map UI can flag them, and report them in the response.
|
|
if (pullCharges) {
|
|
if (unmappedGl.size > 0) {
|
|
const flagRows = [...unmappedGl.values()].map((u) => ({
|
|
association_id: u.association_id,
|
|
buildium_gl_id: u.buildium_gl_id,
|
|
buildium_name: u.buildium_name,
|
|
buildium_number: u.buildium_number,
|
|
buildium_type: u.buildium_type,
|
|
context: "pull_charges",
|
|
last_seen_at: new Date().toISOString(),
|
|
}));
|
|
const { error: flagErr } = await supabase
|
|
.from("buildium_unmapped_gl_accounts")
|
|
.upsert(flagRows, { onConflict: "association_id,buildium_gl_id" });
|
|
if (flagErr) console.warn(`[buildium-sync] Failed to flag unmapped GL accounts: ${flagErr.message}`);
|
|
}
|
|
results.unmapped = [...unmappedGl.values()];
|
|
if (missingGlInfo > 0) results.charges_missing_gl_info = missingGlInfo;
|
|
}
|
|
}
|
|
|
|
// Save last sync timestamp per syncType (not for dry runs)
|
|
if (!dryRun) {
|
|
const syncTimestamp = new Date().toISOString();
|
|
const settingsKey = `buildium_last_sync_${syncType}`;
|
|
await supabase.from("company_settings").upsert(
|
|
{ key: settingsKey, value: syncTimestamp },
|
|
{ onConflict: "key" }
|
|
);
|
|
}
|
|
|
|
// ===== PUSH TO BUILDIUM: Charges & Payments =====
|
|
if (syncType === "push_charges" || syncType === "push_payments" || syncType === "push_all") {
|
|
const unitLookup = await getUnitLookup();
|
|
const ownershipAccounts = await getOwnershipAccounts();
|
|
|
|
// Build reverse lookup: unit buildium_unit_id -> ownership account ID
|
|
const unitToOwnershipAccount = new Map<string, number>();
|
|
for (const acct of ownershipAccounts) {
|
|
const buildiumUnitId = normId(acct.UnitId);
|
|
if (buildiumUnitId) {
|
|
const existing = unitToOwnershipAccount.get(buildiumUnitId);
|
|
if (!existing) {
|
|
unitToOwnershipAccount.set(buildiumUnitId, Number(acct.Id));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch GL accounts from Buildium for mapping charge types.
|
|
// Buildium returns some child accounts nested under SubAccounts, so flatten
|
|
// them here too; otherwise mappings saved to sub-account IDs cannot resolve.
|
|
const rawGlAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
|
const glAccounts: any[] = [];
|
|
const seenGlAccountIds = new Set<string>();
|
|
const walkGlAccount = (gl: any) => {
|
|
if (!gl) return;
|
|
const id = String(gl.Id ?? "");
|
|
if (id && !seenGlAccountIds.has(id)) {
|
|
seenGlAccountIds.add(id);
|
|
glAccounts.push(gl);
|
|
}
|
|
if (Array.isArray(gl.SubAccounts)) {
|
|
for (const sub of gl.SubAccounts) walkGlAccount(sub);
|
|
}
|
|
};
|
|
for (const gl of rawGlAccounts) walkGlAccount(gl);
|
|
interface BuildiumGLAccountMeta { id: number; name: string; normalizedName: string; type: string; isActive: boolean; isChargeable: boolean; }
|
|
const glAccountByName = new Map<string, BuildiumGLAccountMeta>();
|
|
const glAccountByNumber = new Map<string, BuildiumGLAccountMeta>(); // account number -> Buildium account
|
|
const glAccountById = new Map<number, BuildiumGLAccountMeta>(); // Buildium Id -> account
|
|
const chargeableGlAccounts: BuildiumGLAccountMeta[] = [];
|
|
for (const gl of glAccounts) {
|
|
const glType = String(gl.Type || gl.AccountType || "");
|
|
const typeKey = glType.toLowerCase();
|
|
const meta: BuildiumGLAccountMeta = {
|
|
id: Number(gl.Id),
|
|
name: String(gl.Name || ""),
|
|
normalizedName: norm(gl.Name),
|
|
type: glType,
|
|
isActive: gl.IsActive !== false,
|
|
isChargeable: gl.IsActive !== false && (typeKey === "income" || typeKey === "liability"),
|
|
};
|
|
if (gl.Name) {
|
|
const existingName = glAccountByName.get(meta.normalizedName);
|
|
if (!existingName || (!existingName.isChargeable && meta.isChargeable)) glAccountByName.set(meta.normalizedName, meta);
|
|
}
|
|
if (gl.Id) glAccountById.set(Number(gl.Id), meta);
|
|
if (meta.isChargeable) chargeableGlAccounts.push(meta);
|
|
// Map by every plausible "account number" field Buildium might return
|
|
for (const key of ["AccountNumber", "Number", "GlNumber", "GLNumber", "Code"]) {
|
|
const acctNum = String(gl[key] ?? "").trim();
|
|
if (acctNum && acctNum !== "undefined" && acctNum !== "null") {
|
|
const existing = glAccountByNumber.get(acctNum);
|
|
if (!existing || (!existing.isChargeable && meta.isChargeable)) glAccountByNumber.set(acctNum, meta);
|
|
}
|
|
}
|
|
// Also map the Id as a string so "4" -> Id 4 works
|
|
const existingIdKey = glAccountByNumber.get(String(gl.Id));
|
|
if (!existingIdKey || (!existingIdKey.isChargeable && meta.isChargeable)) glAccountByNumber.set(String(gl.Id), meta);
|
|
}
|
|
console.log(`[push] Loaded ${glAccounts.length} Buildium GL accounts (${chargeableGlAccounts.length} chargeable income/liability). byNumber keys: ${[...glAccountByNumber.keys()].slice(0, 20).join(", ")}`);
|
|
console.log(`[push] Chargeable account names: ${chargeableGlAccounts.map(a => `${a.id}:${a.name}`).slice(0, 60).join(" | ")}`);
|
|
if (glAccounts.length > 0) {
|
|
console.log(`[push] Sample GL account keys: ${JSON.stringify(Object.keys(glAccounts[0]))}`);
|
|
}
|
|
|
|
// Load per-association GL mappings from the database (with amount thresholds)
|
|
interface GLMappingRule { glAccountId: string; glAccountName: string | null; amountMin: number | null; amountMax: number | null; customDescription: string | null; }
|
|
const glMappingsByAssoc = new Map<string, Map<string, GLMappingRule[]>>();
|
|
const customDescByAssocType = new Map<string, Map<string, string>>();
|
|
// Account-level links: local accounting account -> Buildium GL account.
|
|
// Takes priority over charge-type rules when the entry carries gl_account_id.
|
|
const accountLinksByAssoc = new Map<string, Map<string, { glId: string; name: string | null }>>();
|
|
for (const assocId of (selectedAssociationIds.length > 0 ? selectedAssociationIds : [])) {
|
|
const { data: linkRows } = await supabase
|
|
.from("buildium_gl_account_links")
|
|
.select("buildium_gl_id, buildium_name, account_id, is_push_target")
|
|
.eq("association_id", assocId);
|
|
const linkMap = new Map<string, { glId: string; name: string | null }>();
|
|
for (const row of linkRows || []) {
|
|
if (row.is_push_target === false) continue;
|
|
linkMap.set(row.account_id, { glId: String(row.buildium_gl_id), name: row.buildium_name ?? null });
|
|
}
|
|
if (linkMap.size > 0) accountLinksByAssoc.set(assocId, linkMap);
|
|
const { data: mappingRows } = await supabase
|
|
.from("buildium_gl_mappings")
|
|
.select("charge_type, buildium_gl_account_id, buildium_gl_account_name, amount_min, amount_max, custom_description")
|
|
.eq("association_id", assocId);
|
|
if (mappingRows && mappingRows.length > 0) {
|
|
const map = new Map<string, GLMappingRule[]>();
|
|
const descMap = new Map<string, string>();
|
|
for (const row of mappingRows) {
|
|
const rules = map.get(row.charge_type) || [];
|
|
rules.push({
|
|
glAccountId: row.buildium_gl_account_id,
|
|
glAccountName: row.buildium_gl_account_name ?? null,
|
|
amountMin: row.amount_min ?? null,
|
|
amountMax: row.amount_max ?? null,
|
|
customDescription: (row as any).custom_description ?? null,
|
|
});
|
|
map.set(row.charge_type, rules);
|
|
const cd = ((row as any).custom_description ?? "").toString().trim();
|
|
if (cd && row.charge_type !== "interest") descMap.set(row.charge_type, cd);
|
|
}
|
|
glMappingsByAssoc.set(assocId, map);
|
|
customDescByAssocType.set(assocId, descMap);
|
|
console.log(`[push] Loaded ${mappingRows.length} GL mappings for assoc ${assocId}: ${[...map.keys()].join(", ")}`);
|
|
}
|
|
}
|
|
|
|
// Resolve a stored GL account value to a real Buildium API Id.
|
|
// The value might already be a valid Buildium Id, or it might be an account number (e.g. "4002").
|
|
function resolveGLId(storedValue: string | number, storedName?: string | null): number | null {
|
|
const asChargeableId = (meta: BuildiumGLAccountMeta | undefined | null, source: string): number | null => {
|
|
if (!meta) return null;
|
|
if (meta.isChargeable) return meta.id;
|
|
console.warn(`[push] GL ${source} resolved to ${meta.id} (${meta.name}, Type=${meta.type}) but charges require Income or Liability`);
|
|
return null;
|
|
};
|
|
const sv = String(storedValue).trim();
|
|
if (!sv) return null;
|
|
const num = Number(sv);
|
|
// If it's already a known Buildium Id, use it directly
|
|
if (!isNaN(num)) {
|
|
const byId = asChargeableId(glAccountById.get(num), `id "${sv}"`);
|
|
if (byId) return byId;
|
|
}
|
|
// Try to look it up as an account number string
|
|
const byNumber = asChargeableId(glAccountByNumber.get(sv), `number "${sv}"`);
|
|
if (byNumber) return byNumber;
|
|
// STRICT: no name-based fuzzy matching — only exact Buildium Ids or
|
|
// account numbers resolve. Anything else must be mapped explicitly.
|
|
console.warn(`[push] resolveGLId: could not resolve "${sv}"${storedName ? ` (${storedName})` : ""} — not found in ${glAccountById.size} IDs or ${glAccountByNumber.size} account numbers`);
|
|
return null;
|
|
}
|
|
|
|
// Find a GL account for a charge type via the explicit buildium_gl_mappings
|
|
// rules (with amount thresholds). STRICT: no fuzzy fallback.
|
|
function findGLAccountId(transactionType: string, assocId?: string, amount?: number): number | null {
|
|
// Priority 1: Per-association mapping from buildium_gl_mappings table
|
|
if (assocId) {
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
// STRICT: no fuzzy name matching. Without an explicit account link or a
|
|
// charge-type rule the entry is skipped with a clear reason so the user
|
|
// can fix the mapping — never silently routed to a guessed account.
|
|
console.warn(`[push] No GL mapping found for ${transactionType} (assoc ${assocId ?? "?"}).`);
|
|
return null;
|
|
}
|
|
|
|
// 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 dryRunSamples: any[] = [];
|
|
const recordSkip = (entry: any, reason: string, extra: any = {}) => {
|
|
pushSkipped++;
|
|
if (skipSamples.length < 20) skipSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, debit: entry.debit, credit: entry.credit, reason, ...extra });
|
|
};
|
|
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, gl_account_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.
|
|
// Priority 1: the entry's own GL account via the explicit
|
|
// account-to-account link. If the entry names an account but no
|
|
// link exists, hold it — falling back to charge-type rules could
|
|
// route it to the wrong Buildium account.
|
|
let glAccountId: number | null = null;
|
|
if (entry.gl_account_id) {
|
|
const link = accountLinksByAssoc.get(assocId)?.get(entry.gl_account_id) || null;
|
|
if (!link) {
|
|
recordSkip(entry, "no_account_link", { gl_account_id: entry.gl_account_id });
|
|
continue;
|
|
}
|
|
glAccountId = resolveGLId(link.glId, link.name);
|
|
if (!glAccountId) {
|
|
recordSkip(entry, "account_link_unresolved_in_buildium", { gl_account_id: entry.gl_account_id, buildium_gl_id: link.glId, buildium_name: link.name });
|
|
continue;
|
|
}
|
|
} else {
|
|
// Priority 2: explicit charge-type rules (buildium_gl_mappings).
|
|
glAccountId = findGLAccountId(entry.transaction_type, assocId, entry.debit);
|
|
}
|
|
if (!glAccountId) {
|
|
recordSkip(entry, "no_gl_account_resolved", {
|
|
transaction_type: entry.transaction_type,
|
|
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);
|
|
|
|
if (dryRun) {
|
|
pushed++;
|
|
if (dryRunSamples.length < 50) dryRunSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, amount: entry.debit, gl_account_id: entry.gl_account_id || null, buildium_gl_account: glAccountId, memo: chargeMemo });
|
|
continue;
|
|
}
|
|
|
|
const chargeBody = {
|
|
Date: entry.date,
|
|
Memo: chargeMemo,
|
|
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; }
|
|
|
|
if (dryRun) {
|
|
pushed++;
|
|
if (dryRunSamples.length < 50) dryRunSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, amount: entry.credit, kind: "payment" });
|
|
continue;
|
|
}
|
|
|
|
const paymentMemo = entry.description || "Payment from management system";
|
|
const paymentBody = {
|
|
Date: entry.date,
|
|
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, ...(dryRun ? { dryRun: true, dryRunSamples } : {}) };
|
|
}
|
|
|
|
// ===== PULL BUDGETS FROM BUILDIUM =====
|
|
if (syncType === "budgets") {
|
|
// Use the year of dateFrom if provided, otherwise current year
|
|
const yr = ledgerDateFrom ? Number(ledgerDateFrom.slice(0, 4)) : new Date().getFullYear();
|
|
|
|
const { bIdToLocalId } = await getAssociationMaps();
|
|
let totalImported = 0;
|
|
let totalUpdated = 0;
|
|
let totalFetched = 0;
|
|
|
|
for (const [buildiumAssocId, localAssocId] of bIdToLocalId.entries()) {
|
|
if (!isSelected(localAssocId)) continue;
|
|
|
|
try {
|
|
// Buildium /v1/budgets — filter by AssociationId and fiscal year
|
|
const budgets = await buildiumFetchAll("/v1/budgets", clientId, clientSecret, {
|
|
associationids: String(buildiumAssocId),
|
|
fiscalyear: String(yr),
|
|
});
|
|
totalFetched += budgets.length;
|
|
|
|
for (const bud of budgets) {
|
|
const details = Array.isArray(bud.Details) ? bud.Details : [];
|
|
for (const detail of details) {
|
|
const accountName = String(
|
|
detail.GLAccountName ||
|
|
detail.AccountName ||
|
|
detail.Name ||
|
|
"Uncategorized"
|
|
);
|
|
// Buildium budget detail months: Monthly amounts array of 12 entries
|
|
const monthly: number[] = Array.isArray(detail.MonthlyAmounts)
|
|
? detail.MonthlyAmounts.map((m: any) => Number(m) || 0)
|
|
: [];
|
|
const annualBudget = monthly.length > 0
|
|
? monthly.reduce((s, n) => s + n, 0)
|
|
: Number(detail.AnnualAmount || detail.TotalAmount || 0);
|
|
|
|
if (!annualBudget && !accountName) continue;
|
|
|
|
const { data: existing } = await supabase
|
|
.from("budgets")
|
|
.select("id")
|
|
.eq("association_id", localAssocId)
|
|
.eq("fiscal_year", yr)
|
|
.eq("category", accountName)
|
|
.maybeSingle();
|
|
|
|
if (existing?.id) {
|
|
await supabase.from("budgets").update({
|
|
budgeted_amount: annualBudget,
|
|
updated_at: new Date().toISOString(),
|
|
}).eq("id", existing.id);
|
|
totalUpdated++;
|
|
} else {
|
|
await supabase.from("budgets").insert({
|
|
association_id: localAssocId,
|
|
fiscal_year: yr,
|
|
category: accountName,
|
|
budgeted_amount: annualBudget,
|
|
actual_amount: 0,
|
|
notes: "Imported from Buildium",
|
|
});
|
|
totalImported++;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Budgets pull for assoc ${localAssocId} failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
results.budgets = { fetched: totalFetched, imported: totalImported, updated: totalUpdated, fiscal_year: yr };
|
|
}
|
|
|
|
if (syncType === "bills") {
|
|
const { bIdToLocalId } = await getAssociationMaps();
|
|
|
|
// 1) Sync vendors so bills can be linked
|
|
const buildiumVendors = await buildiumFetchAll("/v1/vendors", clientId, clientSecret);
|
|
// Paginated fetch so large vendor sets don't get truncated at 1000 rows
|
|
const existingVendors: any[] = [];
|
|
{
|
|
const PAGE = 1000;
|
|
for (let from = 0; ; from += PAGE) {
|
|
const { data, error } = await supabase
|
|
.from("vendors")
|
|
.select("id, name, association_id, association_ids, buildium_vendor_id")
|
|
.order("created_at", { ascending: true })
|
|
.range(from, from + PAGE - 1);
|
|
if (error || !data?.length) break;
|
|
existingVendors.push(...data);
|
|
if (data.length < PAGE) break;
|
|
}
|
|
}
|
|
const vendorByBuildiumId = new Map<string, any>();
|
|
const vendorByAssocName = new Map<string, any>();
|
|
const vendorByName = new Map<string, any>();
|
|
for (const v of existingVendors) {
|
|
if (v.buildium_vendor_id) vendorByBuildiumId.set(String(v.buildium_vendor_id), v);
|
|
vendorByAssocName.set(`${v.association_id}|${norm(v.name)}`, v);
|
|
// Prefer the earliest-created row as the canonical record per name
|
|
if (!vendorByName.has(norm(v.name))) vendorByName.set(norm(v.name), v);
|
|
}
|
|
|
|
let vendorsCreated = 0;
|
|
let vendorsLinked = 0;
|
|
|
|
// Helper to ensure a local vendor exists for a buildium vendor + association
|
|
async function ensureVendor(bVendor: any, assocLocalId: string): Promise<string | null> {
|
|
if (!bVendor) return null;
|
|
const bvid = String(bVendor.Id);
|
|
const existingByBId = vendorByBuildiumId.get(bvid);
|
|
if (existingByBId) return existingByBId.id;
|
|
|
|
const name = (bVendor.CompanyName || bVendor.Name || `${bVendor.FirstName || ""} ${bVendor.LastName || ""}`).trim() || "Unknown Vendor";
|
|
const byName = vendorByAssocName.get(`${assocLocalId}|${norm(name)}`);
|
|
if (byName) {
|
|
await supabase.from("vendors").update({ buildium_vendor_id: bvid }).eq("id", byName.id);
|
|
byName.buildium_vendor_id = bvid;
|
|
vendorByBuildiumId.set(bvid, byName);
|
|
vendorsLinked++;
|
|
return byName.id;
|
|
}
|
|
|
|
// Fall back to a global name match — same vendor across associations should
|
|
// be linked, not duplicated. Append the association_id to association_ids.
|
|
const globalMatch = vendorByName.get(norm(name));
|
|
if (globalMatch) {
|
|
const currentIds: string[] = Array.isArray(globalMatch.association_ids) ? globalMatch.association_ids : [];
|
|
const mergedIds = currentIds.includes(assocLocalId) ? currentIds : [...currentIds, assocLocalId];
|
|
await supabase
|
|
.from("vendors")
|
|
.update({ buildium_vendor_id: bvid, association_ids: mergedIds })
|
|
.eq("id", globalMatch.id);
|
|
globalMatch.buildium_vendor_id = bvid;
|
|
globalMatch.association_ids = mergedIds;
|
|
vendorByBuildiumId.set(bvid, globalMatch);
|
|
vendorByAssocName.set(`${assocLocalId}|${norm(name)}`, globalMatch);
|
|
vendorsLinked++;
|
|
return globalMatch.id;
|
|
}
|
|
|
|
const insertRow = {
|
|
association_id: assocLocalId,
|
|
association_ids: [assocLocalId],
|
|
name,
|
|
email: bVendor.PrimaryEmail || bVendor.Email || null,
|
|
phone: bVendor.PhoneNumbers?.[0]?.Number || null,
|
|
address: formatAddress(bVendor.Address) || null,
|
|
buildium_vendor_id: bvid,
|
|
is_active: bVendor.IsActive !== false,
|
|
};
|
|
const { data: inserted, error } = await supabase.from("vendors").insert(insertRow).select("id, association_id, association_ids, name, buildium_vendor_id").single();
|
|
if (error || !inserted) {
|
|
console.warn(`Vendor insert failed for ${name}: ${error?.message}`);
|
|
return null;
|
|
}
|
|
vendorsCreated++;
|
|
vendorByBuildiumId.set(bvid, inserted);
|
|
vendorByAssocName.set(`${assocLocalId}|${norm(name)}`, inserted);
|
|
vendorByName.set(norm(name), inserted);
|
|
return inserted.id;
|
|
}
|
|
|
|
const buildiumVendorById = new Map<string, any>();
|
|
for (const bv of buildiumVendors) buildiumVendorById.set(String(bv.Id), bv);
|
|
|
|
// ---- Direct A/P import support ------------------------------------
|
|
// Bills/payments/checks resolve their GL accounts STRICTLY through
|
|
// buildium_gl_account_links and, for import-mode companies
|
|
// (gl_auto_post=false), post their own journal entries — so A/P activity
|
|
// no longer depends on the nightly GL pull.
|
|
const acct = (supabase as any).schema("accounting");
|
|
|
|
const apLinksByAssoc = new Map<string, Map<string, string>>();
|
|
async function getApLinks(assocLocalId: string): Promise<Map<string, string>> {
|
|
const cached = apLinksByAssoc.get(assocLocalId);
|
|
if (cached) return cached;
|
|
const { data } = await supabase
|
|
.from("buildium_gl_account_links")
|
|
.select("buildium_gl_id, account_id")
|
|
.eq("association_id", assocLocalId);
|
|
const m = new Map<string, string>();
|
|
for (const row of data || []) m.set(String(row.buildium_gl_id), row.account_id);
|
|
apLinksByAssoc.set(assocLocalId, m);
|
|
return m;
|
|
}
|
|
|
|
const companyByAssoc = new Map<string, { id: string; gl_auto_post: boolean; apCutover: string | null } | null>();
|
|
async function getCompanyForAssoc(assocLocalId: string) {
|
|
if (companyByAssoc.has(assocLocalId)) return companyByAssoc.get(assocLocalId)!;
|
|
const { data } = await acct.from("companies").select("id, gl_auto_post, acmacc_sync_config").eq("association_id", assocLocalId).maybeSingle();
|
|
// Cutover: anything dated on/before the GL pull's watermark is already
|
|
// in the books as buildium_gl journal entries — the direct import must
|
|
// only post A/P dated AFTER it, or we'd double count the history.
|
|
const cfg = (data?.acmacc_sync_config ?? {}) as Record<string, any>;
|
|
const out = data
|
|
? {
|
|
id: data.id as string,
|
|
gl_auto_post: data.gl_auto_post !== false,
|
|
apCutover: (cfg?.buildium_gl?.ap_cutover_date ?? cfg?.buildium_gl?.last_synced_date ?? null) as string | null,
|
|
}
|
|
: null;
|
|
companyByAssoc.set(assocLocalId, out);
|
|
return out;
|
|
}
|
|
|
|
const apAccountByCompany = new Map<string, string | null>();
|
|
async function getApAccount(companyId: string): Promise<string | null> {
|
|
if (apAccountByCompany.has(companyId)) return apAccountByCompany.get(companyId)!;
|
|
const { data, error } = await acct.rpc("coa_ap", { _company_id: companyId });
|
|
if (error) console.warn(`[bills] coa_ap failed for ${companyId}: ${error.message}`);
|
|
const id = (data as string) || null;
|
|
apAccountByCompany.set(companyId, id);
|
|
return id;
|
|
}
|
|
|
|
// Buildium bank account id -> its GL account id (banks ARE GL accounts)
|
|
const bankGlByBankId = new Map<string, string>();
|
|
const buildiumBanks = await buildiumFetchAll("/v1/bankaccounts", clientId, clientSecret);
|
|
for (const bb2 of buildiumBanks) bankGlByBankId.set(String(bb2.Id), String(bb2.GLAccount?.Id ?? bb2.Id));
|
|
|
|
type ApUnmapped = { association_id: string; buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; buildium_type: string | null; count: number };
|
|
const apUnmapped = new Map<string, ApUnmapped>();
|
|
function flagApUnmapped(assocLocalId: string, glId: string, glMeta: any) {
|
|
const key = `${assocLocalId}|${glId}`;
|
|
const existing = apUnmapped.get(key);
|
|
if (existing) { existing.count++; return; }
|
|
apUnmapped.set(key, {
|
|
association_id: assocLocalId,
|
|
buildium_gl_id: glId,
|
|
buildium_name: glMeta?.Name ? String(glMeta.Name) : null,
|
|
buildium_number: glMeta?.AccountNumber != null ? String(glMeta.AccountNumber) : null,
|
|
buildium_type: glMeta?.Type || glMeta?.AccountType ? String(glMeta?.Type || glMeta?.AccountType) : null,
|
|
count: 1,
|
|
});
|
|
}
|
|
|
|
// Resolve every line of a Buildium bill/check through the account links.
|
|
// Returns null (and flags the offenders) when any line is unmapped —
|
|
// strict: the record is held rather than mis-posted.
|
|
async function resolveLineItems(assocLocalId: string, rawLines: any[]): Promise<Array<{ account_id: string; description: string | null; amount: number }> | null> {
|
|
const links = await getApLinks(assocLocalId);
|
|
const out: Array<{ account_id: string; description: string | null; amount: number }> = [];
|
|
let allResolved = true;
|
|
for (const l of rawLines) {
|
|
const amt = Number(l?.Amount ?? l?.TotalAmount ?? 0);
|
|
if (!amt) continue;
|
|
const glId = String(l?.GLAccountId ?? l?.GLAccount?.Id ?? "");
|
|
if (!glId) { allResolved = false; continue; }
|
|
const accountId = links.get(glId);
|
|
if (!accountId) { flagApUnmapped(assocLocalId, glId, l?.GLAccount); allResolved = false; continue; }
|
|
out.push({ account_id: accountId, description: l?.Memo ? String(l.Memo) : null, amount: amt });
|
|
}
|
|
return allResolved ? out : null;
|
|
}
|
|
|
|
// Idempotent direct journal entry (clear + insert), keyed by external id.
|
|
async function postDirectJe(
|
|
companyId: string, source: string, externalId: string, date: string,
|
|
description: string, reference: string | null,
|
|
lines: Array<{ account_id: string; debit: number; credit: number; description: string | null }>,
|
|
): Promise<boolean> {
|
|
const { data: prior } = await acct.from("journal_entries").select("id")
|
|
.eq("company_id", companyId).eq("external_source", source).eq("external_id", externalId);
|
|
for (const p of prior || []) await acct.from("journal_entries").delete().eq("id", p.id);
|
|
const { data: je, error: jeErr } = await acct.from("journal_entries")
|
|
.insert({ company_id: companyId, date, description, reference, external_source: source, external_id: externalId })
|
|
.select("id").single();
|
|
if (jeErr || !je) { console.warn(`[bills] JE insert failed (${source} ${externalId}): ${jeErr?.message}`); return false; }
|
|
const { error: lErr } = await acct.from("journal_entry_lines")
|
|
.insert(lines.map((l) => ({ ...l, journal_entry_id: je.id })));
|
|
if (lErr) {
|
|
await acct.from("journal_entries").delete().eq("id", je.id);
|
|
console.warn(`[bills] JE lines failed (${source} ${externalId}): ${lErr.message}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Cleared bank-register row so reconciliation + the Cash Disbursement
|
|
// report's check#/vendor enrichment work. Import-mode register inserts
|
|
// do not re-post the GL. Deduped by account+reference+amount+date.
|
|
async function ensureRegisterTxn(
|
|
companyId: string, accountId: string, date: string, description: string,
|
|
amount: number, reference: string, acctBillId: string | null, acctVendorId: string | null,
|
|
) {
|
|
const { data: existing } = await acct.from("transactions").select("id")
|
|
.eq("company_id", companyId).eq("account_id", accountId)
|
|
.eq("reference", reference).eq("amount", amount).eq("date", date).limit(1);
|
|
if (existing && existing.length > 0) return;
|
|
const { error } = await acct.from("transactions").insert({
|
|
company_id: companyId, account_id: accountId, date, description,
|
|
amount, type: "debit", reference, cleared: true,
|
|
bill_id: acctBillId, vendor_id: acctVendorId, coa_account_id: null,
|
|
});
|
|
if (error) console.warn(`[bills] register txn failed (${reference}): ${error.message}`);
|
|
}
|
|
|
|
const directPostedCompanies = new Set<string>();
|
|
let billsHeld = 0;
|
|
let paymentsPosted = 0;
|
|
let paymentsHeld = 0;
|
|
let checksCreated = 0;
|
|
const processedPaymentIds = new Set<string>();
|
|
|
|
// Post the A/P payment side (Dr A/P, Cr mapped bank) + register row for
|
|
// an import-mode company. Shared by bill payments and one-off checks.
|
|
async function postPaymentSide(
|
|
assocLocalId: string, company: { id: string }, paymentExtId: string,
|
|
date: string, description: string, checkNo: string | null,
|
|
bankBuildiumId: string | null, amount: number,
|
|
acctBillId: string | null, acctVendorId: string | null,
|
|
): Promise<boolean> {
|
|
const links = await getApLinks(assocLocalId);
|
|
const bankGlId = bankBuildiumId ? bankGlByBankId.get(String(bankBuildiumId)) : null;
|
|
const bankAccountId = bankGlId ? links.get(bankGlId) : null;
|
|
if (!bankAccountId) {
|
|
if (bankGlId) flagApUnmapped(assocLocalId, bankGlId, buildiumBanks.find((b: any) => String(b.Id) === String(bankBuildiumId))?.GLAccount);
|
|
paymentsHeld++;
|
|
return false;
|
|
}
|
|
const ap = await getApAccount(company.id);
|
|
if (!ap) { paymentsHeld++; return false; }
|
|
const ok = await postDirectJe(company.id, "buildium_billpay", paymentExtId, date, description, checkNo, [
|
|
{ account_id: ap, debit: amount, credit: 0, description },
|
|
{ account_id: bankAccountId, debit: 0, credit: amount, description },
|
|
]);
|
|
if (!ok) { paymentsHeld++; return false; }
|
|
await ensureRegisterTxn(company.id, bankAccountId, date, description, amount, checkNo || `BP-${paymentExtId}`, acctBillId, acctVendorId);
|
|
paymentsPosted++;
|
|
directPostedCompanies.add(company.id);
|
|
return true;
|
|
}
|
|
|
|
// Accounting-side bill row (for register linking) by public bill id
|
|
async function getAcctBill(publicBillId: string, companyId: string): Promise<{ id: string; vendor_id: string | null } | null> {
|
|
const { data } = await acct.from("bills").select("id, vendor_id")
|
|
.eq("company_id", companyId).eq("external_source", "acmacc_bill").eq("external_id", publicBillId).maybeSingle();
|
|
return data ?? null;
|
|
}
|
|
|
|
// 2) Fetch bills (both paid and unpaid)
|
|
// Buildium requires BOTH FromPaidDate and ToPaidDate when results may include paid bills.
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const defaultPaidFrom = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
const params: Record<string, string> = {
|
|
frompaiddate: ledgerDateFrom || defaultPaidFrom,
|
|
topaiddate: ledgerDateTo || today,
|
|
};
|
|
if (ledgerDateFrom) params.duedatefrom = ledgerDateFrom;
|
|
if (ledgerDateTo) params.duedateto = ledgerDateTo;
|
|
const buildiumBills = await buildiumFetchAll("/v1/bills", clientId, clientSecret, Object.keys(params).length > 0 ? params : undefined);
|
|
|
|
// Existing bills by buildium id
|
|
const { data: existingBills } = await supabase.from("bills").select("id, buildium_bill_id").not("buildium_bill_id", "is", null);
|
|
const billByBuildiumId = new Map<string, string>();
|
|
for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id);
|
|
|
|
let billsCreated = 0;
|
|
let billsUpdated = 0;
|
|
let billsSkipped = 0;
|
|
let billsLinkedDuplicate = 0;
|
|
|
|
// Cache of existing bills per association for fuzzy duplicate detection
|
|
const existingBillsByAssoc = new Map<string, Array<{ id: string; vendor_id: string | null; amount: number | null; invoice_number: string | null; bill_date: string | null; buildium_bill_id: string | null }>>();
|
|
async function getAssocBills(assocLocalId: string) {
|
|
if (existingBillsByAssoc.has(assocLocalId)) return existingBillsByAssoc.get(assocLocalId)!;
|
|
const { data } = await supabase
|
|
.from("bills")
|
|
.select("id, vendor_id, amount, invoice_number, bill_date, buildium_bill_id")
|
|
.eq("association_id", assocLocalId);
|
|
const list = (data || []) as any[];
|
|
existingBillsByAssoc.set(assocLocalId, list);
|
|
return list;
|
|
}
|
|
const dupKey = (s: any) => String(s || "").trim().toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
|
|
let billsDebugLogged = 0;
|
|
for (const bb of buildiumBills) {
|
|
const bbid = String(bb.Id);
|
|
// Buildium bills often expose the association/property via AccountingEntity on each line
|
|
// (AccountingEntityType = "Association" | "Rental"), not at the top level. Fall back accordingly.
|
|
const lineEntity = Array.isArray(bb.Lines)
|
|
? bb.Lines.map((l: any) => l?.AccountingEntity).find((e: any) => e && (e.Id || e.AssociationId))
|
|
: null;
|
|
const buildiumAssocId = String(
|
|
bb.AssociationId
|
|
|| bb.Association?.Id
|
|
|| lineEntity?.Id
|
|
|| lineEntity?.AssociationId
|
|
|| bb.PropertyId
|
|
|| ""
|
|
);
|
|
const assocLocalId = bIdToLocalId.get(buildiumAssocId) || null;
|
|
if (!assocLocalId || !isSelected(assocLocalId)) {
|
|
if (billsDebugLogged < 3) {
|
|
console.log(`Skipping bill ${bbid}: buildiumAssocId="${buildiumAssocId}", topLevelKeys=${Object.keys(bb).join(",")}, lineEntity=${JSON.stringify(lineEntity)}`);
|
|
billsDebugLogged++;
|
|
}
|
|
billsSkipped++;
|
|
continue;
|
|
}
|
|
|
|
const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
|
|
const vendorId = await ensureVendor(buildiumVendor, assocLocalId);
|
|
|
|
// Resolve EVERY line's GL account through buildium_gl_account_links
|
|
// (platform accounting ids). STRICT: any unmapped account holds the
|
|
// bill — it's flagged in the GL Account Map instead of mis-posting.
|
|
const rawBillLines = Array.isArray(bb.Lines) ? bb.Lines : [];
|
|
const hasGlInfo = rawBillLines.some((l: any) => (l?.GLAccountId ?? l?.GLAccount?.Id) != null);
|
|
let billLineItems: Array<{ account_id: string; description: string | null; amount: number }> = [];
|
|
if (hasGlInfo) {
|
|
const resolved = await resolveLineItems(assocLocalId, rawBillLines);
|
|
if (!resolved) { billsHeld++; billsSkipped++; continue; }
|
|
billLineItems = resolved;
|
|
}
|
|
const expenseAccountId: string | null = billLineItems[0]?.account_id ?? null;
|
|
|
|
let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0);
|
|
if ((!amount || amount === 0) && Array.isArray(bb.Lines)) {
|
|
amount = bb.Lines.reduce((sum: number, l: any) => sum + Number(l?.Amount ?? l?.TotalAmount ?? 0), 0);
|
|
}
|
|
const billDate = (bb.Date || bb.BillDate || new Date().toISOString().slice(0, 10)).slice(0, 10);
|
|
const dueDate = bb.DueDate ? String(bb.DueDate).slice(0, 10) : null;
|
|
const invoiceNumber = bb.ReferenceNumber || bb.InvoiceNumber || null;
|
|
const description = bb.Memo || bb.Description || null;
|
|
const isPaid = !!bb.PaidDate
|
|
|| String(bb.Status || "").toLowerCase() === "paid"
|
|
|| String(bb.ApprovalStatus || "").toLowerCase() === "paid"
|
|
|| (bb.PaidAmount && Number(bb.PaidAmount) >= amount && amount > 0);
|
|
// Route unpaid imports through Bill Approvals so staff can review before posting.
|
|
const status = isPaid ? "paid" : "pending";
|
|
|
|
const payload: Record<string, any> = {
|
|
association_id: assocLocalId,
|
|
vendor_id: vendorId,
|
|
invoice_number: invoiceNumber,
|
|
bill_date: billDate,
|
|
due_date: dueDate,
|
|
amount,
|
|
amount_paid: Number(bb.PaidAmount || (isPaid ? amount : 0)),
|
|
expense_account_id: expenseAccountId,
|
|
line_items: billLineItems.length > 0 ? billLineItems : null,
|
|
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,
|
|
approver_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor",
|
|
amount,
|
|
status: "pending",
|
|
});
|
|
if (aErr) console.warn(`Bill approval insert for ${bbid} failed: ${aErr.message}`);
|
|
}
|
|
}
|
|
|
|
// ---- Direct GL + payments --------------------------------------
|
|
// Import-mode companies (gl_auto_post=false) get their A/P journal
|
|
// entries posted here directly (the platform triggers skip them);
|
|
// gl-managed companies keep posting through their own triggers.
|
|
if (finalBillId) {
|
|
const company = await getCompanyForAssoc(assocLocalId);
|
|
const vendorDisplayName = buildiumVendor?.CompanyName || buildiumVendor?.Name
|
|
|| [buildiumVendor?.FirstName, buildiumVendor?.LastName].filter(Boolean).join(" ").trim() || "Vendor";
|
|
const billDesc = `${vendorDisplayName}${invoiceNumber ? ` Inv # ${invoiceNumber}` : ""}`;
|
|
|
|
if (company && !company.gl_auto_post && billLineItems.length > 0 && (!company.apCutover || billDate > company.apCutover)) {
|
|
const ap = await getApAccount(company.id);
|
|
if (ap) {
|
|
const ok = await postDirectJe(
|
|
company.id, "buildium_bill", bbid, billDate, billDesc,
|
|
invoiceNumber ? String(invoiceNumber) : null,
|
|
[
|
|
...billLineItems.map((li) => ({ account_id: li.account_id, debit: li.amount, credit: 0, description: li.description ?? billDesc })),
|
|
{ account_id: ap, debit: 0, credit: billLineItems.reduce((s, li) => s + li.amount, 0), description: billDesc },
|
|
],
|
|
);
|
|
if (ok) directPostedCompanies.add(company.id);
|
|
}
|
|
}
|
|
|
|
// Bill payments: real payment records (date, check #, bank account)
|
|
if (Number(bb.PaidAmount || 0) > 0 || isPaid) {
|
|
try {
|
|
const payRes = await buildiumFetch(`/v1/bills/${bbid}/payments`, clientId, clientSecret);
|
|
const payments = Array.isArray(payRes) ? payRes : [];
|
|
for (const p of payments) {
|
|
const pid = String(p.Id || "");
|
|
if (!pid || processedPaymentIds.has(pid)) continue;
|
|
processedPaymentIds.add(pid);
|
|
// Platform-managed companies settle bills via their own banking flow
|
|
if (!company || company.gl_auto_post) continue;
|
|
const pAmount = Array.isArray(p.Lines) ? p.Lines.reduce((s: number, l: any) => s + Number(l?.Amount || 0), 0) : 0;
|
|
if (!pAmount) continue;
|
|
const pDate = String(p.EntryDate || billDate).slice(0, 10);
|
|
// Payments on/before the GL watermark already came in via buildium_gl
|
|
if (company.apCutover && pDate <= company.apCutover) continue;
|
|
const checkNo = p.CheckNumber ? String(p.CheckNumber) : null;
|
|
const acctBill = await getAcctBill(finalBillId, company.id);
|
|
await postPaymentSide(assocLocalId, company, pid, pDate, billDesc, checkNo, p.BankAccountId ?? null, pAmount, acctBill?.id ?? null, acctBill?.vendor_id ?? null);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[bills] payments fetch for ${bbid} failed: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- One-off checks / EFTs: imported as a paid bill + payment pair ----
|
|
// (Buildium's /checks endpoint returns only standalone checks; bill
|
|
// payments live under /bills/{id}/payments above.)
|
|
let checksHeld = 0;
|
|
const checkStart = ledgerDateFrom || defaultPaidFrom;
|
|
const checkEnd = ledgerDateTo || today;
|
|
for (const bank of buildiumBanks) {
|
|
let checks: any[] = [];
|
|
try {
|
|
checks = await buildiumFetchAll(`/v1/bankaccounts/${bank.Id}/checks`, clientId, clientSecret, { StartDate: checkStart, EndDate: checkEnd });
|
|
} catch (e) {
|
|
console.warn(`[bills] checks fetch for bank ${bank.Id} failed: ${e}`);
|
|
continue;
|
|
}
|
|
for (const ck of checks) {
|
|
const ckId = String(ck.Id || "");
|
|
if (!ckId) continue;
|
|
const ckLines = Array.isArray(ck.Lines) ? ck.Lines : [];
|
|
const entity = ckLines.map((l: any) => l?.AccountingEntity).find((e: any) => e && (e.Id || e.AssociationId));
|
|
const assocLocalId = entity ? (bIdToLocalId.get(String(entity.Id ?? entity.AssociationId)) || null) : null;
|
|
if (!assocLocalId || !isSelected(assocLocalId)) continue;
|
|
const amount = ckLines.reduce((s: number, l: any) => s + Number(l?.Amount || 0), 0);
|
|
if (!amount) continue;
|
|
|
|
const resolved = await resolveLineItems(assocLocalId, ckLines);
|
|
if (!resolved || resolved.length === 0) { checksHeld++; continue; }
|
|
|
|
const externalKey = `check_${ckId}`;
|
|
const ckDate = String(ck.EntryDate || ck.Date || "").slice(0, 10) || today;
|
|
const checkNo = ck.CheckNumber ? String(ck.CheckNumber) : null;
|
|
const payeeName = ck.Payee?.Name ? String(ck.Payee.Name) : (ck.Memo ? String(ck.Memo) : "Check");
|
|
let ckVendorId: string | null = null;
|
|
if (ck.Payee?.Id && buildiumVendorById.has(String(ck.Payee.Id))) {
|
|
ckVendorId = await ensureVendor(buildiumVendorById.get(String(ck.Payee.Id)), assocLocalId);
|
|
}
|
|
const desc = `${payeeName}${checkNo ? ` Check # ${checkNo}` : ""}${ck.Memo && ck.Payee?.Name ? ` - ${ck.Memo}` : ""}`;
|
|
|
|
let pubBillId = billByBuildiumId.get(externalKey) ?? null;
|
|
const pubPayload: Record<string, any> = {
|
|
association_id: assocLocalId, vendor_id: ckVendorId,
|
|
invoice_number: checkNo, bill_date: ckDate, due_date: null,
|
|
amount, amount_paid: amount, paid_date: ckDate,
|
|
expense_account_id: resolved[0]?.account_id ?? null,
|
|
line_items: resolved, description: desc, status: "paid",
|
|
buildium_bill_id: externalKey,
|
|
};
|
|
if (pubBillId) {
|
|
const { error } = await supabase.from("bills").update(pubPayload).eq("id", pubBillId);
|
|
if (error) { console.warn(`[bills] check bill update ${externalKey}: ${error.message}`); continue; }
|
|
} else {
|
|
const { data: ins, error } = await supabase.from("bills").insert(pubPayload).select("id").single();
|
|
if (error || !ins) { console.warn(`[bills] check bill insert ${externalKey}: ${error?.message}`); continue; }
|
|
pubBillId = ins.id;
|
|
billByBuildiumId.set(externalKey, pubBillId);
|
|
checksCreated++;
|
|
}
|
|
|
|
const company = await getCompanyForAssoc(assocLocalId);
|
|
if (company && !company.gl_auto_post && pubBillId && (!company.apCutover || ckDate > company.apCutover)) {
|
|
const ap = await getApAccount(company.id);
|
|
if (ap) {
|
|
const ok = await postDirectJe(company.id, "buildium_bill", externalKey, ckDate, desc, checkNo, [
|
|
...resolved.map((li) => ({ account_id: li.account_id, debit: li.amount, credit: 0, description: li.description ?? desc })),
|
|
{ account_id: ap, debit: 0, credit: amount, description: desc },
|
|
]);
|
|
if (ok) {
|
|
directPostedCompanies.add(company.id);
|
|
const acctBill = await getAcctBill(pubBillId, company.id);
|
|
await postPaymentSide(assocLocalId, company, externalKey, ckDate, desc, checkNo, bank.Id, amount, acctBill?.id ?? null, acctBill?.vendor_id ?? null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Flag unmapped accounts for the GL Account Map UI ----
|
|
if (apUnmapped.size > 0) {
|
|
const flagRows = [...apUnmapped.values()].map((u) => ({
|
|
association_id: u.association_id,
|
|
buildium_gl_id: u.buildium_gl_id,
|
|
buildium_name: u.buildium_name,
|
|
buildium_number: u.buildium_number,
|
|
buildium_type: u.buildium_type,
|
|
context: "bills",
|
|
last_seen_at: new Date().toISOString(),
|
|
}));
|
|
const { error: flagErr } = await supabase
|
|
.from("buildium_unmapped_gl_accounts")
|
|
.upsert(flagRows, { onConflict: "association_id,buildium_gl_id" });
|
|
if (flagErr) console.warn(`[bills] flagging unmapped accounts failed: ${flagErr.message}`);
|
|
results.unmapped = [...apUnmapped.values()];
|
|
}
|
|
|
|
// Companies now receiving direct A/P postings: the nightly GL pull must
|
|
// skip Bill / Bill Payment / Check transactions to avoid double counting.
|
|
for (const companyId of directPostedCompanies) {
|
|
const { data: comp } = await acct.from("companies").select("acmacc_sync_config").eq("id", companyId).maybeSingle();
|
|
const cfg = (comp?.acmacc_sync_config ?? {}) as Record<string, any>;
|
|
if (cfg?.buildium_gl?.exclude_ap && cfg?.buildium_gl?.ap_cutover_date) continue;
|
|
await acct.from("companies").update({
|
|
acmacc_sync_config: {
|
|
...cfg,
|
|
buildium_gl: {
|
|
...(cfg.buildium_gl ?? {}),
|
|
exclude_ap: true,
|
|
// Freeze the cutover so the boundary never moves as the GL
|
|
// watermark advances: A/P <= cutover lives in buildium_gl
|
|
// entries, A/P > cutover comes from the direct import.
|
|
ap_cutover_date: cfg?.buildium_gl?.ap_cutover_date ?? cfg?.buildium_gl?.last_synced_date ?? "1900-01-01",
|
|
},
|
|
},
|
|
}).eq("id", companyId);
|
|
}
|
|
|
|
results.bills = {
|
|
fetched: buildiumBills.length,
|
|
created: billsCreated,
|
|
updated: billsUpdated,
|
|
skipped: billsSkipped,
|
|
held_unmapped: billsHeld,
|
|
linked_duplicates: billsLinkedDuplicate,
|
|
vendors_created: vendorsCreated,
|
|
vendors_linked: vendorsLinked,
|
|
payments_posted: paymentsPosted,
|
|
payments_held: paymentsHeld,
|
|
checks_created: checksCreated,
|
|
checks_held: checksHeld,
|
|
};
|
|
|
|
await supabase.from("company_settings").upsert(
|
|
{ key: "buildium_last_sync_bills", value: new Date().toISOString() },
|
|
{ onConflict: "key" }
|
|
);
|
|
}
|
|
|
|
if (syncType === "documents") {
|
|
let totalFetched = 0;
|
|
let totalCreated = 0;
|
|
let totalSkipped = 0;
|
|
const errors: string[] = [];
|
|
|
|
const { data: existingDocs } = await supabase
|
|
.from("documents")
|
|
.select("id, file_name, association_id")
|
|
.eq("category", "Buildium");
|
|
const existingKey = new Set<string>(
|
|
(existingDocs || []).map((d: any) => `${d.association_id ?? "company"}|${String(d.file_name || "").toLowerCase()}`)
|
|
);
|
|
|
|
// Build list of (buildiumEntityType, buildiumEntityId, localAssocId|null, label) targets
|
|
type Target = { entityType: string | null; entityId: string | null; localAssocId: string | null; label: string };
|
|
const targets: Target[] = [];
|
|
|
|
if (documentScope === "company") {
|
|
// Company-level files: not associated with any property
|
|
targets.push({ entityType: null, entityId: null, localAssocId: null, label: "company" });
|
|
} else {
|
|
const { bIdToLocalId } = await getAssociationMaps();
|
|
for (const [buildiumAssocId, localAssocId] of bIdToLocalId.entries()) {
|
|
if (!isSelected(localAssocId)) continue;
|
|
targets.push({ entityType: "Association", entityId: String(buildiumAssocId), localAssocId, label: `assoc ${localAssocId}` });
|
|
}
|
|
}
|
|
|
|
for (const tgt of targets) {
|
|
let files: any[] = [];
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (tgt.entityType && tgt.entityId) {
|
|
params.entitytype = tgt.entityType;
|
|
params.entityid = tgt.entityId;
|
|
}
|
|
// For company scope, fetch unfiltered and filter client-side (Buildium /v1/files requires entityid when entitytype is set)
|
|
files = await buildiumFetchAll("/v1/files", clientId, clientSecret, Object.keys(params).length ? params : undefined);
|
|
// For company scope, keep only files attached to the Company entity (or no entity at all)
|
|
if (documentScope === "company") {
|
|
files = files.filter((f: any) => {
|
|
const et = f?.EntityType ?? f?.PhysicalFileEntity?.EntityType ?? null;
|
|
if (!et) return true;
|
|
const norm = String(et).toLowerCase();
|
|
return norm === "company" || norm === "none" || norm === "general";
|
|
});
|
|
}
|
|
} catch (e) {
|
|
errors.push(`${tgt.label}: list failed: ${(e as Error).message}`);
|
|
continue;
|
|
}
|
|
totalFetched += files.length;
|
|
|
|
const filesToProcess = files.slice(documentOffset, documentOffset + documentLimit);
|
|
const dedupAssocKey = tgt.localAssocId ?? "company";
|
|
|
|
for (const f of filesToProcess) {
|
|
const fileId = f.Id;
|
|
const fileName: string = f.PhysicalFileName || f.Title || `buildium-${fileId}`;
|
|
const title: string = f.Title || fileName;
|
|
const dedupKey = `${dedupAssocKey}|${fileName.toLowerCase()}`;
|
|
if (existingKey.has(dedupKey)) { totalSkipped++; continue; }
|
|
|
|
try {
|
|
const dlReq = await fetch(`${BUILDIUM_BASE}/v1/files/${fileId}/downloadrequest`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-buildium-client-id": clientId,
|
|
"x-buildium-client-secret": clientSecret,
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
if (!dlReq.ok) { errors.push(`file ${fileId}: downloadrequest [${dlReq.status}]`); continue; }
|
|
const dl = await dlReq.json();
|
|
const downloadUrl: string | undefined = dl.DownloadUrl || dl.Url;
|
|
if (!downloadUrl) { errors.push(`file ${fileId}: no DownloadUrl`); continue; }
|
|
|
|
const fileRes = await fetch(downloadUrl);
|
|
if (!fileRes.ok) { errors.push(`file ${fileId}: download [${fileRes.status}]`); continue; }
|
|
const blob = await fileRes.blob();
|
|
const contentType = fileRes.headers.get("content-type") || "application/octet-stream";
|
|
|
|
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
const folderPrefix = tgt.localAssocId ?? "company";
|
|
const storagePath = `${folderPrefix}/Buildium/${Date.now()}-${fileId}-${safeName}`;
|
|
const { error: upErr } = await supabase.storage.from("files").upload(storagePath, blob, {
|
|
upsert: false, contentType,
|
|
});
|
|
if (upErr) { errors.push(`file ${fileId}: upload ${upErr.message}`); continue; }
|
|
|
|
const { data: urlData } = supabase.storage.from("files").getPublicUrl(storagePath);
|
|
|
|
const { error: insErr } = await supabase.from("documents").insert({
|
|
title: title.replace(/\.[^/.]+$/, ""),
|
|
file_name: fileName,
|
|
file_url: urlData.publicUrl,
|
|
file_size: Number(f.FileSize || blob.size || 0),
|
|
category: "Buildium",
|
|
association_id: tgt.localAssocId,
|
|
});
|
|
if (insErr) { errors.push(`file ${fileId}: insert ${insErr.message}`); continue; }
|
|
|
|
existingKey.add(dedupKey);
|
|
totalCreated++;
|
|
} catch (e) {
|
|
errors.push(`file ${fileId}: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
results.documents = {
|
|
fetched: totalFetched,
|
|
created: totalCreated,
|
|
skipped: totalSkipped,
|
|
processed: totalCreated + totalSkipped + errors.length,
|
|
nextOffset: documentOffset + documentLimit < files.length ? documentOffset + documentLimit : null,
|
|
errors: errors.slice(0, 20),
|
|
};
|
|
}
|
|
|
|
if (!results.documents) {
|
|
results.documents = { fetched: 0, created: 0, skipped: 0, processed: 0, nextOffset: null, errors };
|
|
}
|
|
|
|
await supabase.from("company_settings").upsert(
|
|
{ key: "buildium_last_sync_documents", value: new Date().toISOString() },
|
|
{ onConflict: "key" }
|
|
);
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, results }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
} catch (error: unknown) {
|
|
console.error("Buildium sync error:", error);
|
|
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
});
|
|
|
|
function mapGLAccountType(buildiumType: string): string {
|
|
const typeMap: Record<string, string> = { Asset: "asset", Liability: "liability", Equity: "equity", Income: "income", Expense: "expense", Revenue: "income" };
|
|
return typeMap[buildiumType] || "expense";
|
|
}
|