From 266a99d4b23f7984f7c087d351a896b58304a26b Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 14 Jun 2026 23:28:43 -0400 Subject: [PATCH] Accounting: recurring bills & journal entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 2 + src/pages/accounting/AccountingIndex.tsx | 1 + src/pages/accounting/AccountingLayout.tsx | 1 + .../accounting/AccountingRecurringPage.tsx | 358 ++++++++++++++++++ ...4210000_accounting_recurring_templates.sql | 163 ++++++++ 5 files changed, 525 insertions(+) create mode 100644 src/pages/accounting/AccountingRecurringPage.tsx create mode 100644 supabase/migrations/20260614210000_accounting_recurring_templates.sql diff --git a/src/App.tsx b/src/App.tsx index 26eff8f..199821c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { AccountingDashboardPage, AccountingChartOfAccountsPage, AccountingJournalEntriesPage, + AccountingRecurringPage, AccountingGeneralLedgerPage, AccountingInvoicesPage, AccountingBillsPage, @@ -381,6 +382,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/accounting/AccountingIndex.tsx b/src/pages/accounting/AccountingIndex.tsx index 2ef8769..e5551ed 100644 --- a/src/pages/accounting/AccountingIndex.tsx +++ b/src/pages/accounting/AccountingIndex.tsx @@ -2,6 +2,7 @@ export { default as AccountingLayout } from "./AccountingLayout"; export { default as AccountingDashboardPage } from "./AccountingDashboardPage"; export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage"; export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage"; +export { default as AccountingRecurringPage } from "./AccountingRecurringPage"; export { default as AccountingGeneralLedgerPage } from "./AccountingGeneralLedgerPage"; export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage"; export { default as AccountingBillsPage } from "./AccountingBillsPage"; diff --git a/src/pages/accounting/AccountingLayout.tsx b/src/pages/accounting/AccountingLayout.tsx index 9598299..5fb5891 100644 --- a/src/pages/accounting/AccountingLayout.tsx +++ b/src/pages/accounting/AccountingLayout.tsx @@ -45,6 +45,7 @@ const NAV: NavSection[] = [ { to: "chart-of-accounts", label: "Chart of Accounts" }, { to: "budgets", label: "Budgeting" }, { to: "journal-entries", label: "Journal Entries" }, + { to: "recurring", label: "Recurring" }, { to: "general-ledger", label: "General Ledger" }, { to: "banking", label: "Banking" }, { to: "reconciliation", label: "Reconciliation" }, diff --git a/src/pages/accounting/AccountingRecurringPage.tsx b/src/pages/accounting/AccountingRecurringPage.tsx new file mode 100644 index 0000000..2652341 --- /dev/null +++ b/src/pages/accounting/AccountingRecurringPage.tsx @@ -0,0 +1,358 @@ +import { useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { accounting } from "@/lib/accountingClient"; +import { supabase } from "@/integrations/supabase/client"; +import { useCompanyId } from "./lib/useCompanyId"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; +import { Plus, Trash2, Pencil, Loader2, Play, Pause, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { money, fmtDate } from "./lib/format"; + +type Kind = "bill" | "journal"; +type Freq = "weekly" | "monthly" | "quarterly" | "yearly"; +type Item = { description: string; quantity: number; rate: number; account_id: string | null }; +type Line = { account_id: string; amount: string; description: string }; + +const FREQS: { value: Freq; label: string }[] = [ + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "quarterly", label: "Quarterly" }, + { value: "yearly", label: "Yearly" }, +]; +const todayISO = () => new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); +const newItem = (): Item => ({ description: "", quantity: 1, rate: 0, account_id: null }); +const newLine = (): Line => ({ account_id: "", amount: "", description: "" }); + +function scheduleLabel(t: any): string { + const every = t.interval_count > 1 ? `every ${t.interval_count} ` : ""; + const unit = { weekly: "week", monthly: "month", quarterly: "quarter", yearly: "year" }[t.frequency as Freq]; + const plural = t.interval_count > 1 ? "s" : ""; + const dom = t.day_of_month && t.frequency !== "weekly" ? ` on day ${t.day_of_month}` : ""; + return `${every}${unit}${plural}${dom}`.replace(/^every 1 /, ""); +} +function templateAmount(t: any): number { + const p = t.payload ?? {}; + if (t.kind === "bill") { + const sub = (p.items ?? []).reduce((s: number, i: any) => s + Number(i.quantity || 0) * Number(i.rate || 0), 0); + return sub * (1 + Number(p.tax_pct || 0) / 100); + } + return (p.lines ?? []).reduce((s: number, l: any) => s + Math.max(0, Number(l.amount || 0)), 0); +} + +export default function AccountingRecurringPage() { + const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); + const cid = companyId ?? ""; + const cur = "USD"; + const qc = useQueryClient(); + + const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [saving, setSaving] = useState(false); + const [generating, setGenerating] = useState(false); + + // shared schedule fields + const [kind, setKind] = useState("bill"); + const [name, setName] = useState(""); + const [frequency, setFrequency] = useState("monthly"); + const [intervalCount, setIntervalCount] = useState("1"); + const [dayOfMonth, setDayOfMonth] = useState(""); + const [startDate, setStartDate] = useState(todayISO()); + const [endDate, setEndDate] = useState(""); + // bill fields + const [vendorId, setVendorId] = useState(""); + const [dueDays, setDueDays] = useState("30"); + const [memo, setMemo] = useState(""); + const [items, setItems] = useState([newItem()]); + // journal fields + const [jDescription, setJDescription] = useState(""); + const [jReference, setJReference] = useState(""); + const [lines, setLines] = useState([newLine(), newLine()]); + + const { data: templates = [], isLoading } = useQuery({ + queryKey: ["recurring-templates", cid], + enabled: !!cid, + queryFn: async () => + (await accounting.from("recurring_templates").select("*").eq("company_id", cid).order("next_run_date")).data ?? [], + }); + + const { data: vendors = [] } = useQuery({ + queryKey: ["vendors-lookup", associationId], + enabled: !!associationId, + queryFn: async () => (await supabase.from("vendors").select("id,name").eq("is_active", true) + .or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`).order("name")).data ?? [], + }); + + const { data: accounts = [] } = useQuery({ + queryKey: ["recurring-accounts", cid], + enabled: !!cid, + queryFn: async () => (await accounting.from("accounts").select("id,name,code,type,is_bank") + .eq("company_id", cid).eq("is_archived", false).order("code")).data ?? [], + }); + const billAccounts = useMemo(() => (accounts as any[]).filter((a) => !a.is_bank && ["expense", "asset", "equity", "liability"].includes(a.type)), [accounts]); + + const resetForm = () => { + setEditingId(null); setKind("bill"); setName(""); setFrequency("monthly"); setIntervalCount("1"); + setDayOfMonth(""); setStartDate(todayISO()); setEndDate(""); + setVendorId(""); setDueDays("30"); setMemo(""); setItems([newItem()]); + setJDescription(""); setJReference(""); setLines([newLine(), newLine()]); + }; + const openCreate = () => { resetForm(); setOpen(true); }; + const openEdit = (t: any) => { + const p = t.payload ?? {}; + setEditingId(t.id); setKind(t.kind); setName(t.name); setFrequency(t.frequency); + setIntervalCount(String(t.interval_count)); setDayOfMonth(t.day_of_month ? String(t.day_of_month) : ""); + setStartDate(t.next_run_date ?? t.start_date ?? todayISO()); setEndDate(t.end_date ?? ""); + if (t.kind === "bill") { + setVendorId(p.vendor_public_id ?? ""); setDueDays(String(p.due_days ?? 30)); + setMemo(p.memo ?? ""); + setItems((p.items ?? []).length ? p.items.map((i: any) => ({ description: i.description ?? "", quantity: Number(i.quantity ?? 1), rate: Number(i.rate ?? 0), account_id: i.account_id ?? null })) : [newItem()]); + } else { + setJDescription(p.description ?? ""); setJReference(p.reference ?? ""); + setLines((p.lines ?? []).length ? p.lines.map((l: any) => ({ account_id: l.account_id ?? "", amount: String(l.amount ?? ""), description: l.description ?? "" })) : [newLine(), newLine()]); + } + setOpen(true); + }; + + const billTotal = useMemo(() => items.reduce((s, i) => s + Number(i.quantity || 0) * Number(i.rate || 0), 0), [items]); + const jSigned = useMemo(() => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0), [lines]); + const jBalanced = Math.abs(jSigned) < 0.005; + + const save = async () => { + if (!name.trim()) return toast.error("Give the template a name"); + const n = Math.max(1, parseInt(intervalCount) || 1); + const dom = dayOfMonth ? Math.min(31, Math.max(1, parseInt(dayOfMonth))) : null; + let payload: any; + if (kind === "bill") { + if (!vendorId) return toast.error("Select a vendor"); + const liveItems = items.filter((i) => i.account_id && (Number(i.quantity) * Number(i.rate)) !== 0); + if (!liveItems.length) return toast.error("Add at least one line with an account and amount"); + // resolve the public vendor to its accounting.vendors row (find-or-create) + const { data: acctVendor, error: vErr } = await supabase.rpc("ensure_accounting_vendor", { _association_id: associationId, _public_vendor_id: vendorId }); + if (vErr) return toast.error(vErr.message); + payload = { + vendor_id: acctVendor, vendor_public_id: vendorId, + due_days: parseInt(dueDays) || 0, tax_pct: 0, memo: memo || null, + items: liveItems.map((i) => ({ account_id: i.account_id, description: i.description, quantity: Number(i.quantity), rate: Number(i.rate) })), + }; + } else { + const liveLines = lines.filter((l) => l.account_id && Number(l.amount)); + if (liveLines.length < 2) return toast.error("Add at least two lines with an account and amount"); + if (!jBalanced) return toast.error(`Out of balance by ${money(Math.abs(jSigned), cur)} — debits (+) must equal credits (−)`); + payload = { + description: jDescription || name, reference: jReference || null, + lines: liveLines.map((l) => ({ account_id: l.account_id, amount: Number(l.amount), description: l.description || null })), + }; + } + setSaving(true); + const row: any = { + company_id: cid, kind, name: name.trim(), frequency, interval_count: n, day_of_month: dom, + start_date: startDate, next_run_date: startDate, end_date: endDate || null, payload, + }; + let error; + if (editingId) { + // keep start_date stable on edit; update everything else incl. next_run_date + delete row.start_date; + ({ error } = await accounting.from("recurring_templates").update(row).eq("id", editingId)); + } else { + ({ error } = await accounting.from("recurring_templates").insert(row)); + } + setSaving(false); + if (error) return toast.error(error.message); + toast.success(editingId ? "Recurring template updated" : "Recurring template created"); + setOpen(false); resetForm(); + qc.invalidateQueries({ queryKey: ["recurring-templates", cid] }); + }; + + const togglePause = async (t: any) => { + const { error } = await accounting.from("recurring_templates").update({ active: !t.active }).eq("id", t.id); + if (error) return toast.error(error.message); + qc.invalidateQueries({ queryKey: ["recurring-templates", cid] }); + }; + const remove = async (t: any) => { + if (!confirm(`Delete recurring ${t.kind} "${t.name}"? Already-generated bills/journals are kept.`)) return; + const { error } = await accounting.from("recurring_templates").delete().eq("id", t.id); + if (error) return toast.error(error.message); + toast.success("Template deleted"); + qc.invalidateQueries({ queryKey: ["recurring-templates", cid] }); + }; + const generateDue = async () => { + setGenerating(true); + const { data, error } = await accounting.rpc("generate_due_recurring", { p_as_of: todayISO(), p_company_id: cid }); + setGenerating(false); + if (error) return toast.error(error.message); + const n = Number(data) || 0; + toast.success(n ? `Generated ${n} item${n === 1 ? "" : "s"}` : "Nothing due right now"); + qc.invalidateQueries({ queryKey: ["recurring-templates", cid] }); + qc.invalidateQueries({ queryKey: ["bills", cid] }); + qc.invalidateQueries({ queryKey: ["journal-entries", cid] }); + qc.invalidateQueries({ queryKey: ["reports-data", cid] }); + }; + + if (companyLoading) return
; + if (!associationId) return

Select an association.

; + if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; + + const dueCount = (templates as any[]).filter((t) => t.active && t.next_run_date <= todayISO()).length; + + return ( +
+
+
+

Recurring Bills & Journals

+

Templates auto-generate nightly; {dueCount > 0 ? `${dueCount} due now.` : "nothing due right now."}

+
+
+ + +
+
+ + + + {isLoading ? ( +
+ ) : (templates as any[]).length === 0 ? ( +
No recurring templates yet. Click New recurring to set up a recurring bill or journal entry.
+ ) : ( + + + + NameTypeSchedule + Next runAmount + StatusActions + + + + {(templates as any[]).map((t) => { + const due = t.active && t.next_run_date <= todayISO(); + return ( + + {t.name} + {t.kind} + {scheduleLabel(t)} + {fmtDate(t.next_run_date)}{due ? " · due" : ""} + {money(templateAmount(t), cur)} + {t.active ? Active : Paused} + + + + + + + ); + })} + +
+ )} +
+
+ + { setOpen(o); if (!o) resetForm(); }}> + + {editingId ? "Edit" : "New"} recurring {kind} +
+
+
+ + +
+
setName(e.target.value)} placeholder="e.g. Monthly management fee" />
+
+ +
+
+ + +
+
setIntervalCount(e.target.value)} />
+
setStartDate(e.target.value)} />
+
setEndDate(e.target.value)} />
+
+ {frequency !== "weekly" && ( +
setDayOfMonth(e.target.value)} placeholder="from start" />
+ )} + + {kind === "bill" ? ( +
+
+
+ + +
+
setDueDays(e.target.value)} />
+
+
setMemo(e.target.value)} placeholder="optional" />
+
+ + {items.map((it, idx) => ( +
+ + setItems(items.map((x, i) => i === idx ? { ...x, description: e.target.value } : x))} /> + setItems(items.map((x, i) => i === idx ? { ...x, quantity: Number(e.target.value) } : x))} /> + setItems(items.map((x, i) => i === idx ? { ...x, rate: Number(e.target.value) } : x))} /> + +
+ ))} + +
+
Total per bill: {money(billTotal, cur)}
+
+ ) : ( +
+
+
setJDescription(e.target.value)} placeholder={name || "Journal description"} />
+
setJReference(e.target.value)} />
+
+
+ + {lines.map((ln, idx) => ( +
+ + setLines(lines.map((x, i) => i === idx ? { ...x, description: e.target.value } : x))} /> + setLines(lines.map((x, i) => i === idx ? { ...x, amount: e.target.value } : x))} /> + +
+ ))} + +
+ {jBalanced ? "Balanced" : `Out of balance by ${money(Math.abs(jSigned), cur)}`} +
+
+
+ )} +
+ + + + +
+
+
+ ); +} diff --git a/supabase/migrations/20260614210000_accounting_recurring_templates.sql b/supabase/migrations/20260614210000_accounting_recurring_templates.sql new file mode 100644 index 0000000..4a24b5f --- /dev/null +++ b/supabase/migrations/20260614210000_accounting_recurring_templates.sql @@ -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$);