diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index d70b474..fe925f3 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2058,6 +2058,7 @@ export type Database = { expense_account_id: string | null id: string invoice_number: string | null + line_items: Json | null notes: string | null paid_date: string | null payment_method: string | null @@ -2085,6 +2086,7 @@ export type Database = { expense_account_id?: string | null id?: string invoice_number?: string | null + line_items?: Json | null notes?: string | null paid_date?: string | null payment_method?: string | null @@ -2112,6 +2114,7 @@ export type Database = { expense_account_id?: string | null id?: string invoice_number?: string | null + line_items?: Json | null notes?: string | null paid_date?: string | null payment_method?: string | null diff --git a/src/pages/settings/BuildiumSettingsPage.tsx b/src/pages/settings/BuildiumSettingsPage.tsx index aa31e75..ac14185 100644 --- a/src/pages/settings/BuildiumSettingsPage.tsx +++ b/src/pages/settings/BuildiumSettingsPage.tsx @@ -11,7 +11,7 @@ import { useToast } from "@/hooks/use-toast"; import { invokeEdgeFunction } from "@/lib/edgeFunctionUtils"; import { supabase } from "@/integrations/supabase/client"; import { - CheckCircle2, XCircle, Loader2, RefreshCw, Building2, Users, BookOpen, DollarSign, Home, Trash2, AlertTriangle, Upload, CalendarIcon, Clock, RotateCcw, Eye, + CheckCircle2, XCircle, Loader2, RefreshCw, Building2, Users, BookOpen, DollarSign, Home, Trash2, AlertTriangle, Upload, CalendarIcon, Clock, RotateCcw, Eye, FileText, } from "lucide-react"; import { cn } from "@/lib/utils"; import BuildiumGLMappingCard from "@/components/settings/BuildiumGLMappingCard"; @@ -23,7 +23,7 @@ import { AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -type SyncType = "associations" | "units" | "owners" | "financials" | "ledger" | "charges" | "push_charges" | "push_payments" | "push_all" | "reset_ledgers" | "all"; +type SyncType = "associations" | "units" | "owners" | "financials" | "ledger" | "charges" | "bills" | "push_charges" | "push_payments" | "push_all" | "reset_ledgers" | "all"; type DeleteType = "charges" | "payments" | "owners" | "units" | "financials" | "all"; interface SyncResults { @@ -32,6 +32,7 @@ interface SyncResults { owners?: { fetched: number; imported: number; skipped: number }; financials?: { fetched: number; upserted: number }; ledger?: { fetched: number; imported: number; skipped: number; updated?: number }; + bills?: { fetched: number; created: number; updated: number; skipped: number; held_unmapped?: number; payments_posted?: number; payments_held?: number; checks_created?: number; checks_held?: number; vendors_created?: number; vendors_linked?: number }; push?: { pushed: number; skipped: number; errors: number; skipSamples?: any[]; errorSamples?: any[]; dryRun?: boolean; dryRunSamples?: any[] }; reset?: { deleted: number }; unmapped?: { buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; count: number }[]; @@ -170,7 +171,7 @@ export default function BuildiumSettingsPage() { body: { syncType: type, selectedAssociationIds: selectedIds, - ...(type === "ledger" || type === "charges" || type === "all" ? { + ...(type === "ledger" || type === "charges" || type === "bills" || type === "all" ? { dateFrom: dateFrom ? format(dateFrom, "yyyy-MM-dd") : undefined, dateTo: dateTo ? format(dateTo, "yyyy-MM-dd") : undefined, } : {}), @@ -291,6 +292,7 @@ export default function BuildiumSettingsPage() { { type: "financials", title: "GL Accounts", desc: "Import chart of accounts and GL structure", icon: BookOpen, deleteType: "financials" }, { type: "ledger", title: "Pull Payments", desc: "Pull payments from Buildium into unit ledgers (charges are not pulled)", icon: DollarSign, deleteType: "charges" }, { type: "charges", title: "Pull Charges", desc: "Pull charges from Buildium into unit ledgers (payments are not pulled). Requires the GL Account Map — charges on unmapped accounts are held.", icon: BookOpen }, + { type: "bills", title: "Pull Bills & Expenses", desc: "Import bills, their payments, and one-off checks directly into Accounting via the GL Account Map — no GL pull needed for A/P. Unmapped accounts hold the bill.", icon: FileText }, ]; const pushCards: { type: SyncType; title: string; desc: string; icon: any }[] = [ @@ -688,6 +690,20 @@ export default function BuildiumSettingsPage() { )} )} + {results.bills && ( +
+
{results.bills.created + (results.bills.checks_created ?? 0)}
+
Bills & Checks Imported
+
+ {results.bills.updated} updated · {results.bills.payments_posted ?? 0} payment(s) posted +
+ {((results.bills.held_unmapped ?? 0) > 0 || (results.bills.payments_held ?? 0) > 0 || (results.bills.checks_held ?? 0) > 0) && ( +
+ {(results.bills.held_unmapped ?? 0) + (results.bills.checks_held ?? 0)} held · {results.bills.payments_held ?? 0} payment(s) held +
+ )} +
+ )} {Array.isArray(results.unmapped) && results.unmapped.length > 0 && (
@@ -702,7 +718,7 @@ export default function BuildiumSettingsPage() { ))}

- Map these in the GL Account Map tab above, then run Pull Charges again. + Map these in the GL Account Map tab above, then re-run the pull.

{(results.charges_missing_gl_info ?? 0) > 0 && (

{results.charges_missing_gl_info} charge(s) had no GL account info from Buildium and were skipped.

diff --git a/supabase/functions/buildium-gl-sync/index.ts b/supabase/functions/buildium-gl-sync/index.ts index c4ec18f..14d16b6 100644 --- a/supabase/functions/buildium-gl-sync/index.ts +++ b/supabase/functions/buildium-gl-sync/index.ts @@ -273,6 +273,23 @@ Deno.serve(async (req) => { } companyResult.pulled = txById.size; + // Direct A/P import (buildium-sync "bills") posts Bill / Bill Payment / + // Check journal entries itself for this company — skip those + // transaction types here so they aren't double counted. + if (cfg?.buildium_gl?.exclude_ap) { + // NOTE: owner "Refund" transactions stay in the GL pull — they are + // not returned by the /checks endpoint the direct import reads. + const AP_TYPES = new Set(["bill", "bill payment", "billpayment", "check", "bill credit", "vendor credit", "applied vendor credit"]); + let excludedAp = 0; + for (const [txId, tx] of [...txById.entries()]) { + if (AP_TYPES.has(String(tx.transactionType || "").toLowerCase())) { + txById.delete(txId); + excludedAp++; + } + } + companyResult.excluded_ap = excludedAp; + } + // ---- Already-imported transaction ids for this company ---- const existingIds = new Set(); for (let offset = 0; ; offset += 1000) { @@ -432,6 +449,7 @@ Deno.serve(async (req) => { const nextCfg = { ...cfg, buildium_gl: { + ...(cfg.buildium_gl ?? {}), last_synced_date: until, last_run_at: new Date().toISOString(), last_result: { diff --git a/supabase/functions/buildium-sync/index.ts b/supabase/functions/buildium-sync/index.ts index f91b833..9167de8 100644 --- a/supabase/functions/buildium-sync/index.ts +++ b/supabase/functions/buildium-sync/index.ts @@ -2186,6 +2186,171 @@ Deno.serve(async (req) => { const buildiumVendorById = new Map(); 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>(); + async function getApLinks(assocLocalId: string): Promise> { + 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(); + 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(); + async function getCompanyForAssoc(assocLocalId: string) { + if (companyByAssoc.has(assocLocalId)) return companyByAssoc.get(assocLocalId)!; + const { data } = await acct.from("companies").select("id, gl_auto_post").eq("association_id", assocLocalId).maybeSingle(); + const out = data ? { id: data.id as string, gl_auto_post: data.gl_auto_post !== false } : null; + companyByAssoc.set(assocLocalId, out); + return out; + } + + const apAccountByCompany = new Map(); + async function getApAccount(companyId: string): Promise { + 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(); + 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(); + 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 | 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 { + 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(); + let billsHeld = 0; + let paymentsPosted = 0; + let paymentsHeld = 0; + let checksCreated = 0; + const processedPaymentIds = new Set(); + + // 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 { + 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); @@ -2203,17 +2368,6 @@ Deno.serve(async (req) => { const billByBuildiumId = new Map(); for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id); - // Chart of accounts lookup per association (account_number -> id) - const coaCache = new Map>(); - async function getCoa(assocLocalId: string) { - if (coaCache.has(assocLocalId)) return coaCache.get(assocLocalId)!; - const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocLocalId); - const m = new Map(); - for (const a of data || []) m.set(String(a.account_number), a.id); - coaCache.set(assocLocalId, m); - return m; - } - let billsCreated = 0; let billsUpdated = 0; let billsSkipped = 0; @@ -2262,22 +2416,18 @@ Deno.serve(async (req) => { const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null; const vendorId = await ensureVendor(buildiumVendor, assocLocalId); - // Pick first line's GL account as expense account if available. - // chart_of_accounts.account_number stores the Buildium GL Id (see the - // glaccounts upsert: account_number = String(gl.Id ...)), so resolve the - // line's GL Id from whichever shape Buildium returns it in. - const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null; - let expenseAccountId: string | null = null; - const lineGlId = firstLine - ? (firstLine.GLAccountId - ?? firstLine.GLAccount?.Id - ?? firstLine.GLAccount?.GLAccountId - ?? null) - : null; - if (lineGlId !== null && lineGlId !== undefined && String(lineGlId) !== "") { - const coa = await getCoa(assocLocalId); - expenseAccountId = coa.get(String(lineGlId)) || null; + // 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)) { @@ -2303,6 +2453,7 @@ Deno.serve(async (req) => { 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, @@ -2372,6 +2523,55 @@ Deno.serve(async (req) => { } } + // ---- 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) { + 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); + 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 { @@ -2431,14 +2631,123 @@ Deno.serve(async (req) => { } } + // ---- 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 = { + 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) { + 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; + if (cfg?.buildium_gl?.exclude_ap) continue; + await acct.from("companies").update({ + acmacc_sync_config: { ...cfg, buildium_gl: { ...(cfg.buildium_gl ?? {}), exclude_ap: true } }, + }).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(