Add multi-account support
- Schema: account_id FK on checks and layout_fields; UNIQUE per-account on check_no and field_name - DB: runtime migration recreates both tables to add account_id (assigns existing rows to account 1) - Routes: GET /api/accounts lists all; GET /api/account/:id replaces hardcoded id=1; POST /api/account/setup always creates a new account and returns accountId - checks.js: all queries scoped by account_id; POST requires account_id in body - pdf.js: resolves account from check's account_id instead of id=1; layout fields fetched per-account - import-mdb.js: always INSERTs a new account (never deletes existing); all records tagged with new accountId - Frontend: account switcher in header; activeAccountId persisted to localStorage; all API calls pass account_id; switching accounts reloads checks; wizard and import auto-switch to newly created account
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ services:
|
|||||||
container_name: check-printing
|
container_name: check-printing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3003:3000"
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent data: SQLite DB lives here
|
# Persistent data: SQLite DB lives here
|
||||||
- check-printing-data:/app/data
|
- check-printing-data:/app/data
|
||||||
|
|||||||
+35
-53
@@ -4,16 +4,13 @@
|
|||||||
/**
|
/**
|
||||||
* import-mdb.js
|
* import-mdb.js
|
||||||
*
|
*
|
||||||
* One-time migration: reads a single ezCheckPrinting .mdb file and imports
|
* Migration: reads an ezCheckPrinting .mdb file and imports account config,
|
||||||
* account config, check layout, and check records into the SQLite database.
|
* check layout, and check records into the SQLite database as a NEW account.
|
||||||
*
|
* Each import creates a separate account row; existing accounts are unaffected.
|
||||||
* Prerequisites:
|
|
||||||
* - mdbtools installed: `sudo apt install mdbtools` or brew install mdbtools
|
|
||||||
* - SQLite DB initialized (runs automatically on first require of database.js)
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb"
|
* node migrations/import-mdb.js --file "/path/to/Account.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" --dry-run
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
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) {
|
function parseCsv(text) {
|
||||||
const lines = text.trim().split('\n');
|
const lines = text.trim().split('\n');
|
||||||
if (lines.length < 2) return [];
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
const headers = splitCsvLine(lines[0]);
|
const headers = splitCsvLine(lines[0]);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const values = splitCsvLine(lines[i]);
|
const values = splitCsvLine(lines[i]);
|
||||||
if (values.length === 0) continue;
|
if (values.length === 0) continue;
|
||||||
@@ -78,16 +69,11 @@ function splitCsvLine(line) {
|
|||||||
const result = [];
|
const result = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
for (let i = 0; i < line.length; i++) {
|
||||||
const ch = line[i];
|
const ch = line[i];
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
if (inQuotes && line[i + 1] === '"') {
|
if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
|
||||||
current += '"';
|
else inQuotes = !inQuotes;
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
}
|
|
||||||
} else if (ch === ',' && !inQuotes) {
|
} else if (ch === ',' && !inQuotes) {
|
||||||
result.push(current);
|
result.push(current);
|
||||||
current = '';
|
current = '';
|
||||||
@@ -100,8 +86,7 @@ function splitCsvLine(line) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Font name normalization -------------------------------------------------
|
// ---- 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 = {
|
const FONT_MAP = {
|
||||||
'Times New Roman': 'Times-Roman',
|
'Times New Roman': 'Times-Roman',
|
||||||
'Helsinki': 'Helvetica',
|
'Helsinki': 'Helvetica',
|
||||||
@@ -119,27 +104,21 @@ function normalizeFont(fontName, isBold) {
|
|||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support multi-account .mdb import -- run migration per account and associate records with account_id
|
|
||||||
|
|
||||||
// ---- Import: T100 (account config) ------------------------------------------
|
// ---- Import: T100 (account config) ------------------------------------------
|
||||||
|
|
||||||
function importAccount() {
|
function importAccount() {
|
||||||
console.log('\n--- Importing account config (T100) ---');
|
console.log('\n--- Importing account config (T100) ---');
|
||||||
const rows = mdbExport('T100');
|
const rows = mdbExport('T100');
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
console.error('No rows in T100. Is this a valid ezCheckPrinting .mdb?');
|
console.error('No rows in T100. Is this a valid ezCheckPrinting .mdb?');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Take the first (and typically only) row
|
|
||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
console.log(`Account: ${r.Company1} / Bank: ${r.BankName}`);
|
console.log(`Account: ${r.Company1} / Bank: ${r.BankName}`);
|
||||||
console.log(`Routing: ${r.BankRouteNo} | Account: ${r.BankAccountNo}`);
|
console.log(`Routing: ${r.BankRouteNo} | Account: ${r.BankAccountNo}`);
|
||||||
console.log(`Current check no: ${r.CurrentCheckNo}`);
|
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 = {
|
const accountData = {
|
||||||
bank_name: r.BankName?.trim() || '',
|
bank_name: r.BankName?.trim() || '',
|
||||||
bank_info1: r.BankInfo1?.trim() || null,
|
bank_info1: r.BankInfo1?.trim() || null,
|
||||||
@@ -165,9 +144,7 @@ function importAccount() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
// Delete existing account row (single-account Phase 1 assumption)
|
const result = db.prepare(`
|
||||||
db.prepare('DELETE FROM account').run();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO account (
|
INSERT INTO account (
|
||||||
bank_name, bank_info1, bank_info2, bank_info3, transit_code,
|
bank_name, bank_info1, bank_info2, bank_info3, transit_code,
|
||||||
routing_number, account_number, start_check_no, current_check_no,
|
routing_number, account_number, start_check_no, current_check_no,
|
||||||
@@ -182,17 +159,18 @@ function importAccount() {
|
|||||||
@blank_stock, @check_position
|
@blank_stock, @check_position
|
||||||
)
|
)
|
||||||
`).run(accountData);
|
`).run(accountData);
|
||||||
console.log('Account config imported.');
|
const accountId = result.lastInsertRowid;
|
||||||
|
console.log(`Account config imported (id=${accountId}).`);
|
||||||
|
return accountId;
|
||||||
} else {
|
} else {
|
||||||
console.log('[dry-run] Would insert:', JSON.stringify(accountData, null, 2));
|
console.log('[dry-run] Would insert:', JSON.stringify(accountData, null, 2));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return accountData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Import: Settings (logo image) ------------------------------------------
|
// ---- Import: Settings (logo image) ------------------------------------------
|
||||||
|
|
||||||
function importLogo() {
|
function importLogo(accountId) {
|
||||||
console.log('\n--- Importing logo from Settings table ---');
|
console.log('\n--- Importing logo from Settings table ---');
|
||||||
const rows = mdbExport('Settings');
|
const rows = mdbExport('Settings');
|
||||||
const logoRow = rows.find(r => r.SettingKey === 'LogoImg');
|
const logoRow = rows.find(r => r.SettingKey === 'LogoImg');
|
||||||
@@ -202,12 +180,11 @@ function importLogo() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value is raw base64 (GIF format based on the data we saw)
|
|
||||||
const base64Data = logoRow.SettingValue.trim();
|
const base64Data = logoRow.SettingValue.trim();
|
||||||
const dataUri = `data:image/gif;base64,${base64Data}`;
|
const dataUri = `data:image/gif;base64,${base64Data}`;
|
||||||
|
|
||||||
if (!dryRun) {
|
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).`);
|
console.log(`Logo imported (${Math.round(base64Data.length / 1024)} KB base64).`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[dry-run] Would import logo (${Math.round(base64Data.length / 1024)} KB base64).`);
|
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) -------------------------------------
|
// ---- Import: T200 (check layout fields) -------------------------------------
|
||||||
|
|
||||||
function importLayoutFields() {
|
function importLayoutFields(accountId) {
|
||||||
console.log('\n--- Importing check layout fields (T200) ---');
|
console.log('\n--- Importing check layout fields (T200) ---');
|
||||||
const rows = mdbExport('T200');
|
const rows = mdbExport('T200');
|
||||||
console.log(`Found ${rows.length} layout fields.`);
|
console.log(`Found ${rows.length} layout fields.`);
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
db.prepare('DELETE FROM layout_fields').run();
|
db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
INSERT INTO layout_fields (
|
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,
|
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos,
|
||||||
visible, not_for_preprint
|
visible, not_for_preprint
|
||||||
) VALUES (
|
) 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,
|
@field_type, @line_thick, @x_pos, @y_pos, @x_end_pos, @y_end_pos,
|
||||||
@visible, @not_for_preprint
|
@visible, @not_for_preprint
|
||||||
)
|
)
|
||||||
@@ -241,6 +218,7 @@ function importLayoutFields() {
|
|||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const isBold = r.FldFontType === '1';
|
const isBold = r.FldFontType === '1';
|
||||||
const fieldData = {
|
const fieldData = {
|
||||||
|
account_id: accountId,
|
||||||
field_name: r.FldName?.trim() || '',
|
field_name: r.FldName?.trim() || '',
|
||||||
field_text: r.FldText?.trim() || null,
|
field_text: r.FldText?.trim() || null,
|
||||||
font_name: normalizeFont(r.FldFontName?.trim(), isBold),
|
font_name: normalizeFont(r.FldFontName?.trim(), isBold),
|
||||||
@@ -269,7 +247,7 @@ function importLayoutFields() {
|
|||||||
|
|
||||||
// ---- Import: T104 (check records) -------------------------------------------
|
// ---- Import: T104 (check records) -------------------------------------------
|
||||||
|
|
||||||
function importChecks() {
|
function importChecks(accountId) {
|
||||||
console.log('\n--- Importing check records (T104) ---');
|
console.log('\n--- Importing check records (T104) ---');
|
||||||
const rows = mdbExport('T104');
|
const rows = mdbExport('T104');
|
||||||
console.log(`Found ${rows.length} check records.`);
|
console.log(`Found ${rows.length} check records.`);
|
||||||
@@ -280,16 +258,16 @@ function importChecks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
db.prepare('DELETE FROM checks').run();
|
db.prepare('DELETE FROM checks WHERE account_id = ?').run(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
INSERT INTO checks (
|
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,
|
payee_address1, payee_address2, payee_address3, payee_address4,
|
||||||
printed, add_date, mdb_check_id
|
printed, add_date, mdb_check_id
|
||||||
) VALUES (
|
) 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,
|
@payee_address1, @payee_address2, @payee_address3, @payee_address4,
|
||||||
@printed, @add_date, @mdb_check_id
|
@printed, @add_date, @mdb_check_id
|
||||||
)
|
)
|
||||||
@@ -298,13 +276,12 @@ function importChecks() {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const r of rows) {
|
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 rawDate = r.CheckDate?.trim() || '';
|
||||||
const checkDate = normalizeDate(rawDate);
|
const checkDate = normalizeDate(rawDate);
|
||||||
|
|
||||||
const addDate = normalizeDate(r.AddDate?.trim() || '') || new Date().toISOString();
|
const addDate = normalizeDate(r.AddDate?.trim() || '') || new Date().toISOString();
|
||||||
|
|
||||||
const checkData = {
|
const checkData = {
|
||||||
|
account_id: accountId,
|
||||||
check_no: parseInt(r.CheckNo),
|
check_no: parseInt(r.CheckNo),
|
||||||
payee: r.Payee?.trim() || '',
|
payee: r.Payee?.trim() || '',
|
||||||
amount: parseFloat(r.Amount) || 0,
|
amount: parseFloat(r.Amount) || 0,
|
||||||
@@ -348,7 +325,6 @@ function importChecks() {
|
|||||||
|
|
||||||
function normalizeDate(raw) {
|
function normalizeDate(raw) {
|
||||||
if (!raw) return null;
|
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})/);
|
const mdyMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
|
||||||
if (mdyMatch) {
|
if (mdyMatch) {
|
||||||
const [, m, d, y] = 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'}`);
|
console.log(`Target database: ${process.env.DB_PATH || 'data/ezcheck.db'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
importAccount();
|
const accountId = importAccount();
|
||||||
importLogo();
|
if (!dryRun && accountId) {
|
||||||
importLayoutFields();
|
importLogo(accountId);
|
||||||
importChecks();
|
importLayoutFields(accountId);
|
||||||
|
importChecks(accountId);
|
||||||
|
} else if (dryRun) {
|
||||||
|
importLogo(null);
|
||||||
|
importLayoutFields(null);
|
||||||
|
importChecks(null);
|
||||||
|
}
|
||||||
console.log('\nMigration complete.');
|
console.log('\nMigration complete.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('\nMigration failed:', err);
|
console.error('\nMigration failed:', err);
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ header {
|
|||||||
.header-brand { font-size: 15px; font-weight: 600; }
|
.header-brand { font-size: 15px; font-weight: 600; }
|
||||||
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
|
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||||
.header-info strong { color: #fff; }
|
.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 ── */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
+4
-5
@@ -8,12 +8,11 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div class="header-brand">
|
<div class="header-left">
|
||||||
<span id="company-name">ezcheck</span>
|
<span class="header-brand" id="company-name">ezcheck</span>
|
||||||
</div>
|
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
||||||
<div class="header-info">
|
|
||||||
Next check: <strong id="current-check-no">—</strong>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="header-info">Next check: <strong id="current-check-no">—</strong></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
|||||||
+50
-12
@@ -3,6 +3,8 @@
|
|||||||
const state = {
|
const state = {
|
||||||
checks: [],
|
checks: [],
|
||||||
account: null,
|
account: null,
|
||||||
|
accounts: [],
|
||||||
|
activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null,
|
||||||
filterStatus: '', // '' = all, '0' = unprinted, '1' = printed
|
filterStatus: '', // '' = all, '0' = unprinted, '1' = printed
|
||||||
filterPayee: '',
|
filterPayee: '',
|
||||||
filterDateFrom: '',
|
filterDateFrom: '',
|
||||||
@@ -27,20 +29,50 @@ async function apiFetch(method, path, body) {
|
|||||||
|
|
||||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadAccount() {
|
async function loadAccounts() {
|
||||||
try {
|
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();
|
renderHeader();
|
||||||
|
await loadChecks();
|
||||||
} catch (err) {
|
} 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 =>
|
||||||
|
`<option value="${a.id}"${a.id === state.activeAccountId ? ' selected' : ''}>${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}</option>`
|
||||||
|
).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() {
|
async function loadChecks() {
|
||||||
|
if (!state.activeAccountId) return;
|
||||||
const tbody = document.getElementById('checks-tbody');
|
const tbody = document.getElementById('checks-tbody');
|
||||||
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
|
||||||
try {
|
try {
|
||||||
state.checks = await apiFetch('GET', '/api/checks');
|
state.checks = await apiFetch('GET', `/api/checks?account_id=${state.activeAccountId}`);
|
||||||
state.selected.clear();
|
state.selected.clear();
|
||||||
renderTable();
|
renderTable();
|
||||||
refreshPdfButton();
|
refreshPdfButton();
|
||||||
@@ -258,10 +290,10 @@ async function saveCheck(e) {
|
|||||||
if (state.editingId !== null) {
|
if (state.editingId !== null) {
|
||||||
await apiFetch('PUT', `/api/checks/${state.editingId}`, data);
|
await apiFetch('PUT', `/api/checks/${state.editingId}`, data);
|
||||||
} else {
|
} else {
|
||||||
await apiFetch('POST', '/api/checks', data);
|
await apiFetch('POST', '/api/checks', { ...data, account_id: state.activeAccountId });
|
||||||
}
|
}
|
||||||
closePanel();
|
closePanel();
|
||||||
await Promise.all([loadAccount(), loadChecks()]);
|
await Promise.all([loadAccounts(), loadChecks()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Error: ${err.message}`);
|
alert(`Error: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -296,7 +328,7 @@ async function generatePdf() {
|
|||||||
const res = await fetch('/api/pdf', {
|
const res = await fetch('/api/pdf', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ checkIds: ids }),
|
body: JSON.stringify({ checkIds: ids, account_id: state.activeAccountId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
@@ -407,9 +439,10 @@ async function finishWizard() {
|
|||||||
btn.textContent = 'Saving…';
|
btn.textContent = 'Saving…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch('POST', '/api/account/setup', payload);
|
const result = await apiFetch('POST', '/api/account/setup', payload);
|
||||||
closeWizard();
|
closeWizard();
|
||||||
await Promise.all([loadAccount(), loadChecks()]);
|
await loadAccounts();
|
||||||
|
if (result.accountId) await switchAccount(result.accountId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errEl = document.getElementById('wizard-error');
|
const errEl = document.getElementById('wizard-error');
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
@@ -464,7 +497,8 @@ async function runImport() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
log.classList.add('success');
|
log.classList.add('success');
|
||||||
btn.textContent = 'Done';
|
btn.textContent = 'Done';
|
||||||
await Promise.all([loadAccount(), loadChecks()]);
|
await loadAccounts();
|
||||||
|
if (data.newAccountId) await switchAccount(data.newAccountId);
|
||||||
} else {
|
} else {
|
||||||
log.classList.add('error');
|
log.classList.add('error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -561,9 +595,13 @@ function init() {
|
|||||||
document.getElementById('import-modal-overlay').addEventListener('click', closeImportModal);
|
document.getElementById('import-modal-overlay').addEventListener('click', closeImportModal);
|
||||||
document.getElementById('btn-run-import').addEventListener('click', runImport);
|
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
|
// Initial data load
|
||||||
loadAccount();
|
loadAccounts();
|
||||||
loadChecks();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
+42
-39
@@ -17,33 +17,30 @@ app.use(express.static(path.join(__dirname, '../public')));
|
|||||||
app.use('/api/checks', require('./routes/checks'));
|
app.use('/api/checks', require('./routes/checks'));
|
||||||
app.use('/api/pdf', require('./routes/pdf'));
|
app.use('/api/pdf', require('./routes/pdf'));
|
||||||
|
|
||||||
// .mdb import endpoint
|
// GET /api/accounts - list all accounts (id + display name)
|
||||||
app.post('/api/import', upload.single('mdbfile'), (req, res) => {
|
app.get('/api/accounts', (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
|
const db = require('./db/database');
|
||||||
const tmpPath = req.file.path;
|
const accounts = db.prepare(
|
||||||
try {
|
'SELECT id, company1, bank_name, current_check_no FROM account ORDER BY id ASC'
|
||||||
const output = execFileSync(
|
).all();
|
||||||
process.execPath,
|
res.json(accounts);
|
||||||
[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, () => {});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) => {
|
app.post('/api/account/setup', (req, res) => {
|
||||||
const db = require('./db/database');
|
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 {
|
const {
|
||||||
company1, company2, company3, company4,
|
company1, company2, company3, company4,
|
||||||
bank_name, bank_info1, bank_info2, transit_code,
|
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.' });
|
return res.status(400).json({ error: 'Starting check number must be a positive integer.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO account (
|
INSERT INTO account (
|
||||||
bank_name, bank_info1, bank_info2, transit_code,
|
bank_name, bank_info1, bank_info2, transit_code,
|
||||||
routing_number, account_number, start_check_no, current_check_no,
|
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,
|
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 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
|
// TODO: Add deposit slip support -- deposits table, PDF generation, ledger, and slide-in entry form
|
||||||
|
|
||||||
// Account info endpoint (read-only for Phase 1)
|
// .mdb import endpoint — always creates a new account
|
||||||
app.get('/api/account', (req, res) => {
|
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 db = require('./db/database');
|
||||||
const account = db.prepare(
|
const tmpPath = req.file.path;
|
||||||
'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' +
|
try {
|
||||||
'routing_number, account_number, current_check_no, ' +
|
const output = execFileSync(
|
||||||
'company1, company2, company3, company4, check_position FROM account WHERE id = 1'
|
process.execPath,
|
||||||
).get();
|
[path.join(__dirname, '../migrations/import-mdb.js'), '--file', tmpPath],
|
||||||
if (!account) {
|
{ encoding: 'utf8', timeout: 120000, env: process.env }
|
||||||
return res.status(404).json({ error: 'No account configured. Run migration first.' });
|
);
|
||||||
|
// 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
|
// Catch-all: serve index.html for client-side routing
|
||||||
|
|||||||
+72
-2
@@ -7,7 +7,6 @@ const path = require('path');
|
|||||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/ezcheck.db');
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/ezcheck.db');
|
||||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
const dataDir = path.dirname(DB_PATH);
|
const dataDir = path.dirname(DB_PATH);
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
@@ -15,7 +14,6 @@ if (!fs.existsSync(dataDir)) {
|
|||||||
|
|
||||||
const db = new Database(DB_PATH);
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent read performance
|
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
@@ -23,4 +21,76 @@ db.pragma('foreign_keys = ON');
|
|||||||
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
||||||
db.exec(schema);
|
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;
|
module.exports = db;
|
||||||
|
|||||||
+12
-19
@@ -1,7 +1,4 @@
|
|||||||
-- ezcheck SQLite schema
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS account (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -16,21 +13,17 @@ CREATE TABLE IF NOT EXISTS account (
|
|||||||
current_check_no INTEGER NOT NULL DEFAULT 1000,
|
current_check_no INTEGER NOT NULL DEFAULT 1000,
|
||||||
check_width REAL NOT NULL DEFAULT 8.5,
|
check_width REAL NOT NULL DEFAULT 8.5,
|
||||||
check_height REAL NOT NULL DEFAULT 3.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_left REAL NOT NULL DEFAULT 0,
|
||||||
offset_right REAL NOT NULL DEFAULT 0,
|
offset_right REAL NOT NULL DEFAULT 0,
|
||||||
offset_up REAL NOT NULL DEFAULT 0,
|
offset_up REAL NOT NULL DEFAULT 0,
|
||||||
offset_down REAL NOT NULL DEFAULT 0,
|
offset_down REAL NOT NULL DEFAULT 0,
|
||||||
-- Company info lines (printed top-left of check)
|
|
||||||
company1 TEXT,
|
company1 TEXT,
|
||||||
company2 TEXT,
|
company2 TEXT,
|
||||||
company3 TEXT,
|
company3 TEXT,
|
||||||
company4 TEXT,
|
company4 TEXT,
|
||||||
-- Images stored as base64 data URIs
|
|
||||||
logo_data TEXT,
|
logo_data TEXT,
|
||||||
signature_data TEXT,
|
signature_data TEXT,
|
||||||
-- Metadata
|
blank_stock INTEGER NOT NULL DEFAULT 1,
|
||||||
blank_stock INTEGER NOT NULL DEFAULT 1, -- 1 = blank check stock
|
|
||||||
check_position TEXT NOT NULL DEFAULT '3-per-page',
|
check_position TEXT NOT NULL DEFAULT '3-per-page',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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 (
|
CREATE TABLE IF NOT EXISTS checks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL DEFAULT 1 REFERENCES account(id),
|
||||||
check_no INTEGER NOT NULL,
|
check_no INTEGER NOT NULL,
|
||||||
payee TEXT NOT NULL,
|
payee TEXT NOT NULL,
|
||||||
amount REAL NOT NULL,
|
amount REAL NOT NULL,
|
||||||
check_date TEXT NOT NULL, -- ISO date string YYYY-MM-DD
|
check_date TEXT NOT NULL,
|
||||||
memo TEXT,
|
memo TEXT,
|
||||||
note1 TEXT,
|
note1 TEXT,
|
||||||
note2 TEXT,
|
note2 TEXT,
|
||||||
@@ -49,22 +43,20 @@ CREATE TABLE IF NOT EXISTS checks (
|
|||||||
payee_address2 TEXT,
|
payee_address2 TEXT,
|
||||||
payee_address3 TEXT,
|
payee_address3 TEXT,
|
||||||
payee_address4 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')),
|
add_date TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
-- original .mdb CheckID preserved if migrated, null if created in app
|
|
||||||
mdb_check_id INTEGER,
|
mdb_check_id INTEGER,
|
||||||
UNIQUE(check_no)
|
UNIQUE(account_id, check_no)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS layout_fields (
|
CREATE TABLE IF NOT EXISTS layout_fields (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
field_name TEXT NOT NULL UNIQUE,
|
account_id INTEGER NOT NULL DEFAULT 1 REFERENCES account(id),
|
||||||
field_text TEXT, -- static label text (for Text type fields)
|
field_name TEXT NOT NULL,
|
||||||
|
field_text TEXT,
|
||||||
font_name TEXT NOT NULL DEFAULT 'Helvetica',
|
font_name TEXT NOT NULL DEFAULT 'Helvetica',
|
||||||
font_size REAL NOT NULL DEFAULT 10,
|
font_size REAL NOT NULL DEFAULT 10,
|
||||||
-- FldFontType from .mdb: 0=normal, 1=bold
|
|
||||||
font_bold INTEGER NOT NULL DEFAULT 0,
|
font_bold INTEGER NOT NULL DEFAULT 0,
|
||||||
-- FldType: 'Regular' (data), 'Text' (static label), 'Graph' (image), 'Line'
|
|
||||||
field_type TEXT NOT NULL DEFAULT 'Regular',
|
field_type TEXT NOT NULL DEFAULT 'Regular',
|
||||||
line_thick INTEGER NOT NULL DEFAULT 1,
|
line_thick INTEGER NOT NULL DEFAULT 1,
|
||||||
x_pos REAL NOT NULL DEFAULT 0,
|
x_pos REAL NOT NULL DEFAULT 0,
|
||||||
@@ -72,11 +64,12 @@ CREATE TABLE IF NOT EXISTS layout_fields (
|
|||||||
x_end_pos REAL NOT NULL DEFAULT 0,
|
x_end_pos REAL NOT NULL DEFAULT 0,
|
||||||
y_end_pos REAL NOT NULL DEFAULT 0,
|
y_end_pos REAL NOT NULL DEFAULT 0,
|
||||||
visible INTEGER NOT NULL DEFAULT 1,
|
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,
|
||||||
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_date ON checks(check_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_printed ON checks(printed);
|
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_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);
|
||||||
|
|||||||
+21
-29
@@ -6,29 +6,25 @@ const db = require('../db/database');
|
|||||||
|
|
||||||
// TODO: Add ledger reporting -- date range filter, payee search, total amount display, CSV export
|
// 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) => {
|
router.get('/', (req, res) => {
|
||||||
const { after, printed } = req.query;
|
const { after, printed, account_id } = req.query;
|
||||||
let query = 'SELECT * FROM checks';
|
if (!account_id) return res.status(400).json({ error: 'account_id query param required' });
|
||||||
const params = [];
|
|
||||||
const conditions = [];
|
let query = 'SELECT * FROM checks WHERE account_id = ?';
|
||||||
|
const params = [account_id];
|
||||||
|
|
||||||
if (after) {
|
if (after) {
|
||||||
conditions.push('check_date >= ?');
|
query += ' AND check_date >= ?';
|
||||||
params.push(after);
|
params.push(after);
|
||||||
}
|
}
|
||||||
if (printed !== undefined) {
|
if (printed !== undefined) {
|
||||||
conditions.push('printed = ?');
|
query += ' AND printed = ?';
|
||||||
params.push(printed === 'true' || printed === '1' ? 1 : 0);
|
params.push(printed === 'true' || printed === '1' ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conditions.length) {
|
|
||||||
query += ' WHERE ' + conditions.join(' AND ');
|
|
||||||
}
|
|
||||||
query += ' ORDER BY check_no DESC';
|
query += ' ORDER BY check_no DESC';
|
||||||
|
res.json(db.prepare(query).all(...params));
|
||||||
const checks = db.prepare(query).all(...params);
|
|
||||||
res.json(checks);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/checks/:id
|
// GET /api/checks/:id
|
||||||
@@ -42,43 +38,41 @@ router.get('/:id', (req, res) => {
|
|||||||
|
|
||||||
// POST /api/checks - create a new check
|
// POST /api/checks - create a new check
|
||||||
router.post('/', (req, res) => {
|
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;
|
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
||||||
|
|
||||||
if (!payee || !amount || !check_date) {
|
if (!account_id || !payee || !amount || !check_date) {
|
||||||
return res.status(400).json({ error: 'payee, amount, and check_date are required' });
|
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 = ?').get(account_id);
|
||||||
const account = db.prepare('SELECT current_check_no FROM account WHERE id = 1').get();
|
if (!account) return res.status(400).json({ error: 'Account not found.' });
|
||||||
if (!account) return res.status(500).json({ error: 'No account configured. Run migration first.' });
|
|
||||||
|
|
||||||
const checkNo = account.current_check_no + 1;
|
const checkNo = account.current_check_no + 1;
|
||||||
|
|
||||||
const insertCheck = db.prepare(`
|
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)
|
payee_address1, payee_address2, payee_address3, payee_address4)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateAccountCheckNo = db.prepare(
|
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 transaction = db.transaction(() => {
|
||||||
const result = insertCheck.run(
|
const result = insertCheck.run(
|
||||||
checkNo, payee, parseFloat(amount), check_date,
|
account_id, checkNo, payee, parseFloat(amount), check_date,
|
||||||
memo || null, note1 || null, note2 || null,
|
memo || null, note1 || null, note2 || null,
|
||||||
payee_address1 || null, payee_address2 || null,
|
payee_address1 || null, payee_address2 || null,
|
||||||
payee_address3 || null, payee_address4 || null
|
payee_address3 || null, payee_address4 || null
|
||||||
);
|
);
|
||||||
updateAccountCheckNo.run(checkNo);
|
updateAccountCheckNo.run(checkNo, account_id);
|
||||||
return result.lastInsertRowid;
|
return result.lastInsertRowid;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newId = transaction();
|
const newId = transaction();
|
||||||
const newCheck = db.prepare('SELECT * FROM checks WHERE id = ?').get(newId);
|
res.status(201).json(db.prepare('SELECT * FROM checks WHERE id = ?').get(newId));
|
||||||
res.status(201).json(newCheck);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/checks/:id - update a check
|
// PUT /api/checks/:id - update a check
|
||||||
@@ -115,18 +109,16 @@ router.put('/:id', (req, res) => {
|
|||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
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) return res.status(404).json({ error: 'Check not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/checks/mark-printed - mark checks as printed
|
// POST /api/checks/mark-printed
|
||||||
router.post('/mark-printed', (req, res) => {
|
router.post('/mark-printed', (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids } = req.body;
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
return res.status(400).json({ error: 'ids array required' });
|
return res.status(400).json({ error: 'ids array required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
db.prepare(`UPDATE checks SET printed = 1 WHERE id IN (${placeholders})`).run(...ids);
|
db.prepare(`UPDATE checks SET printed = 1 WHERE id IN (${placeholders})`).run(...ids);
|
||||||
res.json({ updated: ids.length });
|
res.json({ updated: ids.length });
|
||||||
|
|||||||
+19
-15
@@ -7,40 +7,44 @@ const { generateCheckPdf } = require('../services/pdfService');
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/pdf
|
* 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.
|
* After successful generation, marks all checks as printed.
|
||||||
*
|
* Query param: ?mark_printed=false to suppress auto-marking.
|
||||||
* Query param: ?mark_printed=false to suppress auto-marking (for reprints).
|
|
||||||
*/
|
*/
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { checkIds } = req.body;
|
const { checkIds, account_id } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(checkIds) || checkIds.length === 0) {
|
if (!Array.isArray(checkIds) || checkIds.length === 0) {
|
||||||
return res.status(400).json({ error: 'checkIds must be a non-empty array' });
|
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
|
// Fetch checks in the order provided
|
||||||
const checks = checkIds.map(id => {
|
let checks;
|
||||||
|
try {
|
||||||
|
checks = checkIds.map(id => {
|
||||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(id);
|
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(id);
|
||||||
if (!check) throw new Error(`Check ID ${id} not found`);
|
if (!check) throw new Error(`Check ID ${id} not found`);
|
||||||
return check;
|
return check;
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(404).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch layout fields (all visible fields)
|
// Derive account from checks (all should belong to the same account)
|
||||||
const fields = db.prepare('SELECT * FROM layout_fields WHERE visible = 1').all();
|
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 for this account
|
||||||
|
const fields = db.prepare('SELECT * FROM layout_fields WHERE account_id = ? AND visible = 1').all(resolvedAccountId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfBuffer = await generateCheckPdf(account, checks, fields);
|
const pdfBuffer = await generateCheckPdf(account, checks, fields);
|
||||||
|
|
||||||
// Mark as printed unless explicitly suppressed (e.g., reprint)
|
|
||||||
const markPrinted = req.query.mark_printed !== 'false';
|
const markPrinted = req.query.mark_printed !== 'false';
|
||||||
if (markPrinted) {
|
if (markPrinted) {
|
||||||
const placeholders = checkIds.map(() => '?').join(',');
|
const placeholders = checkIds.map(() => '?').join(',');
|
||||||
|
|||||||
Reference in New Issue
Block a user