mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
2d216e24c9
DB triggers on public.owner_ledger_entries (migration applied to prod): charges (debit) -> accounting.invoices; payments (credit) -> accounting.payments_received (deposited=false, Undeposited Funds). Customer balance recomputed authoritatively from the source ledger; ledger payments FIFO-applied to ledger invoices. Keyed external_source='acmacc_ledger'. Backfilled 6,756 invoices + 4,253 payments; balances reconcile exactly. Frontend: customer Ledger tab now renders real payments_received credits (true dates/amounts); Make Deposit page surfaces undeposited payments_received alongside Undeposited Funds transactions and deposits both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
8.7 KiB
PL/PgSQL
180 lines
8.7 KiB
PL/PgSQL
-- Sync the main-app owner ledger (public.owner_ledger_entries) into the
|
|
-- Accounting platform per association:
|
|
-- * a CHARGE (debit > 0) -> accounting.invoices (the customer's AR ledger)
|
|
-- * a PAYMENT (credit > 0) -> accounting.payments_received, deposited=false
|
|
-- (Undeposited Funds) so the funds flow into the "Make Deposit" page.
|
|
-- The accounting customer's balance is recomputed authoritatively from the
|
|
-- source ledger. Scoped to associations that have an accounting.companies row.
|
|
-- Keyed by external_source='acmacc_ledger', external_id=owner_ledger_entries.id.
|
|
|
|
-- payments_received needs external linkage columns (invoices/bills/vendors already have them)
|
|
alter table accounting.payments_received add column if not exists external_source text;
|
|
alter table accounting.payments_received add column if not exists external_id text;
|
|
create unique index if not exists ux_payments_received_ext
|
|
on accounting.payments_received (company_id, external_source, external_id)
|
|
where external_id is not null;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Recompute a synced customer's balance from the source ledger and FIFO-apply
|
|
-- ledger payments across that customer's ledger invoices (so paid_amount/status
|
|
-- look right). Only touches acmacc_ledger rows — manual invoices/payments are
|
|
-- left alone.
|
|
-- ---------------------------------------------------------------------------
|
|
create or replace function accounting.recompute_customer_from_ledger(
|
|
_company_id uuid, _customer_id uuid, _unit_id uuid, _association_id uuid
|
|
) returns void
|
|
language plpgsql security definer set search_path to 'public','accounting'
|
|
as $$
|
|
declare
|
|
_bal numeric;
|
|
_pool numeric;
|
|
_alloc numeric;
|
|
inv record;
|
|
begin
|
|
if _customer_id is null or _unit_id is null then return; end if;
|
|
|
|
-- Authoritative AR balance = sum(debit - credit) of the unit's live ledger.
|
|
select coalesce(sum(debit - credit), 0) into _bal
|
|
from public.owner_ledger_entries
|
|
where unit_id = _unit_id and association_id = _association_id
|
|
and coalesce(is_archived, false) = false;
|
|
update accounting.customers set balance = _bal where id = _customer_id;
|
|
|
|
-- FIFO-allocate ledger payments across ledger invoices.
|
|
select coalesce(sum(amount), 0) into _pool
|
|
from accounting.payments_received
|
|
where customer_id = _customer_id and external_source = 'acmacc_ledger';
|
|
|
|
for inv in
|
|
select id, total from accounting.invoices
|
|
where customer_id = _customer_id and external_source = 'acmacc_ledger'
|
|
order by issue_date asc, created_at asc
|
|
loop
|
|
_alloc := greatest(0, least(_pool, inv.total));
|
|
update accounting.invoices set
|
|
paid_amount = _alloc,
|
|
status = case when _alloc >= inv.total and inv.total > 0
|
|
then 'paid'::accounting.invoice_status
|
|
else 'sent'::accounting.invoice_status end,
|
|
updated_at = now()
|
|
where id = inv.id;
|
|
_pool := _pool - _alloc;
|
|
end loop;
|
|
end;
|
|
$$;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Sync one ledger entry into accounting (idempotent).
|
|
-- ---------------------------------------------------------------------------
|
|
create or replace function accounting.sync_owner_ledger_entry(_entry_id uuid)
|
|
returns void
|
|
language plpgsql security definer set search_path to 'public','accounting'
|
|
as $$
|
|
declare
|
|
e public.owner_ledger_entries%rowtype;
|
|
_company_id uuid;
|
|
_customer_id uuid;
|
|
begin
|
|
select * into e from public.owner_ledger_entries where id = _entry_id;
|
|
if not found then return; end if;
|
|
|
|
select id into _company_id from accounting.companies where association_id = e.association_id;
|
|
if _company_id is null then return; end if;
|
|
|
|
select id into _customer_id from accounting.customers
|
|
where company_id = _company_id and external_source = 'acmacc_unit'
|
|
and external_id = e.unit_id::text;
|
|
if _customer_id is null then return; end if; -- unit not synced; nothing to attach to
|
|
|
|
if coalesce(e.is_archived, false) then
|
|
delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text;
|
|
delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text;
|
|
|
|
elsif coalesce(e.debit, 0) > 0 then
|
|
-- charge -> invoice (drop any stale payment row for this entry)
|
|
delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text;
|
|
insert into accounting.invoices
|
|
(company_id, customer_id, number, issue_date, due_date, status, subtotal, tax, total, notes, external_source, external_id)
|
|
values
|
|
(_company_id, _customer_id,
|
|
'AR-' || to_char(e.date, 'YYYYMMDD') || '-' || left(replace(e.id::text, '-', ''), 6),
|
|
e.date, e.date, 'sent'::accounting.invoice_status,
|
|
e.debit, 0, e.debit,
|
|
coalesce(nullif(e.description, ''), initcap(e.transaction_type)),
|
|
'acmacc_ledger', e.id::text)
|
|
on conflict (company_id, external_source, external_id) where external_id is not null
|
|
do update set customer_id=excluded.customer_id, issue_date=excluded.issue_date,
|
|
due_date=excluded.due_date, subtotal=excluded.subtotal, total=excluded.total,
|
|
notes=excluded.notes, number=excluded.number, updated_at=now();
|
|
|
|
elsif coalesce(e.credit, 0) > 0 then
|
|
-- payment -> payments_received (Undeposited Funds); never reset deposited/deposit_id
|
|
delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text;
|
|
insert into accounting.payments_received
|
|
(company_id, customer_id, payment_date, amount, method, reference, memo, deposited, external_source, external_id)
|
|
values
|
|
(_company_id, _customer_id, e.date, e.credit,
|
|
coalesce(nullif(e.transaction_type, ''), 'payment'),
|
|
e.reference_id, e.description, false, 'acmacc_ledger', e.id::text)
|
|
on conflict (company_id, external_source, external_id) where external_id is not null
|
|
do update set customer_id=excluded.customer_id, payment_date=excluded.payment_date,
|
|
amount=excluded.amount, method=excluded.method, reference=excluded.reference,
|
|
memo=excluded.memo, updated_at=now();
|
|
|
|
else
|
|
delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text;
|
|
delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text;
|
|
end if;
|
|
|
|
perform accounting.recompute_customer_from_ledger(_company_id, _customer_id, e.unit_id, e.association_id);
|
|
end;
|
|
$$;
|
|
|
|
-- Remove synced rows for a deleted/moved ledger entry, then recompute.
|
|
create or replace function accounting.unsync_owner_ledger_entry(
|
|
_entry_id uuid, _association_id uuid, _unit_id uuid
|
|
) returns void
|
|
language plpgsql security definer set search_path to 'public','accounting'
|
|
as $$
|
|
declare _company_id uuid; _customer_id uuid;
|
|
begin
|
|
select id into _company_id from accounting.companies where association_id=_association_id;
|
|
if _company_id is null then return; end if;
|
|
delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=_entry_id::text;
|
|
delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=_entry_id::text;
|
|
select id into _customer_id from accounting.customers
|
|
where company_id=_company_id and external_source='acmacc_unit' and external_id=_unit_id::text;
|
|
perform accounting.recompute_customer_from_ledger(_company_id, _customer_id, _unit_id, _association_id);
|
|
end;
|
|
$$;
|
|
|
|
-- Trigger glue (error-swallowed so a sync hiccup never blocks a ledger write).
|
|
create or replace function accounting.tg_owner_ledger_sync()
|
|
returns trigger
|
|
language plpgsql security definer set search_path to 'public','accounting'
|
|
as $$
|
|
begin
|
|
begin
|
|
if tg_op = 'DELETE' then
|
|
perform accounting.unsync_owner_ledger_entry(old.id, old.association_id, old.unit_id);
|
|
return old;
|
|
end if;
|
|
if tg_op = 'UPDATE'
|
|
and (old.unit_id is distinct from new.unit_id
|
|
or old.association_id is distinct from new.association_id) then
|
|
perform accounting.unsync_owner_ledger_entry(old.id, old.association_id, old.unit_id);
|
|
end if;
|
|
perform accounting.sync_owner_ledger_entry(new.id);
|
|
return new;
|
|
exception when others then
|
|
raise warning 'accounting: owner_ledger sync failed for %: %', coalesce(new.id, old.id), sqlerrm;
|
|
return coalesce(new, old);
|
|
end;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists trg_acct_sync_owner_ledger on public.owner_ledger_entries;
|
|
create trigger trg_acct_sync_owner_ledger
|
|
after insert or update or delete on public.owner_ledger_entries
|
|
for each row execute function accounting.tg_owner_ledger_sync();
|