mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40: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:
@@ -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);
|
||||
+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