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",
field_positions: {},
logo_url: "",
micr_gap_1: 3,
micr_gap_2: 3,
};
const FIELD_ORDER: CheckFieldKey[] = [
@@ -107,6 +109,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: (data as any).font_family || "helvetica",
field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
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 {
setLayoutId(null);
@@ -178,6 +182,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: (data as any).font_family || "helvetica",
field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
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({
title: "Settings copied",
@@ -255,6 +261,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: layout.font_family || "helvetica",
field_positions: (layout.field_positions || {}) as any,
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
? basePayload
@@ -412,6 +420,17 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
<Input type="number" step="0.05" value={layout.offset_y ?? 0}
onChange={(e) => update("offset_y", Number(e.target.value))} />
</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>
</Card>
@@ -256,9 +256,14 @@ export default function AccountingBankingPage() {
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
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 = {
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,
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 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(
[{
companyName: associationName ?? "Company",
companyAddress: undefined,
companyName: (payerName || associationName) ?? "Company",
companyAddress: returnAddress,
bankName: cs?.bank_name ?? bankAccount?.name ?? undefined,
bankAddress: cs?.bank_address ?? undefined,
routingNumber: cs?.routing_number ?? undefined,
@@ -355,6 +366,8 @@ export default function AccountingBillsPage() {
offsetX: (cs as any)?.offset_x ?? 0,
offsetY: (cs as any)?.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("");
@@ -372,16 +385,24 @@ export default function AccountingBillsPage() {
const vendorName = payBill.vendors?.name ?? "Vendor";
const refLabel = payReference || payMethod.toUpperCase();
// 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor)
// Pull the primary expense account from the first bill item so the COA balance updates
const { data: billItems } = await accounting
.from("bill_items")
.select("account_id")
.eq("bill_id", payBill.id)
.not("account_id", "is", null)
.limit(1);
const primaryCoa = billItems?.[0]?.account_id ?? null;
// Expense category name(s) from the bill — shown on the payment line in the
// transaction journal for visibility (display only; does not affect the GL).
const { data: payItems } = await accounting
.from("bill_items").select("account_id").eq("bill_id", payBill.id).not("account_id", "is", null);
const payAcctIds = Array.from(new Set((payItems ?? []).map((i: any) => i.account_id)));
let categoryLabel = "Bill Payment";
if (payAcctIds.length) {
const { data: payAccs } = await accounting.from("accounts").select("name").in("id", payAcctIds);
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({
company_id: cid,
account_id: payAccountId,
@@ -389,10 +410,10 @@ export default function AccountingBillsPage() {
type: "debit",
amount: payAmount,
description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`,
category: "Bill Payment",
category: categoryLabel,
reference: refLabel,
coa_account_id: primaryCoa, // links to expense account → updates COA balance
vendor_id: payBill.vendor_id ?? null, // link to vendor for reporting
coa_account_id: null, // → posts against Accounts Payable (via vendor)
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
@@ -35,6 +35,8 @@ export default function AccountingCheckSetupPage() {
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [micrOffsetY, setMicrOffsetY] = useState(0);
const [micrGap1, setMicrGap1] = useState(1);
const [micrGap2, setMicrGap2] = useState(1);
const { data: settings } = useQuery({
queryKey: ["check-settings", cid],
@@ -67,6 +69,8 @@ export default function AccountingCheckSetupPage() {
setOffsetX(Number(s.offset_x ?? 0));
setOffsetY(Number(s.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]);
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
@@ -90,6 +94,8 @@ export default function AccountingCheckSetupPage() {
offset_x: offsetX,
offset_y: offsetY,
micr_offset_y: micrOffsetY,
micr_gap_1: micrGap1,
micr_gap_2: micrGap2,
}, { onConflict: "company_id" });
setSaving(false);
if (error) return toast.error(error.message);
@@ -130,7 +136,7 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY,
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
});
const w = window.open("");
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,
position: defaultPosition as any,
fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY,
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
});
const w = window.open("");
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">
Run the alignment test first, measure the offset on blank paper, then adjust here and preview again.
</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 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 }: {
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 { Badge } from "@/components/ui/badge";
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 { 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 {
Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown,
} from "lucide-react";
@@ -31,15 +53,19 @@ export default function AccountingDashboardPage({ association }: { association?:
const logoUrl = (assocMeta as any)?.logo_url || null;
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({
queryKey: ["dashboard", cid],
queryKey: ["dashboard", cid, from, to],
enabled: !!cid,
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([
accounting.from("invoices").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")
.select("amount,type,date,description,category,reference")
.eq("company_id", cid)
.gte("date", from)
.lte("date", to)
.order("date", { ascending: false })
.limit(10),
.limit(50),
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 now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const cursor = new Date(`${from}T00:00:00`);
cursor.setDate(1);
const lastMonth = new Date(`${to}T00:00:00`);
lastMonth.setDate(1);
while (cursor <= lastMonth && months.length < 36) {
months.push({
key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
label: d.toLocaleString("en-US", { month: "short" }),
key: `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`,
label: cursor.toLocaleString("en-US", { month: "short", year: "2-digit" }),
income: 0,
expense: 0,
});
cursor.setMonth(cursor.getMonth() + 1);
}
const bucket = (date: string) => date.slice(0, 7);
const inWin = (d?: string | null) => !!d && d >= from && d <= to;
for (const i of invoices) {
if (i.issue_date && i.issue_date >= sinceIso) {
const m = months.find((x) => x.key === bucket(i.issue_date));
if (inWin(i.issue_date)) {
const m = months.find((x) => x.key === bucket(i.issue_date!));
if (m) m.income += Number(i.total);
}
}
for (const b of billsArr) {
if (b.issue_date && b.issue_date >= sinceIso) {
const m = months.find((x) => x.key === bucket(b.issue_date));
if (inWin(b.issue_date)) {
const m = months.find((x) => x.key === bucket(b.issue_date!));
if (m) m.expense += Number(b.total);
}
}
@@ -124,7 +156,7 @@ export default function AccountingDashboardPage({ association }: { association?:
counts,
months,
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>
<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 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
variant="outline"
size="sm"
@@ -186,7 +238,7 @@ export default function AccountingDashboardPage({ association }: { association?:
if (!data) return;
setExporting(true);
try {
await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data });
await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data, rangeLabel });
} finally {
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
</Button>
</div>
</div>
{/* Cash flow summary */}
<div className="grid gap-4 md:grid-cols-3">
+17 -6
View File
@@ -51,6 +51,10 @@ export type CheckPrintOptions = {
offsetY?: number;
/** Extra vertical shift for the MICR line only (fine-tune for check stock) */
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 ────────────────────────────────────────────────────────────────
@@ -71,12 +75,15 @@ const CHECK_H = SECTION_H; // check occupies the full top section
// Format matches reference check: C000000305C A263191387A 1100034740184C
// 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 a = (account ?? "").replace(/\D/g, "");
const c = String(checkNum).padStart(9, "0");
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) ─
@@ -103,7 +110,7 @@ function hline(doc: jsPDF, x1: number, y: number, x2: number, w = 0.006, gray =
// ── 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 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(
(c.routingNumber ?? "").replace(/\D/g, ""),
(c.accountNumber ?? "").replace(/\D/g, ""),
c.checkNumber
c.checkNumber,
micrGap1,
micrGap2
);
if (micr) {
@@ -351,19 +360,21 @@ export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions):
const ox = opts.offsetX ?? 0;
const oy = opts.offsetY ?? 0;
const micrOy = opts.micrOffsetY ?? 0;
const g1 = opts.micrGap1 ?? 1;
const g2 = opts.micrGap2 ?? 1;
checks.forEach((c, idx) => {
if (idx > 0) doc.addPage();
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 * 2 + oy, c, ox);
} else {
let posY = 0;
if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2;
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;
currency?: string;
data: DashboardPdfData;
rangeLabel?: string;
}) {
const { companyName, data } = opts;
const currency = opts.currency || "USD";
@@ -120,8 +121,11 @@ export async function generateDashboardPdf(opts: {
const logo = await loadBrandedLogo(opts.logoUrl);
let y = drawBrandedHeader(doc, {
logo,
title: "Accounting Dashboard",
metaLines: [{ label: "Properties:", value: companyName }],
title: "Financial Overview",
metaLines: [
{ label: "Properties:", value: companyName },
...(opts.rangeLabel ? [{ label: "Period:", value: opts.rangeLabel }] : []),
],
});
// ── Summary cards ──
@@ -194,7 +198,7 @@ export async function generateDashboardPdf(opts: {
t.description ?? "",
t.category ?? "—",
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 },
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);
const msg = ok
? "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);
y += 28;
}
+10 -4
View File
@@ -93,6 +93,10 @@ export interface CheckLayout {
field_positions?: Partial<Record<CheckFieldKey, CheckFieldPosition>> | null;
/** New: optional logo image URL printed on the check. */
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");
}
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 a = (account || "").replace(/\D/g, "");
const c = (checkNumber || "").replace(/\D/g, "");
// Pad check number with leading zeros to 9 digits
const paddedCheck = c.padStart(9, "0");
// Order: check number (on-us), space, transit/routing, space, account
return `${MICR_ON_US}${paddedCheck}${MICR_ON_US} ${MICR_TRANSIT}${r}${MICR_TRANSIT} ${a}${MICR_ON_US}`;
const g1 = " ".repeat(Math.max(0, Math.round(gap1)));
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 {
@@ -462,7 +468,7 @@ function drawCheckSection(
if (includeMicr && c.bank_routing_number && c.bank_account_number) {
const p = px("micr");
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.text(micr, p.X, p.Y);
}