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:
@@ -24,6 +24,7 @@ import {
|
|||||||
AccountingDashboardPage,
|
AccountingDashboardPage,
|
||||||
AccountingChartOfAccountsPage,
|
AccountingChartOfAccountsPage,
|
||||||
AccountingJournalEntriesPage,
|
AccountingJournalEntriesPage,
|
||||||
|
AccountingRecurringPage,
|
||||||
AccountingGeneralLedgerPage,
|
AccountingGeneralLedgerPage,
|
||||||
AccountingInvoicesPage,
|
AccountingInvoicesPage,
|
||||||
AccountingBillsPage,
|
AccountingBillsPage,
|
||||||
@@ -381,6 +382,7 @@ const App = () => (
|
|||||||
<Route index element={<AccountingDashboardPage />} />
|
<Route index element={<AccountingDashboardPage />} />
|
||||||
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
||||||
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
||||||
|
<Route path="recurring" element={<AccountingRecurringPage />} />
|
||||||
<Route path="general-ledger" element={<AccountingGeneralLedgerPage />} />
|
<Route path="general-ledger" element={<AccountingGeneralLedgerPage />} />
|
||||||
<Route path="invoices" element={<AccountingInvoicesPage />} />
|
<Route path="invoices" element={<AccountingInvoicesPage />} />
|
||||||
<Route path="bills" element={<AccountingBillsPage />} />
|
<Route path="bills" element={<AccountingBillsPage />} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { default as AccountingLayout } from "./AccountingLayout";
|
|||||||
export { default as AccountingDashboardPage } from "./AccountingDashboardPage";
|
export { default as AccountingDashboardPage } from "./AccountingDashboardPage";
|
||||||
export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage";
|
export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage";
|
||||||
export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage";
|
export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage";
|
||||||
|
export { default as AccountingRecurringPage } from "./AccountingRecurringPage";
|
||||||
export { default as AccountingGeneralLedgerPage } from "./AccountingGeneralLedgerPage";
|
export { default as AccountingGeneralLedgerPage } from "./AccountingGeneralLedgerPage";
|
||||||
export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage";
|
export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage";
|
||||||
export { default as AccountingBillsPage } from "./AccountingBillsPage";
|
export { default as AccountingBillsPage } from "./AccountingBillsPage";
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const NAV: NavSection[] = [
|
|||||||
{ to: "chart-of-accounts", label: "Chart of Accounts" },
|
{ to: "chart-of-accounts", label: "Chart of Accounts" },
|
||||||
{ to: "budgets", label: "Budgeting" },
|
{ to: "budgets", label: "Budgeting" },
|
||||||
{ to: "journal-entries", label: "Journal Entries" },
|
{ to: "journal-entries", label: "Journal Entries" },
|
||||||
|
{ to: "recurring", label: "Recurring" },
|
||||||
{ to: "general-ledger", label: "General Ledger" },
|
{ to: "general-ledger", label: "General Ledger" },
|
||||||
{ to: "banking", label: "Banking" },
|
{ to: "banking", label: "Banking" },
|
||||||
{ to: "reconciliation", label: "Reconciliation" },
|
{ 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 & 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$);
|
||||||
Reference in New Issue
Block a user