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