mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -323,6 +323,7 @@ export default function AccountingBillsPage() {
|
|||||||
subtotal, tax, total, status: "open",
|
subtotal, tax, total, status: "open",
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
attachment_url: attachmentUrl,
|
attachment_url: attachmentUrl,
|
||||||
|
gl_post_override: true, // app-entered bill → post expense on the bill date (also for import-mode books)
|
||||||
}).select().single();
|
}).select().single();
|
||||||
if (error || !bill) return toast.error(error?.message ?? "Failed");
|
if (error || !bill) return toast.error(error?.message ?? "Failed");
|
||||||
await accounting.from("bill_items").insert(itemRows(bill.id));
|
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 }>(
|
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: "" },
|
{ 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({
|
const { data: account } = useQuery({
|
||||||
queryKey: ["account", accountId],
|
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) => {
|
const voidTx = async (t: Tx) => {
|
||||||
if (t.reconciliation_id) return toast.error("Reconciled transactions can't be voided");
|
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;
|
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")}>
|
<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
|
<Plus className="h-3.5 w-3.5" /> Withdrawal
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-9 gap-1" onClick={openBill}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Bill
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -774,6 +851,74 @@ export default function AccountingReconcileDetailPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Adjustment modal */}
|
||||||
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
|
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user