Files
acmcc/supabase/migrations/20260614210000_accounting_recurring_templates.sql
T
admin 266a99d4b2 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>
2026-06-14 23:28:43 -04:00

164 lines
8.0 KiB
PL/PgSQL

-- 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$);