Buildium GL account map: strict account links, separate pull/push charges

- buildium_gl_account_links + buildium_unmapped_gl_accounts tables (strict, hold-and-flag)
- buildium-sync: pull charges syncType, account-first push resolution, no fuzzy matching, push dryRun
- buildium-gl-sync: links-only resolution, watermark held while unmapped accounts exist
- GL Account Map settings tab + Pull Charges card + unmapped results panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:07:18 -04:00
parent abd46bcb2b
commit ff65c8a656
5 changed files with 741 additions and 147 deletions
+220 -81
View File
@@ -118,6 +118,7 @@ type OwnerLedgerEntryRow = {
debit: number;
credit: number;
transaction_type: string | null;
gl_account_id?: string | null;
};
type OwnerLedgerEntryMaps = {
@@ -166,7 +167,9 @@ Deno.serve(async (req) => {
const supabase = createClient(supabaseUrl, serviceRoleKey);
const { syncType, selectedAssociationIds: rawIds, dateFrom, dateTo, unitId: rawUnitId, documentOffset: rawDocumentOffset, documentLimit: rawDocumentLimit, documentScope: rawDocumentScope } = await req.json();
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;
@@ -232,17 +235,25 @@ Deno.serve(async (req) => {
};
for (const gl of glAccounts) walk(gl);
const chargeable = flat
.filter((gl) => (gl as any).IsActive !== false)
.map((gl) => ({
id: String((gl as any).Id),
name: String((gl as any).Name || `Buildium GL ${(gl as any).Id}`),
account_number: String((gl as any).AccountNumber ?? (gl as any).Number ?? (gl as any).GlNumber ?? (gl as any).GLNumber ?? (gl as any).Code ?? (gl as any).Id ?? "").trim() || null,
type: String((gl as any).Type || (gl as any).AccountType || ""),
}))
// 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: chargeable }), {
return new Response(JSON.stringify({ success: true, gl_accounts: accounts }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
@@ -306,7 +317,7 @@ Deno.serve(async (req) => {
let query = supabase
.from("owner_ledger_entries")
.select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type");
.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);
@@ -324,7 +335,9 @@ Deno.serve(async (req) => {
defaultOwnerId = row.owner_id;
}
if (row.reference_type === "buildium" && row.reference_id && !byReferenceId.has(String(row.reference_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);
}
@@ -1006,6 +1019,48 @@ Deno.serve(async (req) => {
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 : [];
@@ -1282,15 +1337,29 @@ Deno.serve(async (req) => {
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) {
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 });
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";
@@ -1302,6 +1371,7 @@ Deno.serve(async (req) => {
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 });
@@ -1313,6 +1383,7 @@ Deno.serve(async (req) => {
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 });
@@ -1325,6 +1396,7 @@ Deno.serve(async (req) => {
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
@@ -1374,12 +1446,20 @@ Deno.serve(async (req) => {
let debit = 0;
let credit = 0;
let description: string;
let entryGlAccountId: string | null = null;
const buildiumDesc = getEntryDescription(entry);
// POLICY: From Buildium we only PULL payments. Charges originate locally and are pushed to Buildium.
// 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 (!isPaymentTxn) {
if (pullCharges) {
if (txnType !== "Charge") {
skipped++;
continue;
}
} else if (!isPaymentTxn) {
skipped++;
continue;
}
@@ -1399,7 +1479,27 @@ Deno.serve(async (req) => {
const isMultiAccount = chargeLines.length > 1 && uniqueGLIds.size > 1;
if (isMultiAccount) {
// Multi-line charge: break into separate ledger entries per GL account line
// 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) {
@@ -1413,6 +1513,7 @@ Deno.serve(async (req) => {
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) ||
@@ -1431,6 +1532,7 @@ Deno.serve(async (req) => {
lineRefId, txnDate, lineType, lineDesc,
lineDebit, lineCredit,
ledgerEntryMaps, assocId, targetOwnerId, unit.id,
lineGlAccountId,
);
if (result === "imported") imported++;
else if (result === "updated") updated++;
@@ -1440,6 +1542,16 @@ Deno.serve(async (req) => {
}
// 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);
@@ -1465,6 +1577,7 @@ Deno.serve(async (req) => {
buildiumLedgerId, txnDate, transactionType, description,
debit, credit,
ledgerEntryMaps, assocId, targetOwnerId, unit.id,
entryGlAccountId,
);
if (singleResult === "imported") imported++;
else if (singleResult === "updated") updated++;
@@ -1478,15 +1591,39 @@ Deno.serve(async (req) => {
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
const syncTimestamp = new Date().toISOString();
const settingsKey = `buildium_last_sync_${syncType}`;
await supabase.from("company_settings").upsert(
{ key: settingsKey, value: syncTimestamp },
{ onConflict: "key" }
);
// 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") {
@@ -1567,7 +1704,20 @@ Deno.serve(async (req) => {
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")
@@ -1614,21 +1764,14 @@ Deno.serve(async (req) => {
// Try to look it up as an account number string
const byNumber = asChargeableId(glAccountByNumber.get(sv), `number "${sv}"`);
if (byNumber) return byNumber;
// Try normalized name match (for values like "4000 - Assessment Fees") and saved display names.
const nameCandidates = [sv, storedName || ""].filter(Boolean);
for (const candidate of nameCandidates) {
const normalized = norm(candidate);
const withoutLeadingNumber = norm(String(candidate).replace(/^\s*\d+\s*-\s*/, ""));
for (const [name, meta] of glAccountByName) {
if (!meta.isChargeable) continue;
if (name === normalized || name === withoutLeadingNumber || normalized.includes(name) || name.includes(normalized) || (withoutLeadingNumber && name.includes(withoutLeadingNumber))) return meta.id;
}
}
console.warn(`[push] resolveGLId: could not resolve "${sv}" — not found in ${glAccountById.size} IDs or ${glAccountByNumber.size} account numbers`);
// 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 — check DB mappings first (with amount matching), then fuzzy match
// 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) {
@@ -1663,47 +1806,10 @@ Deno.serve(async (req) => {
}
}
// Priority 2: Fuzzy name matching against Buildium GL accounts.
// Order matters — earlier terms are tried first. Each term is matched as a
// whole-word substring against chargeable account names.
const typeToSearchTerms: Record<string, string[]> = {
assessment: ["assessment fee", "assessment", "hoa dues", "association fee", "condo fee", "maintenance fee", "monthly dues", "dues"],
late_fee: ["late fee", "late fees", "late charge", "late charges", "late payment fee", "late payment charge", "delinquency fee", "delinquent fee", "non payment penalty"],
interest: ["interest income", "interest", "finance charge", "finance charges", "ar interest", "interest charge"],
legal_fee: ["legal fee", "legal fees", "attorney fee", "attorney fees", "legal", "attorney"],
admin_fee: ["administrative fee", "administrative fees", "administration fee", "admin fee", "admin fees", "processing fee", "document fee", "notice fee", "statement fee", "mailing fee", "administrative", "admin"],
violation: ["violation fee", "violation fine", "violation", "fine", "fines"],
bank_fee: ["nsf fee", "nsf", "returned check", "returned payment", "bank fee", "bank charge"],
special_assessment: ["special assessment", "capital contribution", "reserve contribution", "reserve"],
};
const terms = typeToSearchTerms[transactionType] || [transactionType.replace(/_/g, " ")];
// First pass: exact normalized match on full term
for (const term of terms) {
const normTerm = norm(term);
for (const [name, meta] of glAccountByName) {
if (!meta.isChargeable) continue;
if (name === normTerm) {
console.log(`[push] GL exact-matched ${transactionType}: "${term}" === "${name}" -> ${meta.id}`);
return meta.id;
}
}
}
// Second pass: substring match (term inside account name)
for (const term of terms) {
const normTerm = norm(term);
for (const [name, meta] of glAccountByName) {
if (!meta.isChargeable) continue;
if (name.includes(normTerm)) {
console.log(`[push] GL fuzzy-matched ${transactionType}: "${term}" found in "${name}" -> ${meta.id}`);
return meta.id;
}
}
}
// No match — DO NOT silently fall back to a generic income account, that
// routes admin/late/interest charges into the wrong account in Buildium.
// Instead return null so the caller skips the entry with a clear reason
// and the user can fix the mapping.
console.warn(`[push] No GL account found for ${transactionType}. Available chargeable: ${chargeableGlAccounts.map(a => a.name).join(", ")}`);
// 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;
}
@@ -1717,6 +1823,7 @@ Deno.serve(async (req) => {
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 });
@@ -1732,7 +1839,7 @@ Deno.serve(async (req) => {
// 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")
.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");
@@ -1766,8 +1873,27 @@ Deno.serve(async (req) => {
try {
if (entry.debit > 0) {
// Push as charge
const glAccountId = findGLAccountId(entry.transaction_type, assocId, entry.debit);
// 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,
@@ -1780,6 +1906,13 @@ Deno.serve(async (req) => {
? 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,
@@ -1825,6 +1958,12 @@ Deno.serve(async (req) => {
// 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,
@@ -1876,7 +2015,7 @@ Deno.serve(async (req) => {
}
}
results.push = { pushed, skipped: pushSkipped, errors: pushErrors, errorSamples, skipSamples };
results.push = { pushed, skipped: pushSkipped, errors: pushErrors, errorSamples, skipSamples, ...(dryRun ? { dryRun: true, dryRunSamples } : {}) };
}
// ===== PULL BUDGETS FROM BUILDIUM =====