mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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,
|
AccountingOpeningBalancesPage,
|
||||||
AccountingExpensesPage,
|
AccountingExpensesPage,
|
||||||
AccountingEstimatesPage,
|
AccountingEstimatesPage,
|
||||||
|
AccountingSalesReceiptsPage,
|
||||||
AccountingReconcileDetailPage,
|
AccountingReconcileDetailPage,
|
||||||
AccountingBudgetDetailPage,
|
AccountingBudgetDetailPage,
|
||||||
AccountingCustomerDetailPage,
|
AccountingCustomerDetailPage,
|
||||||
@@ -384,6 +385,7 @@ const App = () => (
|
|||||||
<Route path="vendors" element={<AccountingVendorsPage />} />
|
<Route path="vendors" element={<AccountingVendorsPage />} />
|
||||||
<Route path="expenses" element={<AccountingExpensesPage />} />
|
<Route path="expenses" element={<AccountingExpensesPage />} />
|
||||||
<Route path="estimates" element={<AccountingEstimatesPage />} />
|
<Route path="estimates" element={<AccountingEstimatesPage />} />
|
||||||
|
<Route path="sales-receipts" element={<AccountingSalesReceiptsPage />} />
|
||||||
<Route path="deposits" element={<AccountingDepositsPage />} />
|
<Route path="deposits" element={<AccountingDepositsPage />} />
|
||||||
<Route path="receive-payments" element={<AccountingReceivePaymentsPage />} />
|
<Route path="receive-payments" element={<AccountingReceivePaymentsPage />} />
|
||||||
<Route path="banking" element={<AccountingBankingPage />} />
|
<Route path="banking" element={<AccountingBankingPage />} />
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ function fromPlatform(row: any, associationId: string): NormalizedAccount {
|
|||||||
* - `platform` → the Accounting module's `accounting.accounts` (single source of
|
* - `platform` → the Accounting module's `accounting.accounts` (single source of
|
||||||
* truth once an association is on the platform). Returns [] if the association
|
* truth once an association is on the platform). Returns [] if the association
|
||||||
* has no `accounting.companies` row yet.
|
* 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
|
* Returned rows are normalized to {@link NormalizedAccount} so callers never
|
||||||
* branch on the source.
|
* branch on the source.
|
||||||
@@ -81,11 +84,14 @@ export async function fetchChartOfAccounts(
|
|||||||
return (data ?? []).map((row) => fromPlatform(row, associationId));
|
return (data ?? []).map((row) => fromPlatform(row, associationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
let query = supabase
|
||||||
.from("chart_of_accounts")
|
.from("chart_of_accounts")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("accounting_system", system)
|
.eq("accounting_system", system);
|
||||||
.order("account_number", { ascending: true });
|
// 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;
|
if (error) throw error;
|
||||||
return (data ?? []).map(fromPublic);
|
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx";
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
pending: "bg-amber-100 text-amber-700",
|
pending: "bg-amber-100 text-amber-700",
|
||||||
approved: "bg-emerald-100 text-emerald-700",
|
approved: "bg-emerald-100 text-emerald-700",
|
||||||
@@ -172,8 +173,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
setBills([]);
|
setBills([]);
|
||||||
setApprovalsByBill({});
|
setApprovalsByBill({});
|
||||||
const [aRes2, coaRes2, vRes2] = await Promise.all([
|
const [aRes2, coaRes2, vRes2] = await Promise.all([
|
||||||
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
|
supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
|
||||||
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"),
|
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"),
|
||||||
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
||||||
]);
|
]);
|
||||||
setAssociations(aRes2.data || []);
|
setAssociations(aRes2.data || []);
|
||||||
@@ -201,8 +202,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
if (data.length < PAGE) break;
|
if (data.length < PAGE) break;
|
||||||
}
|
}
|
||||||
const [aRes, coaRes, vRes] = await Promise.all([
|
const [aRes, coaRes, vRes] = await Promise.all([
|
||||||
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
|
supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"),
|
||||||
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"),
|
supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"),
|
||||||
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -604,10 +605,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
return bill.status;
|
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
|
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)))
|
? 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 */}
|
{/* GL Account */}
|
||||||
<div>
|
<div>
|
||||||
<Label>GL Account (Expense) <span className="text-destructive">*</span></Label>
|
<Label>GL Account (Expense) <span className="text-destructive">*</span></Label>
|
||||||
<Select
|
<ChartOfAccountsDropdown
|
||||||
|
accountType="expense"
|
||||||
|
associationId={form.association_id || null}
|
||||||
value={form.expense_account_id}
|
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}
|
disabled={!form.association_id}
|
||||||
>
|
placeholder={form.association_id ? "Select GL Account" : "Select a client first"}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Request Approval From */}
|
{/* Request Approval From */}
|
||||||
@@ -1401,12 +1392,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>GL Account (Expense)</Label>
|
<Label>GL Account (Expense)</Label>
|
||||||
<Select value={form.expense_account_id} onValueChange={(v) => setForm({ ...form, expense_account_id: v })} disabled={!form.association_id}>
|
<ChartOfAccountsDropdown
|
||||||
<SelectTrigger><SelectValue placeholder="Select GL Account" /></SelectTrigger>
|
accountType="expense"
|
||||||
<SelectContent>
|
associationId={form.association_id || null}
|
||||||
{filteredAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
|
value={form.expense_account_id}
|
||||||
</SelectContent>
|
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
|
||||||
</Select>
|
disabled={!form.association_id}
|
||||||
|
placeholder="Select GL Account"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{vendorNotFound && (
|
{vendorNotFound && (
|
||||||
<Alert variant="destructive" className="border-amber-300 bg-amber-50 text-amber-900">
|
<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 { parseCsv, pick, parseDateStr } from "./lib/csv";
|
||||||
import { usePlaidLink } from "react-plaid-link";
|
import { usePlaidLink } from "react-plaid-link";
|
||||||
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
||||||
|
import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill";
|
||||||
|
|
||||||
type TxForm = {
|
type TxForm = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -222,11 +223,48 @@ export default function AccountingBankingPage() {
|
|||||||
const acc = (accounts as any[]).find((a) => a.id === accountId);
|
const acc = (accounts as any[]).find((a) => a.id === accountId);
|
||||||
if (!acc || selected.size === 0) return;
|
if (!acc || selected.size === 0) return;
|
||||||
const ids = [...selected];
|
const ids = [...selected];
|
||||||
|
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")
|
const { error } = await accounting.from("transactions")
|
||||||
.update({ coa_account_id: accountId, category: acc.name }).in("id", ids);
|
.update({ coa_account_id: accountId, category: acc.name }).in("id", categorizeIds);
|
||||||
if (error) return toast.error(error.message);
|
if (error) return toast.error(error.message);
|
||||||
toast.success(`Set category for ${ids.length} transaction${ids.length !== 1 ? "s" : ""}`);
|
}
|
||||||
|
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] });
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
|
if (billPayments.length) qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||||||
|
setSelected(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkSetDirection = async (type: "debit" | "credit") => {
|
const bulkSetDirection = async (type: "debit" | "credit") => {
|
||||||
@@ -256,16 +294,30 @@ export default function AccountingBankingPage() {
|
|||||||
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
|
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
|
||||||
const category = coaName;
|
const category = coaName;
|
||||||
|
|
||||||
// A vendor payment (debit) clears Accounts Payable — the expense was already
|
// Vendor-payment recognition rule: count the expense for the bill when it is
|
||||||
// recognized when the bill was entered (accrual). Leaving coa_account_id null
|
// entered (accrual), or — when no bill exists — when the payment is made.
|
||||||
// with the vendor set makes post_transaction_gl post Dr A/P / Cr Bank; the
|
// • Debit matches an OPEN bill (matchOpenBills: same vendor, amount within
|
||||||
// chosen expense account is retained as the display `category` only. Customer
|
// $0.01 or partial, date within ±30 days) → this payment clears Accounts
|
||||||
// deposits (credits) clear A/R via customer_id, so they need no change here.
|
// 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 = {
|
const payload: any = {
|
||||||
account_id, date, description, amount, type, category, reference: reference || null,
|
account_id, date, description, amount, type, category, reference: reference || null,
|
||||||
coa_account_id: type === "debit" ? null : (coa_account_id || null),
|
coa_account_id: debitClearsAp ? null : (coa_account_id || null),
|
||||||
vendor_id: vendor_id || null,
|
vendor_id: vendor_id || null,
|
||||||
customer_id: customer_id || null,
|
customer_id: customer_id || null,
|
||||||
|
// 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) {
|
if (editId) {
|
||||||
@@ -273,8 +325,9 @@ export default function AccountingBankingPage() {
|
|||||||
if (error) return toast.error(error.message);
|
if (error) return toast.error(error.message);
|
||||||
toast.success("Transaction updated");
|
toast.success("Transaction updated");
|
||||||
} else {
|
} else {
|
||||||
const { error } = await accounting.from("transactions").insert({ ...payload, company_id: cid });
|
const { data: inserted, error } = await accounting
|
||||||
if (error) return toast.error(error.message);
|
.from("transactions").insert({ ...payload, company_id: cid }).select("id").single();
|
||||||
|
if (error || !inserted) return toast.error(error?.message ?? "Failed to record");
|
||||||
toast.success(type === "credit" ? "Deposit recorded" : "Payment recorded");
|
toast.success(type === "credit" ? "Deposit recorded" : "Payment recorded");
|
||||||
|
|
||||||
if (type === "debit" && txForm.printCheck) {
|
if (type === "debit" && txForm.printCheck) {
|
||||||
@@ -290,6 +343,22 @@ export default function AccountingBankingPage() {
|
|||||||
bankAccountId: account_id,
|
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" });
|
setTxDialog({ open: false, mode: "deposit" });
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
@@ -1013,6 +1082,7 @@ export default function AccountingBankingPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,10 +74,16 @@ export default function AccountingBillsPage() {
|
|||||||
enabled: !!cid,
|
enabled: !!cid,
|
||||||
queryFn: async () => (await accounting.from("bills").select("*, vendors(name,address)").eq("company_id", cid).order("issue_date", { ascending: false })).data ?? [],
|
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({
|
const { data: vendors = [] } = useQuery({
|
||||||
queryKey: ["vendors-lookup", cid],
|
queryKey: ["vendors-lookup", associationId],
|
||||||
enabled: !!cid,
|
enabled: !!associationId,
|
||||||
queryFn: async () => (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [],
|
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({
|
const { data: expenseAccounts = [] } = useQuery({
|
||||||
queryKey: ["expense-accounts", cid],
|
queryKey: ["expense-accounts", cid],
|
||||||
@@ -128,7 +134,14 @@ export default function AccountingBillsPage() {
|
|||||||
|
|
||||||
const openEdit = async (b: any) => {
|
const openEdit = async (b: any) => {
|
||||||
setEditId(b.id);
|
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 ?? "");
|
setNumber(b.number ?? "");
|
||||||
setIssueDate(b.issue_date ?? issueDate);
|
setIssueDate(b.issue_date ?? issueDate);
|
||||||
setDueDate(b.due_date ?? "");
|
setDueDate(b.due_date ?? "");
|
||||||
@@ -245,6 +258,17 @@ export default function AccountingBillsPage() {
|
|||||||
let attachmentUrl = uploadedUrl;
|
let attachmentUrl = uploadedUrl;
|
||||||
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
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 => ({
|
const itemRows = (billId: string) => items.map(i => ({
|
||||||
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
|
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
|
||||||
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
|
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
|
||||||
@@ -253,7 +277,7 @@ export default function AccountingBillsPage() {
|
|||||||
|
|
||||||
if (editId) {
|
if (editId) {
|
||||||
const { error } = await accounting.from("bills").update({
|
const { error } = await accounting.from("bills").update({
|
||||||
vendor_id: vendorId || null, number,
|
vendor_id: acctVendorId, number,
|
||||||
issue_date: issueDate, due_date: dueDate || null,
|
issue_date: issueDate, due_date: dueDate || null,
|
||||||
subtotal, tax, total,
|
subtotal, tax, total,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
@@ -265,7 +289,7 @@ export default function AccountingBillsPage() {
|
|||||||
toast.success("Bill updated");
|
toast.success("Bill updated");
|
||||||
} else {
|
} else {
|
||||||
const { data: bill, error } = await accounting.from("bills").insert({
|
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,
|
issue_date: issueDate, due_date: dueDate || null,
|
||||||
subtotal, tax, total, status: "open",
|
subtotal, tax, total, status: "open",
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
@@ -415,13 +439,14 @@ export default function AccountingBillsPage() {
|
|||||||
reference: refLabel,
|
reference: refLabel,
|
||||||
coa_account_id: null, // → posts against Accounts Payable (via vendor)
|
coa_account_id: null, // → posts against Accounts Payable (via vendor)
|
||||||
vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P
|
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
|
// 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);
|
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 #
|
// 4) If check + print: insert check record, print, mark printed, bump next #
|
||||||
if (payMethod === "check") {
|
if (payMethod === "check") {
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { money, fmtDate } from "./lib/format";
|
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 { EmptyState } from "./components/EmptyState";
|
||||||
import { ensureUndepositedFunds } from "./lib/undeposited";
|
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() {
|
export default function AccountingDepositsPage() {
|
||||||
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
const cid = companyId ?? "";
|
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 [depositDate, setDepositDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
|
||||||
const [memo, setMemo] = useState("");
|
const [memo, setMemo] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [lines, setLines] = useState<ManualLine[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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 ?? [],
|
(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
|
// Two sources of "awaiting deposit": transactions parked on the Undeposited
|
||||||
// Funds account (banking flow) and payments_received not yet deposited (incl.
|
// Funds account (banking flow) and payments_received not yet deposited (incl.
|
||||||
// payments synced from the main app's owner ledger). Both are unified below.
|
// 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));
|
return rows.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
}, [pendingTx, pendingPmt]);
|
}, [pendingTx, pendingPmt]);
|
||||||
|
|
||||||
const selectedTotal = useMemo(
|
const undepositedTotal = useMemo(
|
||||||
() => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0),
|
() => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0),
|
||||||
[pending, selected]
|
[pending, selected]
|
||||||
);
|
);
|
||||||
|
const manualTotal = useMemo(
|
||||||
|
() => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0),
|
||||||
|
[lines]
|
||||||
|
);
|
||||||
|
const grandTotal = undepositedTotal + manualTotal;
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selected.size === pending.length) setSelected(new Set());
|
if (selected.size === pending.length) setSelected(new Set());
|
||||||
else setSelected(new Set(pending.map((r) => r.key)));
|
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 () => {
|
const submitDeposit = async () => {
|
||||||
if (!bankAccountId) return toast.error("Choose a bank account");
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const bank = (bankAccounts as any[]).find((a) => a.id === bankAccountId);
|
|
||||||
const chosen = pending.filter((r) => selected.has(r.key));
|
const chosen = pending.filter((r) => selected.has(r.key));
|
||||||
const txIds = chosen.filter((r) => r.kind === "tx").map((r) => r.id);
|
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 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
|
const { data: dep, error: depErr } = await accounting
|
||||||
.from("deposits")
|
.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()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (depErr || !dep) throw new Error(depErr?.message ?? "Failed to create deposit");
|
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
|
// 2) Deposit lines (credit side). The selected payments collapse into one
|
||||||
await accounting.from("transactions").insert({
|
// Undeposited Funds line; manual lines book to their chosen accounts.
|
||||||
company_id: cid,
|
// accounting.post_deposit_gl posts Dr Bank (total) / Cr each line.
|
||||||
account_id: bankAccountId,
|
const lineRows: any[] = [];
|
||||||
date: depositDate,
|
if (undepositedTotal > 0 && undepositedId) {
|
||||||
type: "debit",
|
lineRows.push({
|
||||||
amount: selectedTotal,
|
deposit_id: dep.id, company_id: cid, account_id: undepositedId,
|
||||||
description: `Deposit · ${count} payment${count > 1 ? "s" : ""}${memo ? " · " + memo : ""}`,
|
amount: undepositedTotal, memo: `Cleared ${chosen.length} payment${chosen.length !== 1 ? "s" : ""}`,
|
||||||
category: "Deposit",
|
|
||||||
reference: ref,
|
|
||||||
deposit_id: dep.id,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 3) Offsetting credit on Undeposited Funds — only for the portion actually
|
// 3) Clear the deposited items from the awaiting-deposit queue.
|
||||||
// held there as transactions (payments_received aren't posted to it).
|
if (txIds.length) {
|
||||||
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,
|
|
||||||
});
|
|
||||||
await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Mark selected payments_received as deposited so they leave the queue
|
|
||||||
if (pmtIds.length) {
|
if (pmtIds.length) {
|
||||||
await accounting.from("payments_received")
|
await accounting.from("payments_received")
|
||||||
.update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId })
|
.update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId })
|
||||||
.in("id", pmtIds);
|
.in("id", pmtIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Bank balance reflects the full deposit
|
toast.success(`Deposit of ${money(grandTotal, cur)} recorded`);
|
||||||
if (bank) {
|
|
||||||
await accounting.from("accounts").update({ balance: Number(bank.balance) + selectedTotal }).eq("id", bank.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`Deposit of ${money(selectedTotal, cur)} recorded`);
|
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
|
setLines([]);
|
||||||
setMemo("");
|
setMemo("");
|
||||||
qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] });
|
qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] });
|
qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
|
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["all-accounts", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -191,7 +215,8 @@ export default function AccountingDepositsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Make Deposit</h1>
|
<h1 className="text-2xl font-semibold">Make Deposit</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -226,7 +251,7 @@ export default function AccountingDepositsPage() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
|
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
|
||||||
<div className="text-sm">
|
<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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -276,9 +301,63 @@ export default function AccountingDepositsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Card>
|
||||||
<Button onClick={submitDeposit} disabled={saving || selected.size === 0 || !bankAccountId}>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
{saving ? "Recording…" : `Deposit ${money(selectedTotal, cur)}`}
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { default as AccountingWorkOrdersPage } from "./AccountingWorkOrdersPage"
|
|||||||
export { default as AccountingOpeningBalancesPage } from "./AccountingOpeningBalancesPage";
|
export { default as AccountingOpeningBalancesPage } from "./AccountingOpeningBalancesPage";
|
||||||
export { default as AccountingExpensesPage } from "./AccountingExpensesPage";
|
export { default as AccountingExpensesPage } from "./AccountingExpensesPage";
|
||||||
export { default as AccountingEstimatesPage } from "./AccountingEstimatesPage";
|
export { default as AccountingEstimatesPage } from "./AccountingEstimatesPage";
|
||||||
|
export { default as AccountingSalesReceiptsPage } from "./AccountingSalesReceiptsPage";
|
||||||
export { default as AccountingReconcileDetailPage } from "./AccountingReconcileDetailPage";
|
export { default as AccountingReconcileDetailPage } from "./AccountingReconcileDetailPage";
|
||||||
export { default as AccountingBudgetDetailPage } from "./AccountingBudgetDetailPage";
|
export { default as AccountingBudgetDetailPage } from "./AccountingBudgetDetailPage";
|
||||||
export { default as AccountingCustomerDetailPage } from "./AccountingCustomerDetailPage";
|
export { default as AccountingCustomerDetailPage } from "./AccountingCustomerDetailPage";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const NAV: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "receive-payments", label: "Receive Payments" },
|
{ to: "receive-payments", label: "Receive Payments" },
|
||||||
{ to: "invoices", label: "Invoices" },
|
{ to: "invoices", label: "Invoices" },
|
||||||
|
{ to: "sales-receipts", label: "Sales Receipts" },
|
||||||
{ to: "estimates", label: "Estimates" },
|
{ to: "estimates", label: "Estimates" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1294,18 +1294,22 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
|
|||||||
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
|
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
|
||||||
};
|
};
|
||||||
case "expense-summary": {
|
case "expense-summary": {
|
||||||
const byCat: Record<string, number> = {};
|
// GL-driven so it follows the same recognition rule as the P&L: a bill's
|
||||||
// Direct expenses from expenses table
|
// expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment
|
||||||
for (const e of d.expenses) byCat[e.category] = (byCat[e.category] ?? 0) + Number(e.amount);
|
// with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the
|
||||||
// Bill expenses (accrual — total billed, not just paid)
|
// ledger avoids double-counting and never misses direct payments.
|
||||||
for (const b of d.bills) {
|
const byAcct: Record<string, number> = {};
|
||||||
if (b.status === "void" || b.status === "draft") continue;
|
for (const l of (d.glLines ?? []) as any[]) {
|
||||||
const cat = b.vendors?.name ?? "Vendor Expenses";
|
const acc = l.accounts;
|
||||||
byCat[cat] = (byCat[cat] ?? 0) + Number(b.total);
|
if (acc?.type !== "expense") continue;
|
||||||
|
const amt = Number(l.debit) - Number(l.credit);
|
||||||
|
if (amt === 0) continue;
|
||||||
|
const name = acc.name ?? "Expense";
|
||||||
|
byAcct[name] = (byAcct[name] ?? 0) + amt;
|
||||||
}
|
}
|
||||||
const rows = Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, amt]) => [cat, m(amt)]);
|
const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
|
||||||
const total = Object.values(byCat).reduce((s, v) => s + v, 0);
|
const total = Object.values(byAcct).reduce((s, v) => s + v, 0);
|
||||||
return { title: "Expense Summary (Accrual)", columns: ["Category / Vendor", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
||||||
}
|
}
|
||||||
case "vendor-balances": {
|
case "vendor-balances": {
|
||||||
const byVendor: Record<string, number> = {};
|
const byVendor: Record<string, number> = {};
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Search, Trash2, Receipt, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { money, fmtDate } from "./lib/format";
|
||||||
|
import { EmptyState } from "./components/EmptyState";
|
||||||
|
import { ensureUndepositedFunds } from "./lib/undeposited";
|
||||||
|
|
||||||
|
const generateNumber = () => `SR-${Date.now().toString().slice(-6)}`;
|
||||||
|
const today = () => new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" });
|
||||||
|
|
||||||
|
export default function AccountingSalesReceiptsPage() {
|
||||||
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
|
const cid = companyId ?? "";
|
||||||
|
const cur = "USD";
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [number, setNumber] = useState(generateNumber());
|
||||||
|
const [date, setDate] = useState(today());
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerAddress, setCustomerAddress] = useState("");
|
||||||
|
const [incomeAccountId, setIncomeAccountId] = useState("");
|
||||||
|
const [depositAccountId, setDepositAccountId] = useState("");
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [rate, setRate] = useState(0);
|
||||||
|
const [memo, setMemo] = useState("");
|
||||||
|
|
||||||
|
const total = useMemo(() => +(Number(quantity) * Number(rate)).toFixed(2), [quantity, rate]);
|
||||||
|
|
||||||
|
const { data: receipts = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["sales-receipts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("sales_receipts")
|
||||||
|
.select("*, income_account:accounts!sales_receipts_income_account_id_fkey(name,code), deposit_account:accounts!sales_receipts_deposit_account_id_fkey(name,code)")
|
||||||
|
.eq("company_id", cid)
|
||||||
|
.order("receipt_date", { ascending: false })
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: incomeAccounts = [] } = useQuery({
|
||||||
|
queryKey: ["income-accounts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await accounting.from("accounts").select("id,name,code").eq("company_id", cid).eq("type", "income").order("code")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: depositAccounts = [] } = useQuery({
|
||||||
|
queryKey: ["deposit-accounts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("accounts")
|
||||||
|
.select("id,name,code,is_system")
|
||||||
|
.eq("company_id", cid)
|
||||||
|
.or("is_bank.eq.true,name.eq.Undeposited Funds")
|
||||||
|
.order("code");
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setNumber(generateNumber());
|
||||||
|
setDate(today());
|
||||||
|
setCustomerName("");
|
||||||
|
setCustomerAddress("");
|
||||||
|
setIncomeAccountId("");
|
||||||
|
setDepositAccountId("");
|
||||||
|
setQuantity(1);
|
||||||
|
setRate(0);
|
||||||
|
setMemo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialog = async () => {
|
||||||
|
reset();
|
||||||
|
// Make sure there's somewhere to deposit to.
|
||||||
|
await ensureUndepositedFunds(cid);
|
||||||
|
qc.invalidateQueries({ queryKey: ["deposit-accounts", cid] });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!number.trim()) return toast.error("Receipt number is required");
|
||||||
|
if (!incomeAccountId) return toast.error("Select an income account");
|
||||||
|
if (!depositAccountId) return toast.error("Select a deposit account");
|
||||||
|
if (total <= 0) return toast.error("Amount must be greater than 0");
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const incomeName = (incomeAccounts as any[]).find((a) => a.id === incomeAccountId)?.name ?? "Sale";
|
||||||
|
const desc = `Sales Receipt ${number}${customerName ? " · " + customerName : ""} · ${incomeName}`;
|
||||||
|
|
||||||
|
// 1. Record the receipt document
|
||||||
|
const { data: sr, error: srErr } = await accounting
|
||||||
|
.from("sales_receipts")
|
||||||
|
.insert({
|
||||||
|
company_id: cid,
|
||||||
|
number,
|
||||||
|
receipt_date: date,
|
||||||
|
customer_name: customerName || null,
|
||||||
|
customer_address: customerAddress || null,
|
||||||
|
income_account_id: incomeAccountId,
|
||||||
|
deposit_account_id: depositAccountId,
|
||||||
|
quantity,
|
||||||
|
rate,
|
||||||
|
total,
|
||||||
|
memo: memo || null,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (srErr || !sr) throw new Error(srErr?.message ?? "Failed to save sales receipt");
|
||||||
|
|
||||||
|
// 2. Post the money in: debit deposit account, credit income account.
|
||||||
|
// The transaction triggers handle GL posting + account balances.
|
||||||
|
const { data: txn, error: txnErr } = await accounting
|
||||||
|
.from("transactions")
|
||||||
|
.insert({
|
||||||
|
company_id: cid,
|
||||||
|
account_id: depositAccountId,
|
||||||
|
coa_account_id: incomeAccountId,
|
||||||
|
date,
|
||||||
|
type: "credit",
|
||||||
|
amount: total,
|
||||||
|
description: desc,
|
||||||
|
category: "Sales Receipt",
|
||||||
|
reference: number,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (txnErr || !txn) {
|
||||||
|
// Roll back the orphaned document so we don't leave a receipt with no GL impact.
|
||||||
|
await accounting.from("sales_receipts").delete().eq("id", sr.id);
|
||||||
|
throw new Error(txnErr?.message ?? "Failed to post sales receipt");
|
||||||
|
}
|
||||||
|
|
||||||
|
await accounting.from("sales_receipts").update({ transaction_id: txn.id }).eq("id", sr.id);
|
||||||
|
|
||||||
|
toast.success("Sales receipt recorded");
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
qc.invalidateQueries({ queryKey: ["sales-receipts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "Failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (r: any) => {
|
||||||
|
if (!confirm(`Delete sales receipt ${r.number}? This also reverses its accounting entry.`)) return;
|
||||||
|
// Delete the transaction first so its GL + balances are reversed by triggers.
|
||||||
|
if (r.transaction_id) {
|
||||||
|
const { error } = await accounting.from("transactions").delete().eq("id", r.transaction_id);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
}
|
||||||
|
const { error } = await accounting.from("sales_receipts").delete().eq("id", r.id);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
toast.success("Sales receipt deleted");
|
||||||
|
qc.invalidateQueries({ queryKey: ["sales-receipts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return receipts as any[];
|
||||||
|
return (receipts as any[]).filter((r) =>
|
||||||
|
`${r.number} ${r.customer_name ?? ""} ${r.income_account?.name ?? ""}`.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [receipts, search]);
|
||||||
|
|
||||||
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||||
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Sales Receipts</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{filtered.length} of {(receipts as any[]).length}</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) { setOpen(false); reset(); } }}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={openDialog}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" /> New Sales Receipt
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader><DialogTitle>New Sales Receipt</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={customerName} maxLength={160} placeholder="Customer name"
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Receipt #</Label>
|
||||||
|
<Input value={number} onChange={(e) => setNumber(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Address</Label>
|
||||||
|
<Textarea rows={2} maxLength={400} value={customerAddress} placeholder="Street, city, state, zip"
|
||||||
|
onChange={(e) => setCustomerAddress(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Date</Label>
|
||||||
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Deposit to</Label>
|
||||||
|
<Select value={depositAccountId} onValueChange={setDepositAccountId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(depositAccounts as any[]).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.code ? `${a.code} · ` : ""}{a.name}{a.is_system ? " (holding)" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Account</Label>
|
||||||
|
<Select value={incomeAccountId} onValueChange={setIncomeAccountId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select income account" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(incomeAccounts as any[]).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.code ? `${a.code} · ` : ""}{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Price</Label>
|
||||||
|
<Input type="number" min={0} step="0.01" value={rate}
|
||||||
|
onChange={(e) => setRate(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Quantity</Label>
|
||||||
|
<Input type="number" min={0} step="0.01" value={quantity}
|
||||||
|
onChange={(e) => setQuantity(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Memo</Label>
|
||||||
|
<Textarea rows={2} maxLength={400} value={memo}
|
||||||
|
onChange={(e) => setMemo(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 text-base">
|
||||||
|
<span className="font-semibold">Total</span>
|
||||||
|
<span className="font-semibold tabular-nums">{money(total, cur)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setOpen(false); reset(); }}>Cancel</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Saving…" : "Save sales receipt"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border/60 shadow-sm">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="relative min-w-[220px] max-w-sm">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search receipt #, name or account…" className="h-9 pl-9"
|
||||||
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/60 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Receipt #</th>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Date</th>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left font-medium">Account</th>
|
||||||
|
<th className="px-6 py-3 text-right font-medium">Qty</th>
|
||||||
|
<th className="px-6 py-3 text-right font-medium">Price</th>
|
||||||
|
<th className="px-6 py-3 text-right font-medium">Total</th>
|
||||||
|
<th className="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{isLoading && Array.from({ length: 5 }).map((_, r) => (
|
||||||
|
<tr key={`sk-${r}`}>
|
||||||
|
{Array.from({ length: 8 }).map((_, c) => (
|
||||||
|
<td key={c} className="px-6 py-3"><div className="h-4 rounded bg-muted animate-pulse" style={{ width: `${40 + ((r * 13 + c * 17) % 50)}%` }} /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && filtered.map((r: any) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="px-6 py-3 font-medium">{r.number}</td>
|
||||||
|
<td className="px-6 py-3 text-muted-foreground">{fmtDate(r.receipt_date)}</td>
|
||||||
|
<td className="px-6 py-3">{r.customer_name ?? "—"}</td>
|
||||||
|
<td className="px-6 py-3">{r.income_account?.name ?? "—"}</td>
|
||||||
|
<td className="px-6 py-3 text-right tabular-nums">{Number(r.quantity)}</td>
|
||||||
|
<td className="px-6 py-3 text-right tabular-nums">{money(r.rate, cur)}</td>
|
||||||
|
<td className="px-6 py-3 text-right font-semibold tabular-nums">{money(r.total, cur)}</td>
|
||||||
|
<td className="px-2 py-3 text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => remove(r)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<tr><td colSpan={8} className="p-0">
|
||||||
|
<EmptyState icon={Receipt} title="No sales receipts yet" description="Record a cash sale — money is deposited and income is booked in one step." />
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { accounting } from "@/lib/accountingClient";
|
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 = {
|
export type AutoBillSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -50,6 +53,8 @@ export type PaymentPayload = {
|
|||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/** Expense account to book the auto-created bill line against (falls back to the company default). */
|
||||||
|
expenseAccountId?: string | null;
|
||||||
sourceKind: "expense" | "check" | "transaction" | "journal";
|
sourceKind: "expense" | "check" | "transaction" | "journal";
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
};
|
};
|
||||||
@@ -116,6 +121,7 @@ export async function createAutoBill(p: PaymentPayload): Promise<{ id: string; n
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
rate: p.amount,
|
rate: p.amount,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
|
account_id: p.expenseAccountId ?? null,
|
||||||
});
|
});
|
||||||
return bill;
|
return bill;
|
||||||
}
|
}
|
||||||
@@ -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();
|
const { data: bill } = await accounting.from("bills").select("total, paid_amount").eq("id", billId).single();
|
||||||
if (!bill) return;
|
if (!bill) return;
|
||||||
const newPaid = Number(bill.paid_amount ?? 0) + amount;
|
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);
|
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 */
|
/** Orchestrator: returns either a match prompt, an auto-created bill, or skip */
|
||||||
export type HandleResult =
|
export type HandleResult =
|
||||||
| { kind: "skipped"; reason: string }
|
| { 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 buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
|
||||||
const vendorId = await ensureVendor(buildiumVendor, assocLocalId);
|
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;
|
const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null;
|
||||||
let expenseAccountId: string | null = 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);
|
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);
|
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