Add full project structure: backend, frontend, Docker, and CI workflows
- Organize backend into src/ (routes/, services/, db/) per package.json entrypoint - Add migrations/import-mdb.js for one-time .mdb → SQLite migration - Add public/ frontend: check ledger table, slide-in new/edit panel, PDF generation - Add docker/Dockerfile and docker-compose.yml for self-hosted deployment - Add .github/workflows: Docker Hub build+push on main/tags, TODO→Issues scanner - Add GnuMICR font files (GPL-2.0) for MICR E-13B line rendering
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Routes
|
||||
app.use('/api/checks', require('./routes/checks'));
|
||||
app.use('/api/pdf', require('./routes/pdf'));
|
||||
|
||||
// Account info endpoint (read-only for Phase 1)
|
||||
app.get('/api/account', (req, res) => {
|
||||
const db = require('./db/database');
|
||||
const account = db.prepare(
|
||||
'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' +
|
||||
'routing_number, account_number, current_check_no, ' +
|
||||
'company1, company2, company3, company4, check_position FROM account WHERE id = 1'
|
||||
).get();
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'No account configured. Run migration first.' });
|
||||
}
|
||||
// Never send routing/account numbers in cleartext to the browser in production.
|
||||
// For local-only Phase 1 this is acceptable; redact for any network-exposed deployment.
|
||||
res.json(account);
|
||||
});
|
||||
|
||||
// Catch-all: serve index.html for client-side routing
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`ezcheck running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/ezcheck.db');
|
||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Initialize schema on first run
|
||||
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
||||
db.exec(schema);
|
||||
|
||||
module.exports = db;
|
||||
@@ -0,0 +1,82 @@
|
||||
-- ezcheck SQLite schema
|
||||
-- Mirrors .mdb structure (T100, T104, T200) with readable column names.
|
||||
-- One account per database is the Phase 1 assumption.
|
||||
-- Phase 2 will add foreign keys and an account switcher.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
id INTEGER PRIMARY KEY,
|
||||
bank_name TEXT NOT NULL,
|
||||
bank_info1 TEXT,
|
||||
bank_info2 TEXT,
|
||||
bank_info3 TEXT,
|
||||
transit_code TEXT,
|
||||
routing_number TEXT NOT NULL,
|
||||
account_number TEXT NOT NULL,
|
||||
start_check_no INTEGER NOT NULL DEFAULT 1000,
|
||||
current_check_no INTEGER NOT NULL DEFAULT 1000,
|
||||
check_width REAL NOT NULL DEFAULT 8.5,
|
||||
check_height REAL NOT NULL DEFAULT 3.5,
|
||||
-- Per-check offset adjustments in inches (for printer calibration)
|
||||
offset_left REAL NOT NULL DEFAULT 0,
|
||||
offset_right REAL NOT NULL DEFAULT 0,
|
||||
offset_up REAL NOT NULL DEFAULT 0,
|
||||
offset_down REAL NOT NULL DEFAULT 0,
|
||||
-- Company info lines (printed top-left of check)
|
||||
company1 TEXT,
|
||||
company2 TEXT,
|
||||
company3 TEXT,
|
||||
company4 TEXT,
|
||||
-- Images stored as base64 data URIs
|
||||
logo_data TEXT,
|
||||
signature_data TEXT,
|
||||
-- Metadata
|
||||
blank_stock INTEGER NOT NULL DEFAULT 1, -- 1 = blank check stock
|
||||
check_position TEXT NOT NULL DEFAULT '3-per-page',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
check_no INTEGER NOT NULL,
|
||||
payee TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
check_date TEXT NOT NULL, -- ISO date string YYYY-MM-DD
|
||||
memo TEXT,
|
||||
note1 TEXT,
|
||||
note2 TEXT,
|
||||
payee_address1 TEXT,
|
||||
payee_address2 TEXT,
|
||||
payee_address3 TEXT,
|
||||
payee_address4 TEXT,
|
||||
printed INTEGER NOT NULL DEFAULT 0, -- 0 = not printed, 1 = printed
|
||||
add_date TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
-- original .mdb CheckID preserved if migrated, null if created in app
|
||||
mdb_check_id INTEGER,
|
||||
UNIQUE(check_no)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS layout_fields (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
field_name TEXT NOT NULL UNIQUE,
|
||||
field_text TEXT, -- static label text (for Text type fields)
|
||||
font_name TEXT NOT NULL DEFAULT 'Helvetica',
|
||||
font_size REAL NOT NULL DEFAULT 10,
|
||||
-- FldFontType from .mdb: 0=normal, 1=bold
|
||||
font_bold INTEGER NOT NULL DEFAULT 0,
|
||||
-- FldType: 'Regular' (data), 'Text' (static label), 'Graph' (image), 'Line'
|
||||
field_type TEXT NOT NULL DEFAULT 'Regular',
|
||||
line_thick INTEGER NOT NULL DEFAULT 1,
|
||||
x_pos REAL NOT NULL DEFAULT 0,
|
||||
y_pos REAL NOT NULL DEFAULT 0,
|
||||
x_end_pos REAL NOT NULL DEFAULT 0,
|
||||
y_end_pos REAL NOT NULL DEFAULT 0,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
-- 1 = only used on blank stock (not preprinted). We always render these.
|
||||
not_for_preprint INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Index for fast ledger queries
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_date ON checks(check_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_printed ON checks(printed);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_check_no ON checks(check_no);
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db/database');
|
||||
|
||||
// GET /api/checks - list all checks, newest first
|
||||
router.get('/', (req, res) => {
|
||||
const { after, printed } = req.query;
|
||||
let query = 'SELECT * FROM checks';
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
|
||||
if (after) {
|
||||
conditions.push('check_date >= ?');
|
||||
params.push(after);
|
||||
}
|
||||
if (printed !== undefined) {
|
||||
conditions.push('printed = ?');
|
||||
params.push(printed === 'true' || printed === '1' ? 1 : 0);
|
||||
}
|
||||
|
||||
if (conditions.length) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
query += ' ORDER BY check_no DESC';
|
||||
|
||||
const checks = db.prepare(query).all(...params);
|
||||
res.json(checks);
|
||||
});
|
||||
|
||||
// GET /api/checks/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||
res.json(check);
|
||||
});
|
||||
|
||||
// POST /api/checks - create a new check
|
||||
router.post('/', (req, res) => {
|
||||
const { payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
||||
|
||||
if (!payee || !amount || !check_date) {
|
||||
return res.status(400).json({ error: 'payee, amount, and check_date are required' });
|
||||
}
|
||||
|
||||
// Get next check number from account
|
||||
const account = db.prepare('SELECT current_check_no FROM account WHERE id = 1').get();
|
||||
if (!account) return res.status(500).json({ error: 'No account configured. Run migration first.' });
|
||||
|
||||
const checkNo = account.current_check_no + 1;
|
||||
|
||||
const insertCheck = db.prepare(`
|
||||
INSERT INTO checks (check_no, payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateAccountCheckNo = db.prepare(
|
||||
'UPDATE account SET current_check_no = ?, updated_at = datetime(\'now\') WHERE id = 1'
|
||||
);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
const result = insertCheck.run(
|
||||
checkNo, payee, parseFloat(amount), check_date,
|
||||
memo || null, note1 || null, note2 || null,
|
||||
payee_address1 || null, payee_address2 || null,
|
||||
payee_address3 || null, payee_address4 || null
|
||||
);
|
||||
updateAccountCheckNo.run(checkNo);
|
||||
return result.lastInsertRowid;
|
||||
});
|
||||
|
||||
const newId = transaction();
|
||||
const newCheck = db.prepare('SELECT * FROM checks WHERE id = ?').get(newId);
|
||||
res.status(201).json(newCheck);
|
||||
});
|
||||
|
||||
// PUT /api/checks/:id - update a check
|
||||
router.put('/:id', (req, res) => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||
|
||||
if (check.printed) {
|
||||
return res.status(409).json({ error: 'Cannot edit a check that has been printed.' });
|
||||
}
|
||||
|
||||
const { payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE checks SET
|
||||
payee = ?, amount = ?, check_date = ?, memo = ?, note1 = ?, note2 = ?,
|
||||
payee_address1 = ?, payee_address2 = ?, payee_address3 = ?, payee_address4 = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payee ?? check.payee,
|
||||
amount !== undefined ? parseFloat(amount) : check.amount,
|
||||
check_date ?? check.check_date,
|
||||
memo ?? check.memo,
|
||||
note1 ?? check.note1,
|
||||
note2 ?? check.note2,
|
||||
payee_address1 ?? check.payee_address1,
|
||||
payee_address2 ?? check.payee_address2,
|
||||
payee_address3 ?? check.payee_address3,
|
||||
payee_address4 ?? check.payee_address4,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
// DELETE /api/checks/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||
|
||||
if (check.printed) {
|
||||
return res.status(409).json({ error: 'Cannot delete a check that has been printed.' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// POST /api/checks/mark-printed - mark checks as printed
|
||||
router.post('/mark-printed', (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'ids array required' });
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
db.prepare(`UPDATE checks SET printed = 1 WHERE id IN (${placeholders})`).run(...ids);
|
||||
res.json({ updated: ids.length });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db/database');
|
||||
const { generateCheckPdf } = require('../services/pdfService');
|
||||
|
||||
/**
|
||||
* POST /api/pdf
|
||||
* Body: { checkIds: [1, 2, 3] } -- 1 to 3 check IDs
|
||||
*
|
||||
* Returns a PDF with 1–3 checks in a 3-up layout.
|
||||
* After successful generation, marks all checks as printed.
|
||||
*
|
||||
* Query param: ?mark_printed=false to suppress auto-marking (for reprints).
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
const { checkIds } = req.body;
|
||||
|
||||
if (!Array.isArray(checkIds) || checkIds.length === 0 || checkIds.length > 3) {
|
||||
return res.status(400).json({ error: 'checkIds must be an array of 1–3 IDs' });
|
||||
}
|
||||
|
||||
// Fetch account
|
||||
const account = db.prepare('SELECT * FROM account WHERE id = 1').get();
|
||||
if (!account) {
|
||||
return res.status(500).json({ error: 'No account configured. Run migration first.' });
|
||||
}
|
||||
|
||||
// Fetch checks in the order provided
|
||||
const checks = checkIds.map(id => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(id);
|
||||
if (!check) throw new Error(`Check ID ${id} not found`);
|
||||
return check;
|
||||
});
|
||||
|
||||
// Fetch layout fields (all visible fields)
|
||||
const fields = db.prepare('SELECT * FROM layout_fields WHERE visible = 1').all();
|
||||
|
||||
try {
|
||||
const pdfBuffer = await generateCheckPdf(account, checks, fields);
|
||||
|
||||
// Mark as printed unless explicitly suppressed (e.g., reprint)
|
||||
const markPrinted = req.query.mark_printed !== 'false';
|
||||
if (markPrinted) {
|
||||
const placeholders = checkIds.map(() => '?').join(',');
|
||||
db.prepare(`UPDATE checks SET printed = 1 WHERE id IN (${placeholders})`).run(...checkIds);
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="checks-${checkIds.join('-')}.pdf"`,
|
||||
'Content-Length': pdfBuffer.length,
|
||||
});
|
||||
res.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
console.error('PDF generation error:', err);
|
||||
res.status(500).json({ error: 'PDF generation failed', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,292 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* pdfService.js
|
||||
*
|
||||
* Generates a 3-up check PDF from 1–3 check records.
|
||||
* All measurements are in points (72 pts/inch) internally;
|
||||
* layout coordinates from the database are in inches and converted here.
|
||||
*
|
||||
* Page layout:
|
||||
* - 8.5" × 11" letter page
|
||||
* - Three check slots: each 8.5" wide × 3.667" tall
|
||||
* - MICR line: hardcoded at Y = 3.4" from top of each slot
|
||||
*
|
||||
* Coordinate origin for each slot is top-left of that slot.
|
||||
*/
|
||||
|
||||
const PDFDocument = require('pdfkit');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const POINTS_PER_INCH = 72;
|
||||
const PAGE_WIDTH_IN = 8.5;
|
||||
const PAGE_HEIGHT_IN = 11;
|
||||
const SLOT_HEIGHT_IN = PAGE_HEIGHT_IN / 3; // 3.667"
|
||||
const MICR_Y_IN = SLOT_HEIGHT_IN - 0.267; // 0.267" from bottom of slot
|
||||
|
||||
// MICR line format: transit symbol (⑆) and on-us symbol (⑈) in E-13B encoding.
|
||||
// The GnuMICR / micrenc font maps these to specific characters.
|
||||
// Standard MICR layout: [check#] ⑆[routing]⑆ [account#]⑈
|
||||
const MICR_FONT_PATH = process.env.MICR_FONT_PATH
|
||||
? path.resolve(process.env.MICR_FONT_PATH)
|
||||
: path.join(__dirname, '../../fonts/micrenc.ttf');
|
||||
|
||||
// Amount in words conversion
|
||||
function amountToWords(amount) {
|
||||
const dollars = Math.floor(amount);
|
||||
const cents = Math.round((amount - dollars) * 100);
|
||||
|
||||
const ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven',
|
||||
'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen',
|
||||
'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
|
||||
const tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty',
|
||||
'Sixty', 'Seventy', 'Eighty', 'Ninety'];
|
||||
|
||||
function below1000(n) {
|
||||
if (n === 0) return '';
|
||||
if (n < 20) return ones[n] + ' ';
|
||||
if (n < 100) return tens[Math.floor(n / 10)] + (n % 10 ? '-' + ones[n % 10] : '') + ' ';
|
||||
return ones[Math.floor(n / 100)] + ' Hundred ' + below1000(n % 100);
|
||||
}
|
||||
|
||||
function toWords(n) {
|
||||
if (n === 0) return 'Zero';
|
||||
let result = '';
|
||||
if (Math.floor(n / 1000) > 0) {
|
||||
result += below1000(Math.floor(n / 1000)) + 'Thousand ';
|
||||
n = n % 1000;
|
||||
}
|
||||
result += below1000(n);
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
const dollarWords = dollars === 0 ? 'Zero' : toWords(dollars);
|
||||
const centStr = cents.toString().padStart(2, '0');
|
||||
return `${dollarWords} and ${centStr}/100`;
|
||||
}
|
||||
|
||||
// Format amount with ** padding (like the original software)
|
||||
function formatAmountDisplay(amount) {
|
||||
return `**${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Format MICR line
|
||||
// Standard check layout: [spaces][check#][transit symbol][routing][transit symbol][account][on-us symbol]
|
||||
// Using micrenc.ttf character mappings: A=transit, B=amount, C=on-us, D=dash
|
||||
// GnuMICR uses: 'A' for transit, 'C' for on-us
|
||||
function formatMicrLine(routingNo, accountNo, checkNo) {
|
||||
// Pad check number to 4+ digits
|
||||
const checkPadded = checkNo.toString().padStart(4, '0');
|
||||
// Routing: 9 digits, wrapped in transit symbols (A in micrenc)
|
||||
const routing = routingNo.replace(/\D/g, '');
|
||||
// Account: strip non-numeric, wrap in on-us symbols (C in micrenc)
|
||||
const account = accountNo.replace(/[^0-9]/g, '');
|
||||
|
||||
// MICR format: A[routing]A [account]C [check#]A
|
||||
// This is the standard US check layout
|
||||
return `A${routing}A ${account}C ${checkPadded}A`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main export: generates a PDF buffer for 1–3 checks.
|
||||
*
|
||||
* @param {Object} account - Account row from database
|
||||
* @param {Array} checks - Array of 1–3 check rows from database
|
||||
* @param {Array} fields - Layout field rows from layout_fields table
|
||||
* @returns {Promise<Buffer>} PDF as a buffer
|
||||
*/
|
||||
function generateCheckPdf(account, checks, fields) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hasMicrFont = fs.existsSync(MICR_FONT_PATH);
|
||||
if (!hasMicrFont) {
|
||||
console.warn(`MICR font not found at ${MICR_FONT_PATH}. MICR line will use fallback font.`);
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: [
|
||||
PAGE_WIDTH_IN * POINTS_PER_INCH,
|
||||
PAGE_HEIGHT_IN * POINTS_PER_INCH,
|
||||
],
|
||||
margins: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
autoFirstPage: true,
|
||||
});
|
||||
|
||||
if (hasMicrFont) {
|
||||
doc.registerFont('MICR', MICR_FONT_PATH);
|
||||
}
|
||||
|
||||
const buffers = [];
|
||||
doc.on('data', chunk => buffers.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(buffers)));
|
||||
doc.on('error', reject);
|
||||
|
||||
// Separate layout fields into check body vs stub fields
|
||||
const bodyFields = fields.filter(f => !f.field_name.startsWith('Stub'));
|
||||
const stubFields = fields.filter(f => f.field_name.startsWith('Stub'));
|
||||
|
||||
// We always render 3 slots; empty slots get a blank placeholder
|
||||
for (let slot = 0; slot < 3; slot++) {
|
||||
const check = checks[slot] || null;
|
||||
const slotOriginY = slot * SLOT_HEIGHT_IN;
|
||||
|
||||
// Offset adjustments from account calibration
|
||||
const offX = (account.offset_right - account.offset_left);
|
||||
const offY = (account.offset_down - account.offset_up);
|
||||
|
||||
// Helper: convert inches (relative to slot) to PDF points (absolute page)
|
||||
const pt = (xIn, yIn) => ({
|
||||
x: (xIn + offX) * POINTS_PER_INCH,
|
||||
y: (slotOriginY + yIn + offY) * POINTS_PER_INCH,
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
// Draw a faint slot boundary line for empty slots (optional, useful for alignment)
|
||||
doc.moveTo(0, slotOriginY * POINTS_PER_INCH)
|
||||
.lineTo(PAGE_WIDTH_IN * POINTS_PER_INCH, slotOriginY * POINTS_PER_INCH)
|
||||
.stroke('#cccccc');
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Render each layout field ---
|
||||
for (const field of bodyFields) {
|
||||
if (!field.visible) continue;
|
||||
|
||||
const pos = pt(field.x_pos, field.y_pos);
|
||||
|
||||
switch (field.field_type) {
|
||||
case 'Line': {
|
||||
const endPos = pt(field.x_end_pos, field.y_end_pos);
|
||||
doc.moveTo(pos.x, pos.y)
|
||||
.lineTo(endPos.x, endPos.y)
|
||||
.lineWidth(field.line_thick || 1)
|
||||
.stroke('#000000');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Graph': {
|
||||
// Logo or signature image
|
||||
const imgData = field.field_name === 'Logo'
|
||||
? account.logo_data
|
||||
: account.signature_data;
|
||||
|
||||
if (imgData) {
|
||||
try {
|
||||
// Data URI: strip the header, get base64
|
||||
const base64 = imgData.replace(/^data:[^;]+;base64,/, '');
|
||||
const imgBuffer = Buffer.from(base64, 'base64');
|
||||
const endPos = pt(field.x_end_pos, field.y_end_pos);
|
||||
const w = Math.abs(endPos.x - pos.x);
|
||||
const h = Math.abs(endPos.y - pos.y);
|
||||
doc.image(imgBuffer, pos.x, pos.y, { width: w, height: h });
|
||||
} catch (err) {
|
||||
console.warn(`Could not render image for field ${field.field_name}:`, err.message);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Text': {
|
||||
// Static label
|
||||
const label = field.field_text || '';
|
||||
setFont(doc, field);
|
||||
doc.fillColor('#000000')
|
||||
.text(label, pos.x, pos.y, { lineBreak: false });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Regular': {
|
||||
// Dynamic data - map field name to check/account data
|
||||
const value = resolveFieldValue(field.field_name, check, account);
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
setFont(doc, field);
|
||||
doc.fillColor('#000000')
|
||||
.text(String(value), pos.x, pos.y, { lineBreak: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MICR line ---
|
||||
const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no);
|
||||
const micrPos = pt(0.3, MICR_Y_IN);
|
||||
|
||||
if (hasMicrFont) {
|
||||
doc.font('MICR').fontSize(12).fillColor('#000000')
|
||||
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
|
||||
} else {
|
||||
// Fallback: Courier approximation (will not scan, but useful for dev)
|
||||
doc.font('Courier').fontSize(10).fillColor('#000000')
|
||||
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
|
||||
}
|
||||
|
||||
// --- Slot separator line ---
|
||||
if (slot < 2) {
|
||||
const lineY = (slotOriginY + SLOT_HEIGHT_IN) * POINTS_PER_INCH;
|
||||
doc.moveTo(0, lineY)
|
||||
.lineTo(PAGE_WIDTH_IN * POINTS_PER_INCH, lineY)
|
||||
.lineWidth(0.5)
|
||||
.stroke('#999999');
|
||||
}
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a layout field name to its runtime value from check/account data.
|
||||
* Field names come from T200's FldName column.
|
||||
*/
|
||||
function resolveFieldValue(fieldName, check, account) {
|
||||
switch (fieldName) {
|
||||
case 'Payee Name':
|
||||
return check.payee;
|
||||
case 'Amount':
|
||||
return formatAmountDisplay(check.amount);
|
||||
case 'Text Amount':
|
||||
return amountToWords(check.amount) + '***';
|
||||
case 'Date':
|
||||
return check.check_date;
|
||||
case 'Memo':
|
||||
return check.memo;
|
||||
case 'Check Number':
|
||||
return check.check_no;
|
||||
case 'Payee Address':
|
||||
// Multi-line address
|
||||
return [
|
||||
check.payee_address1,
|
||||
check.payee_address2,
|
||||
check.payee_address3,
|
||||
check.payee_address4,
|
||||
].filter(Boolean).join('\n');
|
||||
case 'Company Name':
|
||||
return account.company1;
|
||||
case 'Company Name2':
|
||||
return account.company2;
|
||||
case 'Bank Information':
|
||||
return [account.bank_info1, account.bank_info2, account.bank_info3]
|
||||
.filter(Boolean).join('\n');
|
||||
case 'Bank Transit Code':
|
||||
return account.transit_code;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the PDFKit font based on a layout field's font properties.
|
||||
* Falls back to Helvetica if the stored font name is not a built-in.
|
||||
*/
|
||||
function setFont(doc, field) {
|
||||
const builtins = [
|
||||
'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
|
||||
'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic',
|
||||
'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
|
||||
];
|
||||
const fontName = builtins.includes(field.font_name) ? field.font_name : 'Helvetica';
|
||||
doc.font(fontName).fontSize(field.font_size || 10);
|
||||
}
|
||||
|
||||
module.exports = { generateCheckPdf, amountToWords, formatMicrLine };
|
||||
Reference in New Issue
Block a user