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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:56:18 -04:00
parent 9c87777046
commit 53765e1448
2 changed files with 146 additions and 0 deletions
@@ -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));
@@ -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() {
<Button size="sm" variant="outline" className="h-9 gap-1 text-red-700" onClick={() => openAdd("debit")}>
<Plus className="h-3.5 w-3.5" /> Withdrawal
</Button>
<Button size="sm" variant="outline" className="h-9 gap-1" onClick={openBill}>
<Plus className="h-3.5 w-3.5" /> Bill
</Button>
</div>
</CardHeader>
<CardContent>
@@ -774,6 +851,74 @@ export default function AccountingReconcileDetailPage() {
</DialogContent>
</Dialog>
{/* Add accrual bill modal */}
<Dialog open={billOpen} onOpenChange={setBillOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Bill</DialogTitle>
<DialogDescription>
Records the expense on the <strong>bill date</strong> (accrual) and a withdrawal on the{" "}
<strong>payment date</strong> 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.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Bill date</Label>
<Input type="date" value={bill.bill_date} onChange={(e) => setBill({ ...bill, bill_date: e.target.value })} />
</div>
<div>
<Label>Payment date</Label>
<Input type="date" value={bill.payment_date} onChange={(e) => setBill({ ...bill, payment_date: e.target.value })} />
</div>
</div>
<div>
<Label>Amount</Label>
<Input type="number" step="0.01" min="0" value={bill.amount} onChange={(e) => setBill({ ...bill, amount: e.target.value })} placeholder="0.00" />
</div>
<div>
<Label>Vendor <span className="text-destructive">*</span></Label>
<Select value={bill.vendor_id} onValueChange={(v) => setBill({ ...bill, vendor_id: v })}>
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
<SelectContent>
{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
{(vendors as any[]).length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">No vendors yet add one on the Vendors page first.</p>
)}
</div>
<div>
<Label>Expense account</Label>
<Select value={bill.coa_account_id} onValueChange={(v) => setBill({ ...bill, coa_account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select expense account" /></SelectTrigger>
<SelectContent>
{(allAccounts as any[])
.filter((a: any) => a.type === "expense")
.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Description</Label>
<Input value={bill.description} onChange={(e) => setBill({ ...bill, description: e.target.value })} placeholder="e.g. May landscaping" />
</div>
<div>
<Label>Reference / Bill # (optional)</Label>
<Input value={bill.reference} onChange={(e) => setBill({ ...bill, reference: e.target.value })} placeholder="Invoice / check #" />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setBillOpen(false)}>Cancel</Button>
<Button onClick={addBill} disabled={billSaving}>
{billSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Bill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Adjustment modal */}
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
<DialogContent>