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:
2026-06-05 20:52:43 -04:00
committed by GitHub
28 changed files with 1734 additions and 117 deletions
+12 -3
View File
@@ -2123,12 +2123,21 @@ Deno.serve(async (req) => {
const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
const vendorId = await ensureVendor(buildiumVendor, assocLocalId);
// Pick first line's GL account as expense account if available
// Pick first line's GL account as expense account if available.
// chart_of_accounts.account_number stores the Buildium GL Id (see the
// glaccounts upsert: account_number = String(gl.Id ...)), so resolve the
// line's GL Id from whichever shape Buildium returns it in.
const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null;
let expenseAccountId: string | null = null;
if (firstLine?.GLAccountId) {
const lineGlId = firstLine
? (firstLine.GLAccountId
?? firstLine.GLAccount?.Id
?? firstLine.GLAccount?.GLAccountId
?? null)
: null;
if (lineGlId !== null && lineGlId !== undefined && String(lineGlId) !== "") {
const coa = await getCoa(assocLocalId);
expenseAccountId = coa.get(String(firstLine.GLAccountId)) || null;
expenseAccountId = coa.get(String(lineGlId)) || null;
}
let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0);
@@ -0,0 +1,37 @@
create table if not exists accounting.sales_receipts (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references accounting.companies(id) on delete cascade,
number text not null,
receipt_date date not null default current_date,
customer_name text,
customer_address text,
income_account_id uuid references accounting.accounts(id),
deposit_account_id uuid references accounting.accounts(id),
quantity numeric not null default 1,
rate numeric not null default 0,
total numeric not null default 0,
memo text,
transaction_id uuid references accounting.transactions(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_sales_receipts_company on accounting.sales_receipts(company_id);
create index if not exists idx_sales_receipts_txn on accounting.sales_receipts(transaction_id);
alter table accounting.sales_receipts enable row level security;
create policy "Accounting staff full access" on accounting.sales_receipts
for all using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff());
create policy "Members CRUD sales_receipts" on accounting.sales_receipts
for all using (accounting.is_company_member(company_id, auth.uid()))
with check (accounting.is_company_member(company_id, auth.uid()));
create policy "Board view sales_receipts" on accounting.sales_receipts
for select using (accounting.is_company_board_member(company_id));
create trigger trg_sales_receipts_updated
before update on accounting.sales_receipts
for each row execute function public.update_updated_at_column();
grant select, insert, update, delete on accounting.sales_receipts to authenticated;
grant all on accounting.sales_receipts to service_role;
@@ -0,0 +1,3 @@
alter table public.chart_of_accounts drop constraint chart_of_accounts_accounting_system_check;
alter table public.chart_of_accounts add constraint chart_of_accounts_accounting_system_check
check (accounting_system = any (array['buildium'::text, 'zoho'::text, 'platform'::text]));
@@ -0,0 +1,8 @@
-- Platform associations keep per-association charts (codes can repeat across
-- associations and even within a company), so the global (account_number,
-- accounting_system) uniqueness must not apply to platform-tagged rows. Make it
-- a partial index covering only buildium/zoho (their existing dedupe behavior).
drop index if exists public.chart_of_accounts_number_per_system_unique;
create unique index chart_of_accounts_number_per_system_unique
on public.chart_of_accounts (account_number, accounting_system)
where accounting_system <> 'platform';
@@ -0,0 +1,104 @@
-- Keep the accounting dashboard's accounts (accounting.accounts) reflected into
-- public.chart_of_accounts for PLATFORM associations, so every COA consumer
-- (Bill Approvals, bills, budgets, etc.) shows the same accounts and stays in
-- sync. One-way mirror, same id, so existing FKs + PostgREST embeds keep working.
create or replace function accounting.sync_account_to_public_coa()
returns trigger
language plpgsql
security definer
set search_path to 'public','accounting'
as $$
declare
_assoc uuid;
_system text;
_parent uuid;
begin
if tg_op = 'DELETE' then
delete from public.chart_of_accounts where id = old.id;
return old;
end if;
select c.association_id, a.accounting_system
into _assoc, _system
from accounting.companies c
join public.associations a on a.id = c.association_id
where c.id = new.company_id;
-- Only mirror accounts the accounting dashboard actually manages (platform).
-- If the association isn't on platform, ensure no stale mirror row lingers.
if _system is distinct from 'platform' then
delete from public.chart_of_accounts where id = new.id;
return new;
end if;
-- Only point at a parent that already exists in the public table to satisfy
-- the self-FK; hierarchy fills in as parents sync.
_parent := null;
if new.parent_account_id is not null then
select id into _parent from public.chart_of_accounts where id = new.parent_account_id;
end if;
insert into public.chart_of_accounts as coa
(id, association_id, account_number, account_name, account_type,
parent_account_id, is_active, description, association_ids, accounting_system)
values
(new.id, _assoc, coalesce(new.code, ''), new.name, new.type::text,
_parent, true, new.description, array[_assoc], 'platform')
on conflict (id) do update set
association_id = excluded.association_id,
account_number = excluded.account_number,
account_name = excluded.account_name,
account_type = excluded.account_type,
parent_account_id = excluded.parent_account_id,
is_active = true,
description = excluded.description,
association_ids = excluded.association_ids,
accounting_system = 'platform',
updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_sync_account_to_public_coa on accounting.accounts;
create trigger trg_sync_account_to_public_coa
after insert or update or delete on accounting.accounts
for each row execute function accounting.sync_account_to_public_coa();
-- Backfill: mirror every existing platform account (parent left null for pass 1).
insert into public.chart_of_accounts
(id, association_id, account_number, account_name, account_type,
parent_account_id, is_active, description, association_ids, accounting_system)
select ac.id, c.association_id, coalesce(ac.code, ''), ac.name, ac.type::text,
null, true, ac.description, array[c.association_id], 'platform'
from accounting.accounts ac
join accounting.companies c on c.id = ac.company_id
join public.associations a on a.id = c.association_id
where a.accounting_system = 'platform'
on conflict (id) do update set
association_id = excluded.association_id,
account_number = excluded.account_number,
account_name = excluded.account_name,
account_type = excluded.account_type,
is_active = true,
description = excluded.description,
association_ids = excluded.association_ids,
accounting_system = 'platform',
updated_at = now();
-- Pass 2: wire up parent hierarchy now that all rows exist.
update public.chart_of_accounts coa
set parent_account_id = ac.parent_account_id, updated_at = now()
from accounting.accounts ac
where coa.id = ac.id
and ac.parent_account_id is not null
and exists (select 1 from public.chart_of_accounts p where p.id = ac.parent_account_id);
-- Hide legacy (buildium/zoho) COA rows for platform associations so pickers that
-- respect is_active show only the synced platform accounts. Nothing is deleted.
update public.chart_of_accounts coa
set is_active = false, updated_at = now()
from public.associations a
where a.id = coa.association_id
and a.accounting_system = 'platform'
and coalesce(coa.accounting_system, '') <> 'platform';
@@ -0,0 +1,93 @@
-- Create a paid bill for a vendor payment transaction that has no attached bill.
-- Such payments post Dr Accounts Payable / Cr Bank (the Banking flow assumes a
-- bill exists); with no bill, A/P is left negative and the expense is never
-- recognized. The auto-bill posts Dr Expense / Cr A/P, clearing the A/P and
-- booking the expense (net: Dr Expense / Cr Bank). Idempotent + safe to re-run.
--
-- NOTE: superseded for the Banking flow by 20260603200530 (vendor payments now
-- book the expense directly). This function is retained for reference/backfill.
create or replace function accounting.create_bill_for_payment_txn(_txn_id uuid)
returns uuid
language plpgsql
security definer
set search_path to 'public','accounting'
as $$
declare
t accounting.transactions%rowtype;
_exp uuid;
_num text;
_n int;
_bill_id uuid;
begin
select * into t from accounting.transactions where id = _txn_id;
if not found then return null; end if;
if t.type <> 'debit' or t.vendor_id is null or t.coa_account_id is not null
or t.transfer_id is not null or t.deposit_id is not null then
return null;
end if;
if coalesce(t.amount, 0) <= 0 then return null; end if;
if exists (select 1 from accounting.bills b where b.source_payment_id = t.id) then
return null;
end if;
select a.id into _exp
from accounting.accounts a
where a.company_id = t.company_id and a.name = t.category and a.type = 'expense'
limit 1;
if _exp is null then _exp := accounting.coa_default_expense(t.company_id); end if;
select count(*) into _n from accounting.bills where company_id = t.company_id and auto_created;
_num := 'AUTO-BILL-' || lpad((_n + 1)::text, 4, '0');
insert into accounting.bills
(company_id, vendor_id, number, issue_date, due_date,
subtotal, tax, total, paid_amount, status, notes,
auto_created, source_payment_id, source_payment_kind)
values
(t.company_id, t.vendor_id, _num, t.date, t.date,
t.amount, 0, t.amount, t.amount, 'paid',
'Auto-created from bank payment with no attached bill',
true, t.id, 'transaction')
returning id into _bill_id;
insert into accounting.bill_items (bill_id, description, quantity, rate, amount, account_id)
values (_bill_id, coalesce(nullif(t.category, ''), 'Auto-created payment line'),
1, t.amount, t.amount, _exp);
return _bill_id;
end;
$$;
-- Backfill existing unattached vendor payments + best-effort link a matching check.
do $$
declare r record; _bill uuid;
begin
for r in
select t.id, t.company_id, t.vendor_id, t.amount, t.date
from accounting.transactions t
where t.type = 'debit'
and t.vendor_id is not null
and t.coa_account_id is null
and t.transfer_id is null and t.deposit_id is null
and coalesce(t.description, '') not ilike 'Bill Payment%'
and not exists (select 1 from accounting.bills b where b.source_payment_id = t.id)
order by t.date, t.id
loop
_bill := accounting.create_bill_for_payment_txn(r.id);
if _bill is not null then
update accounting.checks c
set auto_bill_id = _bill, updated_at = now()
where c.id = (
select c2.id from accounting.checks c2
where c2.company_id = r.company_id
and c2.payee_vendor_id = r.vendor_id
and c2.amount = r.amount
and c2.date = r.date
and c2.source_bill_id is null
and c2.auto_bill_id is null
limit 1
);
end if;
end loop;
end $$;
@@ -0,0 +1,19 @@
-- Vendor payments with no bill were posted to Accounts Payable (coa_account_id
-- null + vendor → Dr A/P / Cr Bank), leaving A/P negative and the expense never
-- recognized. Per the rule (expense at payment date when no bill present), set
-- their expense account so post_transaction_gl re-posts them Dr Expense / Cr Bank.
-- Excludes bill payments ('Bill Payment%'), payments already linked to an
-- auto-bill, and imported-GL (non-gl_managed) companies.
update accounting.transactions t
set coa_account_id = coalesce(
(select a.id from accounting.accounts a
where a.company_id = t.company_id and a.name = t.category and a.type = 'expense'
limit 1),
accounting.coa_default_expense(t.company_id))
where t.type = 'debit'
and t.vendor_id is not null
and t.coa_account_id is null
and t.transfer_id is null and t.deposit_id is null
and coalesce(t.description, '') not ilike 'Bill Payment%'
and not exists (select 1 from accounting.bills b where b.source_payment_id = t.id)
and accounting.gl_managed(t.company_id);
@@ -0,0 +1,87 @@
-- Fix: bill_approvals not reflecting "paid" when an accounting.bills row is
-- created already-paid.
--
-- Background (see 20260601140000_accounting_sync_bills.sql):
-- accounting.sync_accounting_bill_paid() flips public.bills + public.bill_approvals
-- to 'paid' once the matching accounting bill is fully paid. It runs from the
-- trigger trg_acct_bill_paid_back, which was AFTER UPDATE only.
--
-- When a public bill is ALREADY paid at the moment it first mirrors into
-- accounting, the forward sync INSERTs the accounting row at status='paid'
-- with paid_amount set. With no subsequent UPDATE to paid_amount/total, the
-- UPDATE-only trigger never fired, so the bill's approvals were left stuck at
-- 'approved'/'pending'. This widens the back-sync to also run on INSERT.
-- ---------------------------------------------------------------------------
-- Back-sync trigger: now fires on INSERT as well as UPDATE.
-- ---------------------------------------------------------------------------
create or replace function accounting.tg_accounting_bill_paid_sync()
returns trigger
language plpgsql security definer set search_path to 'public','accounting'
as $$
begin
begin
if tg_op = 'INSERT' then
-- a bill mirrored in already-paid never produces an UPDATE; handle it here
perform accounting.sync_accounting_bill_paid(new.id);
-- on UPDATE only act when the paid position actually changed, to avoid loops
elsif old.paid_amount is distinct from new.paid_amount
or old.total is distinct from new.total then
perform accounting.sync_accounting_bill_paid(new.id);
end if;
exception when others then
raise warning 'accounting: accounting bill paid sync failed for %: %', new.id, sqlerrm;
end;
return new;
end;
$$;
drop trigger if exists trg_acct_bill_paid_back on accounting.bills;
create trigger trg_acct_bill_paid_back
after insert or update on accounting.bills
for each row execute function accounting.tg_accounting_bill_paid_sync();
-- ---------------------------------------------------------------------------
-- One-time reconciliation: back-sync any accounting bills that are already
-- fully paid but whose linked public bill / approvals were never updated
-- (the rows that fell into the INSERT gap above).
-- ---------------------------------------------------------------------------
do $$
declare _id uuid;
begin
for _id in
select id from accounting.bills
where external_source = 'acmacc_bill' and external_id is not null
and coalesce(paid_amount,0) >= coalesce(total,0) and coalesce(total,0) > 0
loop
perform accounting.sync_accounting_bill_paid(_id);
end loop;
end $$;
-- ---------------------------------------------------------------------------
-- Backfill orphaned, invoice-track approvals (bill_id IS NULL) that were
-- created via the invoice flow and never linked to a public.bills row.
-- Only link when exactly one matching bill exists for the same association,
-- invoice number and amount; adopt that bill's status when it is paid.
-- ---------------------------------------------------------------------------
update public.bill_approvals ba
set bill_id = m.bill_id,
status = case when m.bill_status = 'paid' then 'paid' else ba.status end,
updated_at = now()
from (
select i.id as invoice_id, b.id as bill_id, b.status as bill_status
from public.invoices i
join public.bills b
on b.association_id = i.association_id
and b.amount = i.amount
and b.invoice_number is not distinct from i.invoice_number
group by i.id, b.id, b.status
having (
select count(*) from public.bills b2
where b2.association_id = i.association_id
and b2.amount = i.amount
and b2.invoice_number is not distinct from i.invoice_number
) = 1
) m
where ba.bill_id is null
and ba.invoice_id = m.invoice_id;
@@ -0,0 +1,22 @@
-- Accrual A/P guard: link a bank transaction to the bill it settles, and enforce
-- that a bill-linked payment never carries an expense category.
--
-- Under accrual, a vendor expense is recognized once — when the bill is entered
-- (Dr Expense / Cr A/P). Paying the bill must only relieve the liability
-- (Dr A/P / Cr Bank) via accounting.post_transaction_gl's vendor → A/P branch,
-- which only runs when coa_account_id IS NULL. So any transaction that settles a
-- bill must have coa_account_id NULL; otherwise it re-recognizes the expense.
alter table accounting.transactions
add column if not exists bill_id uuid references accounting.bills(id) on delete set null;
create index if not exists idx_transactions_bill_id
on accounting.transactions(bill_id) where bill_id is not null;
-- Invariant: a transaction linked to a bill posts against Accounts Payable, never
-- an expense account. This makes the accrual rule enforceable, not just convention.
alter table accounting.transactions
drop constraint if exists chk_bill_payment_no_coa;
alter table accounting.transactions
add constraint chk_bill_payment_no_coa
check (bill_id is null or coa_account_id is null);
@@ -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 $$;