mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Accounting: unify vendor roster + COA across bill-approvals and accounting bills
Single vendor source (public.vendors) and single COA source (accounting.accounts) across both bill flows: - Forward sync now carries public.bills.expense_account_id into the mirrored accounting.bill_items.account_id (when it resolves to accounting.accounts). - Reverse trigger flows a GL change on a mirrored accounting bill line back to public.bills.expense_account_id (loop-guarded). - New public.ensure_accounting_vendor RPC resolves a chosen public vendor to its accounting.vendors row; one-time backfill of mirrored line account_id. - BillApprovalsPage GL pickers now use ChartOfAccountsDropdown (accounting.accounts). - AccountingBillsPage vendor picker now lists public.vendors scoped to the company's association and maps to accounting.vendors on save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx";
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-amber-100 text-amber-700",
|
||||
approved: "bg-emerald-100 text-emerald-700",
|
||||
@@ -604,19 +605,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
return bill.status;
|
||||
};
|
||||
|
||||
const selectedSystem = associations.find((a: any) => a.id === form.association_id)?.accounting_system ?? null;
|
||||
const filteredAccounts = form.association_id
|
||||
? accounts.filter((a: any) => {
|
||||
// Platform associations use the accounts managed in the accounting
|
||||
// dashboard (synced into chart_of_accounts as accounting_system 'platform').
|
||||
if (selectedSystem === "platform") {
|
||||
return a.accounting_system === "platform" && a.association_id === form.association_id;
|
||||
}
|
||||
// Buildium / Zoho keep their existing scoping; never surface platform-only rows.
|
||||
return a.accounting_system !== "platform" && (!a.association_id || a.association_id === form.association_id);
|
||||
})
|
||||
: [];
|
||||
|
||||
const filteredVendors = form.association_id
|
||||
? vendors.filter((v: any) => v.association_id === form.association_id || (Array.isArray(v.association_ids) && v.association_ids.includes(form.association_id)))
|
||||
: [];
|
||||
@@ -1162,20 +1150,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
{/* GL Account */}
|
||||
<div>
|
||||
<Label>GL Account (Expense) <span className="text-destructive">*</span></Label>
|
||||
<Select
|
||||
<ChartOfAccountsDropdown
|
||||
accountType="expense"
|
||||
associationId={form.association_id || null}
|
||||
value={form.expense_account_id}
|
||||
onValueChange={(v) => setForm({ ...form, expense_account_id: v })}
|
||||
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
|
||||
disabled={!form.association_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={form.association_id ? "Select GL Account" : "Select a client first"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredAccounts.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder={form.association_id ? "Select GL Account" : "Select a client first"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Request Approval From */}
|
||||
@@ -1410,12 +1392,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
</div>
|
||||
<div>
|
||||
<Label>GL Account (Expense)</Label>
|
||||
<Select value={form.expense_account_id} onValueChange={(v) => setForm({ ...form, expense_account_id: v })} disabled={!form.association_id}>
|
||||
<SelectTrigger><SelectValue placeholder="Select GL Account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ChartOfAccountsDropdown
|
||||
accountType="expense"
|
||||
associationId={form.association_id || null}
|
||||
value={form.expense_account_id}
|
||||
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
|
||||
disabled={!form.association_id}
|
||||
placeholder="Select GL Account"
|
||||
/>
|
||||
</div>
|
||||
{vendorNotFound && (
|
||||
<Alert variant="destructive" className="border-amber-300 bg-amber-50 text-amber-900">
|
||||
|
||||
@@ -74,10 +74,16 @@ export default function AccountingBillsPage() {
|
||||
enabled: !!cid,
|
||||
queryFn: async () => (await accounting.from("bills").select("*, vendors(name,address)").eq("company_id", cid).order("issue_date", { ascending: false })).data ?? [],
|
||||
});
|
||||
// Single vendor roster = public.vendors, scoped to this company's association.
|
||||
// The chosen public vendor is mapped to its accounting.vendors row on save.
|
||||
const { data: vendors = [] } = useQuery({
|
||||
queryKey: ["vendors-lookup", cid],
|
||||
enabled: !!cid,
|
||||
queryFn: async () => (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [],
|
||||
queryKey: ["vendors-lookup", associationId],
|
||||
enabled: !!associationId,
|
||||
queryFn: async () => (await supabase.from("vendors")
|
||||
.select("id,name")
|
||||
.eq("is_active", true)
|
||||
.or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`)
|
||||
.order("name")).data ?? [],
|
||||
});
|
||||
const { data: expenseAccounts = [] } = useQuery({
|
||||
queryKey: ["expense-accounts", cid],
|
||||
@@ -128,7 +134,14 @@ export default function AccountingBillsPage() {
|
||||
|
||||
const openEdit = async (b: any) => {
|
||||
setEditId(b.id);
|
||||
setVendorId(b.vendor_id ?? "");
|
||||
// The dropdown is keyed by public vendor id; map the stored accounting
|
||||
// vendor back to its source public vendor when one exists.
|
||||
let pubVendorId = "";
|
||||
if (b.vendor_id) {
|
||||
const { data: av } = await accounting.from("vendors").select("external_source, external_id").eq("id", b.vendor_id).maybeSingle();
|
||||
if (av?.external_source === "acmacc_vendor" && av?.external_id) pubVendorId = String(av.external_id);
|
||||
}
|
||||
setVendorId(pubVendorId);
|
||||
setNumber(b.number ?? "");
|
||||
setIssueDate(b.issue_date ?? issueDate);
|
||||
setDueDate(b.due_date ?? "");
|
||||
@@ -245,6 +258,17 @@ export default function AccountingBillsPage() {
|
||||
let attachmentUrl = uploadedUrl;
|
||||
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
||||
|
||||
// Resolve the chosen public vendor to its accounting.vendors row (find-or-create).
|
||||
let acctVendorId: string | null = null;
|
||||
if (vendorId) {
|
||||
const { data: mapped, error: mapErr } = await supabase.rpc("ensure_accounting_vendor", {
|
||||
_association_id: associationId,
|
||||
_public_vendor_id: vendorId,
|
||||
});
|
||||
if (mapErr) return toast.error(mapErr.message);
|
||||
acctVendorId = (mapped as string) ?? null;
|
||||
}
|
||||
|
||||
const itemRows = (billId: string) => items.map(i => ({
|
||||
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
|
||||
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
|
||||
@@ -253,7 +277,7 @@ export default function AccountingBillsPage() {
|
||||
|
||||
if (editId) {
|
||||
const { error } = await accounting.from("bills").update({
|
||||
vendor_id: vendorId || null, number,
|
||||
vendor_id: acctVendorId, number,
|
||||
issue_date: issueDate, due_date: dueDate || null,
|
||||
subtotal, tax, total,
|
||||
notes: notes || null,
|
||||
@@ -265,7 +289,7 @@ export default function AccountingBillsPage() {
|
||||
toast.success("Bill updated");
|
||||
} else {
|
||||
const { data: bill, error } = await accounting.from("bills").insert({
|
||||
company_id: cid, vendor_id: vendorId || null, number,
|
||||
company_id: cid, vendor_id: acctVendorId, number,
|
||||
issue_date: issueDate, due_date: dueDate || null,
|
||||
subtotal, tax, total, status: "open",
|
||||
notes: notes || null,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user