mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: recurring bills & journal entries
Add accounting.recurring_templates (bill|journal) with a schedule
(frequency/interval/day-of-month/start/end). New generate_due_recurring()
materialises real bills (-> A/P) and journal entries on cadence — catching
up any missed periods — posting through existing triggers. Runs nightly via
pg_cron ('accounting-recurring-daily') and on demand from a new Recurring
page ('Generate due now'). Page lists templates with pause/resume/edit/
delete; create dialog handles both kinds (vendor + line items for bills,
signed balanced lines for journals). Wired into routes + accounting nav.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
-- Recurring bills & journal entries for platform accounting.
|
||||
-- A template stores a bill/journal definition + a schedule. generate_due_recurring()
|
||||
-- materialises real accounting.bills / accounting.journal_entries on cadence, which
|
||||
-- post to the GL through the existing bill/JE triggers. Run nightly by pg_cron and
|
||||
-- on demand from the Recurring page ("Generate due now").
|
||||
|
||||
create table if not exists accounting.recurring_templates (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
company_id uuid not null references accounting.companies(id) on delete cascade,
|
||||
kind text not null check (kind in ('bill','journal')),
|
||||
name text not null,
|
||||
active boolean not null default true,
|
||||
frequency text not null default 'monthly' check (frequency in ('weekly','monthly','quarterly','yearly')),
|
||||
interval_count integer not null default 1 check (interval_count >= 1),
|
||||
day_of_month integer check (day_of_month between 1 and 31),
|
||||
start_date date not null default current_date,
|
||||
next_run_date date not null,
|
||||
end_date date,
|
||||
last_run_date date,
|
||||
last_generated_id uuid,
|
||||
generated_count integer not null default 0,
|
||||
-- bill: {vendor_id, due_days, tax_pct, memo, items:[{account_id,description,quantity,rate}]}
|
||||
-- journal: {description, reference, lines:[{account_id, amount, description}]} (+amount=debit, -amount=credit)
|
||||
payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_recurring_templates_company on accounting.recurring_templates(company_id);
|
||||
create index if not exists idx_recurring_templates_due on accounting.recurring_templates(next_run_date) where active;
|
||||
|
||||
alter table accounting.recurring_templates enable row level security;
|
||||
|
||||
create policy "Accounting staff full access" on accounting.recurring_templates
|
||||
for all using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff());
|
||||
create policy "Members CRUD recurring_templates" on accounting.recurring_templates
|
||||
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 recurring_templates" on accounting.recurring_templates
|
||||
for select using (accounting.is_company_board_member(company_id));
|
||||
|
||||
create trigger trg_recurring_templates_updated
|
||||
before update on accounting.recurring_templates
|
||||
for each row execute function public.update_updated_at_column();
|
||||
|
||||
grant select, insert, update, delete on accounting.recurring_templates to authenticated;
|
||||
grant all on accounting.recurring_templates to service_role;
|
||||
|
||||
-- Advance a date by N periods of a frequency, optionally pinned to a day-of-month
|
||||
-- (clamped to the month length).
|
||||
create or replace function accounting.advance_recurrence(p_date date, p_freq text, p_n integer, p_dom integer default null)
|
||||
returns date language plpgsql immutable as $$
|
||||
declare d date; dim integer;
|
||||
begin
|
||||
d := case p_freq
|
||||
when 'weekly' then p_date + (7 * p_n)
|
||||
when 'quarterly' then (p_date + ((3 * p_n) || ' month')::interval)::date
|
||||
when 'yearly' then (p_date + (p_n || ' year')::interval)::date
|
||||
else (p_date + (p_n || ' month')::interval)::date -- monthly
|
||||
end;
|
||||
if p_dom is not null and p_freq in ('monthly','quarterly','yearly') then
|
||||
dim := extract(day from (date_trunc('month', d) + interval '1 month - 1 day'))::int;
|
||||
d := (date_trunc('month', d) + ((least(p_dom, dim) - 1) || ' day')::interval)::date;
|
||||
end if;
|
||||
return d;
|
||||
end $$;
|
||||
|
||||
-- Materialise every recurrence due on/before p_as_of (catches up missed periods).
|
||||
-- Returns the number of bills/journals created.
|
||||
create or replace function accounting.generate_due_recurring(p_as_of date default current_date, p_company_id uuid default null)
|
||||
returns integer
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = accounting, public
|
||||
as $$
|
||||
declare
|
||||
t accounting.recurring_templates;
|
||||
v_count int := 0;
|
||||
v_made int;
|
||||
v_run date;
|
||||
v_bill_id uuid;
|
||||
v_je_id uuid;
|
||||
v_last_id uuid;
|
||||
v_item jsonb;
|
||||
v_line jsonb;
|
||||
v_sub numeric;
|
||||
v_tax numeric;
|
||||
v_amt numeric;
|
||||
begin
|
||||
for t in
|
||||
select * from accounting.recurring_templates
|
||||
where active and next_run_date <= p_as_of
|
||||
and (p_company_id is null or company_id = p_company_id)
|
||||
order by next_run_date
|
||||
loop
|
||||
v_run := t.next_run_date;
|
||||
v_made := 0;
|
||||
while v_run <= p_as_of and (t.end_date is null or v_run <= t.end_date) and v_made < 60 loop
|
||||
if t.kind = 'bill' then
|
||||
v_sub := 0;
|
||||
for v_item in select value from jsonb_array_elements(coalesce(t.payload->'items','[]'::jsonb)) loop
|
||||
v_sub := v_sub + round(coalesce((v_item->>'quantity')::numeric,1) * coalesce((v_item->>'rate')::numeric,0), 2);
|
||||
end loop;
|
||||
v_tax := round(v_sub * coalesce((t.payload->>'tax_pct')::numeric,0) / 100, 2);
|
||||
insert into accounting.bills (company_id, vendor_id, number, issue_date, due_date, subtotal, tax, total, status, notes)
|
||||
values (t.company_id, nullif(t.payload->>'vendor_id','')::uuid,
|
||||
'REC-'||to_char(v_run,'YYYYMMDD')||'-'||left(t.id::text,4),
|
||||
v_run,
|
||||
case when nullif(t.payload->>'due_days','') is not null then v_run + (t.payload->>'due_days')::int end,
|
||||
v_sub, v_tax, v_sub + v_tax, 'open', nullif(t.payload->>'memo',''))
|
||||
returning id into v_bill_id;
|
||||
for v_item in select value from jsonb_array_elements(coalesce(t.payload->'items','[]'::jsonb)) loop
|
||||
insert into accounting.bill_items (bill_id, description, quantity, rate, amount, account_id)
|
||||
values (v_bill_id, v_item->>'description',
|
||||
coalesce((v_item->>'quantity')::numeric,1), coalesce((v_item->>'rate')::numeric,0),
|
||||
round(coalesce((v_item->>'quantity')::numeric,1) * coalesce((v_item->>'rate')::numeric,0), 2),
|
||||
nullif(v_item->>'account_id','')::uuid);
|
||||
end loop;
|
||||
v_last_id := v_bill_id;
|
||||
else
|
||||
insert into accounting.journal_entries (company_id, date, description, reference)
|
||||
values (t.company_id, v_run, coalesce(nullif(t.payload->>'description',''), t.name), nullif(t.payload->>'reference',''))
|
||||
returning id into v_je_id;
|
||||
for v_line in select value from jsonb_array_elements(coalesce(t.payload->'lines','[]'::jsonb)) loop
|
||||
v_amt := coalesce((v_line->>'amount')::numeric, 0);
|
||||
if v_amt = 0 then continue; end if;
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
|
||||
values (v_je_id, nullif(v_line->>'account_id','')::uuid,
|
||||
case when v_amt > 0 then v_amt else 0 end,
|
||||
case when v_amt < 0 then -v_amt else 0 end,
|
||||
nullif(v_line->>'description',''));
|
||||
end loop;
|
||||
v_last_id := v_je_id;
|
||||
end if;
|
||||
|
||||
v_count := v_count + 1;
|
||||
v_made := v_made + 1;
|
||||
t.last_run_date := v_run;
|
||||
v_run := accounting.advance_recurrence(v_run, t.frequency, t.interval_count, t.day_of_month);
|
||||
end loop;
|
||||
|
||||
update accounting.recurring_templates
|
||||
set next_run_date = v_run,
|
||||
last_run_date = coalesce(t.last_run_date, last_run_date),
|
||||
last_generated_id = coalesce(v_last_id, last_generated_id),
|
||||
generated_count = generated_count + v_made,
|
||||
active = case when end_date is not null and v_run > end_date then false else active end
|
||||
where id = t.id;
|
||||
end loop;
|
||||
|
||||
return v_count;
|
||||
end $$;
|
||||
|
||||
grant execute on function accounting.advance_recurrence(date, text, integer, integer) to authenticated, service_role;
|
||||
grant execute on function accounting.generate_due_recurring(date, uuid) to authenticated, service_role;
|
||||
|
||||
-- Nightly auto-generation (07:15 UTC). Re-schedule idempotently.
|
||||
do $$
|
||||
begin
|
||||
perform cron.unschedule('accounting-recurring-daily');
|
||||
exception when others then null;
|
||||
end $$;
|
||||
select cron.schedule('accounting-recurring-daily', '15 7 * * *', $cron$ select accounting.generate_due_recurring(current_date); $cron$);
|
||||
Reference in New Issue
Block a user