diff --git a/src/components/BidQuoteDetailsDialog.jsx b/src/components/BidQuoteDetailsDialog.jsx
index 6fb9e66..9a0121c 100644
--- a/src/components/BidQuoteDetailsDialog.jsx
+++ b/src/components/BidQuoteDetailsDialog.jsx
@@ -94,6 +94,14 @@ export function BidQuoteDetailsDialog({ open, onOpenChange, bid, onRefresh }) {
Assign to Associations
@@ -232,7 +283,7 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
-
+
+
+
update("micr_gap_1", Math.max(0, parseInt(e.target.value) || 0))} />
+
Spaces between segments in the MICR line.
+
+
+
+ update("micr_gap_2", Math.max(0, parseInt(e.target.value) || 0))} />
+
diff --git a/src/pages/accounting/AccountingBankingPage.tsx b/src/pages/accounting/AccountingBankingPage.tsx
index 074d5a1..7233530 100644
--- a/src/pages/accounting/AccountingBankingPage.tsx
+++ b/src/pages/accounting/AccountingBankingPage.tsx
@@ -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,
};
diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx
index 36b9f95..69d9f71 100644
--- a/src/pages/accounting/AccountingBillsPage.tsx
+++ b/src/pages/accounting/AccountingBillsPage.tsx
@@ -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,9 @@ 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,
+ fieldPositions: (cs as any)?.field_positions ?? {},
}
);
const w = window.open("");
@@ -372,16 +386,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 +411,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
diff --git a/src/pages/accounting/AccountingCheckSetupPage.tsx b/src/pages/accounting/AccountingCheckSetupPage.tsx
index a5eaa8c..d254095 100644
--- a/src/pages/accounting/AccountingCheckSetupPage.tsx
+++ b/src/pages/accounting/AccountingCheckSetupPage.tsx
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch";
import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react";
import { toast } from "sonner";
-import { generateCheckPDF, generateTestAlignmentPDF } from "./lib/checkPdf";
+import { generateCheckPDF, generateTestAlignmentPDF, CHECK_FIELD_KEYS, CHECK_FIELD_LABELS } from "./lib/checkPdf";
export default function AccountingCheckSetupPage() {
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
@@ -35,6 +35,9 @@ 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 [fieldPositions, setFieldPositions] = useState({});
const { data: settings } = useQuery({
queryKey: ["check-settings", cid],
@@ -67,6 +70,9 @@ 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));
+ setFieldPositions(s.field_positions ?? {});
}, [settings]);
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
@@ -90,6 +96,9 @@ export default function AccountingCheckSetupPage() {
offset_x: offsetX,
offset_y: offsetY,
micr_offset_y: micrOffsetY,
+ micr_gap_1: micrGap1,
+ micr_gap_2: micrGap2,
+ field_positions: fieldPositions,
}, { onConflict: "company_id" });
setSaving(false);
if (error) return toast.error(error.message);
@@ -130,7 +139,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, fieldPositions,
});
const w = window.open("");
if (w) w.document.write(`
`);
@@ -141,12 +150,17 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
- offsetX, offsetY, micrOffsetY,
+ offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
});
const w = window.open("");
if (w) w.document.write(`
`);
};
+ const updateFieldPos = (key, patch) =>
+ setFieldPositions((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), ...patch } }));
+ const resetFieldPos = (key) =>
+ setFieldPositions((prev) => { const n = { ...prev }; delete n[key]; return n; });
+
if (!associationId) return
Select an association.
;
if (companyLoading) return
;
if (companyError || !companyId) return
{companyError || "Accounting setup is not ready."}
;
@@ -345,6 +359,47 @@ export default function AccountingCheckSetupPage() {
Run the alignment test first, measure the offset on blank paper, then adjust here and preview again.
+
+
MICR gaps (spaces between segments)
+
+
+
+
+
+
+
+
Element positions (nudge any element; inches, +right / +down)
+
+
Element
+
Show
+
X
+
Y
+
+
+ {CHECK_FIELD_KEYS.map((key) => {
+ const fp = fieldPositions[key] || {};
+ return (
+
+
{CHECK_FIELD_LABELS[key]}
+
+ updateFieldPos(key, { hidden: !v })} />
+
+
+ updateFieldPos(key, { dx: parseFloat(e.target.value) || 0 })} />
+
+
+ updateFieldPos(key, { dy: parseFloat(e.target.value) || 0 })} />
+
+
+ resetFieldPos(key)} title="Reset"
+ className="text-xs text-muted-foreground hover:text-foreground">βΊ
+
+
+ );
+ })}
+
@@ -368,6 +423,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 (
+
+
{label}
+
+ 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">
+ β
+
+ onChange(Math.max(0, parseInt(e.target.value) || 0))}
+ className="h-7 text-center font-mono text-xs"
+ />
+ 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">
+ +
+
+
+
+ );
+}
+
function OffsetControl({ label, hint, value, onChange }: {
label: string; hint: string; value: number; onChange: (v: number) => void;
}) {
diff --git a/src/pages/accounting/AccountingDashboardPage.tsx b/src/pages/accounting/AccountingDashboardPage.tsx
index 093573d..80b92cc 100644
--- a/src/pages/accounting/AccountingDashboardPage.tsx
+++ b/src/pages/accounting/AccountingDashboardPage.tsx
@@ -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
("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,25 +207,46 @@ export default function AccountingDashboardPage({ association }: { association?:
Dashboard
-
{associationName}
+
{associationName} Β· {rangeLabel}
+
+
-
{
- if (!data) return;
- setExporting(true);
- try {
- await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data });
- } finally {
- setExporting(false);
- }
- }}
- >
- {exporting ? : } Export PDF
-
{/* Cash flow summary */}
diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx
index 665f180..4ed9eb5 100644
--- a/src/pages/accounting/AccountingReportsPage.tsx
+++ b/src/pages/accounting/AccountingReportsPage.tsx
@@ -202,6 +202,10 @@ export default function AccountingReportsPage({ association }: { association?: {
const cid = companyId ?? "";
const cur = "USD";
const [active, setActive] = useState("pnl");
+ // Drill-down: clicking an account amount in a financial report opens the
+ // General Ledger focused on that account (its transaction list for the COA).
+ const [drillAccountId, setDrillAccountId] = useState(null);
+ const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); };
// Period
const [preset, setPreset] = useState("ytd");
@@ -548,7 +552,7 @@ export default function AccountingReportsPage({ association }: { association?: {
)}
{active === "general-ledger" && (
-
+
)}
{active === "reserve-fund" && (
@@ -563,7 +567,7 @@ export default function AccountingReportsPage({ association }: { association?: {
Loadingβ¦
) : structured ? (
-
+
) : (
No data for this report in the selected range.
@@ -622,8 +626,9 @@ function Toggle({ id, checked, onChange, label, disabled }: { id: string; checke
);
}
-function StructuredTable({ report, showCodes, showCompare, showZero, currency }: {
+function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: {
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
+ onDrill?: (accountId: string, label: string) => void;
}) {
let alt = false;
const span = showCompare ? 5 : 2;
@@ -666,14 +671,19 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
const shaded = r.kind === "sub" && alt;
if (r.kind === "sub") alt = !alt;
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
+ const drillable = r.kind === "sub" && !!r.accountId && !!onDrill;
return (
- onDrill!(r.accountId!, r.label) : undefined}
+ title={drillable ? "View transactions for this account" : undefined}
+ className={[
shaded ? "bg-muted/40" : "",
bold ? "border-t font-semibold" : "",
+ drillable ? "cursor-pointer hover:bg-primary/5" : "",
].join(" ")}>
|
{showCodes && r.code && {r.code}}
- {r.label}
+ {r.label}
|
{showCompare && }
@@ -1036,6 +1046,7 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe
rows.push({
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined,
+ accountId: l.accountId,
});
}
};
@@ -1144,7 +1155,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Assets
rows.push({ kind: "section", label: "Assets" });
const assets = byType("asset");
- for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
+ for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
const totalA = sumBal(assets);
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
rows.push({ kind: "spacer", label: "" });
@@ -1152,7 +1163,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Liabilities
rows.push({ kind: "section", label: "Liabilities" });
const liabs = byType("liability");
- for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
+ for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
const totalL = sumBal(liabs);
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
rows.push({ kind: "spacer", label: "" });
@@ -1160,7 +1171,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Equity β equity accounts + calculated RE / current-year earnings
rows.push({ kind: "section", label: "Equity" });
const equityAccs = byType("equity");
- for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
+ for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) });
rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) });
const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye;
diff --git a/src/pages/accounting/components/GeneralLedgerReport.tsx b/src/pages/accounting/components/GeneralLedgerReport.tsx
index d81b73c..9a10a09 100644
--- a/src/pages/accounting/components/GeneralLedgerReport.tsx
+++ b/src/pages/accounting/components/GeneralLedgerReport.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accounting } from "@/lib/accountingClient";
import { Card, CardContent } from "@/components/ui/card";
@@ -37,10 +37,15 @@ type Txn = {
debit: number; credit: number; balance: number; abnormal?: boolean;
};
-export function GeneralLedgerReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
+export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAccountId }: { companyId: string; companyName: string; logoUrl?: string | null; initialAccountId?: string | null }) {
const [from, setFrom] = useState(startOfYear());
const [to, setTo] = useState(today());
- const [selectedAccounts, setSelectedAccounts] = useState([]);
+ const [selectedAccounts, setSelectedAccounts] = useState(initialAccountId ? [initialAccountId] : []);
+
+ // When opened via a report drill-down, focus the chosen account.
+ useEffect(() => {
+ if (initialAccountId) setSelectedAccounts([initialAccountId]);
+ }, [initialAccountId]);
const [basis, setBasis] = useState<"accrual" | "cash">("accrual");
const [showOpening, setShowOpening] = useState(true);
const [search, setSearch] = useState("");
diff --git a/src/pages/accounting/lib/checkPdf.ts b/src/pages/accounting/lib/checkPdf.ts
index 1773e20..cb4d673 100644
--- a/src/pages/accounting/lib/checkPdf.ts
+++ b/src/pages/accounting/lib/checkPdf.ts
@@ -51,6 +51,32 @@ 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;
+ /** Per-field position offsets (inches) + visibility, keyed by field. */
+ fieldPositions?: Record;
+};
+
+/** Field keys for per-element check positioning (bill-payment check face). */
+export const CHECK_FIELD_KEYS = [
+ "check_number", "company", "bank", "date", "pay_to",
+ "amount_box", "amount_words", "address_block", "memo", "signature", "micr",
+] as const;
+
+export const CHECK_FIELD_LABELS: Record = {
+ check_number: "Check Number",
+ company: "Company / Return Address",
+ bank: "Bank Name & Address",
+ date: "Date",
+ pay_to: "Pay to the Order Of",
+ amount_box: "Numeric Amount ($)",
+ amount_words: "Written Amount",
+ address_block: "Payee Address Block",
+ memo: "Memo",
+ signature: "Signature Line & Label",
+ micr: "MICR Line",
};
// ββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -71,12 +97,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,10 +132,26 @@ 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,
+ fields: Record = {}
+) {
const y = (dy: number) => originY + dy;
const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate
+ // Per-field positioning: each labeled element can be nudged (dx/dy in inches)
+ // or hidden. Layered on top of the default layout, so {} renders identically.
+ const F = (k: string): { dx?: number; dy?: number; hidden?: boolean } => (fields && fields[k]) || {};
+ const hid = (k: string) => F(k).hidden === true;
+ const ax = (k: string) => Number(F(k).dx) || 0;
+ const ay = (k: string) => Number(F(k).dy) || 0;
+
// VOID watermark
if (c.voided) {
doc.saveGraphicsState();
@@ -118,174 +163,199 @@ function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0
doc.setTextColor(0);
}
- // ββ Top row ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- doc.setFont("helvetica", "bold");
- doc.setFontSize(12);
- doc.setTextColor(0);
- doc.text(String(c.checkNumber), x(RIGHT), y(0.18), { align: "right" });
+ // ββ Check number (top-right) ββ
+ if (!hid("check_number")) {
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(12);
+ doc.setTextColor(0);
+ doc.text(String(c.checkNumber), x(RIGHT + ax("check_number")), y(0.18 + ay("check_number")), { align: "right" });
+ }
- doc.setFont("helvetica", "bold");
- doc.setFontSize(8.5);
- doc.text(c.companyName, x(ML), y(0.18));
-
- doc.setFont("helvetica", "normal");
- doc.setFontSize(7.5);
- const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean);
- companyAddrLines.slice(0, 4).forEach((line, i) => {
- doc.text(line, x(ML), y(0.32 + i * 0.135));
- });
+ // ββ Company / return address ββ
+ if (!hid("company")) {
+ const dx = ax("company"), dy = ay("company");
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(8.5);
+ doc.setTextColor(0);
+ doc.text(c.companyName, x(ML + dx), y(0.18 + dy));
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(7.5);
+ const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean);
+ companyAddrLines.slice(0, 4).forEach((line, i) => {
+ doc.text(line, x(ML + dx), y(0.32 + i * 0.135 + dy));
+ });
+ }
+ // ββ Bank name / address ββ
const bankX = ML + CW * 0.52;
- doc.setFont("helvetica", "bold");
- doc.setFontSize(9);
- doc.text(c.bankName ?? "", x(bankX), y(0.18));
- doc.setFont("helvetica", "normal");
- doc.setFontSize(7.5);
- if (c.bankAddress) doc.text(c.bankAddress, x(bankX), y(0.32));
+ if (!hid("bank")) {
+ const dx = ax("bank"), dy = ay("bank");
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(9);
+ doc.setTextColor(0);
+ doc.text(c.bankName ?? "", x(bankX + dx), y(0.18 + dy));
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(7.5);
+ if (c.bankAddress) doc.text(c.bankAddress, x(bankX + dx), y(0.32 + dy));
+ }
- // ββ Date line ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- const dateLineY = y(0.78);
- doc.setFont("helvetica", "normal");
- doc.setFontSize(9);
- doc.text(c.date, x(RIGHT), dateLineY - 0.04, { align: "right" });
- hline(doc, x(ML + CW * 0.62), dateLineY, x(RIGHT));
+ // ββ Date + date line ββ
+ if (!hid("date")) {
+ const dx = ax("date");
+ const dateLineY = y(0.78 + ay("date"));
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(9);
+ doc.setTextColor(0);
+ doc.text(c.date, x(RIGHT + dx), dateLineY - 0.04, { align: "right" });
+ hline(doc, x(ML + CW * 0.62 + dx), dateLineY, x(RIGHT + dx));
+ }
- // ββ Pay to the order of ββββββββββββββββββββββββββββββββββββββββββββββββββ
- const payY = y(1.10);
+ // ββ Pay to the order of ββ
const payeeX = ML + 0.78;
+ if (!hid("pay_to")) {
+ const dx = ax("pay_to");
+ const payY = y(1.10 + ay("pay_to"));
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(7);
+ doc.setTextColor(0);
+ doc.text("PAY TO THE", x(ML + dx), payY - 0.10);
+ doc.text("ORDER OF", x(ML + dx), payY + 0.03);
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(10);
+ doc.text(c.payee, x(payeeX + dx), payY);
+ hline(doc, x(payeeX - 0.02 + dx), payY + 0.06, x(RIGHT - 1.55 + dx));
+ }
- doc.setFont("helvetica", "normal");
- doc.setFontSize(7);
- doc.text("PAY TO THE", x(ML), payY - 0.10);
- doc.text("ORDER OF", x(ML), payY + 0.03);
+ // ββ Numeric amount box ββ
+ if (!hid("amount_box")) {
+ const dx = ax("amount_box");
+ const amtY = y(1.10 + ay("amount_box"));
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(11);
+ doc.setTextColor(0);
+ doc.text(`$${c.amount.toFixed(2)}`, x(RIGHT - 0.08 + dx), amtY, { align: "right" });
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(7.5);
+ doc.setTextColor(80);
+ doc.text("DOLLARS", x(RIGHT - 0.08 + dx), amtY + 0.17, { align: "right" });
+ doc.setTextColor(0);
+ }
- doc.setFont("helvetica", "bold");
- doc.setFontSize(10);
- doc.text(c.payee, x(payeeX), payY);
- hline(doc, x(payeeX - 0.02), payY + 0.06, x(RIGHT - 1.55));
+ // ββ Written amount ββ
+ if (!hid("amount_words")) {
+ const dx = ax("amount_words");
+ const writtenY = y(1.40 + ay("amount_words"));
+ const amountWords = numberToWords(c.amount).toUpperCase();
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(8.5);
+ doc.setTextColor(0);
+ const wordsW = doc.getTextWidth(amountWords);
+ const starW = doc.getTextWidth("*");
+ const starsTotal = Math.max(0, Math.floor((CW * 0.90 - wordsW) / starW) - 2);
+ const leftStars = Math.max(4, Math.floor(starsTotal * 0.18));
+ const rightStars = Math.max(0, starsTotal - leftStars);
+ doc.text("*".repeat(leftStars) + " " + amountWords + " " + "*".repeat(rightStars),
+ x(ML + dx), writtenY, { maxWidth: CW });
+ hline(doc, x(ML + dx), writtenY + 0.06, x(RIGHT + dx), 0.008, 0);
+ }
- // Amount box
- doc.setFont("helvetica", "bold");
- doc.setFontSize(11);
- doc.text(`$${c.amount.toFixed(2)}`, x(RIGHT - 0.08), payY, { align: "right" });
- doc.setFont("helvetica", "normal");
- doc.setFontSize(7.5);
- doc.setTextColor(80);
- doc.text("DOLLARS", x(RIGHT - 0.08), payY + 0.17, { align: "right" });
- doc.setTextColor(0);
-
- // ββ Written amount β with more breathing room after the pay-to line ββββββββ
- // numberToWords() already ends with "Dollars" β no suffix needed
- const writtenY = payY + 0.30; // extra gap below pay-to line
- const amountWords = numberToWords(c.amount).toUpperCase();
- doc.setFont("helvetica", "normal");
- doc.setFontSize(8.5);
- const wordsW = doc.getTextWidth(amountWords);
- const starW = doc.getTextWidth("*");
- const starsTotal = Math.max(0, Math.floor((CW * 0.90 - wordsW) / starW) - 2);
- const leftStars = Math.max(4, Math.floor(starsTotal * 0.18));
- const rightStars = Math.max(0, starsTotal - leftStars);
- doc.text("*".repeat(leftStars) + " " + amountWords + " " + "*".repeat(rightStars),
- x(ML), writtenY, { maxWidth: CW });
- // Underline the written amount
- hline(doc, x(ML), writtenY + 0.06, x(RIGHT), 0.008, 0);
-
- // ββ Envelope address block β more space below written amount βββββββββββββ
- // Layout: payee name (bold) on left | "Authorized Signer" on right (same Y)
- // address line 1 (indented)
- // address line 2 (indented)
+ // ββ Envelope address block ββ
const addrLines = c.payeeAddress
? c.payeeAddress.split(/\n/).map(s => s.trim()).filter(Boolean)
: [];
- const addrBlockY = writtenY + 0.28; // extra gap below written amount
const addrIndentX = ML + 0.35;
-
- // Payee name bold (envelope window β first line)
- doc.setFont("helvetica", "bold");
- doc.setFontSize(9);
- doc.text(c.payee, x(addrIndentX), addrBlockY);
-
- // "Authorized Signer" on the RIGHT, same Y as payee name
- doc.setFont("helvetica", "normal");
- doc.setFontSize(7.5);
- doc.setTextColor(80);
- doc.text("Authorized Signer", x(RIGHT), addrBlockY, { align: "right" });
- doc.setTextColor(0);
-
- // Address lines below, slightly indented
- if (addrLines.length > 0) {
- doc.setFont("helvetica", "normal");
- doc.setFontSize(8.5);
- doc.setTextColor(30);
- addrLines.slice(0, 3).forEach((line, i) => {
- doc.text(line, x(addrIndentX + 0.05), addrBlockY + 0.155 + i * 0.145);
- });
+ if (!hid("address_block")) {
+ const dx = ax("address_block");
+ const addrBlockY = y(1.68 + ay("address_block"));
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(9);
doc.setTextColor(0);
+ doc.text(c.payee, x(addrIndentX + dx), addrBlockY);
+ if (addrLines.length > 0) {
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(8.5);
+ doc.setTextColor(30);
+ addrLines.slice(0, 3).forEach((line, i) => {
+ doc.text(line, x(addrIndentX + 0.05 + dx), addrBlockY + 0.155 + i * 0.145);
+ });
+ doc.setTextColor(0);
+ }
}
- // ββ Memo / Authorized Signature line β no grey divider, just space ββββββββ
- const addrEndY = addrBlockY + 0.155 + Math.max(addrLines.length, 0) * 0.145;
- const bottomLineY = addrEndY + 0.30; // generous gap, no grey line between
- const sigLabelX = PW / 2 + 0.2;
-
- // Memo (left side)
- doc.setFont("helvetica", "normal");
- doc.setFontSize(8);
- doc.setTextColor(0);
- doc.text("MEMO", x(ML), bottomLineY);
- hline(doc, x(ML + 0.5), bottomLineY, x(ML + 2.8));
- if (c.memo) {
- doc.setFontSize(7.5);
- doc.text(c.memo, x(ML + 0.55), bottomLineY - 0.04);
- }
-
- // "AUTHORIZED SIGNATURE" label centered above the right-side line
- doc.setFontSize(7);
- doc.setTextColor(80);
+ // ββ Memo + signature (flow below the address block) ββ
+ const addrEndDy = 1.68 + 0.155 + Math.max(addrLines.length, 0) * 0.145;
+ const bottomDy = addrEndDy + 0.30;
+ const sigLabelX = PW / 2 + 0.2;
const sigCenterX = sigLabelX + (RIGHT - sigLabelX) / 2;
- doc.text("AUTHORIZED SIGNATURE", x(sigCenterX), bottomLineY - 0.36, { align: "center" });
- doc.setTextColor(0);
- // Signature image β natural aspect ratio, bottom edge sits ON the line
- if (c.printSignature && c.signatureDataUrl) {
- try {
- const props = doc.getImageProperties(c.signatureDataUrl);
- const maxW = RIGHT - sigLabelX - 0.1;
- const maxH = 0.42;
- const ratio = props.width / props.height;
- let sigW = maxW;
- let sigH = sigW / ratio;
- if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; }
- const sigX = x(sigCenterX - sigW / 2);
- const sigY = bottomLineY - sigH;
- doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH);
- } catch { /* silent */ }
+ if (!hid("memo")) {
+ const dx = ax("memo");
+ const bottomLineY = y(bottomDy + ay("memo"));
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(8);
+ doc.setTextColor(0);
+ doc.text("MEMO", x(ML + dx), bottomLineY);
+ hline(doc, x(ML + 0.5 + dx), bottomLineY, x(ML + 2.8 + dx));
+ if (c.memo) {
+ doc.setFontSize(7.5);
+ doc.text(c.memo, x(ML + 0.55 + dx), bottomLineY - 0.04);
+ }
}
- // Signature LINE
- hline(doc, x(sigLabelX), bottomLineY, x(RIGHT));
-
- // ββ MICR line (X offset + separate MICR Y fine-tune) βββββββββββββββββββββ
- const micrY = y(CHECK_H - 0.22) + micrOy;
- const micr = buildMicr(
- (c.routingNumber ?? "").replace(/\D/g, ""),
- (c.accountNumber ?? "").replace(/\D/g, ""),
- c.checkNumber
- );
-
- if (micr) {
- ensureMicrFont(doc);
- doc.setFontSize(11);
- doc.setTextColor(0);
- doc.text(micr, x(PW / 2), micrY, { align: "center" });
- doc.setFont("helvetica", "normal");
- } else {
+ if (!hid("signature")) {
+ const dx = ax("signature");
+ const bottomLineY = y(bottomDy + ay("signature"));
+ // Signature image β rendered full size above the line (bottom edge sits ON
+ // the line). With the label moved below the line, it can use the full height.
+ if (c.printSignature && c.signatureDataUrl) {
+ try {
+ const props = doc.getImageProperties(c.signatureDataUrl);
+ const maxW = RIGHT - sigLabelX - 0.1;
+ const maxH = 0.65;
+ const ratio = props.width / props.height;
+ let sigW = maxW;
+ let sigH = sigW / ratio;
+ if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; }
+ const sigX = x(sigCenterX + dx - sigW / 2);
+ const sigY = bottomLineY - sigH;
+ doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH);
+ } catch { /* silent */ }
+ }
+ // Signature line
+ hline(doc, x(sigLabelX + dx), bottomLineY, x(RIGHT + dx));
+ // "AUTHORIZED SIGNATURE" label below the line
doc.setFont("helvetica", "normal");
doc.setFontSize(7);
- doc.setTextColor(160);
- doc.text("[ Configure routing & account numbers in Settings β Check Setup ]", x(PW / 2), micrY, { align: "center" });
+ doc.setTextColor(80);
+ doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY + 0.13, { align: "center" });
doc.setTextColor(0);
}
+
+ // ββ MICR line (X offset + separate MICR Y fine-tune) ββ
+ if (!hid("micr")) {
+ const dx = ax("micr");
+ const micrY = y(CHECK_H - 0.22 + ay("micr")) + micrOy;
+ const micr = buildMicr(
+ (c.routingNumber ?? "").replace(/\D/g, ""),
+ (c.accountNumber ?? "").replace(/\D/g, ""),
+ c.checkNumber,
+ micrGap1,
+ micrGap2
+ );
+ if (micr) {
+ ensureMicrFont(doc);
+ doc.setFontSize(11);
+ doc.setTextColor(0);
+ doc.text(micr, x(PW / 2 + dx), micrY, { align: "center" });
+ doc.setFont("helvetica", "normal");
+ } else {
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(7);
+ doc.setTextColor(160);
+ doc.text("[ Configure routing & account numbers in Settings > Check Setup ]", x(PW / 2 + dx), micrY, { align: "center" });
+ doc.setTextColor(0);
+ }
+ }
}
// ββ Voucher stub βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -351,19 +421,22 @@ 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;
+ const fp = opts.fieldPositions || {};
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, fp);
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, fp);
}
});
diff --git a/src/pages/accounting/lib/dashboardPdf.ts b/src/pages/accounting/lib/dashboardPdf.ts
index 4207c67..1fa01d6 100644
--- a/src/pages/accounting/lib/dashboardPdf.ts
+++ b/src/pages/accounting/lib/dashboardPdf.ts
@@ -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" },
diff --git a/src/pages/accounting/lib/reportPdf.ts b/src/pages/accounting/lib/reportPdf.ts
index 7a4e60b..820dbc1 100644
--- a/src/pages/accounting/lib/reportPdf.ts
+++ b/src/pages/accounting/lib/reportPdf.ts
@@ -9,6 +9,8 @@ export type StructuredRow = {
code?: string;
amount?: number;
compare?: number;
+ /** GL account id for per-account rows β enables drill-down from on-screen reports. */
+ accountId?: string;
};
export type StructuredReport = {
@@ -236,7 +238,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;
}
diff --git a/src/utils/checkPdfGenerator.ts b/src/utils/checkPdfGenerator.ts
index f9ced13..841ea02 100644
--- a/src/utils/checkPdfGenerator.ts
+++ b/src/utils/checkPdfGenerator.ts
@@ -93,6 +93,10 @@ export interface CheckLayout {
field_positions?: Partial> | 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);
}