From 53765e1448b5d1bf01628ab5f37482324a82d302 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 17 Jun 2026 19:56:18 -0400 Subject: [PATCH] Reconciliation: add accrual bill (expense on bill date + paying withdrawal) New "Add Bill" action on the bank reconciliation screen: enter a bill (bill date, payment date, vendor, amount, expense account), and it creates the bill (accrual expense on the bill date) plus a withdrawal that pays it (Dr A/P / Cr Bank on the payment date) and clears into the current reconciliation. The expense lands in the bill's period for reporting; the cash leaves on the payment date. Fixes import-mode posting: app-created bills now carry gl_post_override=true (new accounting.bills column) so post_bill_gl posts their expense even for import-mode (gl_auto_post=false) companies. The previous external_source-based gate didn't work because the bill back-sync trigger stamps every app bill with external_source='acmacc_bill', making it indistinguishable from Buildium imports; the explicit flag set only by the app's bill creators (Bills page + reconciliation) is reliable, while Buildium-import/auto-reconcile bills stay gated and never double-count the GL pull. Migration: bills_gl_post_override_for_inapp_bills. Co-Authored-By: Claude Opus 4.8 --- src/pages/accounting/AccountingBillsPage.tsx | 1 + .../AccountingReconcileDetailPage.tsx | 145 ++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index 7997e63..6078620 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -323,6 +323,7 @@ export default function AccountingBillsPage() { subtotal, tax, total, status: "open", notes: notes || null, attachment_url: attachmentUrl, + gl_post_override: true, // app-entered bill → post expense on the bill date (also for import-mode books) }).select().single(); if (error || !bill) return toast.error(error?.message ?? "Failed"); await accounting.from("bill_items").insert(itemRows(bill.id)); diff --git a/src/pages/accounting/AccountingReconcileDetailPage.tsx b/src/pages/accounting/AccountingReconcileDetailPage.tsx index 57a1d5e..3d0dc31 100644 --- a/src/pages/accounting/AccountingReconcileDetailPage.tsx +++ b/src/pages/accounting/AccountingReconcileDetailPage.tsx @@ -97,6 +97,12 @@ export default function AccountingReconcileDetailPage() { const [addTx, setAddTx] = useState<{ type: "credit" | "debit"; date: string; amount: string; description: string; coa_account_id: string; reference: string; vendor_id: string }>( { type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "" }, ); + // Add an accrual bill (expense on the bill date) + the withdrawal that pays it. + const [billOpen, setBillOpen] = useState(false); + const [billSaving, setBillSaving] = useState(false); + const [bill, setBill] = useState<{ bill_date: string; payment_date: string; amount: string; description: string; coa_account_id: string; vendor_id: string; reference: string }>( + { bill_date: "", payment_date: "", amount: "", description: "", coa_account_id: "", vendor_id: "", reference: "" }, + ); const { data: account } = useQuery({ queryKey: ["account", accountId], @@ -386,6 +392,74 @@ export default function AccountingReconcileDetailPage() { } }; + const openBill = () => { + const today = active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); + setBill({ bill_date: today, payment_date: today, amount: "", description: "", coa_account_id: "", vendor_id: "", reference: "" }); + setBillOpen(true); + }; + + // Create an accrual bill (Dr Expense / Cr A/P on the bill date via post_bill_gl) + // and the withdrawal that pays it (Dr A/P / Cr Bank on the payment date). The + // expense lands in the bill's period; the cash leaves in the payment period. + const addBill = async () => { + if (!active) return; + const amt = Number(bill.amount); + if (!amt || amt <= 0) return toast.error("Enter an amount"); + if (!bill.vendor_id) return toast.error("Vendor is required"); + if (!bill.coa_account_id) return toast.error("Pick an expense account"); + if (!bill.bill_date) return toast.error("Enter the bill date"); + if (!bill.payment_date) return toast.error("Enter the payment date"); + setBillSaving(true); + try { + const vendorName = (vendors as any[]).find((v) => v.id === bill.vendor_id)?.name ?? "Vendor"; + const coaName = (allAccounts as any[]).find((a) => a.id === bill.coa_account_id)?.name ?? ""; + const desc = bill.description.trim() || coaName || "Bill"; + const number = bill.reference.trim() || `BILL-${crypto.randomUUID().slice(0, 8)}`; + + // 1) Accrual bill (external_source null → posts the expense on the bill date, + // including for import-mode companies). + const { data: billRow, error: billErr } = await accounting + .from("bills") + .insert({ + company_id: cid, vendor_id: bill.vendor_id, number, + issue_date: bill.bill_date, due_date: bill.payment_date, + subtotal: amt, tax: 0, total: amt, paid_amount: amt, status: "paid", + notes: desc, + gl_post_override: true, // post the accrual expense on the bill date (also for import-mode books) + }) + .select("id") + .single(); + if (billErr || !billRow) return toast.error(billErr?.message ?? "Failed to create bill"); + + const { error: itemErr } = await accounting.from("bill_items").insert({ + bill_id: billRow.id, description: desc, quantity: 1, rate: amt, amount: amt, account_id: bill.coa_account_id, + }); + if (itemErr) return toast.error(itemErr.message); + + // 2) Withdrawal that settles it — coa null + vendor + bill_id → Dr A/P / Cr Bank. + const { data: txRow, error: txErr } = await accounting + .from("transactions") + .insert({ + company_id: cid, account_id: accountId, date: bill.payment_date, type: "debit", + amount: amt, description: `Bill Payment · ${vendorName} · ${desc}`, + category: coaName, coa_account_id: null, vendor_id: bill.vendor_id, bill_id: billRow.id, + reference: bill.reference.trim() || number, cleared: true, + }) + .select("id") + .single(); + if (txErr || !txRow) return toast.error(txErr?.message ?? "Bill created but the payment failed"); + + setBillOpen(false); + toast.success("Bill added and paid"); + qc.invalidateQueries({ queryKey: ["recon-txs", accountId] }); + qc.invalidateQueries({ queryKey: ["transactions", cid] }); + qc.invalidateQueries({ queryKey: ["bills", cid] }); + setChecked((prev) => new Set(prev).add(txRow.id)); // auto-clear into this reconciliation + } finally { + setBillSaving(false); + } + }; + const voidTx = async (t: Tx) => { if (t.reconciliation_id) return toast.error("Reconciled transactions can't be voided"); if (!confirm(`Void this ${t.type === "credit" ? "deposit" : "withdrawal"} of ${money(t.amount, cur)}? It will be removed from the register and this reconciliation.`)) return; @@ -458,6 +532,9 @@ export default function AccountingReconcileDetailPage() { + @@ -774,6 +851,74 @@ export default function AccountingReconcileDetailPage() { + {/* Add accrual bill modal */} + + + + Add Bill + + Records the expense on the bill date (accrual) and a withdrawal on the{" "} + payment date that clears Accounts Payable. Reporting reflects the expense in the + bill's period; the cash leaves on the payment date. Added to this reconciliation automatically. + + +
+
+
+ + setBill({ ...bill, bill_date: e.target.value })} /> +
+
+ + setBill({ ...bill, payment_date: e.target.value })} /> +
+
+
+ + setBill({ ...bill, amount: e.target.value })} placeholder="0.00" /> +
+
+ + + {(vendors as any[]).length === 0 && ( +

No vendors yet — add one on the Vendors page first.

+ )} +
+
+ + +
+
+ + setBill({ ...bill, description: e.target.value })} placeholder="e.g. May landscaping" /> +
+
+ + setBill({ ...bill, reference: e.target.value })} placeholder="Invoice / check #" /> +
+
+ + + + +
+
+ {/* Adjustment modal */}