Add QuickBooks Online CSV importer for checks and deposits
Two-tab modal: "Checks to Print" parses QBO Transaction List / Check Detail CSV exports; "Deposits" parses QBO Deposit Detail CSV exports and groups by date. Both tabs show a preview before confirming import.
This commit is contained in:
@@ -85,6 +85,38 @@ header {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-wide { width: min(720px, 96vw); }
|
||||
|
||||
.qbo-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f9f9f9;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.qbo-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.qbo-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.qbo-tab:hover:not(.active) { color: var(--text); }
|
||||
|
||||
.qbo-preview-count { font-size: 12px; color: var(--text-muted); margin-bottom: 6px; }
|
||||
.qbo-warnings { font-size: 11px; color: #b45309; background: #fef9c3; border: 1px solid #ca8a04; border-radius: 3px; padding: 6px 8px; margin-bottom: 8px; }
|
||||
.qbo-preview-scroll { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; }
|
||||
.qbo-preview-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.qbo-preview-table th { background: #f5f5f5; padding: 5px 8px; text-align: left; font-weight: 600; position: sticky; top: 0; border-bottom: 1px solid var(--border); }
|
||||
.qbo-preview-table td { padding: 4px 8px; border-bottom: 1px solid #f0f0f0; }
|
||||
.qbo-preview-table tr:last-child td { border-bottom: none; }
|
||||
.qbo-preview-table .text-muted { color: var(--text-muted); }
|
||||
.import-result { font-size: 13px; color: #15803d; background: #f0fdf4; border: 1px solid #86efac; border-radius: 4px; padding: 8px 12px; margin-top: 8px; }
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
</button>
|
||||
<button id="btn-new-check" class="btn-secondary">+ New Check</button>
|
||||
<button id="btn-import" class="btn-secondary">Import .mdb</button>
|
||||
<button class="btn-secondary" data-open-qbo="checks">Import QBO</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +84,7 @@
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button id="btn-new-deposit" class="btn-secondary">+ New Deposit</button>
|
||||
<button class="btn-secondary" data-open-qbo="deposits">Import QBO</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -411,6 +413,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QBO Import modal -->
|
||||
<div id="qbo-import-overlay" class="modal-overlay"></div>
|
||||
<div id="qbo-import-modal" class="modal modal-wide" role="dialog" aria-labelledby="qbo-import-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="qbo-import-title">Import from QuickBooks Online</h2>
|
||||
<button id="btn-close-qbo-import" class="btn-icon" title="Close">×</button>
|
||||
</div>
|
||||
<div class="qbo-tabs">
|
||||
<button class="qbo-tab active" data-tab="checks">Checks to Print</button>
|
||||
<button class="qbo-tab" data-tab="deposits">Deposits</button>
|
||||
</div>
|
||||
<!-- Checks pane -->
|
||||
<div class="qbo-pane" id="qbo-pane-checks">
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc">Export a <strong>Transaction List</strong> or <strong>Check Detail</strong> report from QBO (filtered to checks) as CSV, then upload it here. Checks will be imported as unprinted.</p>
|
||||
<div class="form-group">
|
||||
<label for="qbo-checks-file">QBO CSV export</label>
|
||||
<input type="file" id="qbo-checks-file" accept=".csv,.txt">
|
||||
</div>
|
||||
<div id="qbo-checks-preview" hidden></div>
|
||||
<div id="qbo-checks-result" class="import-result" hidden></div>
|
||||
<div id="qbo-checks-error" class="wizard-error" hidden></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-qbo-checks-parse" class="btn-secondary">Preview</button>
|
||||
<button id="btn-qbo-checks-import" class="btn-primary" hidden disabled>Import</button>
|
||||
<button id="btn-qbo-checks-cancel" class="btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Deposits pane -->
|
||||
<div class="qbo-pane" id="qbo-pane-deposits" hidden>
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc">Export a <strong>Deposit Detail</strong> report from QBO as CSV. Deposits are grouped by date — one deposit record per day.</p>
|
||||
<div class="form-group">
|
||||
<label for="qbo-deposits-file">QBO CSV export</label>
|
||||
<input type="file" id="qbo-deposits-file" accept=".csv,.txt">
|
||||
</div>
|
||||
<div id="qbo-deposits-preview" hidden></div>
|
||||
<div id="qbo-deposits-result" class="import-result" hidden></div>
|
||||
<div id="qbo-deposits-error" class="wizard-error" hidden></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-qbo-deposits-parse" class="btn-secondary">Preview</button>
|
||||
<button id="btn-qbo-deposits-import" class="btn-primary" hidden disabled>Import</button>
|
||||
<button id="btn-qbo-deposits-cancel" class="btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deposit slide-in panel -->
|
||||
<div id="dep-panel-overlay"></div>
|
||||
<aside id="deposit-panel">
|
||||
|
||||
@@ -644,6 +644,188 @@ async function saveAccountSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── QBO Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
let qboChecksRecords = null;
|
||||
let qboDepositsRecords = null;
|
||||
|
||||
function openQboImport(tab) {
|
||||
switchQboTab(tab || 'checks');
|
||||
resetQboPane('checks');
|
||||
resetQboPane('deposits');
|
||||
document.getElementById('qbo-import-overlay').classList.add('open');
|
||||
document.getElementById('qbo-import-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeQboImport() {
|
||||
document.getElementById('qbo-import-overlay').classList.remove('open');
|
||||
document.getElementById('qbo-import-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
function resetQboPane(type) {
|
||||
document.getElementById(`qbo-${type}-file`).value = '';
|
||||
document.getElementById(`qbo-${type}-preview`).hidden = true;
|
||||
document.getElementById(`qbo-${type}-preview`).innerHTML = '';
|
||||
document.getElementById(`qbo-${type}-result`).hidden = true;
|
||||
document.getElementById(`qbo-${type}-result`).textContent = '';
|
||||
document.getElementById(`qbo-${type}-error`).hidden = true;
|
||||
document.getElementById(`qbo-${type}-error`).textContent = '';
|
||||
document.getElementById(`btn-qbo-${type}-import`).hidden = true;
|
||||
document.getElementById(`btn-qbo-${type}-import`).disabled = true;
|
||||
if (type === 'checks') qboChecksRecords = null;
|
||||
else qboDepositsRecords = null;
|
||||
}
|
||||
|
||||
function switchQboTab(tab) {
|
||||
document.querySelectorAll('.qbo-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
document.getElementById('qbo-pane-checks').hidden = tab !== 'checks';
|
||||
document.getElementById('qbo-pane-deposits').hidden = tab !== 'deposits';
|
||||
}
|
||||
|
||||
async function qboParseFile(type) {
|
||||
const fileInput = document.getElementById(`qbo-${type}-file`);
|
||||
const errEl = document.getElementById(`qbo-${type}-error`);
|
||||
const previewEl = document.getElementById(`qbo-${type}-preview`);
|
||||
const resultEl = document.getElementById(`qbo-${type}-result`);
|
||||
const importBtn = document.getElementById(`btn-qbo-${type}-import`);
|
||||
const parseBtn = document.getElementById(`btn-qbo-${type}-parse`);
|
||||
|
||||
errEl.hidden = true;
|
||||
previewEl.hidden = true;
|
||||
previewEl.innerHTML = '';
|
||||
resultEl.hidden = true;
|
||||
importBtn.hidden = true;
|
||||
importBtn.disabled = true;
|
||||
|
||||
const file = fileInput.files[0];
|
||||
if (!file) { errEl.textContent = 'Select a CSV file first.'; errEl.hidden = false; return; }
|
||||
|
||||
parseBtn.disabled = true;
|
||||
parseBtn.textContent = 'Parsing\u2026';
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('type', type);
|
||||
const resp = await fetch('/api/qbo-import/parse', { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Parse failed');
|
||||
|
||||
if (type === 'checks') {
|
||||
qboChecksRecords = data.records;
|
||||
previewEl.innerHTML = buildChecksPreviewHTML(data.records, data.warnings);
|
||||
} else {
|
||||
qboDepositsRecords = data.records;
|
||||
previewEl.innerHTML = buildDepositsPreviewHTML(data.records, data.warnings);
|
||||
}
|
||||
previewEl.hidden = false;
|
||||
const depCount = type === 'deposits' ? countDepositDates(data.records) : 0;
|
||||
importBtn.textContent = type === 'checks'
|
||||
? `Import ${data.records.length} Check${data.records.length !== 1 ? 's' : ''}`
|
||||
: `Import ${depCount} Deposit${depCount !== 1 ? 's' : ''}`;
|
||||
importBtn.hidden = false;
|
||||
importBtn.disabled = false;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
} finally {
|
||||
parseBtn.disabled = false;
|
||||
parseBtn.textContent = 'Preview';
|
||||
}
|
||||
}
|
||||
|
||||
function countDepositDates(records) {
|
||||
return new Set(records.map(r => r.date)).size;
|
||||
}
|
||||
|
||||
const fmtCurrency = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
||||
const fmtDateDisp = iso => { const [y, m, d] = iso.split('-'); return `${m}/${d}/${y}`; };
|
||||
|
||||
function buildChecksPreviewHTML(records, warnings) {
|
||||
let html = `<div class="qbo-preview-count">${records.length} check${records.length !== 1 ? 's' : ''} found</div>`;
|
||||
if (warnings && warnings.length) {
|
||||
html += `<div class="qbo-warnings">${warnings.map(w => escHtml(w)).join('<br>')}</div>`;
|
||||
}
|
||||
html += `<div class="qbo-preview-scroll"><table class="qbo-preview-table">
|
||||
<thead><tr><th>Date</th><th>Payee</th><th style="text-align:right">Amount</th><th>Memo</th><th>Check #</th></tr></thead>
|
||||
<tbody>`;
|
||||
for (const r of records) {
|
||||
html += `<tr>
|
||||
<td>${escHtml(fmtDateDisp(r.date))}</td>
|
||||
<td>${escHtml(r.payee || '')}</td>
|
||||
<td style="text-align:right;font-family:monospace">${escHtml(fmtCurrency(r.amount))}</td>
|
||||
<td class="text-muted">${escHtml(r.memo || '')}</td>
|
||||
<td class="text-muted">${r.check_no ? escHtml(String(r.check_no)) : '<em>auto</em>'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildDepositsPreviewHTML(records, warnings) {
|
||||
const byDate = new Map();
|
||||
for (const r of records) {
|
||||
if (!byDate.has(r.date)) byDate.set(r.date, []);
|
||||
byDate.get(r.date).push(r);
|
||||
}
|
||||
const dateCount = byDate.size;
|
||||
let html = `<div class="qbo-preview-count">${records.length} item${records.length !== 1 ? 's' : ''} across ${dateCount} deposit${dateCount !== 1 ? 's' : ''}</div>`;
|
||||
if (warnings && warnings.length) {
|
||||
html += `<div class="qbo-warnings">${warnings.map(w => escHtml(w)).join('<br>')}</div>`;
|
||||
}
|
||||
html += `<div class="qbo-preview-scroll"><table class="qbo-preview-table">
|
||||
<thead><tr><th>Date</th><th>Items</th><th style="text-align:right">Total</th></tr></thead>
|
||||
<tbody>`;
|
||||
for (const [date, items] of byDate) {
|
||||
const total = items.reduce((s, i) => s + i.amount, 0);
|
||||
html += `<tr>
|
||||
<td>${escHtml(fmtDateDisp(date))}</td>
|
||||
<td class="text-muted">${items.length} item${items.length !== 1 ? 's' : ''}</td>
|
||||
<td style="text-align:right;font-family:monospace">${escHtml(fmtCurrency(total))}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function qboConfirmImport(type) {
|
||||
const records = type === 'checks' ? qboChecksRecords : qboDepositsRecords;
|
||||
const errEl = document.getElementById(`qbo-${type}-error`);
|
||||
const resultEl = document.getElementById(`qbo-${type}-result`);
|
||||
const importBtn = document.getElementById(`btn-qbo-${type}-import`);
|
||||
|
||||
errEl.hidden = true;
|
||||
importBtn.disabled = true;
|
||||
importBtn.textContent = 'Importing\u2026';
|
||||
|
||||
try {
|
||||
const data = await apiFetch('POST', '/api/qbo-import/confirm', {
|
||||
type, records, account_id: state.activeAccountId,
|
||||
});
|
||||
|
||||
if (type === 'checks') {
|
||||
resultEl.textContent = `Imported ${data.imported} check${data.imported !== 1 ? 's' : ''}${data.skipped ? `, skipped ${data.skipped} duplicate${data.skipped !== 1 ? 's' : ''}` : ''}.`;
|
||||
await loadChecks();
|
||||
await loadAccounts();
|
||||
renderHeader();
|
||||
} else {
|
||||
resultEl.textContent = `Imported ${data.imported} deposit${data.imported !== 1 ? 's' : ''} (${data.itemCount} items).`;
|
||||
if (typeof loadDeposits === 'function') await loadDeposits();
|
||||
}
|
||||
resultEl.hidden = false;
|
||||
importBtn.hidden = true;
|
||||
|
||||
document.getElementById(`qbo-${type}-file`).value = '';
|
||||
if (type === 'checks') qboChecksRecords = null;
|
||||
else qboDepositsRecords = null;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
importBtn.disabled = false;
|
||||
importBtn.textContent = type === 'checks' ? 'Import Checks' : 'Import Deposits';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Set next check number ─────────────────────────────────────────────────────
|
||||
|
||||
function openSetCheckNo() {
|
||||
@@ -1116,6 +1298,22 @@ function init() {
|
||||
document.getElementById(id).addEventListener('input', recalcDepTotals);
|
||||
});
|
||||
|
||||
// QBO Import
|
||||
document.querySelectorAll('[data-open-qbo]').forEach(btn =>
|
||||
btn.addEventListener('click', () => openQboImport(btn.dataset.openQbo))
|
||||
);
|
||||
document.getElementById('btn-close-qbo-import').addEventListener('click', closeQboImport);
|
||||
document.getElementById('qbo-import-overlay').addEventListener('click', closeQboImport);
|
||||
document.querySelectorAll('.qbo-tab').forEach(t =>
|
||||
t.addEventListener('click', () => switchQboTab(t.dataset.tab))
|
||||
);
|
||||
document.getElementById('btn-qbo-checks-parse').addEventListener('click', () => qboParseFile('checks'));
|
||||
document.getElementById('btn-qbo-deposits-parse').addEventListener('click', () => qboParseFile('deposits'));
|
||||
document.getElementById('btn-qbo-checks-import').addEventListener('click', () => qboConfirmImport('checks'));
|
||||
document.getElementById('btn-qbo-deposits-import').addEventListener('click', () => qboConfirmImport('deposits'));
|
||||
document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport);
|
||||
document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport);
|
||||
|
||||
// Initial data load
|
||||
loadAccounts();
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ app.post('/api/account/setup', (req, res) => {
|
||||
|
||||
// TODO: Add deposit slip support -- deposits table, PDF generation, ledger, and slide-in entry form
|
||||
|
||||
app.use('/api/qbo-import', require('./routes/qbo-import'));
|
||||
|
||||
// .mdb import endpoint — always creates a new account
|
||||
app.post('/api/import', upload.single('mdbfile'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const upload = multer({ dest: os.tmpdir() });
|
||||
|
||||
// ── CSV helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCSVLine(line) {
|
||||
const fields = [];
|
||||
let cur = '';
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuote) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') { cur += '"'; i++; }
|
||||
else inQuote = false;
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"') {
|
||||
inQuote = true;
|
||||
} else if (ch === ',') {
|
||||
fields.push(cur);
|
||||
cur = '';
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findColumns(rows) {
|
||||
const aliases = {
|
||||
date: ['date'],
|
||||
type: ['transaction type', 'type'],
|
||||
num: ['num', 'no.', 'check no', 'check#', 'ref no.', 'reference no'],
|
||||
name: ['name', 'payee', 'vendor', 'received from', 'customer'],
|
||||
memo: ['memo/description', 'description', 'memo', 'memo description'],
|
||||
amount: ['amount'],
|
||||
debit: ['debit'],
|
||||
credit: ['credit'],
|
||||
};
|
||||
|
||||
for (let i = 0; i < Math.min(rows.length, 25); i++) {
|
||||
const row = rows[i];
|
||||
const lower = row.map(c => c.trim().toLowerCase());
|
||||
if (!lower.includes('date')) continue;
|
||||
|
||||
const cols = {};
|
||||
for (const [key, names] of Object.entries(aliases)) {
|
||||
for (const name of names) {
|
||||
const idx = lower.indexOf(name);
|
||||
if (idx !== -1) { cols[key] = idx; break; }
|
||||
}
|
||||
}
|
||||
if (cols.date === undefined) continue;
|
||||
return { headerRow: i, cols };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAmount(str) {
|
||||
if (!str && str !== 0) return null;
|
||||
const s = String(str).replace(/[$,\s]/g, '');
|
||||
if (s === '' || s === '-') return null;
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function parseDate(str) {
|
||||
if (!str) return null;
|
||||
str = str.trim();
|
||||
// MM/DD/YYYY
|
||||
const slash = str.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (slash) return `${slash[3]}-${slash[1].padStart(2, '0')}-${slash[2].padStart(2, '0')}`;
|
||||
// ISO already
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
// Try Date parse as last resort
|
||||
const d = new Date(str);
|
||||
if (!isNaN(d.getTime())) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractRows(text, type) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const rows = lines.map(l => parseCSVLine(l));
|
||||
|
||||
const found = findColumns(rows);
|
||||
if (!found) return { records: [], warnings: ['Could not find a header row with a Date column in the first 25 rows.'] };
|
||||
|
||||
const { headerRow, cols } = found;
|
||||
const warnings = [];
|
||||
const records = [];
|
||||
|
||||
const skipPrefixes = ['total', 'subtotal', 'grand total', 'net total', 'balance'];
|
||||
|
||||
for (let i = headerRow + 1; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row || row.every(c => !c.trim())) continue;
|
||||
|
||||
const rawDate = cols.date !== undefined ? (row[cols.date] || '').trim() : '';
|
||||
if (!rawDate) continue;
|
||||
|
||||
// Skip summary/total rows
|
||||
const firstCell = rawDate.toLowerCase();
|
||||
if (skipPrefixes.some(p => firstCell.startsWith(p))) continue;
|
||||
|
||||
const date = parseDate(rawDate);
|
||||
if (!date) continue;
|
||||
|
||||
// Type filtering
|
||||
if (cols.type !== undefined) {
|
||||
const rowType = (row[cols.type] || '').toLowerCase();
|
||||
if (type === 'checks' && !rowType.includes('check')) continue;
|
||||
if (type === 'deposits' && !rowType.includes('deposit')) continue;
|
||||
}
|
||||
|
||||
let amount = null;
|
||||
if (cols.amount !== undefined) {
|
||||
amount = parseAmount(row[cols.amount]);
|
||||
}
|
||||
if ((amount === null || amount === 0) && type === 'checks' && cols.debit !== undefined) {
|
||||
amount = parseAmount(row[cols.debit]);
|
||||
}
|
||||
if ((amount === null || amount === 0) && type === 'deposits' && cols.credit !== undefined) {
|
||||
amount = parseAmount(row[cols.credit]);
|
||||
}
|
||||
if (amount === null || amount === 0) continue;
|
||||
amount = Math.abs(amount);
|
||||
|
||||
const payee = cols.name !== undefined ? (row[cols.name] || '').trim() : '';
|
||||
const memo = cols.memo !== undefined ? (row[cols.memo] || '').trim() : '';
|
||||
const numRaw = cols.num !== undefined ? (row[cols.num] || '').trim() : '';
|
||||
|
||||
if (type === 'checks') {
|
||||
const check_no = numRaw ? (parseInt(numRaw, 10) || null) : null;
|
||||
records.push({ date, payee, memo, amount, check_no });
|
||||
} else {
|
||||
const ref = numRaw || null;
|
||||
records.push({ date, payee, memo, amount, ref });
|
||||
}
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
warnings.push('No matching records found after filtering.');
|
||||
}
|
||||
|
||||
return { records, warnings };
|
||||
}
|
||||
|
||||
// ── Confirm helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function confirmChecks(db, records, account_id) {
|
||||
const existing = new Set(
|
||||
db.prepare('SELECT check_no FROM checks WHERE account_id = ?').all(account_id).map(r => r.check_no)
|
||||
);
|
||||
|
||||
const account = db.prepare('SELECT current_check_no FROM account WHERE id = ?').get(account_id);
|
||||
if (!account) throw new Error('Account not found.');
|
||||
|
||||
let nextAuto = account.current_check_no + 1;
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let highestUsed = account.current_check_no;
|
||||
|
||||
const insertCheck = db.prepare(`
|
||||
INSERT INTO checks (account_id, check_no, payee, amount, check_date, memo, printed)
|
||||
VALUES (@account_id, @check_no, @payee, @amount, @check_date, @memo, 0)
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const rec of records) {
|
||||
let checkNo;
|
||||
if (rec.check_no !== null && rec.check_no !== undefined) {
|
||||
if (existing.has(rec.check_no)) { skipped++; continue; }
|
||||
checkNo = rec.check_no;
|
||||
} else {
|
||||
while (existing.has(nextAuto)) nextAuto++;
|
||||
checkNo = nextAuto++;
|
||||
}
|
||||
existing.add(checkNo);
|
||||
insertCheck.run({
|
||||
account_id: account_id,
|
||||
check_no: checkNo,
|
||||
payee: rec.payee || '',
|
||||
amount: rec.amount,
|
||||
check_date: rec.date,
|
||||
memo: rec.memo || null,
|
||||
});
|
||||
if (checkNo > highestUsed) highestUsed = checkNo;
|
||||
imported++;
|
||||
}
|
||||
|
||||
if (highestUsed > account.current_check_no) {
|
||||
db.prepare("UPDATE account SET current_check_no = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(highestUsed, account_id);
|
||||
}
|
||||
})();
|
||||
|
||||
return { imported, skipped };
|
||||
}
|
||||
|
||||
function confirmDeposits(db, records, account_id) {
|
||||
// Group by date
|
||||
const byDate = new Map();
|
||||
for (const rec of records) {
|
||||
if (!byDate.has(rec.date)) byDate.set(rec.date, []);
|
||||
byDate.get(rec.date).push(rec);
|
||||
}
|
||||
|
||||
const insertDeposit = db.prepare(`
|
||||
INSERT INTO deposits (account_id, deposit_date, currency, coin, cash_back)
|
||||
VALUES (@account_id, @deposit_date, 0, 0, 0)
|
||||
`);
|
||||
const insertItem = db.prepare(`
|
||||
INSERT INTO deposit_items (deposit_id, sort_order, check_no, payee, memo, amount)
|
||||
VALUES (@deposit_id, @sort_order, @check_no, @payee, @memo, @amount)
|
||||
`);
|
||||
|
||||
let imported = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
db.transaction(() => {
|
||||
for (const [date, items] of byDate) {
|
||||
const result = insertDeposit.run({ account_id, deposit_date: date });
|
||||
const depositId = result.lastInsertRowid;
|
||||
items.forEach((item, idx) => {
|
||||
insertItem.run({
|
||||
deposit_id: depositId,
|
||||
sort_order: idx,
|
||||
check_no: item.ref || null,
|
||||
payee: item.payee || null,
|
||||
memo: item.memo || null,
|
||||
amount: item.amount,
|
||||
});
|
||||
itemCount++;
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
})();
|
||||
|
||||
return { imported, itemCount };
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/qbo-import/parse
|
||||
router.post('/parse', upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
|
||||
const type = req.body.type;
|
||||
if (type !== 'checks' && type !== 'deposits') {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(400).json({ error: 'Invalid type. Must be "checks" or "deposits".' });
|
||||
}
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(req.file.path, 'utf8');
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'Failed to read uploaded file.' });
|
||||
} finally {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
}
|
||||
|
||||
const { records, warnings } = extractRows(text, type);
|
||||
|
||||
if (records.length === 0) {
|
||||
return res.status(422).json({ error: warnings.length ? warnings[0] : 'No matching records found in file.' });
|
||||
}
|
||||
|
||||
res.json({ records, warnings: warnings.length ? warnings : undefined });
|
||||
});
|
||||
|
||||
// POST /api/qbo-import/confirm
|
||||
router.post('/confirm', express.json(), (req, res) => {
|
||||
const { type, records, account_id } = req.body;
|
||||
if (!type || !records || !account_id) {
|
||||
return res.status(400).json({ error: 'Missing required fields: type, records, account_id.' });
|
||||
}
|
||||
if (type !== 'checks' && type !== 'deposits') {
|
||||
return res.status(400).json({ error: 'Invalid type.' });
|
||||
}
|
||||
if (!Array.isArray(records) || records.length === 0) {
|
||||
return res.status(400).json({ error: 'No records provided.' });
|
||||
}
|
||||
|
||||
const db = require('../db/database');
|
||||
try {
|
||||
if (type === 'checks') {
|
||||
const result = confirmChecks(db, records, account_id);
|
||||
res.json(result);
|
||||
} else {
|
||||
const result = confirmDeposits(db, records, account_id);
|
||||
res.json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message || 'Import failed.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user