mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Merge pull request #6 from renee-png/accounting-sales-receipts-coa-sync-expenses
Accounting: sales receipts, COA dashboard sync, accrual A/P, manual deposits, per-association COA, bill-approval sync fix
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
AccountingOpeningBalancesPage,
|
||||
AccountingExpensesPage,
|
||||
AccountingEstimatesPage,
|
||||
AccountingSalesReceiptsPage,
|
||||
AccountingReconcileDetailPage,
|
||||
AccountingBudgetDetailPage,
|
||||
AccountingCustomerDetailPage,
|
||||
@@ -384,6 +385,7 @@ const App = () => (
|
||||
<Route path="vendors" element={<AccountingVendorsPage />} />
|
||||
<Route path="expenses" element={<AccountingExpensesPage />} />
|
||||
<Route path="estimates" element={<AccountingEstimatesPage />} />
|
||||
<Route path="sales-receipts" element={<AccountingSalesReceiptsPage />} />
|
||||
<Route path="deposits" element={<AccountingDepositsPage />} />
|
||||
<Route path="receive-payments" element={<AccountingReceivePaymentsPage />} />
|
||||
<Route path="banking" element={<AccountingBankingPage />} />
|
||||
|
||||
@@ -54,7 +54,10 @@ function fromPlatform(row: any, associationId: string): NormalizedAccount {
|
||||
* - `platform` → the Accounting module's `accounting.accounts` (single source of
|
||||
* truth once an association is on the platform). Returns [] if the association
|
||||
* has no `accounting.companies` row yet.
|
||||
* - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system.
|
||||
* - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system AND, when
|
||||
* an association is given, by that association. Each association owns an independent
|
||||
* set of accounts (one row per association — see the per-association COA migration),
|
||||
* so two associations can both have a "5000" meaning different things.
|
||||
*
|
||||
* Returned rows are normalized to {@link NormalizedAccount} so callers never
|
||||
* branch on the source.
|
||||
@@ -81,11 +84,14 @@ export async function fetchChartOfAccounts(
|
||||
return (data ?? []).map((row) => fromPlatform(row, associationId));
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
let query = supabase
|
||||
.from("chart_of_accounts")
|
||||
.select("*")
|
||||
.eq("accounting_system", system)
|
||||
.order("account_number", { ascending: true });
|
||||
.eq("accounting_system", system);
|
||||
// Scope to the current association so each one sees only its own accounts. When no
|
||||
// association is supplied, fall back to the whole system set (back-compat).
|
||||
if (associationId) query = query.eq("association_id", associationId);
|
||||
const { data, error } = await query.order("account_number", { ascending: true });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(fromPublic);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx";
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-amber-100 text-amber-700",
|
||||
approved: "bg-emerald-100 text-emerald-700",
|
||||
@@ -172,8 +173,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
setBills([]);
|
||||
setApprovalsByBill({});
|
||||
const [aRes2, coaRes2, vRes2] = await Promise.all([
|
||||
supabase.from("associations").select("id, name").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("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
|
||||
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"),
|
||||
]);
|
||||
setAssociations(aRes2.data || []);
|
||||
@@ -201,8 +202,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
if (data.length < PAGE) break;
|
||||
}
|
||||
const [aRes, coaRes, vRes] = await Promise.all([
|
||||
supabase.from("associations").select("id, name").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("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
|
||||
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"),
|
||||
]);
|
||||
|
||||
@@ -604,10 +605,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
return bill.status;
|
||||
};
|
||||
|
||||
const filteredAccounts = form.association_id
|
||||
? accounts.filter((a: any) => !a.association_id || a.association_id === form.association_id)
|
||||
: [];
|
||||
|
||||
const filteredVendors = form.association_id
|
||||
? vendors.filter((v: any) => v.association_id === form.association_id || (Array.isArray(v.association_ids) && v.association_ids.includes(form.association_id)))
|
||||
: [];
|
||||
@@ -1153,20 +1150,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
{/* GL Account */}
|
||||
<div>
|
||||
<Label>GL Account (Expense) <span className="text-destructive">*</span></Label>
|
||||
<Select
|
||||
<ChartOfAccountsDropdown
|
||||
accountType="expense"
|
||||
associationId={form.association_id || null}
|
||||
value={form.expense_account_id}
|
||||
onValueChange={(v) => setForm({ ...form, expense_account_id: v })}
|
||||
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
|
||||
disabled={!form.association_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={form.association_id ? "Select GL Account" : "Select a client first"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredAccounts.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder={form.association_id ? "Select GL Account" : "Select a client first"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Request Approval From */}
|
||||
@@ -1401,12 +1392,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
</div>
|
||||
<div>
|
||||
<Label>GL Account (Expense)</Label>
|
||||
<Select value={form.expense_account_id} onValueChange={(v) => setForm({ ...form, expense_account_id: v })} disabled={!form.association_id}>
|
||||
<SelectTrigger><SelectValue placeholder="Select GL Account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ChartOfAccountsDropdown
|
||||
accountType="expense"
|
||||
associationId={form.association_id || null}
|
||||
value={form.expense_account_id}
|
||||
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
|
||||
disabled={!form.association_id}
|
||||
placeholder="Select GL Account"
|
||||
/>
|
||||
</div>
|
||||
{vendorNotFound && (
|
||||
<Alert variant="destructive" className="border-amber-300 bg-amber-50 text-amber-900">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { generateCheckPDF } from "./lib/checkPdf";
|
||||
import { parseCsv, pick, parseDateStr } from "./lib/csv";
|
||||
import { usePlaidLink } from "react-plaid-link";
|
||||
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
||||
import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill";
|
||||
|
||||
type TxForm = {
|
||||
account_id: string;
|
||||
@@ -222,11 +223,48 @@ export default function AccountingBankingPage() {
|
||||
const acc = (accounts as any[]).find((a) => a.id === accountId);
|
||||
if (!acc || selected.size === 0) return;
|
||||
const ids = [...selected];
|
||||
const { error } = await accounting.from("transactions")
|
||||
.update({ coa_account_id: accountId, category: acc.name }).in("id", ids);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success(`Set category for ${ids.length} transaction${ids.length !== 1 ? "s" : ""}`);
|
||||
const rows = (register as any[]).filter((r) => selected.has(r.id));
|
||||
|
||||
// Accrual A/P: before categorizing a vendor debit to an expense account, check
|
||||
// whether it actually settles an open bill (matchOpenBills). A debit that
|
||||
// uniquely matches one is a bill payment — clear A/P (coa null, link the bill)
|
||||
// instead of re-hitting the expense, which was already booked on the bill.
|
||||
// • exactly one match → auto-apply to the bill
|
||||
// • more than one match → leave for the user to resolve in Pay Bills (skip)
|
||||
// • no match → categorize as a direct expense (below)
|
||||
const billPayments: { id: string; bill: any; amount: number }[] = [];
|
||||
let ambiguous = 0;
|
||||
for (const r of rows) {
|
||||
if (r.type !== "debit" || !r.vendor_id) continue;
|
||||
const matches = await matchOpenBills({ companyId: cid, vendorId: r.vendor_id, amount: Number(r.amount), date: r.date });
|
||||
if (matches.length === 1) billPayments.push({ id: r.id, bill: matches[0], amount: Number(r.amount) });
|
||||
else if (matches.length > 1) ambiguous++;
|
||||
}
|
||||
const handled = new Set([...billPayments.map((p) => p.id)]);
|
||||
const categorizeIds = ids.filter((id) => !handled.has(id));
|
||||
|
||||
if (categorizeIds.length) {
|
||||
const { error } = await accounting.from("transactions")
|
||||
.update({ coa_account_id: accountId, category: acc.name }).in("id", categorizeIds);
|
||||
if (error) return toast.error(error.message);
|
||||
}
|
||||
for (const p of billPayments) {
|
||||
const bal = Number(p.bill.total) - Number(p.bill.paid_amount ?? 0);
|
||||
const { error } = await accounting.from("transactions")
|
||||
.update({ coa_account_id: null, bill_id: p.bill.id, category: `Bill Payment · ${p.bill.number}` })
|
||||
.eq("id", p.id);
|
||||
if (error) return toast.error(error.message);
|
||||
await applyPaymentToBill(p.bill.id, Math.min(p.amount, bal));
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (categorizeIds.length) parts.push(`categorized ${categorizeIds.length}`);
|
||||
if (billPayments.length) parts.push(`applied ${billPayments.length} to open bills`);
|
||||
toast.success(parts.length ? parts.join(", ") : "No changes");
|
||||
if (ambiguous) toast.warning(`${ambiguous} debit${ambiguous !== 1 ? "s" : ""} match multiple open bills — resolve in Pay Bills`);
|
||||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||
if (billPayments.length) qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const bulkSetDirection = async (type: "debit" | "credit") => {
|
||||
@@ -256,16 +294,30 @@ export default function AccountingBankingPage() {
|
||||
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
|
||||
const category = coaName;
|
||||
|
||||
// A vendor payment (debit) clears Accounts Payable — the expense was already
|
||||
// recognized when the bill was entered (accrual). Leaving coa_account_id null
|
||||
// with the vendor set makes post_transaction_gl post Dr A/P / Cr Bank; the
|
||||
// chosen expense account is retained as the display `category` only. Customer
|
||||
// deposits (credits) clear A/R via customer_id, so they need no change here.
|
||||
// Vendor-payment recognition rule: count the expense for the bill when it is
|
||||
// entered (accrual), or — when no bill exists — when the payment is made.
|
||||
// • Debit matches an OPEN bill (matchOpenBills: same vendor, amount within
|
||||
// $0.01 or partial, date within ±30 days) → this payment clears Accounts
|
||||
// Payable (coa null, vendor set → post_transaction_gl posts Dr A/P / Cr
|
||||
// Bank); the expense was already recognized on the bill. We then apply it
|
||||
// to the matched bill(s).
|
||||
// • No matching 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.
|
||||
const matchedBills = type === "debit" && vendor_id
|
||||
? await matchOpenBills({ companyId: cid, vendorId: vendor_id, amount, date })
|
||||
: [];
|
||||
const debitClearsAp = matchedBills.length > 0;
|
||||
|
||||
const payload: any = {
|
||||
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,
|
||||
customer_id: customer_id || null,
|
||||
// Link the settled bill only when a single bill matches (the guard requires
|
||||
// coa null on any bill-linked row, which holds here). Multi-bill payments
|
||||
// stay unlinked but still clear A/P via the vendor branch.
|
||||
bill_id: matchedBills.length === 1 ? matchedBills[0].id : null,
|
||||
};
|
||||
|
||||
if (editId) {
|
||||
@@ -273,8 +325,9 @@ export default function AccountingBankingPage() {
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success("Transaction updated");
|
||||
} else {
|
||||
const { error } = await accounting.from("transactions").insert({ ...payload, company_id: cid });
|
||||
if (error) return toast.error(error.message);
|
||||
const { data: inserted, error } = await accounting
|
||||
.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");
|
||||
|
||||
if (type === "debit" && txForm.printCheck) {
|
||||
@@ -290,6 +343,22 @@ export default function AccountingBankingPage() {
|
||||
bankAccountId: account_id,
|
||||
});
|
||||
}
|
||||
|
||||
// When the payment cleared A/P, apply it to the matched bill(s) oldest-first
|
||||
// so they show paid. The expense lives on the bill, so no expense is booked
|
||||
// here. With no matching bill the payment already posted the expense directly
|
||||
// (above) — nothing further to do.
|
||||
if (debitClearsAp) {
|
||||
let remaining = amount;
|
||||
for (const b of matchedBills) {
|
||||
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" });
|
||||
setEditId(null);
|
||||
@@ -1013,6 +1082,7 @@ export default function AccountingBankingPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,10 +74,16 @@ export default function AccountingBillsPage() {
|
||||
enabled: !!cid,
|
||||
queryFn: async () => (await accounting.from("bills").select("*, vendors(name,address)").eq("company_id", cid).order("issue_date", { ascending: false })).data ?? [],
|
||||
});
|
||||
// Single vendor roster = public.vendors, scoped to this company's association.
|
||||
// The chosen public vendor is mapped to its accounting.vendors row on save.
|
||||
const { data: vendors = [] } = useQuery({
|
||||
queryKey: ["vendors-lookup", cid],
|
||||
enabled: !!cid,
|
||||
queryFn: async () => (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [],
|
||||
queryKey: ["vendors-lookup", associationId],
|
||||
enabled: !!associationId,
|
||||
queryFn: async () => (await supabase.from("vendors")
|
||||
.select("id,name")
|
||||
.eq("is_active", true)
|
||||
.or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`)
|
||||
.order("name")).data ?? [],
|
||||
});
|
||||
const { data: expenseAccounts = [] } = useQuery({
|
||||
queryKey: ["expense-accounts", cid],
|
||||
@@ -128,7 +134,14 @@ export default function AccountingBillsPage() {
|
||||
|
||||
const openEdit = async (b: any) => {
|
||||
setEditId(b.id);
|
||||
setVendorId(b.vendor_id ?? "");
|
||||
// The dropdown is keyed by public vendor id; map the stored accounting
|
||||
// vendor back to its source public vendor when one exists.
|
||||
let pubVendorId = "";
|
||||
if (b.vendor_id) {
|
||||
const { data: av } = await accounting.from("vendors").select("external_source, external_id").eq("id", b.vendor_id).maybeSingle();
|
||||
if (av?.external_source === "acmacc_vendor" && av?.external_id) pubVendorId = String(av.external_id);
|
||||
}
|
||||
setVendorId(pubVendorId);
|
||||
setNumber(b.number ?? "");
|
||||
setIssueDate(b.issue_date ?? issueDate);
|
||||
setDueDate(b.due_date ?? "");
|
||||
@@ -245,6 +258,17 @@ export default function AccountingBillsPage() {
|
||||
let attachmentUrl = uploadedUrl;
|
||||
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
||||
|
||||
// Resolve the chosen public vendor to its accounting.vendors row (find-or-create).
|
||||
let acctVendorId: string | null = null;
|
||||
if (vendorId) {
|
||||
const { data: mapped, error: mapErr } = await supabase.rpc("ensure_accounting_vendor", {
|
||||
_association_id: associationId,
|
||||
_public_vendor_id: vendorId,
|
||||
});
|
||||
if (mapErr) return toast.error(mapErr.message);
|
||||
acctVendorId = (mapped as string) ?? null;
|
||||
}
|
||||
|
||||
const itemRows = (billId: string) => items.map(i => ({
|
||||
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
|
||||
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
|
||||
@@ -253,7 +277,7 @@ export default function AccountingBillsPage() {
|
||||
|
||||
if (editId) {
|
||||
const { error } = await accounting.from("bills").update({
|
||||
vendor_id: vendorId || null, number,
|
||||
vendor_id: acctVendorId, number,
|
||||
issue_date: issueDate, due_date: dueDate || null,
|
||||
subtotal, tax, total,
|
||||
notes: notes || null,
|
||||
@@ -265,7 +289,7 @@ export default function AccountingBillsPage() {
|
||||
toast.success("Bill updated");
|
||||
} else {
|
||||
const { data: bill, error } = await accounting.from("bills").insert({
|
||||
company_id: cid, vendor_id: vendorId || null, number,
|
||||
company_id: cid, vendor_id: acctVendorId, number,
|
||||
issue_date: issueDate, due_date: dueDate || null,
|
||||
subtotal, tax, total, status: "open",
|
||||
notes: notes || null,
|
||||
@@ -415,13 +439,14 @@ export default function AccountingBillsPage() {
|
||||
reference: refLabel,
|
||||
coa_account_id: null, // → posts against Accounts Payable (via vendor)
|
||||
vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P
|
||||
bill_id: payBill.id, // links the payment to the bill it settles
|
||||
});
|
||||
|
||||
// 2) Bank balance auto-updated by DB trigger trg_sync_account_balance
|
||||
|
||||
// 3) Update bill paid amount
|
||||
// 3) Update bill paid amount (partial payments leave the bill partially_paid)
|
||||
const newPaid = Number(payBill.paid_amount ?? 0) + Number(payAmount);
|
||||
await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) ? "paid" : "open" }).eq("id", payBill.id);
|
||||
await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) - 0.005 ? "paid" : "partially_paid" }).eq("id", payBill.id);
|
||||
|
||||
// 4) If check + print: insert check record, print, mark printed, bump next #
|
||||
if (payMethod === "check") {
|
||||
|
||||
@@ -11,10 +11,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { money, fmtDate } from "./lib/format";
|
||||
import { Landmark, Loader2 } from "lucide-react";
|
||||
import { Landmark, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { EmptyState } from "./components/EmptyState";
|
||||
import { ensureUndepositedFunds } from "./lib/undeposited";
|
||||
|
||||
type ManualLine = { account_id: string; amount: string; memo: string };
|
||||
const EMPTY_LINE: ManualLine = { account_id: "", amount: "", memo: "" };
|
||||
|
||||
export default function AccountingDepositsPage() {
|
||||
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||
const cid = companyId ?? "";
|
||||
@@ -26,6 +29,7 @@ export default function AccountingDepositsPage() {
|
||||
const [depositDate, setDepositDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
|
||||
const [memo, setMemo] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [lines, setLines] = useState<ManualLine[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,6 +44,15 @@ export default function AccountingDepositsPage() {
|
||||
(await accounting.from("accounts").select("id,name,code,balance").eq("company_id", cid).eq("is_bank", true).order("code")).data ?? [],
|
||||
});
|
||||
|
||||
// All accounts — for the source-account picker on deposit lines (income, A/R,
|
||||
// reserve, clearing, etc.), so a deposit isn't forced through Undeposited Funds.
|
||||
const { data: allAccounts = [] } = useQuery({
|
||||
queryKey: ["all-accounts", cid],
|
||||
enabled: !!cid,
|
||||
queryFn: async () =>
|
||||
(await accounting.from("accounts").select("id,name,code,type,balance").eq("company_id", cid).order("type").order("code")).data ?? [],
|
||||
});
|
||||
|
||||
// Two sources of "awaiting deposit": transactions parked on the Undeposited
|
||||
// Funds account (banking flow) and payments_received not yet deposited (incl.
|
||||
// payments synced from the main app's owner ledger). Both are unified below.
|
||||
@@ -90,89 +103,100 @@ export default function AccountingDepositsPage() {
|
||||
return rows.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}, [pendingTx, pendingPmt]);
|
||||
|
||||
const selectedTotal = useMemo(
|
||||
const undepositedTotal = useMemo(
|
||||
() => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0),
|
||||
[pending, selected]
|
||||
);
|
||||
const manualTotal = useMemo(
|
||||
() => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0),
|
||||
[lines]
|
||||
);
|
||||
const grandTotal = undepositedTotal + manualTotal;
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === pending.length) setSelected(new Set());
|
||||
else setSelected(new Set(pending.map((r) => r.key)));
|
||||
};
|
||||
|
||||
const addLine = () => setLines((ls) => [...ls, { ...EMPTY_LINE }]);
|
||||
const updateLine = (i: number, patch: Partial<ManualLine>) =>
|
||||
setLines((ls) => ls.map((l, idx) => (idx === i ? { ...l, ...patch } : l)));
|
||||
const removeLine = (i: number) => setLines((ls) => ls.filter((_, idx) => idx !== i));
|
||||
|
||||
const submitDeposit = async () => {
|
||||
if (!bankAccountId) return toast.error("Choose a bank account");
|
||||
if (selected.size === 0) return toast.error("Select at least one payment");
|
||||
if (grandTotal <= 0) return toast.error("Add at least one payment or deposit line");
|
||||
|
||||
// Validate manual lines: each must have an account and a positive amount.
|
||||
const cleanLines = lines.filter((l) => l.account_id || Number(l.amount));
|
||||
for (const l of cleanLines) {
|
||||
if (!l.account_id) return toast.error("Every deposit line needs a source account");
|
||||
if (!(Number(l.amount) > 0)) return toast.error("Every deposit line needs a positive amount");
|
||||
}
|
||||
|
||||
// Optional guard: warn (non-blocking) if crediting Undeposited Funds would exceed
|
||||
// what is currently held there — i.e. depositing more than is sitting in it.
|
||||
const manualToUndeposited = cleanLines
|
||||
.filter((l) => l.account_id === undepositedId)
|
||||
.reduce((s, l) => s + Number(l.amount), 0);
|
||||
const toUndeposited = undepositedTotal + manualToUndeposited;
|
||||
if (toUndeposited > 0 && undepositedId) {
|
||||
const held = Number((allAccounts as any[]).find((a) => a.id === undepositedId)?.balance ?? 0);
|
||||
if (toUndeposited > held + 0.005) {
|
||||
toast.warning(`Crediting ${money(toUndeposited, cur)} to Undeposited Funds, which holds ${money(held, cur)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const bank = (bankAccounts as any[]).find((a) => a.id === bankAccountId);
|
||||
const chosen = pending.filter((r) => selected.has(r.key));
|
||||
const txIds = chosen.filter((r) => r.kind === "tx").map((r) => r.id);
|
||||
const pmtIds = chosen.filter((r) => r.kind === "pmt").map((r) => r.id);
|
||||
const txTotal = chosen.filter((r) => r.kind === "tx").reduce((s, r) => s + r.amount, 0);
|
||||
const count = chosen.length;
|
||||
|
||||
// 1) Create deposit record
|
||||
// 1) Deposit header — amount is the sum of all credit lines.
|
||||
const { data: dep, error: depErr } = await accounting
|
||||
.from("deposits")
|
||||
.insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: selectedTotal, memo: memo || null })
|
||||
.insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: grandTotal, memo: memo || null })
|
||||
.select()
|
||||
.single();
|
||||
if (depErr || !dep) throw new Error(depErr?.message ?? "Failed to create deposit");
|
||||
const ref = `DEP-${dep.id.slice(0, 8).toUpperCase()}`;
|
||||
|
||||
// 2) Single debit on bank account for the full deposit
|
||||
await accounting.from("transactions").insert({
|
||||
company_id: cid,
|
||||
account_id: bankAccountId,
|
||||
date: depositDate,
|
||||
type: "debit",
|
||||
amount: selectedTotal,
|
||||
description: `Deposit · ${count} payment${count > 1 ? "s" : ""}${memo ? " · " + memo : ""}`,
|
||||
category: "Deposit",
|
||||
reference: ref,
|
||||
deposit_id: dep.id,
|
||||
});
|
||||
|
||||
// 3) Offsetting credit on Undeposited Funds — only for the portion actually
|
||||
// held there as transactions (payments_received aren't posted to it).
|
||||
if (txTotal > 0) {
|
||||
await accounting.from("transactions").insert({
|
||||
company_id: cid,
|
||||
account_id: undepositedId,
|
||||
date: depositDate,
|
||||
type: "credit",
|
||||
amount: txTotal,
|
||||
description: `Deposit cleared · ${txIds.length} payment${txIds.length > 1 ? "s" : ""}`,
|
||||
category: "Deposit",
|
||||
reference: ref,
|
||||
deposit_id: dep.id,
|
||||
// 2) Deposit lines (credit side). The selected payments collapse into one
|
||||
// Undeposited Funds line; manual lines book to their chosen accounts.
|
||||
// accounting.post_deposit_gl posts Dr Bank (total) / Cr each line.
|
||||
const lineRows: any[] = [];
|
||||
if (undepositedTotal > 0 && undepositedId) {
|
||||
lineRows.push({
|
||||
deposit_id: dep.id, company_id: cid, account_id: undepositedId,
|
||||
amount: undepositedTotal, memo: `Cleared ${chosen.length} payment${chosen.length !== 1 ? "s" : ""}`,
|
||||
});
|
||||
await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds);
|
||||
const { data: und } = await accounting.from("accounts").select("balance").eq("id", undepositedId).single();
|
||||
if (und) {
|
||||
await accounting.from("accounts").update({ balance: Number(und.balance) - txTotal }).eq("id", undepositedId);
|
||||
}
|
||||
}
|
||||
for (const l of cleanLines) {
|
||||
lineRows.push({ deposit_id: dep.id, company_id: cid, account_id: l.account_id, amount: Number(l.amount), memo: l.memo || null });
|
||||
}
|
||||
if (lineRows.length) {
|
||||
const { error: lineErr } = await accounting.from("deposit_lines").insert(lineRows);
|
||||
if (lineErr) throw new Error(lineErr.message);
|
||||
}
|
||||
|
||||
// 4) Mark selected payments_received as deposited so they leave the queue
|
||||
// 3) Clear the deposited items from the awaiting-deposit queue.
|
||||
if (txIds.length) {
|
||||
await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds);
|
||||
}
|
||||
if (pmtIds.length) {
|
||||
await accounting.from("payments_received")
|
||||
.update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId })
|
||||
.in("id", pmtIds);
|
||||
}
|
||||
|
||||
// 5) Bank balance reflects the full deposit
|
||||
if (bank) {
|
||||
await accounting.from("accounts").update({ balance: Number(bank.balance) + selectedTotal }).eq("id", bank.id);
|
||||
}
|
||||
|
||||
toast.success(`Deposit of ${money(selectedTotal, cur)} recorded`);
|
||||
toast.success(`Deposit of ${money(grandTotal, cur)} recorded`);
|
||||
setSelected(new Set());
|
||||
setLines([]);
|
||||
setMemo("");
|
||||
qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["all-accounts", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||
} catch (e: any) {
|
||||
@@ -191,7 +215,8 @@ export default function AccountingDepositsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Make Deposit</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select customer payments held in Undeposited Funds and deposit them as a single bank transaction.
|
||||
Deposit customer payments held in Undeposited Funds, or record a deposit straight to an
|
||||
income, A/R, or other account by adding deposit lines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -226,7 +251,7 @@ export default function AccountingDepositsPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
|
||||
<div className="text-sm">
|
||||
Selected: <b>{money(selectedTotal, cur)}</b> ({selected.size} of {pending.length})
|
||||
Selected: <b>{money(undepositedTotal, cur)}</b> ({selected.size} of {pending.length})
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -276,9 +301,63 @@ export default function AccountingDepositsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={submitDeposit} disabled={saving || selected.size === 0 || !bankAccountId}>
|
||||
{saving ? "Recording…" : `Deposit ${money(selectedTotal, cur)}`}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Other deposit lines</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Book a deposit directly to an account — interest income, a refund, a reimbursement, etc.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={addLine}><Plus className="h-4 w-4 mr-1" />Add line</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No additional lines. Add one to deposit to a specific account.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="grid gap-2 md:grid-cols-12 items-center">
|
||||
<div className="md:col-span-5">
|
||||
<Select value={l.account_id} onValueChange={(v) => updateLine(i, { account_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="Source account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(allAccounts as any[]).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.code ? `${a.code} · ` : ""}{a.name}<span className="text-muted-foreground"> · {a.type}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-4">
|
||||
<Input value={l.memo} placeholder="Memo (optional)" onChange={(e) => updateLine(i, { memo: e.target.value })} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
type="number" inputMode="decimal" step="0.01" placeholder="0.00"
|
||||
className="text-right tabular-nums"
|
||||
value={l.amount} onChange={(e) => updateLine(i, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex justify-end">
|
||||
<Button size="icon" variant="ghost" onClick={() => removeLine(i)} aria-label="Remove line">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{money(undepositedTotal, cur)} from Undeposited + {money(manualTotal, cur)} direct
|
||||
</div>
|
||||
<Button onClick={submitDeposit} disabled={saving || grandTotal <= 0 || !bankAccountId}>
|
||||
{saving ? "Recording…" : `Deposit ${money(grandTotal, cur)}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ export { default as AccountingWorkOrdersPage } from "./AccountingWorkOrdersPage"
|
||||
export { default as AccountingOpeningBalancesPage } from "./AccountingOpeningBalancesPage";
|
||||
export { default as AccountingExpensesPage } from "./AccountingExpensesPage";
|
||||
export { default as AccountingEstimatesPage } from "./AccountingEstimatesPage";
|
||||
export { default as AccountingSalesReceiptsPage } from "./AccountingSalesReceiptsPage";
|
||||
export { default as AccountingReconcileDetailPage } from "./AccountingReconcileDetailPage";
|
||||
export { default as AccountingBudgetDetailPage } from "./AccountingBudgetDetailPage";
|
||||
export { default as AccountingCustomerDetailPage } from "./AccountingCustomerDetailPage";
|
||||
|
||||
@@ -27,6 +27,7 @@ const NAV: NavSection[] = [
|
||||
items: [
|
||||
{ to: "receive-payments", label: "Receive Payments" },
|
||||
{ to: "invoices", label: "Invoices" },
|
||||
{ to: "sales-receipts", label: "Sales Receipts" },
|
||||
{ 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))]),
|
||||
};
|
||||
case "expense-summary": {
|
||||
const byCat: Record<string, number> = {};
|
||||
// Direct expenses from expenses table
|
||||
for (const e of d.expenses) byCat[e.category] = (byCat[e.category] ?? 0) + Number(e.amount);
|
||||
// Bill expenses (accrual — total billed, not just paid)
|
||||
for (const b of d.bills) {
|
||||
if (b.status === "void" || b.status === "draft") continue;
|
||||
const cat = b.vendors?.name ?? "Vendor Expenses";
|
||||
byCat[cat] = (byCat[cat] ?? 0) + Number(b.total);
|
||||
// GL-driven so it follows the same recognition rule as the P&L: a bill's
|
||||
// expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment
|
||||
// with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the
|
||||
// ledger avoids double-counting and never misses direct payments.
|
||||
const byAcct: Record<string, number> = {};
|
||||
for (const l of (d.glLines ?? []) as any[]) {
|
||||
const acc = l.accounts;
|
||||
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 total = Object.values(byCat).reduce((s, v) => s + v, 0);
|
||||
return { title: "Expense Summary (Accrual)", columns: ["Category / Vendor", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
||||
const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
|
||||
const total = Object.values(byAcct).reduce((s, v) => s + v, 0);
|
||||
return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
||||
}
|
||||
case "vendor-balances": {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { debitMatchesBill, SETTLEABLE_BILL_STATUSES, type BillMatchInput } from "./billMatch";
|
||||
|
||||
export { debitMatchesBill, BILL_MATCH_WINDOW_DAYS, type BillMatchInput } from "./billMatch";
|
||||
|
||||
export type AutoBillSettings = {
|
||||
enabled: boolean;
|
||||
@@ -50,6 +53,8 @@ export type PaymentPayload = {
|
||||
date: string; // YYYY-MM-DD
|
||||
category?: 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";
|
||||
sourceId: string;
|
||||
};
|
||||
@@ -116,6 +121,7 @@ export async function createAutoBill(p: PaymentPayload): Promise<{ id: string; n
|
||||
quantity: 1,
|
||||
rate: p.amount,
|
||||
amount: p.amount,
|
||||
account_id: p.expenseAccountId ?? null,
|
||||
});
|
||||
return bill;
|
||||
}
|
||||
@@ -124,10 +130,34 @@ export async function applyPaymentToBill(billId: string, amount: number) {
|
||||
const { data: bill } = await accounting.from("bills").select("total, paid_amount").eq("id", billId).single();
|
||||
if (!bill) return;
|
||||
const newPaid = Number(bill.paid_amount ?? 0) + amount;
|
||||
const status = newPaid >= Number(bill.total) ? "paid" : "open";
|
||||
const total = Number(bill.total);
|
||||
// Fully paid → "paid"; some balance still owed after a payment → "partially_paid".
|
||||
const status = newPaid >= total - 0.005 ? "paid" : "partially_paid";
|
||||
await accounting.from("bills").update({ paid_amount: newPaid, status }).eq("id", billId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the open vendor bill(s) a bank debit settles, per the accrual matching rule:
|
||||
* • same vendor, status not in (paid, void, draft)
|
||||
* • debit date within ±30 days of the bill's due date (falling back to issue date)
|
||||
* • remaining balance equals the debit within $0.01 (full settlement), or the
|
||||
* debit is ≤ the remaining balance (partial payment)
|
||||
* Returns candidates oldest-first. Empty → treat the debit as a direct expense.
|
||||
* One candidate → apply automatically. More than one → caller should disambiguate.
|
||||
*/
|
||||
export async function matchOpenBills(input: BillMatchInput) {
|
||||
if (!input.vendorId || !(input.amount > 0)) return [];
|
||||
const { data } = await accounting
|
||||
.from("bills")
|
||||
.select("id, number, total, paid_amount, issue_date, due_date, status, vendor_id")
|
||||
.eq("company_id", input.companyId)
|
||||
.eq("vendor_id", input.vendorId)
|
||||
.in("status", SETTLEABLE_BILL_STATUSES);
|
||||
return (data ?? [])
|
||||
.filter((b: any) => debitMatchesBill(b, input))
|
||||
.sort((a: any, b: any) => String(a.issue_date ?? "").localeCompare(String(b.issue_date ?? "")));
|
||||
}
|
||||
|
||||
/** Orchestrator: returns either a match prompt, an auto-created bill, or skip */
|
||||
export type HandleResult =
|
||||
| { kind: "skipped"; reason: string }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { debitMatchesBill } from "./billMatch";
|
||||
|
||||
// Accrual A/P matching rule: a bank debit settles a bill only when the amount and
|
||||
// date line up. Mirrors the brief's acceptance tests 5 and 6.
|
||||
describe("debitMatchesBill", () => {
|
||||
const bill = (over: Partial<Parameters<typeof debitMatchesBill>[0]> = {}) => ({
|
||||
total: 100, paid_amount: 0, issue_date: "2026-04-15", due_date: "2026-04-15", ...over,
|
||||
});
|
||||
|
||||
it("matches a same-amount debit within the ±30 day window (test 5)", () => {
|
||||
expect(debitMatchesBill(bill(), { amount: 100, date: "2026-04-20" })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats $0.01 differences as a full match", () => {
|
||||
expect(debitMatchesBill(bill(), { amount: 100.01, date: "2026-04-15" })).toBe(true);
|
||||
expect(debitMatchesBill(bill(), { amount: 99.99, date: "2026-04-15" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a debit outside the ±30 day window even if the amount is identical (test 6)", () => {
|
||||
// FPL $307.67 billed once in April; identical payments in Jan/Feb must NOT match.
|
||||
const b = bill({ total: 307.67, issue_date: "2026-04-15", due_date: "2026-04-15" });
|
||||
expect(debitMatchesBill(b, { amount: 307.67, date: "2026-01-26" })).toBe(false);
|
||||
expect(debitMatchesBill(b, { amount: 307.67, date: "2026-02-24" })).toBe(false);
|
||||
expect(debitMatchesBill(b, { amount: 307.67, date: "2026-04-15" })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches a partial payment (debit ≤ remaining balance)", () => {
|
||||
expect(debitMatchesBill(bill(), { amount: 40, date: "2026-04-15" })).toBe(true);
|
||||
});
|
||||
|
||||
it("respects remaining balance, not the original total", () => {
|
||||
const partlyPaid = bill({ total: 100, paid_amount: 60 }); // $40 remaining
|
||||
expect(debitMatchesBill(partlyPaid, { amount: 40, date: "2026-04-15" })).toBe(true);
|
||||
expect(debitMatchesBill(partlyPaid, { amount: 100, date: "2026-04-15" })).toBe(false); // exceeds remaining
|
||||
});
|
||||
|
||||
it("does not match a fully paid bill", () => {
|
||||
expect(debitMatchesBill(bill({ total: 100, paid_amount: 100 }), { amount: 100, date: "2026-04-15" })).toBe(false);
|
||||
});
|
||||
|
||||
it("uses issue_date when due_date is absent", () => {
|
||||
expect(debitMatchesBill(bill({ due_date: null, issue_date: "2026-04-15" }), { amount: 100, date: "2026-05-10" })).toBe(true);
|
||||
expect(debitMatchesBill(bill({ due_date: null, issue_date: "2026-04-15" }), { amount: 100, date: "2026-06-01" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Pure accrual A/P matching logic — no DB/client imports so it stays unit-testable.
|
||||
//
|
||||
// A bank debit to a vendor either settles an existing bill (the expense was already
|
||||
// booked when the bill was entered → the payment must only clear A/P) or it is a
|
||||
// direct expense (no matching bill → book it now).
|
||||
|
||||
export const BILL_MATCH_WINDOW_DAYS = 30;
|
||||
export const BILL_MATCH_TOLERANCE = 0.01;
|
||||
// Bill statuses a payment can still settle (excludes paid / void / draft).
|
||||
export const SETTLEABLE_BILL_STATUSES = ["open", "overdue", "partially_paid"];
|
||||
|
||||
export type BillMatchInput = {
|
||||
companyId: string;
|
||||
vendorId: string | null;
|
||||
amount: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
};
|
||||
|
||||
export type MatchableBill = {
|
||||
total: number | string;
|
||||
paid_amount?: number | string | null;
|
||||
issue_date?: string | null;
|
||||
due_date?: string | null;
|
||||
};
|
||||
|
||||
function shiftDays(date: string, days: number): Date {
|
||||
const d = new Date(date + "T00:00:00");
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure predicate: does a single bill settle this bank debit?
|
||||
* • debit date within ±30 days of the bill's due date (falling back to issue date)
|
||||
* • remaining balance equals the debit within $0.01 (full settlement), or the
|
||||
* debit is ≤ the remaining balance (partial payment)
|
||||
* Status/vendor filtering happens in the query; this only judges amount + date.
|
||||
*/
|
||||
export function debitMatchesBill(bill: MatchableBill, input: Pick<BillMatchInput, "amount" | "date">): boolean {
|
||||
if (!(input.amount > 0)) return false;
|
||||
const remaining = Number(bill.total) - Number(bill.paid_amount ?? 0);
|
||||
if (remaining <= BILL_MATCH_TOLERANCE) return false;
|
||||
const ref = new Date(((bill.due_date ?? bill.issue_date) ?? input.date) + "T00:00:00");
|
||||
if (ref < shiftDays(input.date, -BILL_MATCH_WINDOW_DAYS) || ref > shiftDays(input.date, BILL_MATCH_WINDOW_DAYS)) return false;
|
||||
const full = Math.abs(remaining - input.amount) <= BILL_MATCH_TOLERANCE;
|
||||
const partial = input.amount <= remaining + BILL_MATCH_TOLERANCE;
|
||||
return full || partial;
|
||||
}
|
||||
@@ -2123,12 +2123,21 @@ Deno.serve(async (req) => {
|
||||
const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
|
||||
const vendorId = await ensureVendor(buildiumVendor, assocLocalId);
|
||||
|
||||
// Pick first line's GL account as expense account if available
|
||||
// Pick first line's GL account as expense account if available.
|
||||
// chart_of_accounts.account_number stores the Buildium GL Id (see the
|
||||
// glaccounts upsert: account_number = String(gl.Id ...)), so resolve the
|
||||
// line's GL Id from whichever shape Buildium returns it in.
|
||||
const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null;
|
||||
let expenseAccountId: string | null = null;
|
||||
if (firstLine?.GLAccountId) {
|
||||
const lineGlId = firstLine
|
||||
? (firstLine.GLAccountId
|
||||
?? firstLine.GLAccount?.Id
|
||||
?? firstLine.GLAccount?.GLAccountId
|
||||
?? null)
|
||||
: null;
|
||||
if (lineGlId !== null && lineGlId !== undefined && String(lineGlId) !== "") {
|
||||
const coa = await getCoa(assocLocalId);
|
||||
expenseAccountId = coa.get(String(firstLine.GLAccountId)) || null;
|
||||
expenseAccountId = coa.get(String(lineGlId)) || null;
|
||||
}
|
||||
|
||||
let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Fix: bill_approvals not reflecting "paid" when an accounting.bills row is
|
||||
-- created already-paid.
|
||||
--
|
||||
-- Background (see 20260601140000_accounting_sync_bills.sql):
|
||||
-- accounting.sync_accounting_bill_paid() flips public.bills + public.bill_approvals
|
||||
-- to 'paid' once the matching accounting bill is fully paid. It runs from the
|
||||
-- trigger trg_acct_bill_paid_back, which was AFTER UPDATE only.
|
||||
--
|
||||
-- When a public bill is ALREADY paid at the moment it first mirrors into
|
||||
-- accounting, the forward sync INSERTs the accounting row at status='paid'
|
||||
-- with paid_amount set. With no subsequent UPDATE to paid_amount/total, the
|
||||
-- UPDATE-only trigger never fired, so the bill's approvals were left stuck at
|
||||
-- 'approved'/'pending'. This widens the back-sync to also run on INSERT.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Back-sync trigger: now fires on INSERT as well as UPDATE.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function accounting.tg_accounting_bill_paid_sync()
|
||||
returns trigger
|
||||
language plpgsql security definer set search_path to 'public','accounting'
|
||||
as $$
|
||||
begin
|
||||
begin
|
||||
if tg_op = 'INSERT' then
|
||||
-- a bill mirrored in already-paid never produces an UPDATE; handle it here
|
||||
perform accounting.sync_accounting_bill_paid(new.id);
|
||||
-- on UPDATE only act when the paid position actually changed, to avoid loops
|
||||
elsif old.paid_amount is distinct from new.paid_amount
|
||||
or old.total is distinct from new.total then
|
||||
perform accounting.sync_accounting_bill_paid(new.id);
|
||||
end if;
|
||||
exception when others then
|
||||
raise warning 'accounting: accounting bill paid sync failed for %: %', new.id, sqlerrm;
|
||||
end;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_acct_bill_paid_back on accounting.bills;
|
||||
create trigger trg_acct_bill_paid_back
|
||||
after insert or update on accounting.bills
|
||||
for each row execute function accounting.tg_accounting_bill_paid_sync();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- One-time reconciliation: back-sync any accounting bills that are already
|
||||
-- fully paid but whose linked public bill / approvals were never updated
|
||||
-- (the rows that fell into the INSERT gap above).
|
||||
-- ---------------------------------------------------------------------------
|
||||
do $$
|
||||
declare _id uuid;
|
||||
begin
|
||||
for _id in
|
||||
select id from accounting.bills
|
||||
where external_source = 'acmacc_bill' and external_id is not null
|
||||
and coalesce(paid_amount,0) >= coalesce(total,0) and coalesce(total,0) > 0
|
||||
loop
|
||||
perform accounting.sync_accounting_bill_paid(_id);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Backfill orphaned, invoice-track approvals (bill_id IS NULL) that were
|
||||
-- created via the invoice flow and never linked to a public.bills row.
|
||||
-- Only link when exactly one matching bill exists for the same association,
|
||||
-- invoice number and amount; adopt that bill's status when it is paid.
|
||||
-- ---------------------------------------------------------------------------
|
||||
update public.bill_approvals ba
|
||||
set bill_id = m.bill_id,
|
||||
status = case when m.bill_status = 'paid' then 'paid' else ba.status end,
|
||||
updated_at = now()
|
||||
from (
|
||||
select i.id as invoice_id, b.id as bill_id, b.status as bill_status
|
||||
from public.invoices i
|
||||
join public.bills b
|
||||
on b.association_id = i.association_id
|
||||
and b.amount = i.amount
|
||||
and b.invoice_number is not distinct from i.invoice_number
|
||||
group by i.id, b.id, b.status
|
||||
having (
|
||||
select count(*) from public.bills b2
|
||||
where b2.association_id = i.association_id
|
||||
and b2.amount = i.amount
|
||||
and b2.invoice_number is not distinct from i.invoice_number
|
||||
) = 1
|
||||
) m
|
||||
where ba.bill_id is null
|
||||
and ba.invoice_id = m.invoice_id;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Accrual A/P guard: link a bank transaction to the bill it settles, and enforce
|
||||
-- that a bill-linked payment never carries an expense category.
|
||||
--
|
||||
-- Under accrual, a vendor expense is recognized once — when the bill is entered
|
||||
-- (Dr Expense / Cr A/P). Paying the bill must only relieve the liability
|
||||
-- (Dr A/P / Cr Bank) via accounting.post_transaction_gl's vendor → A/P branch,
|
||||
-- which only runs when coa_account_id IS NULL. So any transaction that settles a
|
||||
-- bill must have coa_account_id NULL; otherwise it re-recognizes the expense.
|
||||
|
||||
alter table accounting.transactions
|
||||
add column if not exists bill_id uuid references accounting.bills(id) on delete set null;
|
||||
|
||||
create index if not exists idx_transactions_bill_id
|
||||
on accounting.transactions(bill_id) where bill_id is not null;
|
||||
|
||||
-- Invariant: a transaction linked to a bill posts against Accounts Payable, never
|
||||
-- an expense account. This makes the accrual rule enforceable, not just convention.
|
||||
alter table accounting.transactions
|
||||
drop constraint if exists chk_bill_payment_no_coa;
|
||||
alter table accounting.transactions
|
||||
add constraint chk_bill_payment_no_coa
|
||||
check (bill_id is null or coa_account_id is null);
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- One-time remediation: Ashley Manor bill payments that double-counted the expense.
|
||||
--
|
||||
-- These bank debits settled an existing bill but were written with an expense
|
||||
-- coa_account_id, so the GL posted Dr Expense / Cr Bank — re-recognizing an expense
|
||||
-- already booked when the bill was entered, and never relieving Accounts Payable.
|
||||
-- Clearing coa_account_id makes accounting.post_transaction_gl repost them via the
|
||||
-- vendor → A/P branch as Dr A/P / Cr Bank, correcting prior periods. The matched
|
||||
-- bills are already marked paid, so paid_amount is intentionally left untouched.
|
||||
--
|
||||
-- Match rule (matches the accrual A/P matching rule used by the app): same vendor,
|
||||
-- bill not void/draft, amount within $0.01 of the bill total, debit date within
|
||||
-- ±30 days of the bill's due (else issue) date. When a single bill matches, the
|
||||
-- payment is linked via bill_id; ambiguous (multi-bill) matches clear coa only.
|
||||
with targets as (
|
||||
select t.id as txn_id,
|
||||
(select array_agg(b.id) from accounting.bills b
|
||||
where b.company_id = t.company_id and b.vendor_id = t.vendor_id
|
||||
and b.status <> 'void' and b.status <> 'draft'
|
||||
and abs(b.total - t.amount) <= 0.01
|
||||
and t.date between (coalesce(b.due_date, b.issue_date) - interval '30 days')
|
||||
and (coalesce(b.due_date, b.issue_date) + interval '30 days')
|
||||
) as bill_ids
|
||||
from accounting.transactions t
|
||||
join accounting.companies c on c.id = t.company_id
|
||||
where c.name ilike 'Ashley Manor%'
|
||||
and t.type = 'debit' and t.vendor_id is not null and t.coa_account_id is not null
|
||||
)
|
||||
update accounting.transactions t
|
||||
set coa_account_id = null,
|
||||
bill_id = case when array_length(targets.bill_ids, 1) = 1 then targets.bill_ids[1] else null end
|
||||
from targets
|
||||
where t.id = targets.txn_id
|
||||
and targets.bill_ids is not null;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Manual Deposits: let a deposit's source (credit) account be selectable instead of
|
||||
-- always Undeposited Funds, and support multi-line deposits. This removes the forced
|
||||
-- routing through Undeposited Funds (the structural cause of negative Undeposited
|
||||
-- balances) and lets a deposit book interest income, refunds, reimbursements, etc.
|
||||
|
||||
-- Single-source fallback: a deposit with no lines credits this account (default
|
||||
-- Undeposited Funds when null), keeping the existing "deposit received payments" flow.
|
||||
alter table accounting.deposits
|
||||
add column if not exists source_account_id uuid references accounting.accounts(id);
|
||||
|
||||
-- Multi-line credits: one deposit = Dr Bank (total) and a set of credit lines, each
|
||||
-- with its own account and amount. The deposit's amount equals the sum of its lines.
|
||||
create table if not exists accounting.deposit_lines (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
deposit_id uuid not null references accounting.deposits(id) on delete cascade,
|
||||
company_id uuid not null references accounting.companies(id) on delete cascade,
|
||||
account_id uuid not null references accounting.accounts(id),
|
||||
amount numeric not null default 0,
|
||||
memo text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists idx_deposit_lines_deposit on accounting.deposit_lines(deposit_id);
|
||||
|
||||
alter table accounting.deposit_lines enable row level security;
|
||||
|
||||
drop policy if exists "Accounting staff full access" on accounting.deposit_lines;
|
||||
create policy "Accounting staff full access" on accounting.deposit_lines
|
||||
for all to authenticated
|
||||
using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff());
|
||||
|
||||
drop policy if exists "Members CRUD deposit_lines" on accounting.deposit_lines;
|
||||
create policy "Members CRUD deposit_lines" on accounting.deposit_lines
|
||||
for all to authenticated
|
||||
using (accounting.is_company_member(company_id, auth.uid()))
|
||||
with check (accounting.is_company_member(company_id, auth.uid()));
|
||||
|
||||
grant select, insert, update, delete on accounting.deposit_lines to authenticated, service_role;
|
||||
|
||||
-- The deposit header trigger posts GL on insert (before any lines exist), so re-post
|
||||
-- whenever the lines change too. post_deposit_gl clears + reposts, so this is idempotent.
|
||||
create or replace function accounting.tg_deposit_line_gl()
|
||||
returns trigger language plpgsql security definer set search_path to 'public', 'accounting' as $$
|
||||
begin
|
||||
begin
|
||||
perform accounting.post_deposit_gl(coalesce(new.deposit_id, old.deposit_id));
|
||||
exception when others then raise warning 'accounting: deposit line GL post failed: %', sqlerrm; end;
|
||||
return coalesce(new, old);
|
||||
end$$;
|
||||
|
||||
drop trigger if exists trg_acct_deposit_line_gl on accounting.deposit_lines;
|
||||
create trigger trg_acct_deposit_line_gl
|
||||
after insert or update or delete on accounting.deposit_lines
|
||||
for each row execute function accounting.tg_deposit_line_gl();
|
||||
@@ -0,0 +1,153 @@
|
||||
-- Unify vendor + Chart of Accounts across the bill-approvals (public) and the
|
||||
-- Accounting platform bill flows.
|
||||
-- * Vendors: single roster = public.vendors. New RPC lets the Accounting UI
|
||||
-- pick a public vendor and resolve it to the matching accounting.vendors row.
|
||||
-- * COA: the GL account flows both ways between public.bills.expense_account_id
|
||||
-- and accounting.bill_items.account_id (platform accounting.accounts ids are
|
||||
-- mirrored 1:1 into public.chart_of_accounts, so the same id is valid on both
|
||||
-- sides).
|
||||
-- * One-time backfill of account_id on already-mirrored bills.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) Forward sync now carries the public bill's GL account into the mirrored
|
||||
-- accounting.bill_items.account_id (when it resolves to accounting.accounts).
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function accounting.sync_public_bill(_bill_id uuid)
|
||||
returns void
|
||||
language plpgsql security definer set search_path to 'public','accounting'
|
||||
as $$
|
||||
declare
|
||||
b public.bills%rowtype;
|
||||
_company_id uuid;
|
||||
_vendor_id uuid;
|
||||
_status accounting.bill_status;
|
||||
_paid numeric; _tot numeric;
|
||||
_acct_bill_id uuid;
|
||||
_acct_account_id uuid;
|
||||
begin
|
||||
select * into b from public.bills where id=_bill_id;
|
||||
if not found then return; end if;
|
||||
|
||||
select id into _company_id from accounting.companies where association_id=b.association_id;
|
||||
if _company_id is null then return; end if;
|
||||
|
||||
if not accounting.bill_should_mirror(b.status) then
|
||||
delete from accounting.bills where company_id=_company_id and external_source='acmacc_bill' and external_id=b.id::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
_vendor_id := accounting.ensure_vendor_for_public(_company_id, b.vendor_id);
|
||||
_paid := coalesce(b.amount_paid, 0);
|
||||
_tot := coalesce(b.amount, 0);
|
||||
_status := (case when _paid >= _tot and _tot > 0 then 'paid'
|
||||
when _paid > 0 then 'partially_paid'
|
||||
else 'open' end)::accounting.bill_status;
|
||||
|
||||
insert into accounting.bills
|
||||
(company_id, vendor_id, number, issue_date, due_date, status, subtotal, tax, total,
|
||||
notes, paid_amount, attachment_url, external_source, external_id)
|
||||
values
|
||||
(_company_id, _vendor_id,
|
||||
coalesce(nullif(b.invoice_number,''), 'BILL-' || left(replace(b.id::text,'-',''),8)),
|
||||
b.bill_date, b.due_date, _status, _tot, 0, _tot,
|
||||
b.description, _paid, b.attachment_url, 'acmacc_bill', b.id::text)
|
||||
on conflict (company_id, external_source, external_id) where external_id is not null
|
||||
do update set vendor_id=excluded.vendor_id, number=excluded.number, issue_date=excluded.issue_date,
|
||||
due_date=excluded.due_date, status=excluded.status, subtotal=excluded.subtotal,
|
||||
total=excluded.total, notes=excluded.notes, paid_amount=excluded.paid_amount,
|
||||
attachment_url=excluded.attachment_url, updated_at=now()
|
||||
returning id into _acct_bill_id;
|
||||
|
||||
if _acct_bill_id is null then
|
||||
select id into _acct_bill_id from accounting.bills
|
||||
where company_id=_company_id and external_source='acmacc_bill' and external_id=b.id::text;
|
||||
end if;
|
||||
|
||||
-- Adopt the public GL account when it maps to an accounting.accounts row for
|
||||
-- this company (platform COA shares ids with public.chart_of_accounts).
|
||||
_acct_account_id := null;
|
||||
if b.expense_account_id is not null then
|
||||
select id into _acct_account_id from accounting.accounts
|
||||
where id = b.expense_account_id and company_id = _company_id;
|
||||
end if;
|
||||
|
||||
-- Single line item mirroring the bill amount (refresh on each sync).
|
||||
delete from accounting.bill_items where bill_id=_acct_bill_id;
|
||||
insert into accounting.bill_items (bill_id, description, quantity, rate, amount, account_id)
|
||||
values (_acct_bill_id, coalesce(nullif(b.description,''), 'Bill ' || coalesce(b.invoice_number,'')), 1, _tot, _tot, _acct_account_id);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) Reverse: a GL change on a mirrored accounting bill line flows the account
|
||||
-- back to public.bills.expense_account_id. Guarded against loops (only when
|
||||
-- the value actually differs and the id is a valid public COA row).
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function accounting.tg_bill_item_coa_back_sync()
|
||||
returns trigger
|
||||
language plpgsql security definer set search_path to 'public','accounting'
|
||||
as $$
|
||||
declare ab accounting.bills%rowtype; _public_id uuid;
|
||||
begin
|
||||
begin
|
||||
if new.account_id is null then return new; end if;
|
||||
select * into ab from accounting.bills where id = new.bill_id;
|
||||
if not found then return new; end if;
|
||||
if ab.external_source <> 'acmacc_bill' or ab.external_id is null then return new; end if;
|
||||
|
||||
_public_id := ab.external_id::uuid;
|
||||
update public.bills
|
||||
set expense_account_id = new.account_id, updated_at = now()
|
||||
where id = _public_id
|
||||
and expense_account_id is distinct from new.account_id
|
||||
and exists (select 1 from public.chart_of_accounts c where c.id = new.account_id);
|
||||
exception when others then
|
||||
raise warning 'accounting: bill item COA back-sync failed for %: %', new.id, sqlerrm;
|
||||
end;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_acct_bill_item_coa_back on accounting.bill_items;
|
||||
create trigger trg_acct_bill_item_coa_back
|
||||
after insert or update of account_id on accounting.bill_items
|
||||
for each row execute function accounting.tg_bill_item_coa_back_sync();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3) RPC: resolve a chosen public vendor to its accounting.vendors row for the
|
||||
-- association's company (find-or-create). Used by the Accounting bill UI so
|
||||
-- the vendor roster stays single-sourced from public.vendors.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function public.ensure_accounting_vendor(_association_id uuid, _public_vendor_id uuid)
|
||||
returns uuid
|
||||
language plpgsql security definer set search_path to 'public','accounting'
|
||||
as $$
|
||||
declare _company_id uuid; _vid uuid;
|
||||
begin
|
||||
if _public_vendor_id is null then return null; end if;
|
||||
select id into _company_id from accounting.companies where association_id=_association_id;
|
||||
if _company_id is null then return null; end if;
|
||||
_vid := accounting.ensure_vendor_for_public(_company_id, _public_vendor_id);
|
||||
return _vid;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.ensure_accounting_vendor(uuid, uuid) to authenticated, anon, service_role;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4) One-time backfill: populate account_id on already-mirrored bill lines from
|
||||
-- the linked public bill's expense account (only when it maps to this
|
||||
-- company's accounting.accounts).
|
||||
-- ---------------------------------------------------------------------------
|
||||
update accounting.bill_items bi
|
||||
set account_id = b.expense_account_id
|
||||
from accounting.bills ab
|
||||
join public.bills b on b.id = ab.external_id::uuid
|
||||
where bi.bill_id = ab.id
|
||||
and ab.external_source = 'acmacc_bill' and ab.external_id is not null
|
||||
and bi.account_id is null
|
||||
and b.expense_account_id is not null
|
||||
and exists (
|
||||
select 1 from accounting.accounts a
|
||||
where a.id = b.expense_account_id and a.company_id = ab.company_id
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
-- post_deposit_gl: credit the chosen source account(s) instead of always Undeposited.
|
||||
-- Debit the bank for the deposit total; credit each deposit_lines row's account for its
|
||||
-- amount; if there are no lines, credit source_account_id; if neither is set, fall back
|
||||
-- to Undeposited Funds (backward compatible with every existing deposit). The GL
|
||||
-- contract (one journal entry per deposit, cleared by external ref acmacc_dep) is
|
||||
-- otherwise unchanged.
|
||||
create or replace function accounting.post_deposit_gl(_deposit_id uuid)
|
||||
returns void language plpgsql security definer set search_path to 'public', 'accounting' as $function$
|
||||
declare
|
||||
d accounting.deposits%rowtype;
|
||||
_je uuid;
|
||||
_line_count int;
|
||||
_line_sum numeric;
|
||||
_remainder numeric;
|
||||
begin
|
||||
select * into d from accounting.deposits where id = _deposit_id;
|
||||
if not found then return; end if;
|
||||
perform accounting._gl_clear(d.company_id, 'acmacc_dep', d.id::text);
|
||||
if not accounting.gl_managed(d.company_id) then return; end if;
|
||||
if coalesce(d.amount, 0) = 0 or d.bank_account_id is null then return; end if;
|
||||
|
||||
insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id)
|
||||
values (d.company_id, d.date, coalesce(nullif(d.memo, ''), 'Deposit'), null, 'acmacc_dep', d.id::text)
|
||||
returning id into _je;
|
||||
|
||||
-- Debit the bank for the full deposit total.
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
|
||||
values (_je, d.bank_account_id, d.amount, 0, 'Deposit');
|
||||
|
||||
select count(*), coalesce(sum(amount), 0) into _line_count, _line_sum
|
||||
from accounting.deposit_lines where deposit_id = d.id;
|
||||
|
||||
if _line_count > 0 then
|
||||
-- Credit each line's account for its amount.
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
|
||||
select _je, dl.account_id, 0, dl.amount, coalesce(nullif(dl.memo, ''), 'Deposit')
|
||||
from accounting.deposit_lines dl where dl.deposit_id = d.id;
|
||||
-- Safety net: if the lines don't cover the total, balance the remainder to
|
||||
-- Undeposited Funds so the entry never posts unbalanced (UI keeps them equal).
|
||||
_remainder := d.amount - _line_sum;
|
||||
if _remainder > 0.005 then
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
|
||||
values (_je, accounting.coa_undeposited(d.company_id), 0, _remainder, 'Deposit');
|
||||
end if;
|
||||
else
|
||||
-- No lines: single-source deposit. Credit source_account_id, else Undeposited Funds.
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
|
||||
values (_je, coalesce(d.source_account_id, accounting.coa_undeposited(d.company_id)), 0, d.amount, 'Deposit');
|
||||
end if;
|
||||
end$function$;
|
||||
@@ -0,0 +1,140 @@
|
||||
-- Per-association Chart of Accounts.
|
||||
--
|
||||
-- Today public.chart_of_accounts shares one row across many associations via the
|
||||
-- association_ids[] array (buildium system: 94 rows shared by up to 13 associations).
|
||||
-- Editing a number for one association edits the row the others use. This migration
|
||||
-- gives every association its own row: it splits each shared buildium row into one
|
||||
-- row per association in its association_ids, repoints all references by each
|
||||
-- referencing record's association, and swaps the uniqueness to be per-association.
|
||||
--
|
||||
-- Nothing is deleted: the original shared row simply becomes the per-association row
|
||||
-- for its own association_id; clones are added for the other associations. So no
|
||||
-- foreign key can dangle. association_ids is kept as a single-element mirror of
|
||||
-- association_id during the transition (existing `association_ids @> [assoc]`
|
||||
-- callers keep working); drop it in a later cleanup pass.
|
||||
--
|
||||
-- Pre-audited on live data: association_id is always in association_ids; account
|
||||
-- numbers are globally unique within buildium (so no per-association collisions);
|
||||
-- all buildium parents are buildium rows; every reference's association is genuinely
|
||||
-- in its target row's array (zero mismatches). One row carries a stray NULL array
|
||||
-- element (excluded). Two child accounts (4000, 4004) are shared with associations
|
||||
-- that don't own their parent (4999) — those clones become top-level.
|
||||
|
||||
-- Pristine snapshot for rollback / original-parent lookups.
|
||||
drop table if exists public._coa_perassoc_backup;
|
||||
create table public._coa_perassoc_backup as table public.chart_of_accounts;
|
||||
|
||||
-- Drop the old per-system uniqueness up front: clones reuse the same account_number
|
||||
-- within buildium, which the old (account_number, accounting_system) index forbids.
|
||||
-- The new per-association index is created at the end, after the data is consistent.
|
||||
drop index if exists public.chart_of_accounts_number_per_system_unique;
|
||||
|
||||
-- (old_id, association) -> new_id. The owning association keeps the original row id;
|
||||
-- every other (non-null) association in the array gets a fresh clone id.
|
||||
-- Only map to associations that still exist — some arrays carry ids of deleted
|
||||
-- associations (stale memberships); those simply don't get a row.
|
||||
create temp table coa_map as
|
||||
select c.id as old_id, u.assoc as association_id,
|
||||
case when u.assoc = c.association_id then c.id else gen_random_uuid() end as new_id,
|
||||
(u.assoc = c.association_id) as is_keep
|
||||
from public.chart_of_accounts c
|
||||
cross join lateral (
|
||||
select distinct e as assoc from unnest(c.association_ids) e
|
||||
where e is not null and exists (select 1 from public.associations a where a.id = e)
|
||||
) u
|
||||
where c.accounting_system = 'buildium';
|
||||
|
||||
-- Clone rows for the non-owning associations.
|
||||
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, created_at, updated_at)
|
||||
select m.new_id, m.association_id, c.account_number, c.account_name, c.account_type,
|
||||
c.parent_account_id, c.is_active, c.description, array[m.association_id],
|
||||
c.accounting_system, c.created_at, now()
|
||||
from coa_map m
|
||||
join public.chart_of_accounts c on c.id = m.old_id
|
||||
where not m.is_keep;
|
||||
|
||||
-- Collapse the kept rows' arrays to their single association.
|
||||
update public.chart_of_accounts c
|
||||
set association_ids = array[c.association_id], updated_at = now()
|
||||
where c.accounting_system = 'buildium' and association_ids <> array[c.association_id];
|
||||
|
||||
-- Remap parent_account_id to the same-association copy of the parent (original
|
||||
-- parents read from the pristine snapshot so in-place updates don't interfere).
|
||||
update public.chart_of_accounts r
|
||||
set parent_account_id = pm.new_id, updated_at = now()
|
||||
from coa_map rm
|
||||
join public._coa_perassoc_backup b on b.id = rm.old_id
|
||||
join coa_map pm on pm.old_id = b.parent_account_id and pm.association_id = rm.association_id
|
||||
where r.id = rm.new_id and b.parent_account_id is not null
|
||||
and r.parent_account_id is distinct from pm.new_id;
|
||||
|
||||
-- Any parent that still isn't owned by the row's association (a child shared with an
|
||||
-- association that doesn't own its parent) can't nest there — make it top-level.
|
||||
update public.chart_of_accounts c
|
||||
set parent_account_id = null, updated_at = now()
|
||||
from public.chart_of_accounts p
|
||||
where c.parent_account_id = p.id and c.accounting_system = 'buildium'
|
||||
and p.association_id is distinct from c.association_id;
|
||||
|
||||
-- Repoint every reference to the association-specific row, by the record's association.
|
||||
update public.bills x set expense_account_id = m.new_id
|
||||
from coa_map m where m.old_id = x.expense_account_id and m.association_id = x.association_id and m.new_id <> x.expense_account_id;
|
||||
|
||||
update public.budgets x set gl_account_id = m.new_id
|
||||
from coa_map m where m.old_id = x.gl_account_id and m.association_id = x.association_id and m.new_id <> x.gl_account_id;
|
||||
|
||||
update public.owner_ledger_entries x set gl_account_id = m.new_id
|
||||
from coa_map m where m.old_id = x.gl_account_id and m.association_id = x.association_id and m.new_id <> x.gl_account_id;
|
||||
|
||||
-- budget_actuals_monthly is a VIEW derived from the base tables above; it recomputes
|
||||
-- and needs no repointing (it is still read in verification below).
|
||||
|
||||
update public.vendor_coa_mappings x set chart_of_accounts_id = m.new_id
|
||||
from coa_map m where m.old_id = x.chart_of_accounts_id and m.association_id = x.association_id and m.new_id <> x.chart_of_accounts_id;
|
||||
|
||||
update public.units x set assessment_account_id = m.new_id
|
||||
from coa_map m where m.old_id = x.assessment_account_id and m.association_id = x.association_id and m.new_id <> x.assessment_account_id;
|
||||
|
||||
update public.vendors x set default_expense_account_id = m.new_id
|
||||
from coa_map m where m.old_id = x.default_expense_account_id and m.association_id = x.association_id and m.new_id <> x.default_expense_account_id;
|
||||
|
||||
update public.journal_entries x set chart_of_account_id = m.new_id
|
||||
from coa_map m where m.old_id = x.chart_of_account_id and m.association_id = x.association_id and m.new_id <> x.chart_of_account_id;
|
||||
|
||||
-- Add per-association uniqueness. Excludes 'platform' (whose rows are id-keyed
|
||||
-- mirrors of accounting.accounts and may carry blank/duplicate codes), matching the
|
||||
-- old index's exclusion. Also validates the importer's existing onConflict target.
|
||||
create unique index chart_of_accounts_assoc_number_unique
|
||||
on public.chart_of_accounts (association_id, account_number)
|
||||
where accounting_system <> 'platform';
|
||||
|
||||
-- Verify (raises -> rolls back the whole migration on any inconsistency).
|
||||
do $$
|
||||
declare _n int; _bad int;
|
||||
begin
|
||||
select count(*) into _n from public.chart_of_accounts where accounting_system='buildium';
|
||||
if _n <> 370 then raise exception 'buildium row count % <> expected 370', _n; end if;
|
||||
|
||||
if exists (select 1 from public.chart_of_accounts where accounting_system='buildium' and array_length(association_ids,1) <> 1) then
|
||||
raise exception 'buildium rows with non-singleton association_ids remain';
|
||||
end if;
|
||||
|
||||
select count(*) into _bad from (
|
||||
select x.association_id ra, c.association_id ca from public.bills x join public.chart_of_accounts c on c.id=x.expense_account_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.budgets x join public.chart_of_accounts c on c.id=x.gl_account_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.owner_ledger_entries x join public.chart_of_accounts c on c.id=x.gl_account_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.budget_actuals_monthly x join public.chart_of_accounts c on c.id=x.gl_account_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.vendor_coa_mappings x join public.chart_of_accounts c on c.id=x.chart_of_accounts_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.units x join public.chart_of_accounts c on c.id=x.assessment_account_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.vendors x join public.chart_of_accounts c on c.id=x.default_expense_account_id where c.accounting_system='buildium'
|
||||
union all select x.association_id, c.association_id from public.journal_entries x join public.chart_of_accounts c on c.id=x.chart_of_account_id where c.accounting_system='buildium'
|
||||
) t where ra is distinct from ca;
|
||||
if _bad > 0 then raise exception '% references point to a different-association COA row', _bad; end if;
|
||||
|
||||
select count(*) into _bad from public.chart_of_accounts c
|
||||
join public.chart_of_accounts p on p.id=c.parent_account_id
|
||||
where c.accounting_system='buildium' and p.association_id is distinct from c.association_id;
|
||||
if _bad > 0 then raise exception '% buildium rows have a cross-association parent', _bad; end if;
|
||||
end $$;
|
||||
@@ -0,0 +1,153 @@
|
||||
-- Make bill creation truly two-way between the app (public.bills / bill_approvals)
|
||||
-- and the Accounting platform (accounting.bills), and mirror pending bills.
|
||||
--
|
||||
-- 1. bill_should_mirror now includes 'pending' so approval bills appear in
|
||||
-- Payables immediately (still excludes draft/rejected/void/cancelled/denied).
|
||||
-- 2. A bill created NATIVELY in the Accounting module (external_source IS NULL)
|
||||
-- now creates a matching public.bills row. The accounting row is pre-linked
|
||||
-- to the new public id so the existing forward sync adopts it (no duplicate),
|
||||
-- the vendor is mapped back to public.vendors, and the line's GL is carried.
|
||||
-- 3. Backfill: mirror existing pending public bills + reverse-create eligible
|
||||
-- native accounting bills.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) Mirror pending bills too.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function accounting.bill_should_mirror(_status text)
|
||||
returns boolean language sql immutable as $$
|
||||
select coalesce(lower(_status), '') not in
|
||||
('draft','rejected','void','voided','cancelled','denied');
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2) Reverse creation: native accounting bill -> public.bills (idempotent-ish;
|
||||
-- only acts on unlinked native rows).
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function accounting.create_public_from_accounting_bill(_acct_id uuid)
|
||||
returns uuid
|
||||
language plpgsql security definer set search_path to 'public','accounting'
|
||||
as $$
|
||||
declare
|
||||
ab accounting.bills%rowtype;
|
||||
_assoc uuid;
|
||||
_pub_vendor uuid;
|
||||
_avs text; _avext text; _avname text;
|
||||
_exp uuid;
|
||||
_new_id uuid;
|
||||
_pub_status text;
|
||||
begin
|
||||
select * into ab from accounting.bills where id=_acct_id;
|
||||
if not found then return null; end if;
|
||||
|
||||
-- only native, real, non-void bills
|
||||
if ab.external_source is not null then return null; end if;
|
||||
if coalesce(ab.auto_created,false) then return null; end if;
|
||||
if ab.source_payment_id is not null then return null; end if;
|
||||
if ab.status::text = 'void' then return null; end if;
|
||||
|
||||
select association_id into _assoc from accounting.companies where id=ab.company_id;
|
||||
if _assoc is null then return null; end if;
|
||||
|
||||
-- Map the vendor back to a single public.vendors roster (find-or-create).
|
||||
_pub_vendor := null;
|
||||
if ab.vendor_id is not null then
|
||||
select external_source, external_id, name into _avs, _avext, _avname
|
||||
from accounting.vendors where id=ab.vendor_id;
|
||||
if _avs='acmacc_vendor' and nullif(_avext,'') is not null then
|
||||
_pub_vendor := _avext::uuid;
|
||||
end if;
|
||||
if _pub_vendor is null or not exists (select 1 from public.vendors where id=_pub_vendor) then
|
||||
select id into _pub_vendor from public.vendors
|
||||
where (association_id=_assoc or _assoc = any(association_ids))
|
||||
and lower(trim(name))=lower(trim(coalesce(_avname,''))) limit 1;
|
||||
if _pub_vendor is null then
|
||||
insert into public.vendors (name, association_id, is_active)
|
||||
values (coalesce(nullif(_avname,''),'Vendor'), _assoc, true)
|
||||
returning id into _pub_vendor;
|
||||
end if;
|
||||
update accounting.vendors
|
||||
set external_source='acmacc_vendor', external_id=_pub_vendor::text, updated_at=now()
|
||||
where id=ab.vendor_id and nullif(external_id,'') is null;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Carry the first line's GL account if it is a valid public COA id.
|
||||
select account_id into _exp from accounting.bill_items
|
||||
where bill_id=ab.id and account_id is not null limit 1;
|
||||
if _exp is not null and not exists (select 1 from public.chart_of_accounts where id=_exp) then
|
||||
_exp := null;
|
||||
end if;
|
||||
|
||||
_pub_status := case ab.status::text when 'paid' then 'paid' else 'approved' end;
|
||||
_new_id := gen_random_uuid();
|
||||
|
||||
-- Pre-link the accounting row so the forward sync (fired by the insert below)
|
||||
-- updates THIS row on conflict instead of creating a duplicate mirror.
|
||||
update accounting.bills
|
||||
set external_source='acmacc_bill', external_id=_new_id::text, updated_at=now()
|
||||
where id=ab.id;
|
||||
|
||||
insert into public.bills
|
||||
(id, association_id, vendor_id, invoice_number, bill_date, due_date,
|
||||
amount, amount_paid, expense_account_id, description, status, attachment_url, approved_date)
|
||||
values
|
||||
(_new_id, _assoc, _pub_vendor, ab.number, coalesce(ab.issue_date, current_date), ab.due_date,
|
||||
coalesce(ab.total,0), coalesce(ab.paid_amount,0), _exp, ab.notes,
|
||||
_pub_status, ab.attachment_url,
|
||||
case when _pub_status in ('approved','paid') then current_date else null end);
|
||||
|
||||
return _new_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function accounting.tg_accounting_bill_reverse_create()
|
||||
returns trigger
|
||||
language plpgsql security definer set search_path to 'public','accounting'
|
||||
as $$
|
||||
begin
|
||||
begin
|
||||
if new.external_source is null
|
||||
and not coalesce(new.auto_created,false)
|
||||
and new.source_payment_id is null
|
||||
and new.status::text <> 'void' then
|
||||
perform accounting.create_public_from_accounting_bill(new.id);
|
||||
end if;
|
||||
exception when others then
|
||||
raise warning 'accounting: reverse bill create failed for %: %', new.id, sqlerrm;
|
||||
end;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_acct_bill_reverse_create on accounting.bills;
|
||||
create trigger trg_acct_bill_reverse_create
|
||||
after insert on accounting.bills
|
||||
for each row execute function accounting.tg_accounting_bill_reverse_create();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3a) Backfill: mirror existing pending public bills into Payables now.
|
||||
-- ---------------------------------------------------------------------------
|
||||
do $$
|
||||
declare r record;
|
||||
begin
|
||||
for r in select id from public.bills where lower(coalesce(status,'')) = 'pending' loop
|
||||
perform accounting.sync_public_bill(r.id);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3b) Backfill: reverse-create eligible native accounting bills.
|
||||
-- ---------------------------------------------------------------------------
|
||||
do $$
|
||||
declare r record;
|
||||
begin
|
||||
for r in
|
||||
select id from accounting.bills
|
||||
where external_source is null
|
||||
and not coalesce(auto_created,false)
|
||||
and source_payment_id is null
|
||||
and status::text <> 'void'
|
||||
loop
|
||||
perform accounting.create_public_from_accounting_bill(r.id);
|
||||
end loop;
|
||||
end $$;
|
||||
Reference in New Issue
Block a user