Direct Buildium A/P import: bills, payments, one-off checks via GL Account Map

- public.bills.line_items + line-aware accounting mirror (one bill_item per
  Buildium line with its mapped platform account)
- buildium-sync bills: strict per-line resolution through
  buildium_gl_account_links (unmapped -> bill held + flagged); pulls
  /bills/{id}/payments (check#, bank, date) and /bankaccounts/{id}/checks
  (one-off checks become paid bill+payment pairs)
- import-mode companies get direct JEs (buildium_bill Dr expense/Cr AP,
  buildium_billpay Dr AP/Cr mapped bank) + cleared register rows; sets
  buildium_gl.exclude_ap so the nightly GL pull skips Bill/Bill Payment/Check
- buildium-gl-sync: exclude_ap transaction-type filter; preserve buildium_gl
  config keys when advancing the watermark
- Settings: Pull Bills & Expenses card with held/unmapped reporting

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:43:58 -04:00
parent 4e77098f88
commit 25064d8418
4 changed files with 376 additions and 30 deletions
+3
View File
@@ -2058,6 +2058,7 @@ export type Database = {
expense_account_id: string | null expense_account_id: string | null
id: string id: string
invoice_number: string | null invoice_number: string | null
line_items: Json | null
notes: string | null notes: string | null
paid_date: string | null paid_date: string | null
payment_method: string | null payment_method: string | null
@@ -2085,6 +2086,7 @@ export type Database = {
expense_account_id?: string | null expense_account_id?: string | null
id?: string id?: string
invoice_number?: string | null invoice_number?: string | null
line_items?: Json | null
notes?: string | null notes?: string | null
paid_date?: string | null paid_date?: string | null
payment_method?: string | null payment_method?: string | null
@@ -2112,6 +2114,7 @@ export type Database = {
expense_account_id?: string | null expense_account_id?: string | null
id?: string id?: string
invoice_number?: string | null invoice_number?: string | null
line_items?: Json | null
notes?: string | null notes?: string | null
paid_date?: string | null paid_date?: string | null
payment_method?: string | null payment_method?: string | null
+20 -4
View File
@@ -11,7 +11,7 @@ import { useToast } from "@/hooks/use-toast";
import { invokeEdgeFunction } from "@/lib/edgeFunctionUtils"; import { invokeEdgeFunction } from "@/lib/edgeFunctionUtils";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { 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"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import BuildiumGLMappingCard from "@/components/settings/BuildiumGLMappingCard"; import BuildiumGLMappingCard from "@/components/settings/BuildiumGLMappingCard";
@@ -23,7 +23,7 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } 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"; type DeleteType = "charges" | "payments" | "owners" | "units" | "financials" | "all";
interface SyncResults { interface SyncResults {
@@ -32,6 +32,7 @@ interface SyncResults {
owners?: { fetched: number; imported: number; skipped: number }; owners?: { fetched: number; imported: number; skipped: number };
financials?: { fetched: number; upserted: number }; financials?: { fetched: number; upserted: number };
ledger?: { fetched: number; imported: number; skipped: number; updated?: 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[] }; push?: { pushed: number; skipped: number; errors: number; skipSamples?: any[]; errorSamples?: any[]; dryRun?: boolean; dryRunSamples?: any[] };
reset?: { deleted: number }; reset?: { deleted: number };
unmapped?: { buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; count: 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: { body: {
syncType: type, syncType: type,
selectedAssociationIds: selectedIds, selectedAssociationIds: selectedIds,
...(type === "ledger" || type === "charges" || type === "all" ? { ...(type === "ledger" || type === "charges" || type === "bills" || type === "all" ? {
dateFrom: dateFrom ? format(dateFrom, "yyyy-MM-dd") : undefined, dateFrom: dateFrom ? format(dateFrom, "yyyy-MM-dd") : undefined,
dateTo: dateTo ? format(dateTo, "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: "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: "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: "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 }[] = [ const pushCards: { type: SyncType; title: string; desc: string; icon: any }[] = [
@@ -688,6 +690,20 @@ export default function BuildiumSettingsPage() {
)} )}
</div> </div>
)} )}
{results.bills && (
<div className="bg-background p-4 rounded-lg border text-center">
<div className="text-2xl font-bold text-emerald-600">{results.bills.created + (results.bills.checks_created ?? 0)}</div>
<div className="text-xs text-muted-foreground mt-1">Bills & Checks Imported</div>
<div className="text-[10px] text-muted-foreground">
{results.bills.updated} updated · {results.bills.payments_posted ?? 0} payment(s) posted
</div>
{((results.bills.held_unmapped ?? 0) > 0 || (results.bills.payments_held ?? 0) > 0 || (results.bills.checks_held ?? 0) > 0) && (
<div className="text-[10px] text-amber-500 mt-1">
{(results.bills.held_unmapped ?? 0) + (results.bills.checks_held ?? 0)} held · {results.bills.payments_held ?? 0} payment(s) held
</div>
)}
</div>
)}
{Array.isArray(results.unmapped) && results.unmapped.length > 0 && ( {Array.isArray(results.unmapped) && results.unmapped.length > 0 && (
<div className="bg-background p-4 rounded-lg border border-amber-200 col-span-full"> <div className="bg-background p-4 rounded-lg border border-amber-200 col-span-full">
<div className="text-xs font-semibold text-amber-600 mb-1 flex items-center gap-1"> <div className="text-xs font-semibold text-amber-600 mb-1 flex items-center gap-1">
@@ -702,7 +718,7 @@ export default function BuildiumSettingsPage() {
))} ))}
</ul> </ul>
<p className="text-[11px] text-muted-foreground mt-2"> <p className="text-[11px] text-muted-foreground mt-2">
Map these in the <strong>GL Account Map</strong> tab above, then run Pull Charges again. Map these in the <strong>GL Account Map</strong> tab above, then re-run the pull.
</p> </p>
{(results.charges_missing_gl_info ?? 0) > 0 && ( {(results.charges_missing_gl_info ?? 0) > 0 && (
<p className="text-[11px] text-amber-600 mt-1">{results.charges_missing_gl_info} charge(s) had no GL account info from Buildium and were skipped.</p> <p className="text-[11px] text-amber-600 mt-1">{results.charges_missing_gl_info} charge(s) had no GL account info from Buildium and were skipped.</p>
@@ -273,6 +273,23 @@ Deno.serve(async (req) => {
} }
companyResult.pulled = txById.size; 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 ---- // ---- Already-imported transaction ids for this company ----
const existingIds = new Set<string>(); const existingIds = new Set<string>();
for (let offset = 0; ; offset += 1000) { for (let offset = 0; ; offset += 1000) {
@@ -432,6 +449,7 @@ Deno.serve(async (req) => {
const nextCfg = { const nextCfg = {
...cfg, ...cfg,
buildium_gl: { buildium_gl: {
...(cfg.buildium_gl ?? {}),
last_synced_date: until, last_synced_date: until,
last_run_at: new Date().toISOString(), last_run_at: new Date().toISOString(),
last_result: { last_result: {
+335 -26
View File
@@ -2186,6 +2186,171 @@ Deno.serve(async (req) => {
const buildiumVendorById = new Map<string, any>(); const buildiumVendorById = new Map<string, any>();
for (const bv of buildiumVendors) buildiumVendorById.set(String(bv.Id), bv); 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 } | 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").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<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) // 2) Fetch bills (both paid and unpaid)
// Buildium requires BOTH FromPaidDate and ToPaidDate when results may include paid bills. // Buildium requires BOTH FromPaidDate and ToPaidDate when results may include paid bills.
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@@ -2203,17 +2368,6 @@ Deno.serve(async (req) => {
const billByBuildiumId = new Map<string, string>(); const billByBuildiumId = new Map<string, string>();
for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id); for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id);
// Chart of accounts lookup per association (account_number -> id)
const coaCache = new Map<string, Map<string, string>>();
async function getCoa(assocLocalId: string) {
if (coaCache.has(assocLocalId)) return coaCache.get(assocLocalId)!;
const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocLocalId);
const m = new Map<string, string>();
for (const a of data || []) m.set(String(a.account_number), a.id);
coaCache.set(assocLocalId, m);
return m;
}
let billsCreated = 0; let billsCreated = 0;
let billsUpdated = 0; let billsUpdated = 0;
let billsSkipped = 0; let billsSkipped = 0;
@@ -2262,22 +2416,18 @@ Deno.serve(async (req) => {
const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null; const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
const vendorId = await ensureVendor(buildiumVendor, assocLocalId); const vendorId = await ensureVendor(buildiumVendor, assocLocalId);
// Pick first line's GL account as expense account if available. // Resolve EVERY line's GL account through buildium_gl_account_links
// chart_of_accounts.account_number stores the Buildium GL Id (see the // (platform accounting ids). STRICT: any unmapped account holds the
// glaccounts upsert: account_number = String(gl.Id ...)), so resolve the // bill — it's flagged in the GL Account Map instead of mis-posting.
// line's GL Id from whichever shape Buildium returns it in. const rawBillLines = Array.isArray(bb.Lines) ? bb.Lines : [];
const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null; const hasGlInfo = rawBillLines.some((l: any) => (l?.GLAccountId ?? l?.GLAccount?.Id) != null);
let expenseAccountId: string | null = null; let billLineItems: Array<{ account_id: string; description: string | null; amount: number }> = [];
const lineGlId = firstLine if (hasGlInfo) {
? (firstLine.GLAccountId const resolved = await resolveLineItems(assocLocalId, rawBillLines);
?? firstLine.GLAccount?.Id if (!resolved) { billsHeld++; billsSkipped++; continue; }
?? firstLine.GLAccount?.GLAccountId billLineItems = resolved;
?? null)
: null;
if (lineGlId !== null && lineGlId !== undefined && String(lineGlId) !== "") {
const coa = await getCoa(assocLocalId);
expenseAccountId = coa.get(String(lineGlId)) || null;
} }
const expenseAccountId: string | null = billLineItems[0]?.account_id ?? null;
let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0); let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0);
if ((!amount || amount === 0) && Array.isArray(bb.Lines)) { if ((!amount || amount === 0) && Array.isArray(bb.Lines)) {
@@ -2303,6 +2453,7 @@ Deno.serve(async (req) => {
amount, amount,
amount_paid: Number(bb.PaidAmount || (isPaid ? amount : 0)), amount_paid: Number(bb.PaidAmount || (isPaid ? amount : 0)),
expense_account_id: expenseAccountId, expense_account_id: expenseAccountId,
line_items: billLineItems.length > 0 ? billLineItems : null,
description, description,
status, status,
buildium_bill_id: bbid, 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 // Download bill attachments from Buildium and link the first one to the bill
if (finalBillId) { if (finalBillId) {
try { 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<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) {
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) continue;
await acct.from("companies").update({
acmacc_sync_config: { ...cfg, buildium_gl: { ...(cfg.buildium_gl ?? {}), exclude_ap: true } },
}).eq("id", companyId);
}
results.bills = { results.bills = {
fetched: buildiumBills.length, fetched: buildiumBills.length,
created: billsCreated, created: billsCreated,
updated: billsUpdated, updated: billsUpdated,
skipped: billsSkipped, skipped: billsSkipped,
held_unmapped: billsHeld,
linked_duplicates: billsLinkedDuplicate, linked_duplicates: billsLinkedDuplicate,
vendors_created: vendorsCreated, vendors_created: vendorsCreated,
vendors_linked: vendorsLinked, vendors_linked: vendorsLinked,
payments_posted: paymentsPosted,
payments_held: paymentsHeld,
checks_created: checksCreated,
checks_held: checksHeld,
}; };
await supabase.from("company_settings").upsert( await supabase.from("company_settings").upsert(