Accounting: A/P-clearing payments, check return address + MICR gaps, dashboard fixes

Bill/vendor payments:
- Bill "Pay" and bank-register vendor debits now clear Accounts Payable
  (Dr A/P / Cr Bank) instead of re-debiting the expense, which had
  double-counted expenses in the P&L and never cleared A/P. The expense
  account is kept as a display-only category on the payment.

Checks:
- Bill-payment checks now print the return address (payer name/address)
  from the company check layout (was hardcoded blank).
- Per-segment MICR gap control (check# / routing / account) in both check
  generators, wired to Check Setup (bill payments) and the Check Layout
  editor (Print Checks). New columns: check_settings.micr_gap_1/2 and
  check_layouts/company_check_layouts.micr_gap_1/2.

Accounting Dashboard / Financial Overview:
- Date-range selector (presets + custom) drives the charts, top expenses,
  and recent transactions.
- PDF title renamed to "Financial Overview" and shows the period.
- Fixed amounts rendering as "$ 5 0 0. 0 0": the U+2212 minus sign forced
  jsPDF into a UTF-16 fallback; replaced with an ASCII hyphen (also in the
  report PDF out-of-balance line).

DB migrations applied to the project: repost_gl_on_line_item_change,
budget_actuals_accrual_owner_income, add_micr_gap_controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 00:35:30 -04:00
parent 5bf2a5887e
commit c3a0682e57
9 changed files with 228 additions and 67 deletions
+19
View File
@@ -53,6 +53,8 @@ const DEFAULTS: CheckLayout = {
font_family: "helvetica", font_family: "helvetica",
field_positions: {}, field_positions: {},
logo_url: "", logo_url: "",
micr_gap_1: 3,
micr_gap_2: 3,
}; };
const FIELD_ORDER: CheckFieldKey[] = [ const FIELD_ORDER: CheckFieldKey[] = [
@@ -107,6 +109,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: (data as any).font_family || "helvetica", font_family: (data as any).font_family || "helvetica",
field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {}, field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
logo_url: (data as any).logo_url || "", logo_url: (data as any).logo_url || "",
micr_gap_1: Number((data as any).micr_gap_1 ?? 3),
micr_gap_2: Number((data as any).micr_gap_2 ?? 3),
}); });
} else { } else {
setLayoutId(null); setLayoutId(null);
@@ -178,6 +182,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: (data as any).font_family || "helvetica", font_family: (data as any).font_family || "helvetica",
field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {}, field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
logo_url: (data as any).logo_url || "", logo_url: (data as any).logo_url || "",
micr_gap_1: Number((data as any).micr_gap_1 ?? 3),
micr_gap_2: Number((data as any).micr_gap_2 ?? 3),
}); });
toast({ toast({
title: "Settings copied", title: "Settings copied",
@@ -255,6 +261,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: layout.font_family || "helvetica", font_family: layout.font_family || "helvetica",
field_positions: (layout.field_positions || {}) as any, field_positions: (layout.field_positions || {}) as any,
logo_url: layout.logo_url || null, logo_url: layout.logo_url || null,
micr_gap_1: Number(layout.micr_gap_1 ?? 3),
micr_gap_2: Number(layout.micr_gap_2 ?? 3),
}; };
const payload: any = isCompany const payload: any = isCompany
? basePayload ? basePayload
@@ -412,6 +420,17 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
<Input type="number" step="0.05" value={layout.offset_y ?? 0} <Input type="number" step="0.05" value={layout.offset_y ?? 0}
onChange={(e) => update("offset_y", Number(e.target.value))} /> onChange={(e) => update("offset_y", Number(e.target.value))} />
</div> </div>
<div>
<Label>MICR gap: Check # → Routing</Label>
<Input type="number" min={0} step={1} value={layout.micr_gap_1 ?? 3}
onChange={(e) => update("micr_gap_1", Math.max(0, parseInt(e.target.value) || 0))} />
<p className="text-xs text-muted-foreground mt-1">Spaces between segments in the MICR line.</p>
</div>
<div>
<Label>MICR gap: Routing → Account</Label>
<Input type="number" min={0} step={1} value={layout.micr_gap_2 ?? 3}
onChange={(e) => update("micr_gap_2", Math.max(0, parseInt(e.target.value) || 0))} />
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -256,9 +256,14 @@ export default function AccountingBankingPage() {
const description = [partyName, coaName, memo].filter(Boolean).join(" · "); const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
const category = coaName; const category = coaName;
// A vendor payment (debit) clears Accounts Payable — the expense was already
// recognized when the bill was entered (accrual). Leaving coa_account_id null
// with the vendor set makes post_transaction_gl post Dr A/P / Cr Bank; the
// chosen expense account is retained as the display `category` only. Customer
// deposits (credits) clear A/R via customer_id, so they need no change here.
const payload: any = { const payload: any = {
account_id, date, description, amount, type, category, reference: reference || null, account_id, date, description, amount, type, category, reference: reference || null,
coa_account_id: coa_account_id || null, coa_account_id: type === "debit" ? null : (coa_account_id || null),
vendor_id: vendor_id || null, vendor_id: vendor_id || null,
customer_id: customer_id || null, customer_id: customer_id || null,
}; };
+35 -14
View File
@@ -329,10 +329,21 @@ export default function AccountingBillsPage() {
const { data: billItems } = await accounting.from("bill_items").select("description,amount").eq("bill_id", payBill?.id ?? ""); const { data: billItems } = await accounting.from("bill_items").select("description,amount").eq("bill_id", payBill?.id ?? "");
const vendorAddress = payBill?.vendors?.address ?? undefined; const vendorAddress = payBill?.vendors?.address ?? undefined;
// Return-address (payer) block comes from the company check layout the user
// configured in Settings → Check Layout. Without this, checks print no
// return address.
const { data: layout } = await supabase
.from("company_check_layouts")
.select("payer_name, payer_address, show_payer_block")
.maybeSingle();
const payerName = (layout?.payer_name || "").trim();
const showPayer = layout?.show_payer_block !== false;
const returnAddress = showPayer ? (layout?.payer_address || undefined) : undefined;
const dataUrl = generateCheckPDF( const dataUrl = generateCheckPDF(
[{ [{
companyName: associationName ?? "Company", companyName: (payerName || associationName) ?? "Company",
companyAddress: undefined, companyAddress: returnAddress,
bankName: cs?.bank_name ?? bankAccount?.name ?? undefined, bankName: cs?.bank_name ?? bankAccount?.name ?? undefined,
bankAddress: cs?.bank_address ?? undefined, bankAddress: cs?.bank_address ?? undefined,
routingNumber: cs?.routing_number ?? undefined, routingNumber: cs?.routing_number ?? undefined,
@@ -355,6 +366,8 @@ export default function AccountingBillsPage() {
offsetX: (cs as any)?.offset_x ?? 0, offsetX: (cs as any)?.offset_x ?? 0,
offsetY: (cs as any)?.offset_y ?? 0, offsetY: (cs as any)?.offset_y ?? 0,
micrOffsetY: (cs as any)?.micr_offset_y ?? 0, micrOffsetY: (cs as any)?.micr_offset_y ?? 0,
micrGap1: (cs as any)?.micr_gap_1 ?? 1,
micrGap2: (cs as any)?.micr_gap_2 ?? 1,
} }
); );
const w = window.open(""); const w = window.open("");
@@ -372,16 +385,24 @@ export default function AccountingBillsPage() {
const vendorName = payBill.vendors?.name ?? "Vendor"; const vendorName = payBill.vendors?.name ?? "Vendor";
const refLabel = payReference || payMethod.toUpperCase(); const refLabel = payReference || payMethod.toUpperCase();
// 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor) // Expense category name(s) from the bill — shown on the payment line in the
// Pull the primary expense account from the first bill item so the COA balance updates // transaction journal for visibility (display only; does not affect the GL).
const { data: billItems } = await accounting const { data: payItems } = await accounting
.from("bill_items") .from("bill_items").select("account_id").eq("bill_id", payBill.id).not("account_id", "is", null);
.select("account_id") const payAcctIds = Array.from(new Set((payItems ?? []).map((i: any) => i.account_id)));
.eq("bill_id", payBill.id) let categoryLabel = "Bill Payment";
.not("account_id", "is", null) if (payAcctIds.length) {
.limit(1); const { data: payAccs } = await accounting.from("accounts").select("name").in("id", payAcctIds);
const primaryCoa = billItems?.[0]?.account_id ?? null; const names = (payAccs ?? []).map((a: any) => a.name).filter(Boolean);
if (names.length) categoryLabel = names.join(", ");
}
// 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor).
// A bill payment must clear Accounts Payable, NOT re-hit the expense account:
// the expense was already recognized when the bill was entered (accrual).
// coa_account_id stays null with vendor_id set, so post_transaction_gl posts
// Dr Accounts Payable / Cr Bank (clears the payable, no second P&L hit). The
// expense category is stored in `category` for the journal view only.
await accounting.from("transactions").insert({ await accounting.from("transactions").insert({
company_id: cid, company_id: cid,
account_id: payAccountId, account_id: payAccountId,
@@ -389,10 +410,10 @@ export default function AccountingBillsPage() {
type: "debit", type: "debit",
amount: payAmount, amount: payAmount,
description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`, description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`,
category: "Bill Payment", category: categoryLabel,
reference: refLabel, reference: refLabel,
coa_account_id: primaryCoa, // links to expense account → updates COA balance coa_account_id: null, // → posts against Accounts Payable (via vendor)
vendor_id: payBill.vendor_id ?? null, // link to vendor for reporting vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P
}); });
// 2) Bank balance auto-updated by DB trigger trg_sync_account_balance // 2) Bank balance auto-updated by DB trigger trg_sync_account_balance
@@ -35,6 +35,8 @@ export default function AccountingCheckSetupPage() {
const [offsetX, setOffsetX] = useState(0); const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0); const [offsetY, setOffsetY] = useState(0);
const [micrOffsetY, setMicrOffsetY] = useState(0); const [micrOffsetY, setMicrOffsetY] = useState(0);
const [micrGap1, setMicrGap1] = useState(1);
const [micrGap2, setMicrGap2] = useState(1);
const { data: settings } = useQuery({ const { data: settings } = useQuery({
queryKey: ["check-settings", cid], queryKey: ["check-settings", cid],
@@ -67,6 +69,8 @@ export default function AccountingCheckSetupPage() {
setOffsetX(Number(s.offset_x ?? 0)); setOffsetX(Number(s.offset_x ?? 0));
setOffsetY(Number(s.offset_y ?? 0)); setOffsetY(Number(s.offset_y ?? 0));
setMicrOffsetY(Number(s.micr_offset_y ?? 0)); setMicrOffsetY(Number(s.micr_offset_y ?? 0));
setMicrGap1(Number(s.micr_gap_1 ?? 1));
setMicrGap2(Number(s.micr_gap_2 ?? 1));
}, [settings]); }, [settings]);
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, "")); const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
@@ -90,6 +94,8 @@ export default function AccountingCheckSetupPage() {
offset_x: offsetX, offset_x: offsetX,
offset_y: offsetY, offset_y: offsetY,
micr_offset_y: micrOffsetY, micr_offset_y: micrOffsetY,
micr_gap_1: micrGap1,
micr_gap_2: micrGap2,
}, { onConflict: "company_id" }); }, { onConflict: "company_id" });
setSaving(false); setSaving(false);
if (error) return toast.error(error.message); if (error) return toast.error(error.message);
@@ -130,7 +136,7 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any, style: defaultStyle as any,
position: defaultPosition as any, position: defaultPosition as any,
fontSize: fontSize as any, fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY, offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
}); });
const w = window.open(""); const w = window.open("");
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`); if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`);
@@ -141,7 +147,7 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any, style: defaultStyle as any,
position: defaultPosition as any, position: defaultPosition as any,
fontSize: fontSize as any, fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY, offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
}); });
const w = window.open(""); const w = window.open("");
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`); if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
@@ -345,6 +351,13 @@ export default function AccountingCheckSetupPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Run the alignment test first, measure the offset on blank paper, then adjust here and preview again. Run the alignment test first, measure the offset on blank paper, then adjust here and preview again.
</p> </p>
<div className="border-t pt-3">
<p className="text-sm font-medium">MICR gaps <span className="text-xs font-normal text-muted-foreground">(spaces between segments)</span></p>
<div className="grid grid-cols-2 gap-6 mt-2">
<GapControl label="Check # → Routing" value={micrGap1} onChange={setMicrGap1} />
<GapControl label="Routing → Account" value={micrGap2} onChange={setMicrGap2} />
</div>
</div>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -368,6 +381,35 @@ export default function AccountingCheckSetupPage() {
); );
} }
function GapControl({ label, value, onChange }: {
label: string; value: number; onChange: (v: number) => void;
}) {
const adj = (d: number) => onChange(Math.max(0, Math.round(value + d)));
return (
<div className="space-y-1.5">
<p className="text-xs font-medium text-foreground">{label}</p>
<div className="flex items-center gap-1">
<button onClick={() => adj(-1)}
className="h-7 w-7 rounded border bg-background hover:bg-muted flex items-center justify-center font-bold text-sm shrink-0">
</button>
<Input
type="number"
min={0}
step={1}
value={value}
onChange={(e) => onChange(Math.max(0, parseInt(e.target.value) || 0))}
className="h-7 text-center font-mono text-xs"
/>
<button onClick={() => adj(1)}
className="h-7 w-7 rounded border bg-background hover:bg-muted flex items-center justify-center font-bold text-sm shrink-0">
+
</button>
</div>
</div>
);
}
function OffsetControl({ label, hint, value, onChange }: { function OffsetControl({ label, hint, value, onChange }: {
label: string; hint: string; value: number; onChange: (v: number) => void; label: string; hint: string; value: number; onChange: (v: number) => void;
}) { }) {
@@ -6,8 +6,30 @@ import { useCompanyId } from "./lib/useCompanyId";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
import { generateDashboardPdf } from "./lib/dashboardPdf"; import { generateDashboardPdf } from "./lib/dashboardPdf";
type RangePreset = "this-month" | "this-quarter" | "ytd" | "last-6-months" | "custom";
function presetRange(p: RangePreset): { from: string; to: string } {
const now = new Date();
const y = now.getFullYear();
const iso = (d: Date) => d.toISOString().slice(0, 10);
const today = iso(now);
switch (p) {
case "this-month": return { from: iso(new Date(y, now.getMonth(), 1)), to: today };
case "this-quarter": return { from: iso(new Date(y, Math.floor(now.getMonth() / 3) * 3, 1)), to: today };
case "ytd": return { from: `${y}-01-01`, to: today };
case "custom": return { from: `${y}-01-01`, to: today };
case "last-6-months":
default: {
const s = new Date(y, now.getMonth() - 5, 1);
return { from: iso(s), to: today };
}
}
}
import { import {
Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown, Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown,
} from "lucide-react"; } from "lucide-react";
@@ -31,15 +53,19 @@ export default function AccountingDashboardPage({ association }: { association?:
const logoUrl = (assocMeta as any)?.logo_url || null; const logoUrl = (assocMeta as any)?.logo_url || null;
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
// Date range for the charts + recent transactions (receivables/payables/cash
// remain current snapshots — they are point-in-time by nature).
const [preset, setPreset] = useState<RangePreset>("last-6-months");
const [range, setRange] = useState(() => presetRange("last-6-months"));
const from = range.from;
const to = range.to;
const applyPreset = (p: RangePreset) => { setPreset(p); if (p !== "custom") setRange(presetRange(p)); };
const rangeLabel = `${fmtDate(from)} ${fmtDate(to)}`;
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["dashboard", cid], queryKey: ["dashboard", cid, from, to],
enabled: !!cid, enabled: !!cid,
queryFn: async () => { queryFn: async () => {
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5);
sixMonthsAgo.setDate(1);
const sinceIso = sixMonthsAgo.toISOString().slice(0, 10);
const [inv, bills, tx, acc] = await Promise.all([ const [inv, bills, tx, acc] = await Promise.all([
accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid), accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid),
accounting.from("bills").select("total,status,issue_date").eq("company_id", cid), accounting.from("bills").select("total,status,issue_date").eq("company_id", cid),
@@ -47,8 +73,10 @@ export default function AccountingDashboardPage({ association }: { association?:
.from("transactions") .from("transactions")
.select("amount,type,date,description,category,reference") .select("amount,type,date,description,category,reference")
.eq("company_id", cid) .eq("company_id", cid)
.gte("date", from)
.lte("date", to)
.order("date", { ascending: false }) .order("date", { ascending: false })
.limit(10), .limit(50),
accounting.from("accounts").select("balance,is_bank").eq("company_id", cid), accounting.from("accounts").select("balance,is_bank").eq("company_id", cid),
]); ]);
@@ -78,28 +106,32 @@ export default function AccountingDashboardPage({ association }: { association?:
} }
} }
// 6 month income/expense // Income/expense by month across the selected range
const months: { key: string; label: string; income: number; expense: number }[] = []; const months: { key: string; label: string; income: number; expense: number }[] = [];
const now = new Date(); const cursor = new Date(`${from}T00:00:00`);
for (let i = 5; i >= 0; i--) { cursor.setDate(1);
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const lastMonth = new Date(`${to}T00:00:00`);
lastMonth.setDate(1);
while (cursor <= lastMonth && months.length < 36) {
months.push({ months.push({
key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`, key: `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`,
label: d.toLocaleString("en-US", { month: "short" }), label: cursor.toLocaleString("en-US", { month: "short", year: "2-digit" }),
income: 0, income: 0,
expense: 0, expense: 0,
}); });
cursor.setMonth(cursor.getMonth() + 1);
} }
const bucket = (date: string) => date.slice(0, 7); const bucket = (date: string) => date.slice(0, 7);
const inWin = (d?: string | null) => !!d && d >= from && d <= to;
for (const i of invoices) { for (const i of invoices) {
if (i.issue_date && i.issue_date >= sinceIso) { if (inWin(i.issue_date)) {
const m = months.find((x) => x.key === bucket(i.issue_date)); const m = months.find((x) => x.key === bucket(i.issue_date!));
if (m) m.income += Number(i.total); if (m) m.income += Number(i.total);
} }
} }
for (const b of billsArr) { for (const b of billsArr) {
if (b.issue_date && b.issue_date >= sinceIso) { if (inWin(b.issue_date)) {
const m = months.find((x) => x.key === bucket(b.issue_date)); const m = months.find((x) => x.key === bucket(b.issue_date!));
if (m) m.expense += Number(b.total); if (m) m.expense += Number(b.total);
} }
} }
@@ -124,7 +156,7 @@ export default function AccountingDashboardPage({ association }: { association?:
counts, counts,
months, months,
topExpenses, topExpenses,
recent: txAll, recent: txAll.slice(0, 15),
}; };
}, },
}); });
@@ -175,8 +207,28 @@ export default function AccountingDashboardPage({ association }: { association?:
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">{associationName}</p> <p className="text-sm text-muted-foreground">{associationName} · {rangeLabel}</p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Select value={preset} onValueChange={(v) => applyPreset(v as RangePreset)}>
<SelectTrigger className="h-9 w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="this-month">This Month</SelectItem>
<SelectItem value="this-quarter">This Quarter</SelectItem>
<SelectItem value="ytd">Year to Date</SelectItem>
<SelectItem value="last-6-months">Last 6 Months</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{preset === "custom" && (
<div className="flex items-center gap-1">
<Input type="date" value={from} className="h-9 w-[140px]"
onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))} />
<span className="text-muted-foreground text-xs">to</span>
<Input type="date" value={to} className="h-9 w-[140px]"
onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))} />
</div>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -186,7 +238,7 @@ export default function AccountingDashboardPage({ association }: { association?:
if (!data) return; if (!data) return;
setExporting(true); setExporting(true);
try { try {
await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data }); await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data, rangeLabel });
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -195,6 +247,7 @@ export default function AccountingDashboardPage({ association }: { association?:
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />} Export PDF {exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />} Export PDF
</Button> </Button>
</div> </div>
</div>
{/* Cash flow summary */} {/* Cash flow summary */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
+17 -6
View File
@@ -51,6 +51,10 @@ export type CheckPrintOptions = {
offsetY?: number; offsetY?: number;
/** Extra vertical shift for the MICR line only (fine-tune for check stock) */ /** Extra vertical shift for the MICR line only (fine-tune for check stock) */
micrOffsetY?: number; micrOffsetY?: number;
/** Spaces between MICR check# and routing segments (default 1) */
micrGap1?: number;
/** Spaces between MICR routing and account segments (default 1) */
micrGap2?: number;
}; };
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
@@ -71,12 +75,15 @@ const CHECK_H = SECTION_H; // check occupies the full top section
// Format matches reference check: C000000305C A263191387A 1100034740184C // Format matches reference check: C000000305C A263191387A 1100034740184C
// C{check#}C A{routing}A {account}C // C{check#}C A{routing}A {account}C
function buildMicr(routing: string, account: string, checkNum: number): string { function buildMicr(routing: string, account: string, checkNum: number, gap1 = 1, gap2 = 1): string {
const r = (routing ?? "").replace(/\D/g, "").slice(0, 9); const r = (routing ?? "").replace(/\D/g, "").slice(0, 9);
const a = (account ?? "").replace(/\D/g, ""); const a = (account ?? "").replace(/\D/g, "");
const c = String(checkNum).padStart(9, "0"); const c = String(checkNum).padStart(9, "0");
if (!r && !a) return ""; if (!r && !a) return "";
return `C${c}C A${r}A ${a}C`; const g1 = " ".repeat(Math.max(0, Math.round(gap1)));
const g2 = " ".repeat(Math.max(0, Math.round(gap2)));
// C{check#}C {gap1} A{routing}A {gap2} {account}C
return `C${c}C${g1}A${r}A${g2}${a}C`;
} }
// ── Fill a line to width with a pad character (for written-amount security) ─ // ── Fill a line to width with a pad character (for written-amount security) ─
@@ -103,7 +110,7 @@ function hline(doc: jsPDF, x1: number, y: number, x2: number, w = 0.006, gray =
// ── Check face ─────────────────────────────────────────────────────────────── // ── Check face ───────────────────────────────────────────────────────────────
function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0) { function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0, micrGap1 = 1, micrGap2 = 1) {
const y = (dy: number) => originY + dy; const y = (dy: number) => originY + dy;
const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate
@@ -270,7 +277,9 @@ function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0
const micr = buildMicr( const micr = buildMicr(
(c.routingNumber ?? "").replace(/\D/g, ""), (c.routingNumber ?? "").replace(/\D/g, ""),
(c.accountNumber ?? "").replace(/\D/g, ""), (c.accountNumber ?? "").replace(/\D/g, ""),
c.checkNumber c.checkNumber,
micrGap1,
micrGap2
); );
if (micr) { if (micr) {
@@ -351,19 +360,21 @@ export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions):
const ox = opts.offsetX ?? 0; const ox = opts.offsetX ?? 0;
const oy = opts.offsetY ?? 0; const oy = opts.offsetY ?? 0;
const micrOy = opts.micrOffsetY ?? 0; const micrOy = opts.micrOffsetY ?? 0;
const g1 = opts.micrGap1 ?? 1;
const g2 = opts.micrGap2 ?? 1;
checks.forEach((c, idx) => { checks.forEach((c, idx) => {
if (idx > 0) doc.addPage(); if (idx > 0) doc.addPage();
if (opts.style === "voucher") { if (opts.style === "voucher") {
drawCheck(doc, 0 + oy, c, ox, micrOy); drawCheck(doc, 0 + oy, c, ox, micrOy, g1, g2);
drawStub(doc, SECTION_H + oy, c, ox); drawStub(doc, SECTION_H + oy, c, ox);
drawStub(doc, SECTION_H * 2 + oy, c, ox); drawStub(doc, SECTION_H * 2 + oy, c, ox);
} else { } else {
let posY = 0; let posY = 0;
if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2; if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2;
if (opts.position === "bottom") posY = PH - CHECK_H; if (opts.position === "bottom") posY = PH - CHECK_H;
drawCheck(doc, posY + oy, c, ox, micrOy); drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2);
} }
}); });
+7 -3
View File
@@ -109,6 +109,7 @@ export async function generateDashboardPdf(opts: {
logoUrl?: string | null; logoUrl?: string | null;
currency?: string; currency?: string;
data: DashboardPdfData; data: DashboardPdfData;
rangeLabel?: string;
}) { }) {
const { companyName, data } = opts; const { companyName, data } = opts;
const currency = opts.currency || "USD"; const currency = opts.currency || "USD";
@@ -120,8 +121,11 @@ export async function generateDashboardPdf(opts: {
const logo = await loadBrandedLogo(opts.logoUrl); const logo = await loadBrandedLogo(opts.logoUrl);
let y = drawBrandedHeader(doc, { let y = drawBrandedHeader(doc, {
logo, logo,
title: "Accounting Dashboard", title: "Financial Overview",
metaLines: [{ label: "Properties:", value: companyName }], metaLines: [
{ label: "Properties:", value: companyName },
...(opts.rangeLabel ? [{ label: "Period:", value: opts.rangeLabel }] : []),
],
}); });
// ── Summary cards ── // ── Summary cards ──
@@ -194,7 +198,7 @@ export async function generateDashboardPdf(opts: {
t.description ?? "", t.description ?? "",
t.category ?? "—", t.category ?? "—",
t.reference ?? "—", t.reference ?? "—",
`${t.type === "credit" ? "+" : ""}${money(Math.abs(Number(t.amount || 0)), currency)}`, `${t.type === "credit" ? "+" : "-"}${money(Math.abs(Number(t.amount || 0)), currency)}`,
]), ]),
styles: { font: "helvetica", fontSize: 8, textColor: TEXT, lineColor: [225, 228, 232], lineWidth: 0.1 }, styles: { font: "helvetica", fontSize: 8, textColor: TEXT, lineColor: [225, 228, 232], lineWidth: 0.1 },
headStyles: { fillColor: [237, 239, 242], textColor: TEXT, fontStyle: "bold" }, headStyles: { fillColor: [237, 239, 242], textColor: TEXT, fontStyle: "bold" },
+1 -1
View File
@@ -236,7 +236,7 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...txt); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...txt);
const msg = ok const msg = ok
? "Balance Sheet is balanced" ? "Balance Sheet is balanced"
: `Balance Sheet is OUT OF BALANCE by ${fmtAmount(report.outOfBalanceAmount ?? 0)} (Assets Liabilities Equity)`; : `Balance Sheet is OUT OF BALANCE by ${fmtAmount(report.outOfBalanceAmount ?? 0)} (Assets - Liabilities - Equity)`;
doc.text(msg, ML + 8, y + 14); doc.text(msg, ML + 8, y + 14);
y += 28; y += 28;
} }
+10 -4
View File
@@ -93,6 +93,10 @@ export interface CheckLayout {
field_positions?: Partial<Record<CheckFieldKey, CheckFieldPosition>> | null; field_positions?: Partial<Record<CheckFieldKey, CheckFieldPosition>> | null;
/** New: optional logo image URL printed on the check. */ /** New: optional logo image URL printed on the check. */
logo_url?: string | null; logo_url?: string | null;
/** Spaces between MICR check# and routing segments (default 3). */
micr_gap_1?: number | null;
/** Spaces between MICR routing and account segments (default 3). */
micr_gap_2?: number | null;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -200,14 +204,16 @@ function registerMicrFont(doc: jsPDF) {
doc.addFont("MICRCHECK.ttf", "MICRCHECK", "normal"); doc.addFont("MICRCHECK.ttf", "MICRCHECK", "normal");
} }
export function formatMicrLine(routing: string, account: string, checkNumber: string): string { export function formatMicrLine(routing: string, account: string, checkNumber: string, gap1 = 3, gap2 = 3): string {
const r = (routing || "").replace(/\D/g, ""); const r = (routing || "").replace(/\D/g, "");
const a = (account || "").replace(/\D/g, ""); const a = (account || "").replace(/\D/g, "");
const c = (checkNumber || "").replace(/\D/g, ""); const c = (checkNumber || "").replace(/\D/g, "");
// Pad check number with leading zeros to 9 digits // Pad check number with leading zeros to 9 digits
const paddedCheck = c.padStart(9, "0"); const paddedCheck = c.padStart(9, "0");
// Order: check number (on-us), space, transit/routing, space, account const g1 = " ".repeat(Math.max(0, Math.round(gap1)));
return `${MICR_ON_US}${paddedCheck}${MICR_ON_US} ${MICR_TRANSIT}${r}${MICR_TRANSIT} ${a}${MICR_ON_US}`; const g2 = " ".repeat(Math.max(0, Math.round(gap2)));
// Order: check number (on-us), gap1, transit/routing, gap2, account
return `${MICR_ON_US}${paddedCheck}${MICR_ON_US}${g1}${MICR_TRANSIT}${r}${MICR_TRANSIT}${g2}${a}${MICR_ON_US}`;
} }
export function amountToWords(amount: number): string { export function amountToWords(amount: number): string {
@@ -462,7 +468,7 @@ function drawCheckSection(
if (includeMicr && c.bank_routing_number && c.bank_account_number) { if (includeMicr && c.bank_routing_number && c.bank_account_number) {
const p = px("micr"); const p = px("micr");
if (p.visible !== false) { if (p.visible !== false) {
const micr = formatMicrLine(c.bank_routing_number, c.bank_account_number, c.check_number || ""); const micr = formatMicrLine(c.bank_routing_number, c.bank_account_number, c.check_number || "", layout.micr_gap_1 ?? 3, layout.micr_gap_2 ?? 3);
doc.setFont("MICRCHECK", "normal").setFontSize(p.font_size || 12); doc.setFont("MICRCHECK", "normal").setFontSize(p.font_size || 12);
doc.text(micr, p.X, p.Y); doc.text(micr, p.X, p.Y);
} }