diff --git a/docker-compose.yml b/docker-compose.yml index 28f81b2..0bf4f00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: check-printing restart: unless-stopped ports: - - "3000:3000" + - "3003:3000" volumes: # Persistent data: SQLite DB lives here - check-printing-data:/app/data diff --git a/migrations/import-mdb.js b/migrations/import-mdb.js index fb1d824..7aeecea 100644 --- a/migrations/import-mdb.js +++ b/migrations/import-mdb.js @@ -4,16 +4,13 @@ /** * import-mdb.js * - * One-time migration: reads a single ezCheckPrinting .mdb file and imports - * account config, check layout, and check records into the SQLite database. - * - * Prerequisites: - * - mdbtools installed: `sudo apt install mdbtools` or brew install mdbtools - * - SQLite DB initialized (runs automatically on first require of database.js) + * Migration: reads an ezCheckPrinting .mdb file and imports account config, + * check layout, and check records into the SQLite database as a NEW account. + * Each import creates a separate account row; existing accounts are unaffected. * * Usage: - * node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb" - * node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb" --dry-run + * node migrations/import-mdb.js --file "/path/to/Account.mdb" + * node migrations/import-mdb.js --file "/path/to/Account.mdb" --dry-run */ const { execSync } = require('child_process'); @@ -51,17 +48,11 @@ function mdbExport(table) { } } -/** - * Minimal CSV parser. Handles quoted fields with embedded commas and newlines. - * Not a full RFC 4180 implementation but sufficient for mdb-export output. - */ function parseCsv(text) { const lines = text.trim().split('\n'); if (lines.length < 2) return []; - const headers = splitCsvLine(lines[0]); const rows = []; - for (let i = 1; i < lines.length; i++) { const values = splitCsvLine(lines[i]); if (values.length === 0) continue; @@ -78,16 +69,11 @@ function splitCsvLine(line) { const result = []; let current = ''; let inQuotes = false; - for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { - if (inQuotes && line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = !inQuotes; - } + if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } + else inQuotes = !inQuotes; } else if (ch === ',' && !inQuotes) { result.push(current); current = ''; @@ -100,8 +86,7 @@ function splitCsvLine(line) { } // ---- Font name normalization ------------------------------------------------- -// .mdb stores Windows font names; map common ones to PDFKit built-ins. -// Any unmapped font will fall back to Helvetica at render time. + const FONT_MAP = { 'Times New Roman': 'Times-Roman', 'Helsinki': 'Helvetica', @@ -119,27 +104,21 @@ function normalizeFont(fontName, isBold) { return mapped; } -// TODO: Support multi-account .mdb import -- run migration per account and associate records with account_id - // ---- Import: T100 (account config) ------------------------------------------ function importAccount() { console.log('\n--- Importing account config (T100) ---'); const rows = mdbExport('T100'); - if (rows.length === 0) { console.error('No rows in T100. Is this a valid ezCheckPrinting .mdb?'); process.exit(1); } - // Take the first (and typically only) row const r = rows[0]; console.log(`Account: ${r.Company1} / Bank: ${r.BankName}`); console.log(`Routing: ${r.BankRouteNo} | Account: ${r.BankAccountNo}`); console.log(`Current check no: ${r.CurrentCheckNo}`); - // Logo is stored as base64 in the Settings table (not T100) - // We fetch it separately below and update after insert. const accountData = { bank_name: r.BankName?.trim() || '', bank_info1: r.BankInfo1?.trim() || null, @@ -165,9 +144,7 @@ function importAccount() { }; if (!dryRun) { - // Delete existing account row (single-account Phase 1 assumption) - db.prepare('DELETE FROM account').run(); - db.prepare(` + const result = db.prepare(` INSERT INTO account ( bank_name, bank_info1, bank_info2, bank_info3, transit_code, routing_number, account_number, start_check_no, current_check_no, @@ -182,17 +159,18 @@ function importAccount() { @blank_stock, @check_position ) `).run(accountData); - console.log('Account config imported.'); + const accountId = result.lastInsertRowid; + console.log(`Account config imported (id=${accountId}).`); + return accountId; } else { console.log('[dry-run] Would insert:', JSON.stringify(accountData, null, 2)); + return null; } - - return accountData; } // ---- Import: Settings (logo image) ------------------------------------------ -function importLogo() { +function importLogo(accountId) { console.log('\n--- Importing logo from Settings table ---'); const rows = mdbExport('Settings'); const logoRow = rows.find(r => r.SettingKey === 'LogoImg'); @@ -202,12 +180,11 @@ function importLogo() { return; } - // Value is raw base64 (GIF format based on the data we saw) const base64Data = logoRow.SettingValue.trim(); const dataUri = `data:image/gif;base64,${base64Data}`; if (!dryRun) { - db.prepare('UPDATE account SET logo_data = ? WHERE id = 1').run(dataUri); + db.prepare('UPDATE account SET logo_data = ? WHERE id = ?').run(dataUri, accountId); console.log(`Logo imported (${Math.round(base64Data.length / 1024)} KB base64).`); } else { console.log(`[dry-run] Would import logo (${Math.round(base64Data.length / 1024)} KB base64).`); @@ -216,22 +193,22 @@ function importLogo() { // ---- Import: T200 (check layout fields) ------------------------------------- -function importLayoutFields() { +function importLayoutFields(accountId) { console.log('\n--- Importing check layout fields (T200) ---'); const rows = mdbExport('T200'); console.log(`Found ${rows.length} layout fields.`); if (!dryRun) { - db.prepare('DELETE FROM layout_fields').run(); + db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(accountId); } const insert = db.prepare(` INSERT INTO layout_fields ( - field_name, field_text, font_name, font_size, font_bold, + account_id, field_name, field_text, font_name, font_size, font_bold, field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible, not_for_preprint ) VALUES ( - @field_name, @field_text, @font_name, @font_size, @font_bold, + @account_id, @field_name, @field_text, @font_name, @font_size, @font_bold, @field_type, @line_thick, @x_pos, @y_pos, @x_end_pos, @y_end_pos, @visible, @not_for_preprint ) @@ -241,6 +218,7 @@ function importLayoutFields() { for (const r of rows) { const isBold = r.FldFontType === '1'; const fieldData = { + account_id: accountId, field_name: r.FldName?.trim() || '', field_text: r.FldText?.trim() || null, font_name: normalizeFont(r.FldFontName?.trim(), isBold), @@ -269,7 +247,7 @@ function importLayoutFields() { // ---- Import: T104 (check records) ------------------------------------------- -function importChecks() { +function importChecks(accountId) { console.log('\n--- Importing check records (T104) ---'); const rows = mdbExport('T104'); console.log(`Found ${rows.length} check records.`); @@ -280,16 +258,16 @@ function importChecks() { } if (!dryRun) { - db.prepare('DELETE FROM checks').run(); + db.prepare('DELETE FROM checks WHERE account_id = ?').run(accountId); } const insert = db.prepare(` INSERT INTO checks ( - check_no, payee, amount, check_date, memo, note1, note2, + account_id, check_no, payee, amount, check_date, memo, note1, note2, payee_address1, payee_address2, payee_address3, payee_address4, printed, add_date, mdb_check_id ) VALUES ( - @check_no, @payee, @amount, @check_date, @memo, @note1, @note2, + @account_id, @check_no, @payee, @amount, @check_date, @memo, @note1, @note2, @payee_address1, @payee_address2, @payee_address3, @payee_address4, @printed, @add_date, @mdb_check_id ) @@ -298,13 +276,12 @@ function importChecks() { let count = 0; let skipped = 0; for (const r of rows) { - // Normalize date: .mdb uses MM/DD/YYYY or similar; convert to YYYY-MM-DD const rawDate = r.CheckDate?.trim() || ''; const checkDate = normalizeDate(rawDate); - const addDate = normalizeDate(r.AddDate?.trim() || '') || new Date().toISOString(); const checkData = { + account_id: accountId, check_no: parseInt(r.CheckNo), payee: r.Payee?.trim() || '', amount: parseFloat(r.Amount) || 0, @@ -348,7 +325,6 @@ function importChecks() { function normalizeDate(raw) { if (!raw) return null; - // mdb-export outputs dates as "MM/DD/YYYY HH:MM:SS", "MM/DD/YY", or "YYYY-MM-DD" const mdyMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/); if (mdyMatch) { const [, m, d, y] = mdyMatch; @@ -368,10 +344,16 @@ console.log(`\nImporting from: ${mdbFile}`); console.log(`Target database: ${process.env.DB_PATH || 'data/ezcheck.db'}`); try { - importAccount(); - importLogo(); - importLayoutFields(); - importChecks(); + const accountId = importAccount(); + if (!dryRun && accountId) { + importLogo(accountId); + importLayoutFields(accountId); + importChecks(accountId); + } else if (dryRun) { + importLogo(null); + importLayoutFields(null); + importChecks(null); + } console.log('\nMigration complete.'); } catch (err) { console.error('\nMigration failed:', err); diff --git a/public/css/style.css b/public/css/style.css index 1ca847c..65b7af6 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -41,6 +41,18 @@ header { .header-brand { font-size: 15px; font-weight: 600; } .header-info { font-size: 12px; color: rgba(255,255,255,0.7); } .header-info strong { color: #fff; } +.header-left { display: flex; align-items: center; gap: 10px; } + +.account-switcher { + background: rgba(255,255,255,0.15); + border: 1px solid rgba(255,255,255,0.3); + color: #fff; + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + cursor: pointer; +} +.account-switcher option { background: var(--header-bg); color: #fff; } /* ── Toolbar ── */ .toolbar { diff --git a/public/index.html b/public/index.html index d694f8d..cdefa7b 100644 --- a/public/index.html +++ b/public/index.html @@ -8,12 +8,11 @@
-
- ezcheck -
-
- Next check: +
+ ezcheck +
+ Next check:
diff --git a/public/js/app.js b/public/js/app.js index d242bad..2ba1ab7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3,6 +3,8 @@ const state = { checks: [], account: null, + accounts: [], + activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null, filterStatus: '', // '' = all, '0' = unprinted, '1' = printed filterPayee: '', filterDateFrom: '', @@ -27,20 +29,50 @@ async function apiFetch(method, path, body) { // ── Data loading ───────────────────────────────────────────────────────────── -async function loadAccount() { +async function loadAccounts() { try { - state.account = await apiFetch('GET', '/api/account'); + state.accounts = await apiFetch('GET', '/api/accounts'); + if (state.accounts.length === 0) { + openWizard(); + return; + } + // Use stored account or default to first + const stored = state.activeAccountId; + const valid = stored && state.accounts.find(a => a.id === stored); + state.activeAccountId = valid ? stored : state.accounts[0].id; + localStorage.setItem('activeAccountId', state.activeAccountId); + + populateAccountSwitcher(); + state.account = await apiFetch('GET', `/api/account/${state.activeAccountId}`); renderHeader(); + await loadChecks(); } catch (err) { - if (err.message && err.message.includes('No account')) openWizard(); + console.error('Failed to load accounts:', err); } } +function populateAccountSwitcher() { + const sel = document.getElementById('account-switcher'); + sel.innerHTML = state.accounts.map(a => + `` + ).join(''); +} + +async function switchAccount(accountId) { + state.activeAccountId = accountId; + localStorage.setItem('activeAccountId', accountId); + state.selected.clear(); + state.account = await apiFetch('GET', `/api/account/${accountId}`); + renderHeader(); + await loadChecks(); +} + async function loadChecks() { + if (!state.activeAccountId) return; const tbody = document.getElementById('checks-tbody'); tbody.innerHTML = 'Loading…'; try { - state.checks = await apiFetch('GET', '/api/checks'); + state.checks = await apiFetch('GET', `/api/checks?account_id=${state.activeAccountId}`); state.selected.clear(); renderTable(); refreshPdfButton(); @@ -258,10 +290,10 @@ async function saveCheck(e) { if (state.editingId !== null) { await apiFetch('PUT', `/api/checks/${state.editingId}`, data); } else { - await apiFetch('POST', '/api/checks', data); + await apiFetch('POST', '/api/checks', { ...data, account_id: state.activeAccountId }); } closePanel(); - await Promise.all([loadAccount(), loadChecks()]); + await Promise.all([loadAccounts(), loadChecks()]); } catch (err) { alert(`Error: ${err.message}`); } finally { @@ -296,7 +328,7 @@ async function generatePdf() { const res = await fetch('/api/pdf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkIds: ids }), + body: JSON.stringify({ checkIds: ids, account_id: state.activeAccountId }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); @@ -407,9 +439,10 @@ async function finishWizard() { btn.textContent = 'Saving…'; try { - await apiFetch('POST', '/api/account/setup', payload); + const result = await apiFetch('POST', '/api/account/setup', payload); closeWizard(); - await Promise.all([loadAccount(), loadChecks()]); + await loadAccounts(); + if (result.accountId) await switchAccount(result.accountId); } catch (err) { const errEl = document.getElementById('wizard-error'); errEl.textContent = err.message; @@ -464,7 +497,8 @@ async function runImport() { if (res.ok) { log.classList.add('success'); btn.textContent = 'Done'; - await Promise.all([loadAccount(), loadChecks()]); + await loadAccounts(); + if (data.newAccountId) await switchAccount(data.newAccountId); } else { log.classList.add('error'); btn.disabled = false; @@ -561,9 +595,13 @@ function init() { document.getElementById('import-modal-overlay').addEventListener('click', closeImportModal); document.getElementById('btn-run-import').addEventListener('click', runImport); + // Account switcher + document.getElementById('account-switcher').addEventListener('change', e => { + switchAccount(parseInt(e.target.value, 10)); + }); + // Initial data load - loadAccount(); - loadChecks(); + loadAccounts(); } document.addEventListener('DOMContentLoaded', init); diff --git a/src/app.js b/src/app.js index c163587..46f6e4c 100644 --- a/src/app.js +++ b/src/app.js @@ -17,33 +17,30 @@ app.use(express.static(path.join(__dirname, '../public'))); app.use('/api/checks', require('./routes/checks')); app.use('/api/pdf', require('./routes/pdf')); -// .mdb import endpoint -app.post('/api/import', upload.single('mdbfile'), (req, res) => { - if (!req.file) return res.status(400).json({ error: 'No file uploaded.' }); - const tmpPath = req.file.path; - try { - const output = execFileSync( - process.execPath, - [path.join(__dirname, '../migrations/import-mdb.js'), '--file', tmpPath], - { encoding: 'utf8', timeout: 120000, env: process.env } - ); - res.json({ success: true, log: output }); - } catch (err) { - res.status(500).json({ - error: 'Import failed.', - log: [err.stdout, err.stderr, err.message].filter(Boolean).join('\n'), - }); - } finally { - fs.unlink(tmpPath, () => {}); - } +// GET /api/accounts - list all accounts (id + display name) +app.get('/api/accounts', (req, res) => { + const db = require('./db/database'); + const accounts = db.prepare( + 'SELECT id, company1, bank_name, current_check_no FROM account ORDER BY id ASC' + ).all(); + res.json(accounts); }); -// Account setup endpoint (first-run wizard) +// GET /api/account/:id - get full account by id +app.get('/api/account/:id', (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 = ?' + ).get(req.params.id); + if (!account) return res.status(404).json({ error: 'Account not found.' }); + res.json(account); +}); + +// POST /api/account/setup - create a new account (wizard) app.post('/api/account/setup', (req, res) => { const db = require('./db/database'); - const existing = db.prepare('SELECT id FROM account WHERE id = 1').get(); - if (existing) return res.status(409).json({ error: 'Account already configured.' }); - const { company1, company2, company3, company4, bank_name, bank_info1, bank_info2, transit_code, @@ -58,7 +55,7 @@ app.post('/api/account/setup', (req, res) => { return res.status(400).json({ error: 'Starting check number must be a positive integer.' }); } - db.prepare(` + const result = db.prepare(` INSERT INTO account ( bank_name, bank_info1, bank_info2, transit_code, routing_number, account_number, start_check_no, current_check_no, @@ -84,29 +81,35 @@ app.post('/api/account/setup', (req, res) => { logo_data: logo_data || null, }); - res.status(201).json({ success: true }); + res.status(201).json({ success: true, accountId: result.lastInsertRowid }); }); -// TODO: Add multi-account support -- account switcher, per-account routing/logo/layout, account_id FK on checks and layout_fields - // TODO: Add basic auth or simple password gate for any network-exposed deployment // TODO: Add deposit slip support -- deposits table, PDF generation, ledger, and slide-in entry form -// Account info endpoint (read-only for Phase 1) -app.get('/api/account', (req, res) => { +// .mdb import endpoint — always creates a new account +app.post('/api/import', upload.single('mdbfile'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No file uploaded.' }); 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.' }); + const tmpPath = req.file.path; + try { + const output = execFileSync( + process.execPath, + [path.join(__dirname, '../migrations/import-mdb.js'), '--file', tmpPath], + { encoding: 'utf8', timeout: 120000, env: process.env } + ); + // Grab the newly created account (highest id) + const newAccount = db.prepare('SELECT id, company1 FROM account ORDER BY id DESC LIMIT 1').get(); + res.json({ success: true, log: output, newAccountId: newAccount ? newAccount.id : null }); + } catch (err) { + res.status(500).json({ + error: 'Import failed.', + log: [err.stdout, err.stderr, err.message].filter(Boolean).join('\n'), + }); + } finally { + fs.unlink(tmpPath, () => {}); } - // 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 diff --git a/src/db/database.js b/src/db/database.js index 17a2a5a..f6b5bb8 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -7,7 +7,6 @@ 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 }); @@ -15,7 +14,6 @@ if (!fs.existsSync(dataDir)) { const db = new Database(DB_PATH); -// Enable WAL mode for better concurrent read performance db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); @@ -23,4 +21,76 @@ db.pragma('foreign_keys = ON'); const schema = fs.readFileSync(SCHEMA_PATH, 'utf8'); db.exec(schema); +// --- Runtime migrations for schema upgrades --- + +// Migration: add account_id to checks, fix UNIQUE to be per-account +const checksInfo = db.prepare('PRAGMA table_info(checks)').all(); +if (!checksInfo.some(c => c.name === 'account_id')) { + db.exec(` + ALTER TABLE checks RENAME TO checks_old; + CREATE TABLE checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL DEFAULT 1 REFERENCES account(id), + check_no INTEGER NOT NULL, + payee TEXT NOT NULL, + amount REAL NOT NULL, + check_date TEXT NOT NULL, + memo TEXT, + note1 TEXT, + note2 TEXT, + payee_address1 TEXT, + payee_address2 TEXT, + payee_address3 TEXT, + payee_address4 TEXT, + printed INTEGER NOT NULL DEFAULT 0, + add_date TEXT NOT NULL DEFAULT (datetime('now')), + mdb_check_id INTEGER, + UNIQUE(account_id, check_no) + ); + INSERT INTO checks (id, account_id, check_no, payee, amount, check_date, memo, note1, note2, + payee_address1, payee_address2, payee_address3, payee_address4, printed, add_date, mdb_check_id) + SELECT id, 1, check_no, payee, amount, check_date, memo, note1, note2, + payee_address1, payee_address2, payee_address3, payee_address4, printed, add_date, mdb_check_id + FROM checks_old; + DROP TABLE checks_old; + 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); + CREATE INDEX IF NOT EXISTS idx_checks_account ON checks(account_id); + `); +} + +// Migration: add account_id to layout_fields, change UNIQUE to per-account +const lfInfo = db.prepare('PRAGMA table_info(layout_fields)').all(); +if (!lfInfo.some(c => c.name === 'account_id')) { + db.exec(` + ALTER TABLE layout_fields RENAME TO layout_fields_old; + CREATE TABLE layout_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL DEFAULT 1 REFERENCES account(id), + field_name TEXT NOT NULL, + field_text TEXT, + font_name TEXT NOT NULL DEFAULT 'Helvetica', + font_size REAL NOT NULL DEFAULT 10, + font_bold INTEGER NOT NULL DEFAULT 0, + 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, + not_for_preprint INTEGER NOT NULL DEFAULT 0, + UNIQUE(account_id, field_name) + ); + INSERT INTO layout_fields (id, account_id, field_name, field_text, font_name, font_size, font_bold, + field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible, not_for_preprint) + SELECT id, 1, field_name, field_text, font_name, font_size, font_bold, + field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible, not_for_preprint + FROM layout_fields_old; + DROP TABLE layout_fields_old; + CREATE INDEX IF NOT EXISTS idx_layout_account ON layout_fields(account_id); + `); +} + module.exports = db; diff --git a/src/db/schema.sql b/src/db/schema.sql index 9fa7aed..4323d35 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,7 +1,4 @@ -- 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, @@ -16,21 +13,17 @@ CREATE TABLE IF NOT EXISTS account ( 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 + blank_stock INTEGER NOT NULL DEFAULT 1, 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')) @@ -38,10 +31,11 @@ CREATE TABLE IF NOT EXISTS account ( CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL DEFAULT 1 REFERENCES account(id), check_no INTEGER NOT NULL, payee TEXT NOT NULL, amount REAL NOT NULL, - check_date TEXT NOT NULL, -- ISO date string YYYY-MM-DD + check_date TEXT NOT NULL, memo TEXT, note1 TEXT, note2 TEXT, @@ -49,34 +43,33 @@ CREATE TABLE IF NOT EXISTS checks ( payee_address2 TEXT, payee_address3 TEXT, payee_address4 TEXT, - printed INTEGER NOT NULL DEFAULT 0, -- 0 = not printed, 1 = printed + printed INTEGER NOT NULL DEFAULT 0, 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) + UNIQUE(account_id, 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 + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL DEFAULT 1 REFERENCES account(id), + field_name TEXT NOT NULL, + field_text TEXT, + font_name TEXT NOT NULL DEFAULT 'Helvetica', + font_size REAL NOT NULL DEFAULT 10, + font_bold INTEGER NOT NULL DEFAULT 0, + 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, + not_for_preprint INTEGER NOT NULL DEFAULT 0, + UNIQUE(account_id, field_name) ); --- 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); +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); +CREATE INDEX IF NOT EXISTS idx_checks_account ON checks(account_id); +CREATE INDEX IF NOT EXISTS idx_layout_account ON layout_fields(account_id); diff --git a/src/routes/checks.js b/src/routes/checks.js index 865ad05..845592e 100644 --- a/src/routes/checks.js +++ b/src/routes/checks.js @@ -6,29 +6,25 @@ const db = require('../db/database'); // TODO: Add ledger reporting -- date range filter, payee search, total amount display, CSV export -// GET /api/checks - list all checks, newest first +// GET /api/checks?account_id=X - list checks for an account, newest first router.get('/', (req, res) => { - const { after, printed } = req.query; - let query = 'SELECT * FROM checks'; - const params = []; - const conditions = []; + const { after, printed, account_id } = req.query; + if (!account_id) return res.status(400).json({ error: 'account_id query param required' }); + + let query = 'SELECT * FROM checks WHERE account_id = ?'; + const params = [account_id]; if (after) { - conditions.push('check_date >= ?'); + query += ' AND check_date >= ?'; params.push(after); } if (printed !== undefined) { - conditions.push('printed = ?'); + query += ' AND 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); + res.json(db.prepare(query).all(...params)); }); // GET /api/checks/:id @@ -42,43 +38,41 @@ router.get('/:id', (req, res) => { // POST /api/checks - create a new check router.post('/', (req, res) => { - const { payee, amount, check_date, memo, note1, note2, + const { account_id, 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' }); + if (!account_id || !payee || !amount || !check_date) { + return res.status(400).json({ error: 'account_id, 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 account = db.prepare('SELECT current_check_no FROM account WHERE id = ?').get(account_id); + if (!account) return res.status(400).json({ error: 'Account not found.' }); const checkNo = account.current_check_no + 1; const insertCheck = db.prepare(` - INSERT INTO checks (check_no, payee, amount, check_date, memo, note1, note2, + INSERT INTO checks (account_id, check_no, payee, amount, check_date, memo, note1, note2, payee_address1, payee_address2, payee_address3, payee_address4) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const updateAccountCheckNo = db.prepare( - 'UPDATE account SET current_check_no = ?, updated_at = datetime(\'now\') WHERE id = 1' + "UPDATE account SET current_check_no = ?, updated_at = datetime('now') WHERE id = ?" ); const transaction = db.transaction(() => { const result = insertCheck.run( - checkNo, payee, parseFloat(amount), check_date, + account_id, 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); + updateAccountCheckNo.run(checkNo, account_id); return result.lastInsertRowid; }); const newId = transaction(); - const newCheck = db.prepare('SELECT * FROM checks WHERE id = ?').get(newId); - res.status(201).json(newCheck); + res.status(201).json(db.prepare('SELECT * FROM checks WHERE id = ?').get(newId)); }); // PUT /api/checks/:id - update a check @@ -115,18 +109,16 @@ router.put('/:id', (req, res) => { 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' }); - db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id); res.status(204).send(); }); -// POST /api/checks/mark-printed - mark checks as printed +// POST /api/checks/mark-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 }); diff --git a/src/routes/pdf.js b/src/routes/pdf.js index 4255925..99bd3d8 100644 --- a/src/routes/pdf.js +++ b/src/routes/pdf.js @@ -7,40 +7,44 @@ const { generateCheckPdf } = require('../services/pdfService'); /** * POST /api/pdf - * Body: { checkIds: [1, 2, 3] } -- 1 to 3 check IDs + * Body: { checkIds: [1, 2, ...], account_id: X } * - * Returns a PDF with 1–3 checks in a 3-up layout. + * Returns a multi-page PDF (3 checks per page). * After successful generation, marks all checks as printed. - * - * Query param: ?mark_printed=false to suppress auto-marking (for reprints). + * Query param: ?mark_printed=false to suppress auto-marking. */ router.post('/', async (req, res) => { - const { checkIds } = req.body; + const { checkIds, account_id } = req.body; if (!Array.isArray(checkIds) || checkIds.length === 0) { return res.status(400).json({ error: 'checkIds must be a non-empty array' }); } - // 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 + let checks; + try { + 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; + }); + } catch (err) { + return res.status(404).json({ error: err.message }); } - // 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; - }); + // Derive account from checks (all should belong to the same account) + const resolvedAccountId = account_id || checks[0].account_id; + const account = db.prepare('SELECT * FROM account WHERE id = ?').get(resolvedAccountId); + if (!account) { + return res.status(500).json({ error: 'No account configured.' }); + } - // Fetch layout fields (all visible fields) - const fields = db.prepare('SELECT * FROM layout_fields WHERE visible = 1').all(); + // Fetch layout fields for this account + const fields = db.prepare('SELECT * FROM layout_fields WHERE account_id = ? AND visible = 1').all(resolvedAccountId); 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(',');