#!/usr/bin/env node /** * Apply a bank .FH deposit file to QuickBooks Online. * * Reads a .FH line-delimited deposit file (one check per line) plus a * user-supplied mapping JSON (which check pays which invoice(s)), then: * 1. Validates that .FH amounts match mapping amounts (per-check + total) * 2. Resolves invoice CustomerRef + qbo_invoices.id via Postgres lookup * 3. Resolves deposit account ref id from qbo_deposits (most recent match) * 4. POSTs one Payment per check (applied to its invoice(s)) * 5. POSTs one Deposit grouping all payments under the bank account * * Idempotency: writes a sibling `.applied.json` after each successful * QBO post; re-running skips entries already present in that file. * * Usage: * npx tsx scripts/apply-fh-deposit.ts [--dry-run] * * Environment: requires QBO_CLIENT_ID, QBO_CLIENT_SECRET, QBO_REALM_ID, and a * valid QBO token row in qbo_tokens (i.e. someone has completed /api/qbo/auth). */ import * as fs from 'fs'; import * as path from 'path'; import postgresClient from '../lib/services/postgres-client'; import { getQboClient } from '../lib/services/qbo-client'; import type { QboPaymentCreatePayload, QboDepositCreatePayload, } from '../lib/types/qbo'; // ─── Types ────────────────────────────────────────────────────────────────── interface FhRow { rawDate: string; // MMDDYYYY txnDate: string; // YYYY-MM-DD slipNum: string; seq: number; checkNum: string; // exact string from file, leading zeros preserved amount: number; payerAcct: string; payerRouting: string; } interface MappingInvoice { doc_number: string; amount: number; } interface MappingCheck { seq: number; check_num: string; amount: number; customer_name?: string; invoices: MappingInvoice[]; } interface Mapping { deposit_date: string; // YYYY-MM-DD deposit_account_name: string; private_note_prefix?: string; checks: MappingCheck[]; } interface Applied { payments: Record; // key = seq deposit?: { qbo_deposit_id: string; applied_at: string }; } // ─── Helpers ──────────────────────────────────────────────────────────────── function normalizeCheckNum(s: string): string { return s.replace(/^0+/, '') || '0'; } function round2(n: number): number { return Math.round(n * 100) / 100; } function parseFh(file: string): FhRow[] { const text = fs.readFileSync(file, 'utf8'); const rows: FhRow[] = []; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; const [rawDate, slipNum, seq, checkNum, amount, payerAcct, payerRouting] = line.split(','); if (!rawDate || !amount) continue; // MMDDYYYY -> YYYY-MM-DD const mm = rawDate.slice(0, 2); const dd = rawDate.slice(2, 4); const yyyy = rawDate.slice(4, 8); rows.push({ rawDate, txnDate: `${yyyy}-${mm}-${dd}`, slipNum, seq: parseInt(seq, 10), checkNum, amount: parseFloat(amount), payerAcct, payerRouting, }); } return rows; } function loadApplied(mappingPath: string): { path: string; data: Applied } { const appliedPath = mappingPath.replace(/\.json$/, '.applied.json'); let data: Applied = { payments: {} }; if (fs.existsSync(appliedPath)) { try { data = JSON.parse(fs.readFileSync(appliedPath, 'utf8')) as Applied; if (!data.payments) data.payments = {}; } catch (err) { throw new Error(`Failed to parse ${appliedPath}: ${err}`); } } return { path: appliedPath, data }; } function saveApplied(appliedPath: string, data: Applied): void { fs.writeFileSync(appliedPath, JSON.stringify(data, null, 2) + '\n', 'utf8'); } // ─── Validation ───────────────────────────────────────────────────────────── function validate(fh: FhRow[], mapping: Mapping): { ok: true } | { ok: false; errors: string[] } { const errors: string[] = []; // Same row count if (fh.length !== mapping.checks.length) { errors.push(`FH has ${fh.length} rows but mapping has ${mapping.checks.length} checks`); } // Per-row: match by seq, then verify check_num (normalized) + amount const fhBySeq = new Map(fh.map((r) => [r.seq, r])); for (const c of mapping.checks) { const f = fhBySeq.get(c.seq); if (!f) { errors.push(`Mapping seq ${c.seq} has no matching FH row`); continue; } if (normalizeCheckNum(f.checkNum) !== normalizeCheckNum(c.check_num)) { errors.push(`seq ${c.seq}: check_num mismatch (FH=${f.checkNum} vs mapping=${c.check_num})`); } if (round2(f.amount) !== round2(c.amount)) { errors.push(`seq ${c.seq}: amount mismatch (FH=${f.amount.toFixed(2)} vs mapping=${c.amount.toFixed(2)})`); } // Per-check: sum of invoice amounts == check amount const invSum = round2(c.invoices.reduce((s, i) => s + i.amount, 0)); if (invSum !== round2(c.amount)) { errors.push(`seq ${c.seq}: invoice splits sum to ${invSum.toFixed(2)} but check is ${c.amount.toFixed(2)}`); } } // Total const fhTotal = round2(fh.reduce((s, r) => s + r.amount, 0)); const mapTotal = round2(mapping.checks.reduce((s, c) => s + c.amount, 0)); if (fhTotal !== mapTotal) { errors.push(`Deposit total mismatch: FH=${fhTotal.toFixed(2)} mapping=${mapTotal.toFixed(2)}`); } return errors.length === 0 ? { ok: true } : { ok: false, errors }; } // ─── DB lookups ───────────────────────────────────────────────────────────── async function resolveInvoices(docNumbers: string[]): Promise> { const res = await postgresClient.query<{ id: string; doc_number: string; customer_ref_id: string | null; customer_ref_name: string | null; balance: string; }>( `SELECT id, doc_number, customer_ref_id, customer_ref_name, balance::text FROM qbo_invoices WHERE is_deleted = false AND doc_number = ANY($1::text[])`, [docNumbers], ); const map = new Map(); for (const r of res.rows) { if (!r.doc_number || !r.customer_ref_id) continue; map.set(r.doc_number, { qbo_invoice_id: r.id, customer_ref_id: r.customer_ref_id, balance: parseFloat(r.balance), customer_ref_name: r.customer_ref_name ?? '', }); } return map; } async function resolveDepositAccountId(accountName: string): Promise { const res = await postgresClient.query<{ deposit_to_account_ref_id: string | null }>( `SELECT deposit_to_account_ref_id FROM qbo_deposits WHERE deposit_to_account_ref_name = $1 AND deposit_to_account_ref_id IS NOT NULL ORDER BY txn_date DESC LIMIT 1`, [accountName], ); const id = res.rows[0]?.deposit_to_account_ref_id; if (!id) { throw new Error(`Could not resolve deposit account "${accountName}" — no prior qbo_deposits row has that name. Run a QBO sync or check the name spelling.`); } return id; } // ─── Payload builders ─────────────────────────────────────────────────────── function buildPaymentPayload( check: MappingCheck, fhRow: FhRow, mapping: Mapping, depositAccountId: string, invoiceMap: Map, ): QboPaymentCreatePayload { // All invoices for a single check must belong to the same customer. const customerIds = new Set(); for (const inv of check.invoices) { const resolved = invoiceMap.get(inv.doc_number); if (!resolved) { throw new Error(`seq ${check.seq}: invoice doc_number ${inv.doc_number} not found in qbo_invoices`); } customerIds.add(resolved.customer_ref_id); } if (customerIds.size > 1) { throw new Error(`seq ${check.seq}: invoices span multiple customers (${[...customerIds].join(', ')}). One check = one customer.`); } const customerRefId = [...customerIds][0]; // fhRow is used for context (seq validation happens in validate()); suppress unused-var void fhRow; return { CustomerRef: { value: customerRefId }, TotalAmt: round2(check.amount), TxnDate: mapping.deposit_date, DepositToAccountRef: { value: depositAccountId }, PaymentRefNum: check.check_num.slice(-21), // QBO limits to 21 chars PrivateNote: `${mapping.private_note_prefix ?? ''} / seq ${String(check.seq).padStart(4, '0')} / check ${check.check_num}`.trim(), Line: check.invoices.map((inv) => ({ Amount: round2(inv.amount), LinkedTxn: [{ TxnId: invoiceMap.get(inv.doc_number)!.qbo_invoice_id, TxnType: 'Invoice' }], })), }; } function buildDepositPayload( mapping: Mapping, depositAccountId: string, paymentIds: Array<{ seq: number; amount: number; paymentId: string }>, ): QboDepositCreatePayload { return { TxnDate: mapping.deposit_date, DepositToAccountRef: { value: depositAccountId }, PrivateNote: `${mapping.private_note_prefix ?? ''} / ${mapping.deposit_date}`.trim(), Line: paymentIds.map((p) => ({ Amount: round2(p.amount), DetailType: 'DepositLineDetail', LinkedTxn: [{ TxnId: p.paymentId, TxnType: 'Payment' }], DepositLineDetail: {}, })), }; } // ─── Main ─────────────────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); const positional = args.filter((a) => !a.startsWith('--')); if (positional.length < 2) { console.error('Usage: npx tsx scripts/apply-fh-deposit.ts [--dry-run]'); process.exit(2); } const [fhPath, mappingPath] = positional.map((p) => path.resolve(p)); console.log(`[fh-deposit] FH file: ${fhPath}`); console.log(`[fh-deposit] Mapping: ${mappingPath}`); console.log(`[fh-deposit] Mode: ${dryRun ? 'DRY-RUN (no QBO writes)' : 'LIVE'}`); const fhRows = parseFh(fhPath); const mapping: Mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8')); const { path: appliedPath, data: applied } = loadApplied(mappingPath); // 1. Validate const v = validate(fhRows, mapping); if (!v.ok) { console.error('[fh-deposit] Validation failed:'); for (const e of v.errors) console.error(` - ${e}`); process.exit(1); } const total = round2(fhRows.reduce((s, r) => s + r.amount, 0)); console.log(`[fh-deposit] Validation OK — ${fhRows.length} checks, $${total.toFixed(2)} total`); // 2. Resolve invoices + deposit account const allDocs = mapping.checks.flatMap((c) => c.invoices.map((i) => i.doc_number)); const invoiceMap = await resolveInvoices(allDocs); const missing = allDocs.filter((d) => !invoiceMap.has(d)); if (missing.length) { console.error(`[fh-deposit] Missing invoices in qbo_invoices: ${missing.join(', ')}`); process.exit(1); } const depositAccountId = await resolveDepositAccountId(mapping.deposit_account_name); console.log(`[fh-deposit] Deposit account "${mapping.deposit_account_name}" -> ${depositAccountId}`); // 3. Build + post payments // Defer QBO client construction until first live write — dry-run needs no QBO credentials. let client: ReturnType | null = null; const getClient = () => { if (!client) client = getQboClient(); return client; }; const paymentResults: Array<{ seq: number; amount: number; paymentId: string }> = []; for (const check of mapping.checks) { const seqKey = String(check.seq); const existing = applied.payments[seqKey]; if (existing?.qbo_payment_id) { console.log(`[fh-deposit] seq ${check.seq}: SKIP — already posted as Payment ${existing.qbo_payment_id}`); paymentResults.push({ seq: check.seq, amount: check.amount, paymentId: existing.qbo_payment_id }); continue; } const fhRow = fhRows.find((r) => r.seq === check.seq)!; const payload = buildPaymentPayload(check, fhRow, mapping, depositAccountId, invoiceMap); console.log(`[fh-deposit] seq ${check.seq}: ${check.customer_name ?? '?'} — check ${check.check_num} $${check.amount.toFixed(2)} -> invoice(s) ${check.invoices.map((i) => i.doc_number).join(', ')}`); if (dryRun) { console.log(` PAYLOAD: ${JSON.stringify(payload)}`); paymentResults.push({ seq: check.seq, amount: check.amount, paymentId: `DRY-RUN-seq-${check.seq}` }); continue; } try { const created = await getClient().createPayment(payload); console.log(` -> Payment ${created.Id} created`); applied.payments[seqKey] = { qbo_payment_id: created.Id, applied_at: new Date().toISOString() }; saveApplied(appliedPath, applied); paymentResults.push({ seq: check.seq, amount: check.amount, paymentId: created.Id }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(` -> FAILED: ${msg}`); console.error(' Stopping. Already-applied payments are recorded in:', appliedPath); process.exit(1); } } // 4. Build + post deposit if (applied.deposit?.qbo_deposit_id) { console.log(`[fh-deposit] Deposit already posted as ${applied.deposit.qbo_deposit_id} — skipping.`); } else { const depositPayload = buildDepositPayload(mapping, depositAccountId, paymentResults); console.log(`[fh-deposit] Deposit: ${paymentResults.length} lines, total $${total.toFixed(2)}`); if (dryRun) { console.log(` PAYLOAD: ${JSON.stringify(depositPayload)}`); } else { try { const created = await getClient().createDeposit(depositPayload); console.log(` -> Deposit ${created.Id} created`); applied.deposit = { qbo_deposit_id: created.Id, applied_at: new Date().toISOString() }; saveApplied(appliedPath, applied); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(` -> FAILED: ${msg}`); console.error(' Payments were posted successfully but the Deposit failed. Re-run the script to retry the Deposit only.'); process.exit(1); } } } console.log(`[fh-deposit] ${dryRun ? 'Dry-run complete' : 'Done'}.`); // Allow the postgres pool to drain so the process can exit cleanly. process.exit(0); } main().catch((err) => { console.error('[fh-deposit] Fatal:', err); process.exit(1); });