mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: Sales Receipts, COA sync to dashboard, vendor-expense recognition
- Add Sales Receipts page (dashboard/accounting/sales-receipts): records a cash sale (name, address, income account, price, qty) — deposits and books income in one step via a transaction. New accounting.sales_receipts table. - Sync chart of accounts to the accounting dashboard: mirror accounting.accounts into public.chart_of_accounts for platform associations (one-way, same id) so Bill Approvals and every COA consumer use the dashboard's accounts. Legacy rows hidden; Bill Approvals made system-aware. - Vendor-expense recognition: a vendor payment with no bill now books the expense directly (Dr Expense / Cr Bank) on the payment date instead of going to A/P; payments against open bills still clear A/P (applied FIFO). Backfill reclassifies unbilled payments stuck in A/P. Expense Summary report made GL-driven so it follows the same rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
|||||||
AccountingOpeningBalancesPage,
|
AccountingOpeningBalancesPage,
|
||||||
AccountingExpensesPage,
|
AccountingExpensesPage,
|
||||||
AccountingEstimatesPage,
|
AccountingEstimatesPage,
|
||||||
|
AccountingSalesReceiptsPage,
|
||||||
AccountingReconcileDetailPage,
|
AccountingReconcileDetailPage,
|
||||||
AccountingBudgetDetailPage,
|
AccountingBudgetDetailPage,
|
||||||
AccountingCustomerDetailPage,
|
AccountingCustomerDetailPage,
|
||||||
@@ -384,6 +385,7 @@ const App = () => (
|
|||||||
<Route path="vendors" element={<AccountingVendorsPage />} />
|
<Route path="vendors" element={<AccountingVendorsPage />} />
|
||||||
<Route path="expenses" element={<AccountingExpensesPage />} />
|
<Route path="expenses" element={<AccountingExpensesPage />} />
|
||||||
<Route path="estimates" element={<AccountingEstimatesPage />} />
|
<Route path="estimates" element={<AccountingEstimatesPage />} />
|
||||||
|
<Route path="sales-receipts" element={<AccountingSalesReceiptsPage />} />
|
||||||
<Route path="deposits" element={<AccountingDepositsPage />} />
|
<Route path="deposits" element={<AccountingDepositsPage />} />
|
||||||
<Route path="receive-payments" element={<AccountingReceivePaymentsPage />} />
|
<Route path="receive-payments" element={<AccountingReceivePaymentsPage />} />
|
||||||
<Route path="banking" element={<AccountingBankingPage />} />
|
<Route path="banking" element={<AccountingBankingPage />} />
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
setBills([]);
|
setBills([]);
|
||||||
setApprovalsByBill({});
|
setApprovalsByBill({});
|
||||||
const [aRes2, coaRes2, vRes2] = await Promise.all([
|
const [aRes2, coaRes2, vRes2] = await Promise.all([
|
||||||
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
|
supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
|
||||||
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"),
|
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"),
|
||||||
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
||||||
]);
|
]);
|
||||||
setAssociations(aRes2.data || []);
|
setAssociations(aRes2.data || []);
|
||||||
@@ -201,8 +201,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
if (data.length < PAGE) break;
|
if (data.length < PAGE) break;
|
||||||
}
|
}
|
||||||
const [aRes, coaRes, vRes] = await Promise.all([
|
const [aRes, coaRes, vRes] = await Promise.all([
|
||||||
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
|
supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
|
||||||
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"),
|
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"),
|
||||||
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -604,8 +604,17 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
return bill.status;
|
return bill.status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedSystem = associations.find((a: any) => a.id === form.association_id)?.accounting_system ?? null;
|
||||||
const filteredAccounts = form.association_id
|
const filteredAccounts = form.association_id
|
||||||
? accounts.filter((a: any) => !a.association_id || a.association_id === form.association_id)
|
? accounts.filter((a: any) => {
|
||||||
|
// Platform associations use the accounts managed in the accounting
|
||||||
|
// dashboard (synced into chart_of_accounts as accounting_system 'platform').
|
||||||
|
if (selectedSystem === "platform") {
|
||||||
|
return a.accounting_system === "platform" && a.association_id === form.association_id;
|
||||||
|
}
|
||||||
|
// Buildium / Zoho keep their existing scoping; never surface platform-only rows.
|
||||||
|
return a.accounting_system !== "platform" && (!a.association_id || a.association_id === form.association_id);
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const filteredVendors = form.association_id
|
const filteredVendors = form.association_id
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { generateCheckPDF } from "./lib/checkPdf";
|
|||||||
import { parseCsv, pick, parseDateStr } from "./lib/csv";
|
import { parseCsv, pick, parseDateStr } from "./lib/csv";
|
||||||
import { usePlaidLink } from "react-plaid-link";
|
import { usePlaidLink } from "react-plaid-link";
|
||||||
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
||||||
|
import { applyPaymentToBill } from "./lib/autoBill";
|
||||||
|
|
||||||
type TxForm = {
|
type TxForm = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -256,14 +257,27 @@ export default function AccountingBankingPage() {
|
|||||||
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
|
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
|
||||||
const category = coaName;
|
const category = coaName;
|
||||||
|
|
||||||
// A vendor payment (debit) clears Accounts Payable — the expense was already
|
// Vendor-payment recognition rule: count the expense for the bill when it is
|
||||||
// recognized when the bill was entered (accrual). Leaving coa_account_id null
|
// entered (accrual), or — when no bill exists — when the payment is made.
|
||||||
// with the vendor set makes post_transaction_gl post Dr A/P / Cr Bank; the
|
// • Vendor has OPEN bill(s) → this payment clears Accounts Payable (coa null,
|
||||||
// chosen expense account is retained as the display `category` only. Customer
|
// vendor set → post_transaction_gl posts Dr A/P / Cr Bank); the expense was
|
||||||
// deposits (credits) clear A/R via customer_id, so they need no change here.
|
// already recognized on the bill. We then apply it to those bills (FIFO).
|
||||||
|
// • No open bill → the payment IS the expense: keep the chosen expense account
|
||||||
|
// so it posts Dr Expense / Cr Bank on the payment date.
|
||||||
|
// Customer deposits (credits) clear A/R via customer_id and are unchanged.
|
||||||
|
let openVendorBills: any[] = [];
|
||||||
|
if (type === "debit" && vendor_id) {
|
||||||
|
const { data: vbills } = await accounting
|
||||||
|
.from("bills").select("id,number,total,paid_amount,issue_date,status")
|
||||||
|
.eq("company_id", cid).eq("vendor_id", vendor_id);
|
||||||
|
openVendorBills = (vbills ?? []).filter((b: any) =>
|
||||||
|
!["void", "draft"].includes(b.status) && Number(b.total) - Number(b.paid_amount ?? 0) > 0.005);
|
||||||
|
}
|
||||||
|
const debitClearsAp = type === "debit" && openVendorBills.length > 0;
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
account_id, date, description, amount, type, category, reference: reference || null,
|
account_id, date, description, amount, type, category, reference: reference || null,
|
||||||
coa_account_id: type === "debit" ? null : (coa_account_id || null),
|
coa_account_id: debitClearsAp ? null : (coa_account_id || null),
|
||||||
vendor_id: vendor_id || null,
|
vendor_id: vendor_id || null,
|
||||||
customer_id: customer_id || null,
|
customer_id: customer_id || null,
|
||||||
};
|
};
|
||||||
@@ -273,8 +287,9 @@ export default function AccountingBankingPage() {
|
|||||||
if (error) return toast.error(error.message);
|
if (error) return toast.error(error.message);
|
||||||
toast.success("Transaction updated");
|
toast.success("Transaction updated");
|
||||||
} else {
|
} else {
|
||||||
const { error } = await accounting.from("transactions").insert({ ...payload, company_id: cid });
|
const { data: inserted, error } = await accounting
|
||||||
if (error) return toast.error(error.message);
|
.from("transactions").insert({ ...payload, company_id: cid }).select("id").single();
|
||||||
|
if (error || !inserted) return toast.error(error?.message ?? "Failed to record");
|
||||||
toast.success(type === "credit" ? "Deposit recorded" : "Payment recorded");
|
toast.success(type === "credit" ? "Deposit recorded" : "Payment recorded");
|
||||||
|
|
||||||
if (type === "debit" && txForm.printCheck) {
|
if (type === "debit" && txForm.printCheck) {
|
||||||
@@ -290,6 +305,23 @@ export default function AccountingBankingPage() {
|
|||||||
bankAccountId: account_id,
|
bankAccountId: account_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the payment cleared A/P (vendor had open bills), apply it to those
|
||||||
|
// bills oldest-first so they show paid. The expense lives on the bill, so no
|
||||||
|
// expense is booked here. With no open bill the payment already posted the
|
||||||
|
// expense directly (above) — nothing further to do.
|
||||||
|
if (debitClearsAp) {
|
||||||
|
let remaining = amount;
|
||||||
|
const ordered = [...openVendorBills].sort((a, b) => String(a.issue_date ?? "").localeCompare(String(b.issue_date ?? "")));
|
||||||
|
for (const b of ordered) {
|
||||||
|
if (remaining <= 0.005) break;
|
||||||
|
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
|
||||||
|
const applied = Math.min(bal, remaining);
|
||||||
|
await applyPaymentToBill(b.id, applied);
|
||||||
|
remaining -= applied;
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTxDialog({ open: false, mode: "deposit" });
|
setTxDialog({ open: false, mode: "deposit" });
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
@@ -1013,6 +1045,7 @@ export default function AccountingBankingPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { default as AccountingWorkOrdersPage } from "./AccountingWorkOrdersPage"
|
|||||||
export { default as AccountingOpeningBalancesPage } from "./AccountingOpeningBalancesPage";
|
export { default as AccountingOpeningBalancesPage } from "./AccountingOpeningBalancesPage";
|
||||||
export { default as AccountingExpensesPage } from "./AccountingExpensesPage";
|
export { default as AccountingExpensesPage } from "./AccountingExpensesPage";
|
||||||
export { default as AccountingEstimatesPage } from "./AccountingEstimatesPage";
|
export { default as AccountingEstimatesPage } from "./AccountingEstimatesPage";
|
||||||
|
export { default as AccountingSalesReceiptsPage } from "./AccountingSalesReceiptsPage";
|
||||||
export { default as AccountingReconcileDetailPage } from "./AccountingReconcileDetailPage";
|
export { default as AccountingReconcileDetailPage } from "./AccountingReconcileDetailPage";
|
||||||
export { default as AccountingBudgetDetailPage } from "./AccountingBudgetDetailPage";
|
export { default as AccountingBudgetDetailPage } from "./AccountingBudgetDetailPage";
|
||||||
export { default as AccountingCustomerDetailPage } from "./AccountingCustomerDetailPage";
|
export { default as AccountingCustomerDetailPage } from "./AccountingCustomerDetailPage";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const NAV: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "receive-payments", label: "Receive Payments" },
|
{ to: "receive-payments", label: "Receive Payments" },
|
||||||
{ to: "invoices", label: "Invoices" },
|
{ to: "invoices", label: "Invoices" },
|
||||||
|
{ to: "sales-receipts", label: "Sales Receipts" },
|
||||||
{ to: "estimates", label: "Estimates" },
|
{ to: "estimates", label: "Estimates" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1294,18 +1294,22 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
|
|||||||
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
|
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
|
||||||
};
|
};
|
||||||
case "expense-summary": {
|
case "expense-summary": {
|
||||||
const byCat: Record<string, number> = {};
|
// GL-driven so it follows the same recognition rule as the P&L: a bill's
|
||||||
// Direct expenses from expenses table
|
// expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment
|
||||||
for (const e of d.expenses) byCat[e.category] = (byCat[e.category] ?? 0) + Number(e.amount);
|
// with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the
|
||||||
// Bill expenses (accrual — total billed, not just paid)
|
// ledger avoids double-counting and never misses direct payments.
|
||||||
for (const b of d.bills) {
|
const byAcct: Record<string, number> = {};
|
||||||
if (b.status === "void" || b.status === "draft") continue;
|
for (const l of (d.glLines ?? []) as any[]) {
|
||||||
const cat = b.vendors?.name ?? "Vendor Expenses";
|
const acc = l.accounts;
|
||||||
byCat[cat] = (byCat[cat] ?? 0) + Number(b.total);
|
if (acc?.type !== "expense") continue;
|
||||||
|
const amt = Number(l.debit) - Number(l.credit);
|
||||||
|
if (amt === 0) continue;
|
||||||
|
const name = acc.name ?? "Expense";
|
||||||
|
byAcct[name] = (byAcct[name] ?? 0) + amt;
|
||||||
}
|
}
|
||||||
const rows = Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, amt]) => [cat, m(amt)]);
|
const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
|
||||||
const total = Object.values(byCat).reduce((s, v) => s + v, 0);
|
const total = Object.values(byAcct).reduce((s, v) => s + v, 0);
|
||||||
return { title: "Expense Summary (Accrual)", columns: ["Category / Vendor", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
||||||
}
|
}
|
||||||
case "vendor-balances": {
|
case "vendor-balances": {
|
||||||
const byVendor: Record<string, number> = {};
|
const byVendor: Record<string, number> = {};
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Search, Trash2, Receipt, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { money, fmtDate } from "./lib/format";
|
||||||
|
import { EmptyState } from "./components/EmptyState";
|
||||||
|
import { ensureUndepositedFunds } from "./lib/undeposited";
|
||||||
|
|
||||||
|
const generateNumber = () => `SR-${Date.now().toString().slice(-6)}`;
|
||||||
|
const today = () => new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" });
|
||||||
|
|
||||||
|
export default function AccountingSalesReceiptsPage() {
|
||||||
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
|
const cid = companyId ?? "";
|
||||||
|
const cur = "USD";
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [number, setNumber] = useState(generateNumber());
|
||||||
|
const [date, setDate] = useState(today());
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerAddress, setCustomerAddress] = useState("");
|
||||||
|
const [incomeAccountId, setIncomeAccountId] = useState("");
|
||||||
|
const [depositAccountId, setDepositAccountId] = useState("");
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [rate, setRate] = useState(0);
|
||||||
|
const [memo, setMemo] = useState("");
|
||||||
|
|
||||||
|
const total = useMemo(() => +(Number(quantity) * Number(rate)).toFixed(2), [quantity, rate]);
|
||||||
|
|
||||||
|
const { data: receipts = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["sales-receipts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("sales_receipts")
|
||||||
|
.select("*, income_account:accounts!sales_receipts_income_account_id_fkey(name,code), deposit_account:accounts!sales_receipts_deposit_account_id_fkey(name,code)")
|
||||||
|
.eq("company_id", cid)
|
||||||
|
.order("receipt_date", { ascending: false })
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: incomeAccounts = [] } = useQuery({
|
||||||
|
queryKey: ["income-accounts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await accounting.from("accounts").select("id,name,code").eq("company_id", cid).eq("type", "income").order("code")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: depositAccounts = [] } = useQuery({
|
||||||
|
queryKey: ["deposit-accounts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("accounts")
|
||||||
|
.select("id,name,code,is_system")
|
||||||
|
.eq("company_id", cid)
|
||||||
|
.or("is_bank.eq.true,name.eq.Undeposited Funds")
|
||||||
|
.order("code");
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setNumber(generateNumber());
|
||||||
|
setDate(today());
|
||||||
|
setCustomerName("");
|
||||||
|
setCustomerAddress("");
|
||||||
|
setIncomeAccountId("");
|
||||||
|
setDepositAccountId("");
|
||||||
|
setQuantity(1);
|
||||||
|
setRate(0);
|
||||||
|
setMemo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialog = async () => {
|
||||||
|
reset();
|
||||||
|
// Make sure there's somewhere to deposit to.
|
||||||
|
await ensureUndepositedFunds(cid);
|
||||||
|
qc.invalidateQueries({ queryKey: ["deposit-accounts", cid] });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!number.trim()) return toast.error("Receipt number is required");
|
||||||
|
if (!incomeAccountId) return toast.error("Select an income account");
|
||||||
|
if (!depositAccountId) return toast.error("Select a deposit account");
|
||||||
|
if (total <= 0) return toast.error("Amount must be greater than 0");
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const incomeName = (incomeAccounts as any[]).find((a) => a.id === incomeAccountId)?.name ?? "Sale";
|
||||||
|
const desc = `Sales Receipt ${number}${customerName ? " · " + customerName : ""} · ${incomeName}`;
|
||||||
|
|
||||||
|
// 1. Record the receipt document
|
||||||
|
const { data: sr, error: srErr } = await accounting
|
||||||
|
.from("sales_receipts")
|
||||||
|
.insert({
|
||||||
|
company_id: cid,
|
||||||
|
number,
|
||||||
|
receipt_date: date,
|
||||||
|
customer_name: customerName || null,
|
||||||
|
customer_address: customerAddress || null,
|
||||||
|
income_account_id: incomeAccountId,
|
||||||
|
deposit_account_id: depositAccountId,
|
||||||
|
quantity,
|
||||||
|
rate,
|
||||||
|
total,
|
||||||
|
memo: memo || null,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (srErr || !sr) throw new Error(srErr?.message ?? "Failed to save sales receipt");
|
||||||
|
|
||||||
|
// 2. Post the money in: debit deposit account, credit income account.
|
||||||
|
// The transaction triggers handle GL posting + account balances.
|
||||||
|
const { data: txn, error: txnErr } = await accounting
|
||||||
|
.from("transactions")
|
||||||
|
.insert({
|
||||||
|
company_id: cid,
|
||||||
|
account_id: depositAccountId,
|
||||||
|
coa_account_id: incomeAccountId,
|
||||||
|
date,
|
||||||
|
type: "credit",
|
||||||
|
amount: total,
|
||||||
|
description: desc,
|
||||||
|
category: "Sales Receipt",
|
||||||
|
reference: number,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (txnErr || !txn) {
|
||||||
|
// Roll back the orphaned document so we don't leave a receipt with no GL impact.
|
||||||
|
await accounting.from("sales_receipts").delete().eq("id", sr.id);
|
||||||
|
throw new Error(txnErr?.message ?? "Failed to post sales receipt");
|
||||||
|
}
|
||||||
|
|
||||||
|
await accounting.from("sales_receipts").update({ transaction_id: txn.id }).eq("id", sr.id);
|
||||||
|
|
||||||
|
toast.success("Sales receipt recorded");
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
qc.invalidateQueries({ queryKey: ["sales-receipts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (r: any) => {
|
||||||
|
if (!confirm(`Delete sales receipt ${r.number}? This also reverses its accounting entry.`)) return;
|
||||||
|
// Delete the transaction first so its GL + balances are reversed by triggers.
|
||||||
|
if (r.transaction_id) {
|
||||||
|
const { error } = await accounting.from("transactions").delete().eq("id", r.transaction_id);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
}
|
||||||
|
const { error } = await accounting.from("sales_receipts").delete().eq("id", r.id);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
toast.success("Sales receipt deleted");
|
||||||
|
qc.invalidateQueries({ queryKey: ["sales-receipts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return receipts as any[];
|
||||||
|
return (receipts as any[]).filter((r) =>
|
||||||
|
`${r.number} ${r.customer_name ?? ""} ${r.income_account?.name ?? ""}`.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [receipts, search]);
|
||||||
|
|
||||||
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||||
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Sales Receipts</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{filtered.length} of {(receipts as any[]).length}</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) { setOpen(false); reset(); } }}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={openDialog}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" /> New Sales Receipt
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader><DialogTitle>New Sales Receipt</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={customerName} maxLength={160} placeholder="Customer name"
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Receipt #</Label>
|
||||||
|
<Input value={number} onChange={(e) => setNumber(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Address</Label>
|
||||||
|
<Textarea rows={2} maxLength={400} value={customerAddress} placeholder="Street, city, state, zip"
|
||||||
|
onChange={(e) => setCustomerAddress(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Date</Label>
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Deposit to</Label>
|
||||||
|
<Select value={depositAccountId} onValueChange={setDepositAccountId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(depositAccounts as any[]).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.code ? `${a.code} · ` : ""}{a.name}{a.is_system ? " (holding)" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Account</Label>
|
||||||
|
<Select value={incomeAccountId} onValueChange={setIncomeAccountId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select income account" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(incomeAccounts as any[]).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.code ? `${a.code} · ` : ""}{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Price</Label>
|
||||||
|
<Input type="number" min={0} step="0.01" value={rate}
|
||||||
|
onChange={(e) => setRate(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Quantity</Label>
|
||||||
|
<Input type="number" min={0} step="0.01" value={quantity}
|
||||||
|
onChange={(e) => setQuantity(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Memo</Label>
|
||||||
|
<Textarea rows={2} maxLength={400} value={memo}
|
||||||
|
onChange={(e) => setMemo(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 text-base">
|
||||||
|
<span className="font-semibold">Total</span>
|
||||||
|
<span className="font-semibold tabular-nums">{money(total, cur)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setOpen(false); reset(); }}>Cancel</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Saving…" : "Save sales receipt"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border/60 shadow-sm">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="relative min-w-[220px] max-w-sm">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search receipt #, name or account…" className="h-9 pl-9"
|
||||||
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/60 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Receipt #</th>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Date</th>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Account</th>
|
||||||
|
<th className="px-6 py-3 text-right font-medium">Qty</th>
|
||||||
|
<th className="px-6 py-3 text-right font-medium">Price</th>
|
||||||
|
<th className="px-6 py-3 text-right font-medium">Total</th>
|
||||||
|
<th className="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{isLoading && Array.from({ length: 5 }).map((_, r) => (
|
||||||
|
<tr key={`sk-${r}`}>
|
||||||
|
{Array.from({ length: 8 }).map((_, c) => (
|
||||||
|
<td key={c} className="px-6 py-3"><div className="h-4 rounded bg-muted animate-pulse" style={{ width: `${40 + ((r * 13 + c * 17) % 50)}%` }} /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && filtered.map((r: any) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="px-6 py-3 font-medium">{r.number}</td>
|
||||||
|
<td className="px-6 py-3 text-muted-foreground">{fmtDate(r.receipt_date)}</td>
|
||||||
|
<td className="px-6 py-3">{r.customer_name ?? "—"}</td>
|
||||||
|
<td className="px-6 py-3">{r.income_account?.name ?? "—"}</td>
|
||||||
|
<td className="px-6 py-3 text-right tabular-nums">{Number(r.quantity)}</td>
|
||||||
|
<td className="px-6 py-3 text-right tabular-nums">{money(r.rate, cur)}</td>
|
||||||
|
<td className="px-6 py-3 text-right font-semibold tabular-nums">{money(r.total, cur)}</td>
|
||||||
|
<td className="px-2 py-3 text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => remove(r)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<tr><td colSpan={8} className="p-0">
|
||||||
|
<EmptyState icon={Receipt} title="No sales receipts yet" description="Record a cash sale — money is deposited and income is booked in one step." />
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ export type PaymentPayload = {
|
|||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/** Expense account to book the auto-created bill line against (falls back to the company default). */
|
||||||
|
expenseAccountId?: string | null;
|
||||||
sourceKind: "expense" | "check" | "transaction" | "journal";
|
sourceKind: "expense" | "check" | "transaction" | "journal";
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
};
|
};
|
||||||
@@ -116,6 +118,7 @@ export async function createAutoBill(p: PaymentPayload): Promise<{ id: string; n
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
rate: p.amount,
|
rate: p.amount,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
|
account_id: p.expenseAccountId ?? null,
|
||||||
});
|
});
|
||||||
return bill;
|
return bill;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
create table if not exists accounting.sales_receipts (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
company_id uuid not null references accounting.companies(id) on delete cascade,
|
||||||
|
number text not null,
|
||||||
|
receipt_date date not null default current_date,
|
||||||
|
customer_name text,
|
||||||
|
customer_address text,
|
||||||
|
income_account_id uuid references accounting.accounts(id),
|
||||||
|
deposit_account_id uuid references accounting.accounts(id),
|
||||||
|
quantity numeric not null default 1,
|
||||||
|
rate numeric not null default 0,
|
||||||
|
total numeric not null default 0,
|
||||||
|
memo text,
|
||||||
|
transaction_id uuid references accounting.transactions(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_sales_receipts_company on accounting.sales_receipts(company_id);
|
||||||
|
create index if not exists idx_sales_receipts_txn on accounting.sales_receipts(transaction_id);
|
||||||
|
|
||||||
|
alter table accounting.sales_receipts enable row level security;
|
||||||
|
|
||||||
|
create policy "Accounting staff full access" on accounting.sales_receipts
|
||||||
|
for all using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff());
|
||||||
|
create policy "Members CRUD sales_receipts" on accounting.sales_receipts
|
||||||
|
for all using (accounting.is_company_member(company_id, auth.uid()))
|
||||||
|
with check (accounting.is_company_member(company_id, auth.uid()));
|
||||||
|
create policy "Board view sales_receipts" on accounting.sales_receipts
|
||||||
|
for select using (accounting.is_company_board_member(company_id));
|
||||||
|
|
||||||
|
create trigger trg_sales_receipts_updated
|
||||||
|
before update on accounting.sales_receipts
|
||||||
|
for each row execute function public.update_updated_at_column();
|
||||||
|
|
||||||
|
grant select, insert, update, delete on accounting.sales_receipts to authenticated;
|
||||||
|
grant all on accounting.sales_receipts to service_role;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
alter table public.chart_of_accounts drop constraint chart_of_accounts_accounting_system_check;
|
||||||
|
alter table public.chart_of_accounts add constraint chart_of_accounts_accounting_system_check
|
||||||
|
check (accounting_system = any (array['buildium'::text, 'zoho'::text, 'platform'::text]));
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Platform associations keep per-association charts (codes can repeat across
|
||||||
|
-- associations and even within a company), so the global (account_number,
|
||||||
|
-- accounting_system) uniqueness must not apply to platform-tagged rows. Make it
|
||||||
|
-- a partial index covering only buildium/zoho (their existing dedupe behavior).
|
||||||
|
drop index if exists public.chart_of_accounts_number_per_system_unique;
|
||||||
|
create unique index chart_of_accounts_number_per_system_unique
|
||||||
|
on public.chart_of_accounts (account_number, accounting_system)
|
||||||
|
where accounting_system <> 'platform';
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
-- Keep the accounting dashboard's accounts (accounting.accounts) reflected into
|
||||||
|
-- public.chart_of_accounts for PLATFORM associations, so every COA consumer
|
||||||
|
-- (Bill Approvals, bills, budgets, etc.) shows the same accounts and stays in
|
||||||
|
-- sync. One-way mirror, same id, so existing FKs + PostgREST embeds keep working.
|
||||||
|
create or replace function accounting.sync_account_to_public_coa()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
_assoc uuid;
|
||||||
|
_system text;
|
||||||
|
_parent uuid;
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
delete from public.chart_of_accounts where id = old.id;
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select c.association_id, a.accounting_system
|
||||||
|
into _assoc, _system
|
||||||
|
from accounting.companies c
|
||||||
|
join public.associations a on a.id = c.association_id
|
||||||
|
where c.id = new.company_id;
|
||||||
|
|
||||||
|
-- Only mirror accounts the accounting dashboard actually manages (platform).
|
||||||
|
-- If the association isn't on platform, ensure no stale mirror row lingers.
|
||||||
|
if _system is distinct from 'platform' then
|
||||||
|
delete from public.chart_of_accounts where id = new.id;
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Only point at a parent that already exists in the public table to satisfy
|
||||||
|
-- the self-FK; hierarchy fills in as parents sync.
|
||||||
|
_parent := null;
|
||||||
|
if new.parent_account_id is not null then
|
||||||
|
select id into _parent from public.chart_of_accounts where id = new.parent_account_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.chart_of_accounts as coa
|
||||||
|
(id, association_id, account_number, account_name, account_type,
|
||||||
|
parent_account_id, is_active, description, association_ids, accounting_system)
|
||||||
|
values
|
||||||
|
(new.id, _assoc, coalesce(new.code, ''), new.name, new.type::text,
|
||||||
|
_parent, true, new.description, array[_assoc], 'platform')
|
||||||
|
on conflict (id) do update set
|
||||||
|
association_id = excluded.association_id,
|
||||||
|
account_number = excluded.account_number,
|
||||||
|
account_name = excluded.account_name,
|
||||||
|
account_type = excluded.account_type,
|
||||||
|
parent_account_id = excluded.parent_account_id,
|
||||||
|
is_active = true,
|
||||||
|
description = excluded.description,
|
||||||
|
association_ids = excluded.association_ids,
|
||||||
|
accounting_system = 'platform',
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_sync_account_to_public_coa on accounting.accounts;
|
||||||
|
create trigger trg_sync_account_to_public_coa
|
||||||
|
after insert or update or delete on accounting.accounts
|
||||||
|
for each row execute function accounting.sync_account_to_public_coa();
|
||||||
|
|
||||||
|
-- Backfill: mirror every existing platform account (parent left null for pass 1).
|
||||||
|
insert into public.chart_of_accounts
|
||||||
|
(id, association_id, account_number, account_name, account_type,
|
||||||
|
parent_account_id, is_active, description, association_ids, accounting_system)
|
||||||
|
select ac.id, c.association_id, coalesce(ac.code, ''), ac.name, ac.type::text,
|
||||||
|
null, true, ac.description, array[c.association_id], 'platform'
|
||||||
|
from accounting.accounts ac
|
||||||
|
join accounting.companies c on c.id = ac.company_id
|
||||||
|
join public.associations a on a.id = c.association_id
|
||||||
|
where a.accounting_system = 'platform'
|
||||||
|
on conflict (id) do update set
|
||||||
|
association_id = excluded.association_id,
|
||||||
|
account_number = excluded.account_number,
|
||||||
|
account_name = excluded.account_name,
|
||||||
|
account_type = excluded.account_type,
|
||||||
|
is_active = true,
|
||||||
|
description = excluded.description,
|
||||||
|
association_ids = excluded.association_ids,
|
||||||
|
accounting_system = 'platform',
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- Pass 2: wire up parent hierarchy now that all rows exist.
|
||||||
|
update public.chart_of_accounts coa
|
||||||
|
set parent_account_id = ac.parent_account_id, updated_at = now()
|
||||||
|
from accounting.accounts ac
|
||||||
|
where coa.id = ac.id
|
||||||
|
and ac.parent_account_id is not null
|
||||||
|
and exists (select 1 from public.chart_of_accounts p where p.id = ac.parent_account_id);
|
||||||
|
|
||||||
|
-- Hide legacy (buildium/zoho) COA rows for platform associations so pickers that
|
||||||
|
-- respect is_active show only the synced platform accounts. Nothing is deleted.
|
||||||
|
update public.chart_of_accounts coa
|
||||||
|
set is_active = false, updated_at = now()
|
||||||
|
from public.associations a
|
||||||
|
where a.id = coa.association_id
|
||||||
|
and a.accounting_system = 'platform'
|
||||||
|
and coalesce(coa.accounting_system, '') <> 'platform';
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- Create a paid bill for a vendor payment transaction that has no attached bill.
|
||||||
|
-- Such payments post Dr Accounts Payable / Cr Bank (the Banking flow assumes a
|
||||||
|
-- bill exists); with no bill, A/P is left negative and the expense is never
|
||||||
|
-- recognized. The auto-bill posts Dr Expense / Cr A/P, clearing the A/P and
|
||||||
|
-- booking the expense (net: Dr Expense / Cr Bank). Idempotent + safe to re-run.
|
||||||
|
--
|
||||||
|
-- NOTE: superseded for the Banking flow by 20260603200530 (vendor payments now
|
||||||
|
-- book the expense directly). This function is retained for reference/backfill.
|
||||||
|
create or replace function accounting.create_bill_for_payment_txn(_txn_id uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t accounting.transactions%rowtype;
|
||||||
|
_exp uuid;
|
||||||
|
_num text;
|
||||||
|
_n int;
|
||||||
|
_bill_id uuid;
|
||||||
|
begin
|
||||||
|
select * into t from accounting.transactions where id = _txn_id;
|
||||||
|
if not found then return null; end if;
|
||||||
|
|
||||||
|
if t.type <> 'debit' or t.vendor_id is null or t.coa_account_id is not null
|
||||||
|
or t.transfer_id is not null or t.deposit_id is not null then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
if coalesce(t.amount, 0) <= 0 then return null; end if;
|
||||||
|
if exists (select 1 from accounting.bills b where b.source_payment_id = t.id) then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select a.id into _exp
|
||||||
|
from accounting.accounts a
|
||||||
|
where a.company_id = t.company_id and a.name = t.category and a.type = 'expense'
|
||||||
|
limit 1;
|
||||||
|
if _exp is null then _exp := accounting.coa_default_expense(t.company_id); end if;
|
||||||
|
|
||||||
|
select count(*) into _n from accounting.bills where company_id = t.company_id and auto_created;
|
||||||
|
_num := 'AUTO-BILL-' || lpad((_n + 1)::text, 4, '0');
|
||||||
|
|
||||||
|
insert into accounting.bills
|
||||||
|
(company_id, vendor_id, number, issue_date, due_date,
|
||||||
|
subtotal, tax, total, paid_amount, status, notes,
|
||||||
|
auto_created, source_payment_id, source_payment_kind)
|
||||||
|
values
|
||||||
|
(t.company_id, t.vendor_id, _num, t.date, t.date,
|
||||||
|
t.amount, 0, t.amount, t.amount, 'paid',
|
||||||
|
'Auto-created from bank payment with no attached bill',
|
||||||
|
true, t.id, 'transaction')
|
||||||
|
returning id into _bill_id;
|
||||||
|
|
||||||
|
insert into accounting.bill_items (bill_id, description, quantity, rate, amount, account_id)
|
||||||
|
values (_bill_id, coalesce(nullif(t.category, ''), 'Auto-created payment line'),
|
||||||
|
1, t.amount, t.amount, _exp);
|
||||||
|
|
||||||
|
return _bill_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Backfill existing unattached vendor payments + best-effort link a matching check.
|
||||||
|
do $$
|
||||||
|
declare r record; _bill uuid;
|
||||||
|
begin
|
||||||
|
for r in
|
||||||
|
select t.id, t.company_id, t.vendor_id, t.amount, t.date
|
||||||
|
from accounting.transactions t
|
||||||
|
where t.type = 'debit'
|
||||||
|
and t.vendor_id is not null
|
||||||
|
and t.coa_account_id is null
|
||||||
|
and t.transfer_id is null and t.deposit_id is null
|
||||||
|
and coalesce(t.description, '') not ilike 'Bill Payment%'
|
||||||
|
and not exists (select 1 from accounting.bills b where b.source_payment_id = t.id)
|
||||||
|
order by t.date, t.id
|
||||||
|
loop
|
||||||
|
_bill := accounting.create_bill_for_payment_txn(r.id);
|
||||||
|
if _bill is not null then
|
||||||
|
update accounting.checks c
|
||||||
|
set auto_bill_id = _bill, updated_at = now()
|
||||||
|
where c.id = (
|
||||||
|
select c2.id from accounting.checks c2
|
||||||
|
where c2.company_id = r.company_id
|
||||||
|
and c2.payee_vendor_id = r.vendor_id
|
||||||
|
and c2.amount = r.amount
|
||||||
|
and c2.date = r.date
|
||||||
|
and c2.source_bill_id is null
|
||||||
|
and c2.auto_bill_id is null
|
||||||
|
limit 1
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Vendor payments with no bill were posted to Accounts Payable (coa_account_id
|
||||||
|
-- null + vendor → Dr A/P / Cr Bank), leaving A/P negative and the expense never
|
||||||
|
-- recognized. Per the rule (expense at payment date when no bill present), set
|
||||||
|
-- their expense account so post_transaction_gl re-posts them Dr Expense / Cr Bank.
|
||||||
|
-- Excludes bill payments ('Bill Payment%'), payments already linked to an
|
||||||
|
-- auto-bill, and imported-GL (non-gl_managed) companies.
|
||||||
|
update accounting.transactions t
|
||||||
|
set coa_account_id = coalesce(
|
||||||
|
(select a.id from accounting.accounts a
|
||||||
|
where a.company_id = t.company_id and a.name = t.category and a.type = 'expense'
|
||||||
|
limit 1),
|
||||||
|
accounting.coa_default_expense(t.company_id))
|
||||||
|
where t.type = 'debit'
|
||||||
|
and t.vendor_id is not null
|
||||||
|
and t.coa_account_id is null
|
||||||
|
and t.transfer_id is null and t.deposit_id is null
|
||||||
|
and coalesce(t.description, '') not ilike 'Bill Payment%'
|
||||||
|
and not exists (select 1 from accounting.bills b where b.source_payment_id = t.id)
|
||||||
|
and accounting.gl_managed(t.company_id);
|
||||||
Reference in New Issue
Block a user