diff --git a/src/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index d9e9b89..80120a6 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -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 = { 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 */}
- + placeholder={form.association_id ? "Select GL Account" : "Select a client first"} + />
{/* Request Approval From */} @@ -1410,12 +1392,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
- + setForm({ ...form, expense_account_id: v })} + disabled={!form.association_id} + placeholder="Select GL Account" + />
{vendorNotFound && ( diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index e155349..aa3439a 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -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, diff --git a/supabase/migrations/20260604130000_unify_vendor_coa_bill_sync.sql b/supabase/migrations/20260604130000_unify_vendor_coa_bill_sync.sql new file mode 100644 index 0000000..aad8cf5 --- /dev/null +++ b/supabase/migrations/20260604130000_unify_vendor_coa_bill_sync.sql @@ -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 + );