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:
2026-03-12 10:29:36 -06:00
parent 9fcb31ba0d
commit e252ddb952
35 changed files with 4112 additions and 1 deletions
+292
View File
@@ -0,0 +1,292 @@
'use strict';
/**
* pdfService.js
*
* Generates a 3-up check PDF from 13 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 13 checks.
*
* @param {Object} account - Account row from database
* @param {Array} checks - Array of 13 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 };