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:
2026-06-14 23:28:43 -04:00
parent 91882a0422
commit 266a99d4b2
5 changed files with 525 additions and 0 deletions
+2
View File
@@ -24,6 +24,7 @@ import {
AccountingDashboardPage,
AccountingChartOfAccountsPage,
AccountingJournalEntriesPage,
AccountingRecurringPage,
AccountingGeneralLedgerPage,
AccountingInvoicesPage,
AccountingBillsPage,
@@ -381,6 +382,7 @@ const App = () => (
<Route index element={<AccountingDashboardPage />} />
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
<Route path="recurring" element={<AccountingRecurringPage />} />
<Route path="general-ledger" element={<AccountingGeneralLedgerPage />} />
<Route path="invoices" element={<AccountingInvoicesPage />} />
<Route path="bills" element={<AccountingBillsPage />} />
+1
View File
@@ -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";
@@ -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" },
@@ -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<string | null>(null);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
// shared schedule fields
const [kind, setKind] = useState<Kind>("bill");
const [name, setName] = useState("");
const [frequency, setFrequency] = useState<Freq>("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<Item[]>([newItem()]);
// journal fields
const [jDescription, setJDescription] = useState("");
const [jReference, setJReference] = useState("");
const [lines, setLines] = useState<Line[]>([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 <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
const dueCount = (templates as any[]).filter((t) => t.active && t.next_run_date <= todayISO()).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-xl font-bold tracking-tight">Recurring Bills &amp; Journals</h1>
<p className="text-sm text-muted-foreground">Templates auto-generate nightly; {dueCount > 0 ? `${dueCount} due now.` : "nothing due right now."}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={generateDue} disabled={generating}>
{generating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-1 h-4 w-4" />} Generate due now
</Button>
<Button onClick={openCreate}><Plus className="mr-1 h-4 w-4" /> New recurring</Button>
</div>
</div>
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
) : (templates as any[]).length === 0 ? (
<div className="p-10 text-center text-sm text-muted-foreground">No recurring templates yet. Click <strong>New recurring</strong> to set up a recurring bill or journal entry.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead><TableHead>Type</TableHead><TableHead>Schedule</TableHead>
<TableHead>Next run</TableHead><TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(templates as any[]).map((t) => {
const due = t.active && t.next_run_date <= todayISO();
return (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{t.kind}</Badge></TableCell>
<TableCell className="capitalize text-muted-foreground">{scheduleLabel(t)}</TableCell>
<TableCell className={due ? "text-amber-600 font-medium" : ""}>{fmtDate(t.next_run_date)}{due ? " · due" : ""}</TableCell>
<TableCell className="text-right tabular-nums">{money(templateAmount(t), cur)}</TableCell>
<TableCell>{t.active ? <Badge>Active</Badge> : <Badge variant="secondary">Paused</Badge>}</TableCell>
<TableCell className="text-right whitespace-nowrap">
<Button variant="ghost" size="icon" title={t.active ? "Pause" : "Resume"} onClick={() => togglePause(t)}>
{t.active ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" title="Edit" onClick={() => openEdit(t)}><Pencil className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" title="Delete" onClick={() => remove(t)}><Trash2 className="h-4 w-4" /></Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{editingId ? "Edit" : "New"} recurring {kind}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Type</Label>
<Select value={kind} onValueChange={(v) => setKind(v as Kind)} disabled={!!editingId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="bill">Bill (A/P)</SelectItem><SelectItem value="journal">Journal entry</SelectItem></SelectContent>
</Select>
</div>
<div><Label>Name</Label><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Monthly management fee" /></div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<Label>Frequency</Label>
<Select value={frequency} onValueChange={(v) => setFrequency(v as Freq)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{FREQS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div><Label>Every</Label><Input type="number" min="1" value={intervalCount} onChange={(e) => setIntervalCount(e.target.value)} /></div>
<div><Label>{editingId ? "Next run" : "Start date"}</Label><Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} /></div>
<div><Label>End date <span className="text-muted-foreground">(optional)</span></Label><Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} /></div>
</div>
{frequency !== "weekly" && (
<div className="w-40"><Label>Day of month <span className="text-muted-foreground">(opt)</span></Label><Input type="number" min="1" max="31" value={dayOfMonth} onChange={(e) => setDayOfMonth(e.target.value)} placeholder="from start" /></div>
)}
{kind === "bill" ? (
<div className="space-y-3 border-t pt-3">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div className="col-span-2">
<Label>Vendor (Pay to)</Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
<SelectContent>{(vendors as any[]).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div><Label>Due in (days)</Label><Input type="number" min="0" value={dueDays} onChange={(e) => setDueDays(e.target.value)} /></div>
</div>
<div><Label>Memo</Label><Input value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="optional" /></div>
<div className="space-y-2">
<Label>Line items</Label>
{items.map((it, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center">
<Select value={it.account_id ?? ""} onValueChange={(v) => setItems(items.map((x, i) => i === idx ? { ...x, account_id: v } : x))}>
<SelectTrigger className="col-span-4"><SelectValue placeholder="Account" /></SelectTrigger>
<SelectContent>{billAccounts.map((a) => <SelectItem key={a.id} value={a.id}>{a.code} · {a.name}</SelectItem>)}</SelectContent>
</Select>
<Input className="col-span-4" placeholder="Description" value={it.description} onChange={(e) => setItems(items.map((x, i) => i === idx ? { ...x, description: e.target.value } : x))} />
<Input className="col-span-1" type="number" title="Qty" value={it.quantity} onChange={(e) => setItems(items.map((x, i) => i === idx ? { ...x, quantity: Number(e.target.value) } : x))} />
<Input className="col-span-2" type="number" title="Rate" value={it.rate} onChange={(e) => setItems(items.map((x, i) => i === idx ? { ...x, rate: Number(e.target.value) } : x))} />
<Button variant="ghost" size="icon" className="col-span-1" onClick={() => setItems(items.length > 1 ? items.filter((_, i) => i !== idx) : items)}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => setItems([...items, newItem()])}><Plus className="mr-1 h-3 w-3" /> Add line</Button>
</div>
<div className="text-right text-sm font-medium">Total per bill: {money(billTotal, cur)}</div>
</div>
) : (
<div className="space-y-3 border-t pt-3">
<div className="grid grid-cols-2 gap-3">
<div><Label>Description</Label><Input value={jDescription} onChange={(e) => setJDescription(e.target.value)} placeholder={name || "Journal description"} /></div>
<div><Label>Reference <span className="text-muted-foreground">(opt)</span></Label><Input value={jReference} onChange={(e) => setJReference(e.target.value)} /></div>
</div>
<div className="space-y-2">
<Label>Lines <span className="text-muted-foreground">(+ debit, credit)</span></Label>
{lines.map((ln, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center">
<Select value={ln.account_id} onValueChange={(v) => setLines(lines.map((x, i) => i === idx ? { ...x, account_id: v } : x))}>
<SelectTrigger className="col-span-5"><SelectValue placeholder="Account" /></SelectTrigger>
<SelectContent>{(accounts as any[]).map((a) => <SelectItem key={a.id} value={a.id}>{a.code} · {a.name}</SelectItem>)}</SelectContent>
</Select>
<Input className="col-span-4" placeholder="Description" value={ln.description} onChange={(e) => setLines(lines.map((x, i) => i === idx ? { ...x, description: e.target.value } : x))} />
<Input className="col-span-2" type="number" placeholder="+/-" value={ln.amount} onChange={(e) => setLines(lines.map((x, i) => i === idx ? { ...x, amount: e.target.value } : x))} />
<Button variant="ghost" size="icon" className="col-span-1" onClick={() => setLines(lines.length > 2 ? lines.filter((_, i) => i !== idx) : lines)}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => setLines([...lines, newLine()])}><Plus className="mr-1 h-3 w-3" /> Add line</Button>
<div className={`text-right text-sm font-medium ${jBalanced ? "text-muted-foreground" : "text-destructive"}`}>
{jBalanced ? "Balanced" : `Out of balance by ${money(Math.abs(jSigned), cur)}`}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
<Button onClick={save} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} {editingId ? "Save" : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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$);