mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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<string>();
|
||||
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: {
|
||||
|
||||
@@ -2186,6 +2186,171 @@ Deno.serve(async (req) => {
|
||||
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 } | 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)
|
||||
// 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<string, string>();
|
||||
for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id);
|
||||
|
||||
// Chart of accounts lookup per association (account_number -> id)
|
||||
const coaCache = new Map<string, Map<string, string>>();
|
||||
async function getCoa(assocLocalId: string) {
|
||||
if (coaCache.has(assocLocalId)) return coaCache.get(assocLocalId)!;
|
||||
const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocLocalId);
|
||||
const m = new Map<string, string>();
|
||||
for (const a of data || []) m.set(String(a.account_number), a.id);
|
||||
coaCache.set(assocLocalId, m);
|
||||
return m;
|
||||
}
|
||||
|
||||
let billsCreated = 0;
|
||||
let billsUpdated = 0;
|
||||
let billsSkipped = 0;
|
||||
@@ -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<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 = {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user