Accounting: Sales Receipts, COA sync to dashboard, vendor-expense recognition

- Add Sales Receipts page (dashboard/accounting/sales-receipts): records a
  cash sale (name, address, income account, price, qty) — deposits and books
  income in one step via a transaction. New accounting.sales_receipts table.
- Sync chart of accounts to the accounting dashboard: mirror accounting.accounts
  into public.chart_of_accounts for platform associations (one-way, same id) so
  Bill Approvals and every COA consumer use the dashboard's accounts. Legacy
  rows hidden; Bill Approvals made system-aware.
- Vendor-expense recognition: a vendor payment with no bill now books the
  expense directly (Dr Expense / Cr Bank) on the payment date instead of going
  to A/P; payments against open bills still clear A/P (applied FIFO). Backfill
  reclassifies unbilled payments stuck in A/P. Expense Summary report made
  GL-driven so it follows the same rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 10:01:18 -04:00
parent bd5caf5415
commit d82466f826
14 changed files with 688 additions and 24 deletions
@@ -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);