Add deposit slip and report generation

- New Deposits tab with ledger: date, checks total, cash, deposit total, item count, status
- Slide-in deposit panel: date, currency, coin, cash back, dynamic check entry rows, live totals
- Save deposit, then generate Deposit Slip or Deposit Report PDF
- Deposit slip: 3.375" x 8.5" portrait with Style A background drawn server-side,
  digit-column amounts, GnuMICR routing/account line rotated 90 deg, rotated
  deposit total and check count in left margin
- Deposit report: plain Courier ledger with depositor/bank info, check grid, totals
- deposits and deposit_items tables in schema; ON DELETE CASCADE for items
- Routes: GET/POST/PUT/DELETE /api/deposits, POST /api/deposit-pdf
- Generating a slip marks deposit as printed; date range and status filters
- README updated to describe deposit slip feature
This commit is contained in:
2026-03-13 08:43:34 -06:00
parent a89db179cd
commit 4fb7fd209c
9 changed files with 1345 additions and 3 deletions
+21 -1
View File
@@ -1,6 +1,6 @@
# check-printing
Self-hosted web app for printing checks on blank check stock. A Dockerized Node.js web app accessible on the local network.
Self-hosted web app for printing checks and bank deposit slips. A containerized Node.js web app accessible on the local network.
## Stack
@@ -58,6 +58,26 @@ The script imports account config (T100), logo (Settings), check layout (T200),
Use the **Reprint** button on printed checks to regenerate without re-marking them.
## Deposit slips
Switch to the **Deposits** tab in the toolbar to manage bank deposits.
1. Click **+ New Deposit** to open the deposit entry panel
2. Enter the deposit date, currency, coin, and cash back amounts
3. Add each check being deposited (check number, payee, memo, amount) — totals update live
4. Click **Save Deposit**, then **Deposit Slip** or **Report** to generate a PDF
**Deposit Slip** generates a precisely positioned 3.375" × 8.5" PDF matching physical bank deposit slip stock, including:
- Style A background (form lines and labels drawn server-side — no preprinted stock required)
- Digit-column amount formatting
- Routing/account line in E-13B magnetic ink character recognition font, rotated 90°
- Rotated deposit total and check count in the left margin
**Deposit Report** generates a plain formatted ledger document listing all checks, cash totals, and the final deposit amount — suitable for filing.
Generating a deposit slip marks the deposit as printed in the ledger.
## Check layout
- Page: 8.5" × 11", zero margins
+160
View File
@@ -535,3 +535,163 @@ input[type="file"] {
max-height: calc(100vh - 160px);
overflow-y: auto;
}
/* ── View nav tabs ── */
.view-nav {
display: flex;
gap: 0;
background: var(--surface);
border-bottom: 2px solid var(--border);
flex-shrink: 0;
padding: 0 1rem;
}
.view-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
margin-bottom: -2px;
}
.view-tab:hover { color: var(--text); }
.view-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
/* ── View panes ── */
.view-pane {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.view-pane[hidden] { display: none; }
/* ── Deposit panel ── */
#dep-panel-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#dep-panel-overlay.open { opacity: 1; pointer-events: auto; }
#deposit-panel {
position: fixed;
top: 0;
right: 0;
width: 560px;
max-width: 98vw;
height: 100vh;
background: var(--surface);
z-index: 101;
box-shadow: -4px 0 24px rgba(0,0,0,0.15);
transform: translateX(100%);
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
overflow-y: auto;
}
#deposit-panel.open { transform: translateX(0); }
#deposit-panel-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
flex: 1;
}
.dep-summary {
display: flex;
flex-direction: column;
gap: 10px;
}
.dep-totals {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 3px;
}
.dep-total-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
.dep-total-grand {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
/* Deposit check items table */
.dep-checks-section { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.dep-checks-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.dep-items-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.dep-items-table thead th {
background: #f8f8f8;
border-bottom: 1px solid var(--border);
padding: 4px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
position: static;
}
.dep-items-table td {
padding: 2px 4px;
vertical-align: middle;
border-bottom: 1px solid #f0f0f0;
}
.dep-item-input {
border: 1px solid var(--border);
border-radius: 3px;
padding: 3px 6px;
font-size: 12px;
font-family: var(--font);
background: var(--surface);
color: var(--text);
}
.dep-item-input:focus {
outline: none;
border-color: var(--primary);
}
.dep-form-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding-top: 4px;
border-top: 1px solid var(--border);
margin-top: auto;
flex-wrap: wrap;
}
.dep-pdf-btns { display: flex; gap: 6px; }
+121
View File
@@ -16,6 +16,14 @@
<span class="header-info">Next check: <strong id="current-check-no"></strong></span>
</header>
<!-- View nav tabs -->
<nav class="view-nav">
<button class="view-tab active" data-view="checks">Checks</button>
<button class="view-tab" data-view="deposits">Deposits</button>
</nav>
<!-- Checks view -->
<div id="view-checks" class="view-pane">
<div class="toolbar">
<div class="toolbar-left">
<input type="search" id="filter-payee" placeholder="Search payee…" style="width:160px">
@@ -56,6 +64,45 @@
</tbody>
</table>
</div>
</div><!-- /view-checks -->
<!-- Deposits view -->
<div id="view-deposits" class="view-pane" hidden>
<div class="toolbar">
<div class="toolbar-left">
<input type="date" id="dep-filter-from" title="From date">
<span style="color:var(--text-muted)"></span>
<input type="date" id="dep-filter-to" title="To date">
<select id="dep-filter-status">
<option value="" selected>All</option>
<option value="0">Unprinted</option>
<option value="1">Printed</option>
</select>
</div>
<div class="toolbar-right">
<button id="btn-new-deposit" class="btn-secondary">+ New Deposit</button>
</div>
</div>
<div class="table-wrap">
<table id="deposits-table">
<thead>
<tr>
<th class="col-date">Date</th>
<th class="col-amount" style="text-align:right">Checks Total</th>
<th class="col-amount" style="text-align:right">Cash</th>
<th class="col-amount" style="text-align:right">Cash Back</th>
<th class="col-amount" style="text-align:right">Deposit Total</th>
<th style="width:50px;text-align:center">Items</th>
<th class="col-status">Status</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody id="deposits-tbody">
<tr class="loading-row"><td colspan="8">Loading…</td></tr>
</tbody>
</table>
</div>
</div><!-- /view-deposits -->
<!-- Slide-in panel -->
<div id="panel-overlay"></div>
@@ -334,6 +381,80 @@
</div>
</div>
<!-- Deposit slide-in panel -->
<div id="dep-panel-overlay"></div>
<aside id="deposit-panel">
<div class="panel-header">
<h2 id="dep-panel-title">New Deposit</h2>
<button id="btn-close-dep-panel" class="btn-icon" title="Close">×</button>
</div>
<div id="deposit-panel-body">
<!-- Top section: date + cash fields -->
<div class="dep-summary">
<div class="form-row">
<div class="form-group required">
<label for="dep-date">Deposit Date</label>
<input type="date" id="dep-date">
</div>
<div class="form-group">
<label for="dep-currency">Currency ($)</label>
<input type="number" id="dep-currency" min="0" step="0.01" placeholder="0.00">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="dep-coin">Coin ($)</label>
<input type="number" id="dep-coin" min="0" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label for="dep-cashback">Cash Back ($)</label>
<input type="number" id="dep-cashback" min="0" step="0.01" placeholder="0.00">
</div>
</div>
<div class="dep-totals">
<div class="dep-total-row"><span>Cash Total</span><span id="dep-cash-total">$0.00</span></div>
<div class="dep-total-row"><span>Checks Total</span><span id="dep-checks-total">$0.00</span></div>
<div class="dep-total-row"><span>Subtotal</span><span id="dep-subtotal">$0.00</span></div>
<div class="dep-total-row"><span>Cash Back</span><span id="dep-cashback-display">$0.00</span></div>
<div class="dep-total-row dep-total-grand"><span>Deposit Total</span><span id="dep-grand-total">$0.00</span></div>
</div>
</div>
<!-- Check items grid -->
<div class="dep-checks-section">
<div class="dep-checks-header">
<span>Checks</span>
<button type="button" id="btn-add-dep-item" class="btn-sm btn-secondary">+ Add Row</button>
</div>
<table class="dep-items-table">
<thead>
<tr>
<th>Check #</th>
<th>Payee</th>
<th>Memo</th>
<th style="text-align:right">Amount</th>
<th></th>
</tr>
</thead>
<tbody id="dep-items-tbody"></tbody>
</table>
</div>
<div id="dep-panel-error" class="wizard-error" hidden></div>
<div class="form-actions dep-form-actions">
<div class="dep-pdf-btns">
<button type="button" id="btn-dep-slip" class="btn-secondary" disabled>Deposit Slip</button>
<button type="button" id="btn-dep-report" class="btn-secondary" disabled>Report</button>
</div>
<div>
<button type="button" id="btn-save-deposit" class="btn-primary">Save Deposit</button>
<button type="button" id="btn-cancel-deposit" class="btn-ghost">Cancel</button>
</div>
</div>
</div>
</aside>
<script src="/js/app.js"></script>
</body>
</html>
+300
View File
@@ -607,6 +607,271 @@ async function saveAccountSettings() {
}
}
// ── Deposits ─────────────────────────────────────────────────────────────────
const depState = {
deposits: [],
editingId: null,
items: [], // working list of check rows in the panel
};
async function loadDeposits() {
if (!state.activeAccountId) return;
const tbody = document.getElementById('deposits-tbody');
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
try {
depState.deposits = await apiFetch('GET', `/api/deposits?account_id=${state.activeAccountId}`);
renderDepositsTable();
} catch (err) {
tbody.innerHTML = `<tr class="empty-row"><td colspan="8">Error: ${escHtml(err.message)}</td></tr>`;
}
}
function renderDepositsTable() {
const tbody = document.getElementById('deposits-tbody');
const from = document.getElementById('dep-filter-from').value;
const to = document.getElementById('dep-filter-to').value;
const status = document.getElementById('dep-filter-status').value;
const fmt = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n || 0);
const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
let list = depState.deposits.filter(d => {
if (from && d.deposit_date < from) return false;
if (to && d.deposit_date > to) return false;
if (status === '0' && d.printed) return false;
if (status === '1' && !d.printed) return false;
return true;
});
if (list.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No deposits found.</td></tr>';
return;
}
tbody.innerHTML = list.map(d => {
const cashTotal = (d.currency || 0) + (d.coin || 0);
const checksTotal = d.checks_total || 0;
const depositTotal = cashTotal + checksTotal - (d.cash_back || 0);
const printed = !!d.printed;
const badge = printed
? '<span class="status-badge status-printed">Printed</span>'
: '<span class="status-badge status-unprinted">Unprinted</span>';
return `<tr class="${printed ? 'printed' : ''}">
<td class="col-date">${fmtDate(d.deposit_date)}</td>
<td class="col-amount" style="text-align:right">${fmt(checksTotal)}</td>
<td class="col-amount" style="text-align:right">${fmt(cashTotal)}</td>
<td class="col-amount" style="text-align:right">${fmt(d.cash_back)}</td>
<td class="col-amount" style="text-align:right"><strong>${fmt(depositTotal)}</strong></td>
<td style="text-align:center">${d.item_count || 0}</td>
<td class="col-status">${badge}</td>
<td class="col-actions">
<button class="btn-sm btn-edit dep-btn-edit" data-id="${d.id}">Edit</button>
<button class="btn-sm btn-delete dep-btn-delete" data-id="${d.id}">Delete</button>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('.dep-btn-edit').forEach(btn =>
btn.addEventListener('click', () => openDepositPanel(parseInt(btn.dataset.id, 10))));
tbody.querySelectorAll('.dep-btn-delete').forEach(btn =>
btn.addEventListener('click', () => deleteDeposit(parseInt(btn.dataset.id, 10))));
}
async function openDepositPanel(id = null) {
depState.editingId = id;
depState.items = [];
document.getElementById('dep-panel-error').hidden = true;
document.getElementById('dep-panel-title').textContent = id ? 'Edit Deposit' : 'New Deposit';
document.getElementById('dep-date').value = new Date().toISOString().slice(0, 10);
document.getElementById('dep-currency').value = '';
document.getElementById('dep-coin').value = '';
document.getElementById('dep-cashback').value = '';
if (id !== null) {
try {
const dep = await apiFetch('GET', `/api/deposits/${id}`);
document.getElementById('dep-date').value = dep.deposit_date || '';
document.getElementById('dep-currency').value = dep.currency || '';
document.getElementById('dep-coin').value = dep.coin || '';
document.getElementById('dep-cashback').value = dep.cash_back || '';
depState.items = (dep.items || []).map(it => ({ ...it }));
} catch (err) {
alert('Error loading deposit: ' + err.message);
return;
}
} else {
depState.items = [newDepItem()];
}
renderDepItems();
recalcDepTotals();
const slipBtn = document.getElementById('btn-dep-slip');
const reportBtn = document.getElementById('btn-dep-report');
slipBtn.disabled = id === null;
reportBtn.disabled = id === null;
document.getElementById('dep-panel-overlay').classList.add('open');
document.getElementById('deposit-panel').classList.add('open');
document.getElementById('dep-date').focus();
}
function closeDepositPanel() {
document.getElementById('dep-panel-overlay').classList.remove('open');
document.getElementById('deposit-panel').classList.remove('open');
depState.editingId = null;
depState.items = [];
}
function newDepItem() {
return { _key: Math.random(), check_no: '', bank_no: '', payee: '', memo: '', amount: '' };
}
function renderDepItems() {
const tbody = document.getElementById('dep-items-tbody');
tbody.innerHTML = depState.items.map((item, i) => `
<tr data-idx="${i}">
<td><input class="dep-item-input" data-field="check_no" value="${escHtml(item.check_no || '')}" placeholder="Check #" style="width:70px"></td>
<td><input class="dep-item-input" data-field="payee" value="${escHtml(item.payee || '')}" placeholder="Payee" style="width:110px"></td>
<td><input class="dep-item-input" data-field="memo" value="${escHtml(item.memo || '')}" placeholder="Memo" style="width:90px"></td>
<td><input class="dep-item-input dep-amount-input" data-field="amount" value="${item.amount !== '' ? item.amount : ''}" placeholder="0.00" style="width:80px;text-align:right" type="number" min="0" step="0.01"></td>
<td><button class="btn-sm btn-delete dep-item-remove" data-idx="${i}" tabindex="-1">✕</button></td>
</tr>
`).join('');
tbody.querySelectorAll('.dep-item-input').forEach(inp => {
inp.addEventListener('input', e => {
const row = e.target.closest('tr');
const idx = parseInt(row.dataset.idx, 10);
depState.items[idx][e.target.dataset.field] = e.target.value;
if (e.target.dataset.field === 'amount') recalcDepTotals();
});
});
tbody.querySelectorAll('.dep-item-remove').forEach(btn => {
btn.addEventListener('click', () => {
depState.items.splice(parseInt(btn.dataset.idx, 10), 1);
renderDepItems();
recalcDepTotals();
});
});
}
function recalcDepTotals() {
const fmt = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
const currency = parseFloat(document.getElementById('dep-currency').value) || 0;
const coin = parseFloat(document.getElementById('dep-coin').value) || 0;
const cashBack = parseFloat(document.getElementById('dep-cashback').value) || 0;
const cashTotal = currency + coin;
const checksTotal = depState.items.reduce((s, it) => s + (parseFloat(it.amount) || 0), 0);
const subTotal = cashTotal + checksTotal;
const grand = subTotal - cashBack;
document.getElementById('dep-cash-total').textContent = fmt(cashTotal);
document.getElementById('dep-checks-total').textContent = fmt(checksTotal);
document.getElementById('dep-subtotal').textContent = fmt(subTotal);
document.getElementById('dep-cashback-display').textContent = fmt(cashBack);
document.getElementById('dep-grand-total').textContent = fmt(grand);
}
async function saveDeposit() {
const errEl = document.getElementById('dep-panel-error');
errEl.hidden = true;
const deposit_date = document.getElementById('dep-date').value;
if (!deposit_date) {
errEl.textContent = 'Deposit date is required.';
errEl.hidden = false;
return;
}
const payload = {
account_id: state.activeAccountId,
deposit_date,
currency: parseFloat(document.getElementById('dep-currency').value) || 0,
coin: parseFloat(document.getElementById('dep-coin').value) || 0,
cash_back: parseFloat(document.getElementById('dep-cashback').value) || 0,
items: depState.items
.filter(it => parseFloat(it.amount) > 0 || it.check_no || it.payee)
.map((it, i) => ({
sort_order: i,
check_no: it.check_no || null,
bank_no: it.bank_no || null,
payee: it.payee || null,
memo: it.memo || null,
amount: parseFloat(it.amount) || 0,
})),
};
const btn = document.getElementById('btn-save-deposit');
btn.disabled = true;
btn.textContent = 'Saving…';
try {
let saved;
if (depState.editingId !== null) {
saved = await apiFetch('PUT', `/api/deposits/${depState.editingId}`, payload);
} else {
saved = await apiFetch('POST', '/api/deposits', payload);
}
depState.editingId = saved.id;
// Enable PDF buttons now that deposit is saved
document.getElementById('btn-dep-slip').disabled = false;
document.getElementById('btn-dep-report').disabled = false;
document.getElementById('dep-panel-title').textContent = 'Edit Deposit';
await loadDeposits();
btn.disabled = false;
btn.textContent = 'Save Deposit';
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
btn.disabled = false;
btn.textContent = 'Save Deposit';
}
}
async function deleteDeposit(id) {
const dep = depState.deposits.find(d => d.id === id);
const label = dep ? dep.deposit_date : `#${id}`;
if (!confirm(`Delete deposit from ${label}?`)) return;
try {
await apiFetch('DELETE', `/api/deposits/${id}`);
await loadDeposits();
} catch (err) {
alert('Error: ' + err.message);
}
}
async function generateDepositPdf(type) {
if (!depState.editingId) return;
const btn = type === 'slip'
? document.getElementById('btn-dep-slip')
: document.getElementById('btn-dep-report');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = '…';
try {
const res = await fetch('/api/deposit-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ depositId: depState.editingId, type, mark_printed: type === 'slip' }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
if (type === 'slip') await loadDeposits();
} catch (err) {
alert('PDF error: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
// ── Utilities ────────────────────────────────────────────────────────────────
function escHtml(str) {
@@ -714,6 +979,41 @@ function init() {
reader.readAsDataURL(file);
});
// View tabs (Checks / Deposits)
document.querySelectorAll('.view-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const view = tab.dataset.view;
document.getElementById('view-checks').hidden = view !== 'checks';
document.getElementById('view-deposits').hidden = view !== 'deposits';
if (view === 'deposits') loadDeposits();
});
});
// Deposit filters
document.getElementById('dep-filter-from').addEventListener('change', renderDepositsTable);
document.getElementById('dep-filter-to').addEventListener('change', renderDepositsTable);
document.getElementById('dep-filter-status').addEventListener('change', renderDepositsTable);
// Deposit panel
document.getElementById('btn-new-deposit').addEventListener('click', () => openDepositPanel());
document.getElementById('btn-close-dep-panel').addEventListener('click', closeDepositPanel);
document.getElementById('btn-cancel-deposit').addEventListener('click', closeDepositPanel);
document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel);
document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit);
document.getElementById('btn-add-dep-item').addEventListener('click', () => {
depState.items.push(newDepItem());
renderDepItems();
});
document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip'));
document.getElementById('btn-dep-report').addEventListener('click', () => generateDepositPdf('report'));
// Deposit panel live recalc
['dep-currency', 'dep-coin', 'dep-cashback'].forEach(id => {
document.getElementById(id).addEventListener('input', recalcDepTotals);
});
// Initial data load
loadAccounts();
}
+4 -2
View File
@@ -14,8 +14,10 @@ app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
// Routes
app.use('/api/checks', require('./routes/checks'));
app.use('/api/pdf', require('./routes/pdf'));
app.use('/api/checks', require('./routes/checks'));
app.use('/api/pdf', require('./routes/pdf'));
app.use('/api/deposits', require('./routes/deposits'));
app.use('/api/deposit-pdf', require('./routes/deposit-pdf'));
// GET /api/accounts - list all accounts (id + display name)
app.get('/api/accounts', (req, res) => {
+25
View File
@@ -71,3 +71,28 @@ CREATE TABLE IF NOT EXISTS layout_fields (
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 TABLE IF NOT EXISTS deposits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES account(id),
deposit_date TEXT NOT NULL,
currency REAL NOT NULL DEFAULT 0,
coin REAL NOT NULL DEFAULT 0,
cash_back REAL NOT NULL DEFAULT 0,
printed INTEGER NOT NULL DEFAULT 0,
add_date TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deposit_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deposit_id INTEGER NOT NULL REFERENCES deposits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
check_no TEXT,
bank_no TEXT,
payee TEXT,
memo TEXT,
amount REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_deposits_account ON deposits(account_id);
CREATE INDEX IF NOT EXISTS idx_deposit_items ON deposit_items(deposit_id);
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const express = require('express');
const router = express.Router();
const db = require('../db/database');
const { generateDepositPdf } = require('../services/depositPdfService');
// POST /api/deposit-pdf
// Body: { depositId, type: 'slip' | 'report', mark_printed: true }
router.post('/', async (req, res) => {
const { depositId, type = 'slip', mark_printed = true } = req.body;
if (!depositId) return res.status(400).json({ error: 'depositId is required.' });
if (!['slip', 'report'].includes(type)) {
return res.status(400).json({ error: 'type must be "slip" or "report".' });
}
const deposit = db.prepare('SELECT * FROM deposits WHERE id = ?').get(depositId);
if (!deposit) return res.status(404).json({ error: 'Deposit not found.' });
const account = db.prepare('SELECT * FROM account WHERE id = ?').get(deposit.account_id);
if (!account) return res.status(404).json({ error: 'Account not found.' });
const items = db.prepare(
'SELECT * FROM deposit_items WHERE deposit_id = ? ORDER BY sort_order ASC, id ASC'
).all(depositId);
try {
const pdfBuffer = await generateDepositPdf(account, deposit, items, type);
if (mark_printed && type === 'slip') {
db.prepare('UPDATE deposits SET printed = 1 WHERE id = ?').run(depositId);
}
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="deposit-${type}-${deposit.deposit_date}.pdf"`,
'Content-Length': pdfBuffer.length,
});
res.send(pdfBuffer);
} catch (err) {
console.error('Deposit PDF generation error:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+144
View File
@@ -0,0 +1,144 @@
'use strict';
const express = require('express');
const router = express.Router();
const db = require('../db/database');
// Helper: fetch deposit with items
function getDepositWithItems(id) {
const deposit = db.prepare('SELECT * FROM deposits WHERE id = ?').get(id);
if (!deposit) return null;
deposit.items = db.prepare(
'SELECT * FROM deposit_items WHERE deposit_id = ? ORDER BY sort_order ASC, id ASC'
).all(id);
return deposit;
}
// GET /api/deposits?account_id=X
router.get('/', (req, res) => {
const { account_id } = req.query;
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
const deposits = db.prepare(`
SELECT d.*, COUNT(di.id) AS item_count,
COALESCE(SUM(di.amount), 0) AS checks_total
FROM deposits d
LEFT JOIN deposit_items di ON di.deposit_id = d.id
WHERE d.account_id = ?
GROUP BY d.id
ORDER BY d.deposit_date DESC, d.id DESC
`).all(account_id);
res.json(deposits);
});
// GET /api/deposits/:id
router.get('/:id', (req, res) => {
const deposit = getDepositWithItems(req.params.id);
if (!deposit) return res.status(404).json({ error: 'Deposit not found.' });
res.json(deposit);
});
// POST /api/deposits
router.post('/', (req, res) => {
const { account_id, deposit_date, currency, coin, cash_back, items } = req.body;
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' });
const insert = db.transaction(() => {
const result = db.prepare(`
INSERT INTO deposits (account_id, deposit_date, currency, coin, cash_back)
VALUES (?, ?, ?, ?, ?)
`).run(
account_id,
deposit_date,
parseFloat(currency) || 0,
parseFloat(coin) || 0,
parseFloat(cash_back) || 0,
);
const depositId = result.lastInsertRowid;
if (Array.isArray(items)) {
const stmt = db.prepare(`
INSERT INTO deposit_items (deposit_id, sort_order, check_no, bank_no, payee, memo, amount)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
items.forEach((item, i) => {
stmt.run(
depositId, i,
item.check_no || null,
item.bank_no || null,
item.payee || null,
item.memo || null,
parseFloat(item.amount) || 0,
);
});
}
return depositId;
});
const depositId = insert();
res.status(201).json(getDepositWithItems(depositId));
});
// PUT /api/deposits/:id
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
const { deposit_date, currency, coin, cash_back, items } = req.body;
if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' });
const update = db.transaction(() => {
db.prepare(`
UPDATE deposits SET deposit_date = ?, currency = ?, coin = ?, cash_back = ?
WHERE id = ?
`).run(
deposit_date,
parseFloat(currency) || 0,
parseFloat(coin) || 0,
parseFloat(cash_back) || 0,
req.params.id,
);
if (Array.isArray(items)) {
db.prepare('DELETE FROM deposit_items WHERE deposit_id = ?').run(req.params.id);
const stmt = db.prepare(`
INSERT INTO deposit_items (deposit_id, sort_order, check_no, bank_no, payee, memo, amount)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
items.forEach((item, i) => {
stmt.run(
req.params.id, i,
item.check_no || null,
item.bank_no || null,
item.payee || null,
item.memo || null,
parseFloat(item.amount) || 0,
);
});
}
});
update();
res.json(getDepositWithItems(req.params.id));
});
// DELETE /api/deposits/:id
router.delete('/:id', (req, res) => {
const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
// deposit_items deleted via ON DELETE CASCADE
db.prepare('DELETE FROM deposits WHERE id = ?').run(req.params.id);
res.status(204).end();
});
// PATCH /api/deposits/:id/mark-printed
router.patch('/:id/mark-printed', (req, res) => {
db.prepare('UPDATE deposits SET printed = 1 WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
module.exports = router;
+523
View File
@@ -0,0 +1,523 @@
'use strict';
/**
* depositPdfService.js
*
* Generates two PDF types for a deposit:
* - Deposit Report: plain formatted document (Courier, monospaced)
* - Deposit Slip: precisely positioned 3.375" × 8.5" slip with Style A background,
* digit-column amounts, and a GnuMICR line rotated 90°.
*
* All measurements in inches; converted to points (× 72) for PDFKit.
*
* TMDC slip layout is hardcoded. Tune the LAYOUT constants below if fields
* print slightly off on physical stock.
*/
const PDFDocument = require('pdfkit');
const path = require('path');
const fs = require('fs');
const PT = 72; // points per inch
const MICR_FONT_PATH = path.join(__dirname, '../../fonts/GnuMICR.ttf');
// ── Deposit Slip Layout Constants (inches) ────────────────────────────────────
// Page is 3.375" wide × 8.5" tall (portrait).
// A 0.625" strip on the LEFT holds all rotated elements (MICR, deposit total,
// check count). The remaining 2.75" is the main form content.
const SL = {
W: 3.375,
H: 8.5,
// Left rotated strip — right edge of reserved area
stripX: 0.625,
// Content X start (inside the strip)
cX: 0.65,
// ── Depositor / Bank block ────────────────────────────────────────────────
depositorY: 0.28, // Y of first depositor line
bankX: 1.9, // X of bank name (right column)
// ── Date ─────────────────────────────────────────────────────────────────
dateY: 1.38, // Y of DATE label
dateValueX: 0.92, // X where date value prints
// ── Disclaimer ────────────────────────────────────────────────────────────
disclaimerY: 1.56,
// ── Amount grid ───────────────────────────────────────────────────────────
gridTop: 1.72, // top border of grid
rowH: 0.175, // height of each row
// Column positions (right edges, in inches from left of page)
colCentsR: 3.26, // right edge of cents column
colCentsW: 0.42, // width of cents column
colDollarSep: 0.08, // gap between dollars and cents columns
// dollars column right edge = colCentsR - colCentsW - colDollarSep
// dollars column width = 7 digit slots × digitW
digitW: 0.115, // width of each digit slot (Courier 8pt ≈ 4.8pt + spacing)
centDigitW: 0.115,
// Row Y offsets from gridTop (label baseline)
currencyRow: 1, // grid row index
coinRow: 2,
checksRow: 3, // "CHECKS:" label row (no amount on this row)
firstCheckRow: 4, // first numbered check row
maxChecks: 30,
// Check number column X
checkNoX: 0.67,
checkNoW: 0.55, // max width for check number text
// ── Footer ────────────────────────────────────────────────────────────────
// "TOTAL $" row sits just below the last check row
// (computed dynamically from firstCheckRow + maxChecks)
// ── Rotated left strip ────────────────────────────────────────────────────
// Rotated text is drawn with doc.rotate(-90) centred in the strip.
// X positions below are distance from LEFT edge of page (strip area).
micrY: 8.3, // Y (on page) where rotated MICR baseline sits
micrX: 0.12, // X anchor for rotated MICR (left side of text after rotation)
depTotalLabelY: 6.8, // Y where "DEPOSIT TOTAL $" rotated label baseline sits
depTotalAmtY: 5.6, // Y where deposit total digits start (reading upward)
depTotalX: 0.44, // X of rotated deposit total elements
checkCountLabelY: 3.5, // Y of rotated "TOTAL ITEMS" label
checkCountValY: 2.8, // Y of rotated check count value
checkCountX: 0.44,
// ── Style A background colours ───────────────────────────────────────────
bgStripColor: '#d4c9a8', // beige shaded strip (left margin)
bgLineColor: '#888888', // grid lines
bgLabelColor: '#444444', // row labels (CURRENCY, COIN, etc.)
bgHeaderColor: '#000000', // DEPOSIT TICKET header
};
// Grid row Y baseline (from top of page, in inches)
function rowY(rowIndex) {
return SL.gridTop + rowIndex * SL.rowH + SL.rowH * 0.7; // text baseline within row
}
function rowTopY(rowIndex) {
return SL.gridTop + rowIndex * SL.rowH;
}
// ── Report PDF ────────────────────────────────────────────────────────────────
function generateDepositReport(account, deposit, items) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: 'LETTER',
margins: { top: 36, bottom: 36, left: 54, right: 54 },
autoFirstPage: true,
});
const buffers = [];
doc.on('data', c => buffers.push(c));
doc.on('end', () => resolve(Buffer.concat(buffers)));
doc.on('error', reject);
const cashTotal = (deposit.currency || 0) + (deposit.coin || 0);
const checksTotal = items.reduce((s, i) => s + (i.amount || 0), 0);
const subTotal = cashTotal + checksTotal;
const depositTotal = subTotal - (deposit.cash_back || 0);
const fmt = n => n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const W = 504; // usable width in points
// Header
doc.font('Helvetica-Bold').fontSize(14)
.text('Deposit Report', { align: 'center' });
doc.font('Helvetica').fontSize(10)
.text(deposit.deposit_date, { align: 'right' });
doc.moveDown(0.5);
// Two-column depositor / bank block
const colW = W / 2;
const startY = doc.y;
doc.font('Helvetica-Bold').fontSize(9)
.text(account.company1 || '', 54, startY, { width: colW });
if (account.company2) doc.font('Helvetica').fontSize(9).text(account.company2, 54, doc.y, { width: colW });
if (account.company3) doc.font('Helvetica').fontSize(9).text(account.company3, 54, doc.y, { width: colW });
const bankX = 54 + colW;
doc.font('Helvetica-Bold').fontSize(9)
.text(account.bank_name || '', bankX, startY, { width: colW });
if (account.bank_info1) doc.font('Helvetica').fontSize(9).text(account.bank_info1, bankX, doc.y, { width: colW });
if (account.bank_info2) doc.font('Helvetica').fontSize(9).text(account.bank_info2, bankX, doc.y, { width: colW });
doc.moveDown(1);
// Cash summary
doc.font('Courier').fontSize(9);
const lw = 200;
const rx = 54 + W - 80;
function reportLine(label, value) {
doc.font('Courier').fontSize(9)
.text(label + ':', 54, doc.y, { width: lw, continued: false });
doc.text(fmt(value), rx, doc.y - doc.currentLineHeight(), { width: 80, align: 'right' });
}
reportLine('Currency', deposit.currency || 0);
reportLine('Coin', deposit.coin || 0);
reportLine('Cash Total', cashTotal);
doc.moveDown(0.3);
// Check grid header
doc.moveTo(54, doc.y).lineTo(54 + W, doc.y).lineWidth(0.5).stroke();
doc.moveDown(0.2);
doc.font('Courier-Bold').fontSize(8)
.text('#', 54, doc.y, { width: 20 })
.text('Check#', 74, doc.y - doc.currentLineHeight(), { width: 55 })
.text('Bank#', 134, doc.y - doc.currentLineHeight(), { width: 55 })
.text('Received From', 194, doc.y - doc.currentLineHeight(), { width: 140 })
.text('Memo', 340, doc.y - doc.currentLineHeight(), { width: 120 })
.text('Amount', 460, doc.y - doc.currentLineHeight(), { width: 98, align: 'right' });
doc.moveDown(0.2);
doc.moveTo(54, doc.y).lineTo(54 + W, doc.y).lineWidth(0.5).stroke();
doc.moveDown(0.2);
// Check rows
doc.font('Courier').fontSize(8);
items.forEach((item, i) => {
const y = doc.y;
doc.text(String(i + 1) + '.', 54, y, { width: 20 });
doc.text(item.check_no || '', 74, y, { width: 55 });
doc.text(item.bank_no || '', 134, y, { width: 55 });
doc.text(item.payee || '', 194, y, { width: 140, ellipsis: true });
doc.text(item.memo || '', 340, y, { width: 120, ellipsis: true });
doc.text(fmt(item.amount || 0), 460, y, { width: 98, align: 'right' });
});
doc.moveDown(0.3);
doc.moveTo(54, doc.y).lineTo(54 + W, doc.y).lineWidth(0.5).stroke();
doc.moveDown(0.3);
// Totals block
function totalLine(label, value, bold) {
const y = doc.y;
doc.font(bold ? 'Courier-Bold' : 'Courier').fontSize(9)
.text(label + ':', 54, y, { width: lw })
.text(fmt(value), rx, y, { width: 80, align: 'right' });
}
totalLine('Checks Total', checksTotal);
totalLine('Subtotal', subTotal);
totalLine('Cash Back', deposit.cash_back || 0);
doc.moveDown(0.2);
doc.moveTo(rx - 10, doc.y).lineTo(54 + W, doc.y).lineWidth(0.5).stroke();
doc.moveDown(0.2);
totalLine('Deposit Total', depositTotal, true);
doc.end();
});
}
// ── Slip PDF ──────────────────────────────────────────────────────────────────
function generateDepositSlip(account, deposit, items) {
return new Promise((resolve, reject) => {
const hasMicrFont = fs.existsSync(MICR_FONT_PATH);
const doc = new PDFDocument({
size: [SL.W * PT, SL.H * PT],
margins: { top: 0, bottom: 0, left: 0, right: 0 },
autoFirstPage: true,
});
if (hasMicrFont) doc.registerFont('MICR', MICR_FONT_PATH);
const buffers = [];
doc.on('data', c => buffers.push(c));
doc.on('end', () => resolve(Buffer.concat(buffers)));
doc.on('error', reject);
const cashTotal = (deposit.currency || 0) + (deposit.coin || 0);
const checksTotal = items.reduce((s, i) => s + (i.amount || 0), 0);
const subTotal = cashTotal + checksTotal;
const depositTotal = subTotal - (deposit.cash_back || 0);
const checkCount = items.length;
const totalRows = SL.firstCheckRow + SL.maxChecks; // last row index
const totalRowY_ = rowTopY(totalRows); // top of TOTAL $ row
const gridBottom = totalRowY_ + SL.rowH;
// ── Style A background ──────────────────────────────────────────────────
// Left beige strip
doc.rect(0, 0, SL.stripX * PT, SL.H * PT)
.fill(SL.bgStripColor);
// Outer border
doc.rect(SL.stripX * PT, 0, (SL.W - SL.stripX) * PT, SL.H * PT)
.lineWidth(1).stroke('#000000');
// Vertical divider between check# and amount columns
const dividerX = (SL.colCentsR - SL.colCentsW - SL.colDollarSep - 7 * SL.digitW) * PT;
const gridTopPt = SL.gridTop * PT;
const gridBotPt = gridBottom * PT;
doc.moveTo(dividerX, gridTopPt).lineTo(dividerX, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
// Vertical divider between dollars and cents
const dollarsCentsX = (SL.colCentsR - SL.colCentsW - SL.colDollarSep) * PT;
doc.moveTo(dollarsCentsX, gridTopPt).lineTo(dollarsCentsX, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
// Right border of cents column
doc.moveTo(SL.colCentsR * PT, gridTopPt).lineTo(SL.colCentsR * PT, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
// Column header labels
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor);
const hdrY = (SL.gridTop - 0.1) * PT;
doc.text('DOLLARS', dollarsCentsX - 7 * SL.digitW * PT, hdrY,
{ width: 7 * SL.digitW * PT, align: 'center', lineBreak: false });
doc.text('CENTS', (SL.colCentsR - SL.colCentsW) * PT, hdrY,
{ width: SL.colCentsW * PT, align: 'center', lineBreak: false });
// Horizontal grid lines for all rows
for (let r = 0; r <= totalRows + 1; r++) {
const y = rowTopY(r) * PT;
doc.moveTo(SL.stripX * PT, y).lineTo(SL.colCentsR * PT, y)
.lineWidth(r === 0 || r === totalRows + 1 ? 0.75 : 0.3)
.stroke(SL.bgLineColor);
}
// Row labels
doc.font('Courier').fontSize(7).fillColor(SL.bgLabelColor);
function rowLabel(label, rowIdx) {
doc.text(label, SL.cX * PT, rowY(rowIdx) * PT - 5, { lineBreak: false });
}
rowLabel('CURRENCY', SL.currencyRow);
rowLabel('COIN', SL.coinRow);
rowLabel('CHECKS:', SL.checksRow);
// Numbered check rows
doc.font('Courier').fontSize(6).fillColor(SL.bgLabelColor);
for (let i = 0; i < SL.maxChecks; i++) {
const r = SL.firstCheckRow + i;
doc.text(String(i + 1), SL.cX * PT, rowY(r) * PT - 4,
{ width: 14, align: 'right', lineBreak: false });
}
// TOTAL $ row label
doc.font('Courier-Bold').fontSize(7).fillColor('#000000');
doc.text('TOTAL $', SL.cX * PT, rowY(totalRows) * PT - 5, { lineBreak: false });
// Disclaimer text (below date, above grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666')
.text(
'DEPOSITS MAY NOT BE AVAILABLE FOR IMMEDIATE WITHDRAWAL',
SL.cX * PT, SL.disclaimerY * PT,
{ width: (SL.W - SL.cX - 0.05) * PT, lineBreak: false }
);
// Bottom disclaimer (inside form, near total row)
doc.font('Helvetica').fontSize(5).fillColor('#666666')
.text(
'Checks and other items are received for deposit subject to the provisions of the Uniform Commercial Code or any applicable collection agreements.',
SL.cX * PT, (gridBottom + 0.05) * PT,
{ width: (SL.W - SL.cX - 0.1) * PT }
);
// DEPOSIT TICKET header
doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor);
const headerText = 'D E P O S I T T I C K E T';
doc.text(headerText, SL.cX * PT, 0.08 * PT,
{ width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false });
// Vertical separator between depositor and bank columns
const midX = ((SL.cX + SL.bankX) / 2) * PT;
doc.moveTo(midX, 0.18 * PT).lineTo(midX, (SL.dateY - 0.05) * PT)
.lineWidth(0.5).stroke('#aaaaaa');
// ── Depositor / Bank block ──────────────────────────────────────────────
doc.font('Helvetica-Bold').fontSize(8).fillColor('#000000')
.text(account.company1 || '', SL.cX * PT, SL.depositorY * PT,
{ lineBreak: false });
let depY = SL.depositorY + 0.12;
doc.font('Helvetica').fontSize(7);
[account.company2, account.company3, account.company4].forEach(line => {
if (line) {
doc.text(line, SL.cX * PT, depY * PT, { lineBreak: false });
depY += 0.10;
}
});
doc.font('Helvetica-Bold').fontSize(8).fillColor('#000000')
.text(account.bank_name || '', SL.bankX * PT, SL.depositorY * PT,
{ lineBreak: false });
let bnkY = SL.depositorY + 0.12;
doc.font('Helvetica').fontSize(7);
[account.bank_info1, account.bank_info2].forEach(line => {
if (line) {
doc.text(line, SL.bankX * PT, bnkY * PT, { lineBreak: false });
bnkY += 0.10;
}
});
// ── Date ───────────────────────────────────────────────────────────────
doc.font('Helvetica').fontSize(7).fillColor(SL.bgLabelColor)
.text('DATE', SL.cX * PT, SL.dateY * PT, { lineBreak: false });
// Underline
const dateUnderX1 = (SL.cX + 0.33) * PT;
const dateUnderX2 = (SL.dateValueX + 0.85) * PT;
const dateUnderY = (SL.dateY + 0.01) * PT;
doc.moveTo(dateUnderX1, dateUnderY).lineTo(dateUnderX2, dateUnderY)
.lineWidth(0.5).stroke('#000000');
doc.font('Courier').fontSize(8).fillColor('#000000')
.text(deposit.deposit_date || '', (SL.cX + 0.36) * PT, (SL.dateY - 0.03) * PT,
{ lineBreak: false });
// ── Amount data ─────────────────────────────────────────────────────────
// Draw digits in fixed-width slots, right-aligned in the dollars column
const dollarsRightX = (SL.colCentsR - SL.colCentsW - SL.colDollarSep);
function drawAmountRow(amount, rowIdx) {
const y = (rowY(rowIdx) - 0.015) * PT;
doc.font('Courier').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, amount, dollarsRightX, y);
}
drawAmountRow(deposit.currency || 0, SL.currencyRow);
drawAmountRow(deposit.coin || 0, SL.coinRow);
// Check items
items.slice(0, SL.maxChecks).forEach((item, i) => {
const r = SL.firstCheckRow + i;
const y = (rowY(r) - 0.015) * PT;
// Check number
if (item.check_no) {
doc.font('Courier').fontSize(7).fillColor('#000000')
.text(String(item.check_no).slice(0, 8),
(SL.cX + 0.16) * PT, y,
{ width: SL.checkNoW * PT, lineBreak: false });
}
drawAmountRow(item.amount || 0, r);
});
// Total $ row
drawAmountRow(checksTotal, totalRows);
// ── Rotated left strip elements ─────────────────────────────────────────
// MICR line (routing + account, no check number for deposits)
const routing = (account.routing_number || '').replace(/\D/g, '');
const acctNo = (account.account_number || '').replace(/[^0-9]/g, '');
const micrStr = `A${routing}A ${acctNo}C`;
doc.save();
doc.translate(SL.micrX * PT, SL.micrY * PT);
doc.rotate(-90);
if (hasMicrFont) {
doc.font('MICR').fontSize(11).fillColor('#000000')
.text(micrStr, 0, 0, { lineBreak: false });
} else {
doc.font('Courier').fontSize(8).fillColor('#000000')
.text(micrStr, 0, 0, { lineBreak: false });
}
doc.restore();
// Rotated "DEPOSIT TOTAL $" label + amount
doc.save();
doc.translate(SL.depTotalX * PT, SL.depTotalLabelY * PT);
doc.rotate(-90);
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor)
.text('DEPOSIT TOTAL $', 0, 0, { lineBreak: false });
doc.restore();
// Deposit total digits (rotated, spaced)
drawRotatedDigitAmount(doc, depositTotal, SL.depTotalX, SL.depTotalAmtY);
// Rotated "TOTAL ITEMS" label + count
doc.save();
doc.translate(SL.checkCountX * PT, SL.checkCountLabelY * PT);
doc.rotate(-90);
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor)
.text('TOTAL ITEMS', 0, 0, { lineBreak: false });
doc.restore();
doc.save();
doc.translate(SL.checkCountX * PT, SL.checkCountValY * PT);
doc.rotate(-90);
doc.font('Courier').fontSize(9).fillColor('#000000')
.text(String(checkCount), 0, 0, { lineBreak: false });
doc.restore();
doc.end();
});
}
// ── Amount rendering helpers ──────────────────────────────────────────────────
/**
* Draw a dollar amount in digit-column format (each digit in its own fixed slot).
* @param {PDFDocument} doc
* @param {number} amount e.g. 9224.45
* @param {number} dollarsRightX right edge of dollars column (inches)
* @param {number} y PDFKit Y in points (absolute)
*/
function drawDigitAmount(doc, amount, dollarsRightX, y) {
const totalCents = Math.round(Math.abs(amount) * 100);
const dollars = Math.floor(totalCents / 100);
const cents = totalCents % 100;
const NSLOTS = 7; // dollar digit slots
const dolStr = dollars === 0 ? '' : String(dollars);
const ctStr = String(cents).padStart(2, '0');
// Dollars: place each digit right-to-left
const dW = SL.digitW * PT;
const rightPt = dollarsRightX * PT;
for (let i = 0; i < NSLOTS; i++) {
const digitIdx = dolStr.length - 1 - i;
if (digitIdx < 0) break;
const x = rightPt - (i + 1) * dW + (dW - 4.8) / 2; // centre 4.8pt char in slot
doc.text(dolStr[digitIdx], x, y, { lineBreak: false });
}
// Cents: two digits in cents column
const SL_colCentsR = SL.colCentsR * PT;
const cW = SL.centDigitW * PT;
for (let i = 0; i < 2; i++) {
const x = SL_colCentsR - (2 - i) * cW + (cW - 4.8) / 2;
doc.text(ctStr[i], x, y, { lineBreak: false });
}
}
/**
* Draw deposit total digits rotated 90° in the left strip.
* Each digit is stacked vertically (reading downward when viewed in portrait).
*/
function drawRotatedDigitAmount(doc, amount, stripX, startY) {
const totalCents = Math.round(Math.abs(amount) * 100);
const dollars = Math.floor(totalCents / 100);
const cents = totalCents % 100;
const fullStr = String(dollars) + String(cents).padStart(2, '0');
const spacing = 0.16; // inches between each digit
doc.font('Courier').fontSize(9).fillColor('#000000');
fullStr.split('').forEach((ch, i) => {
doc.save();
doc.translate(stripX * PT, (startY - i * spacing) * PT);
doc.rotate(-90);
doc.text(ch, 0, 0, { lineBreak: false });
doc.restore();
});
}
// ── Main export ───────────────────────────────────────────────────────────────
/**
* @param {Object} account - account row
* @param {Object} deposit - deposit row
* @param {Array} items - deposit_items rows
* @param {string} type - 'slip' | 'report'
* @returns {Promise<Buffer>}
*/
function generateDepositPdf(account, deposit, items, type) {
if (type === 'report') return generateDepositReport(account, deposit, items);
return generateDepositSlip(account, deposit, items);
}
module.exports = { generateDepositPdf };