diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx
index 291c1a9..69d9f71 100644
--- a/src/pages/accounting/AccountingBillsPage.tsx
+++ b/src/pages/accounting/AccountingBillsPage.tsx
@@ -368,6 +368,7 @@ export default function AccountingBillsPage() {
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("");
diff --git a/src/pages/accounting/AccountingCheckSetupPage.tsx b/src/pages/accounting/AccountingCheckSetupPage.tsx
index 0d7529a..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();
@@ -37,6 +37,7 @@ export default function AccountingCheckSetupPage() {
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],
@@ -71,6 +72,7 @@ export default function AccountingCheckSetupPage() {
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, ""));
@@ -96,6 +98,7 @@ export default function AccountingCheckSetupPage() {
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);
@@ -136,7 +139,7 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
- offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
+ offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
});
const w = window.open("");
if (w) w.document.write(``);
@@ -147,12 +150,17 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
- offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
+ 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."}
;
@@ -358,6 +366,40 @@ export default function AccountingCheckSetupPage() {
+
+
+
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 })} />
+
+
+
+
+
+ );
+ })}
+
diff --git a/src/pages/accounting/lib/checkPdf.ts b/src/pages/accounting/lib/checkPdf.ts
index 23299c1..727dfab 100644
--- a/src/pages/accounting/lib/checkPdf.ts
+++ b/src/pages/accounting/lib/checkPdf.ts
@@ -55,6 +55,28 @@ export type CheckPrintOptions = {
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 ────────────────────────────────────────────────────────────────
@@ -110,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, micrGap1 = 1, micrGap2 = 1) {
+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();
@@ -125,175 +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);
- }
-
- // ── 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.text(c.payee, x(addrIndentX + dx), addrBlockY);
+ doc.setFont("helvetica", "normal");
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);
- 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 */ }
- }
-
- // 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,
- micrGap1,
- micrGap2
- );
-
- if (micr) {
- ensureMicrFont(doc);
- doc.setFontSize(11);
+ doc.setTextColor(80);
+ doc.text("Authorized Signer", x(RIGHT + dx), addrBlockY, { align: "right" });
doc.setTextColor(0);
- doc.text(micr, x(PW / 2), micrY, { align: "center" });
+ 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 + 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;
+
+ if (!hid("memo")) {
+ const dx = ax("memo");
+ const bottomLineY = y(bottomDy + ay("memo"));
doc.setFont("helvetica", "normal");
- } else {
+ 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);
+ }
+ }
+
+ if (!hid("signature")) {
+ const dx = ax("signature");
+ const bottomLineY = y(bottomDy + ay("signature"));
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.36, { align: "center" });
doc.setTextColor(0);
+ 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 + dx - sigW / 2);
+ const sigY = bottomLineY - sigH;
+ doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH);
+ } catch { /* silent */ }
+ }
+ hline(doc, x(sigLabelX + dx), bottomLineY, x(RIGHT + dx));
+ }
+
+ // ── 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);
+ }
}
}
@@ -362,19 +424,20 @@ export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions):
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, g1, g2);
+ 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, g1, g2);
+ drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2, fp);
}
});