From 4745de1bce26b32ef54031f68648aa65d792a279 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 19 May 2026 00:37:31 -0400 Subject: [PATCH] feat(260519-0oz-02): add apply-fh-deposit.ts CLI script and 5/18 batch mapping JSON - scripts/apply-fh-deposit.ts: parse .FH, validate per-check + total against mapping, resolve invoices + deposit account from Postgres, post Payments + Deposit to QBO - Idempotency via sibling .applied.json written after each successful QBO post - --dry-run flag prints all payloads and makes zero QBO writes - Check number normalization (strip leading zeros) handles bank-vs-remittance mismatch - dev/fin/4183_mapping_20260518.json: 11 checks, $18,962.12 total, 2026-05-18 batch --- dev/fin/4183_mapping_20260518.json | 18 ++ scripts/apply-fh-deposit.ts | 374 +++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 dev/fin/4183_mapping_20260518.json create mode 100644 scripts/apply-fh-deposit.ts diff --git a/dev/fin/4183_mapping_20260518.json b/dev/fin/4183_mapping_20260518.json new file mode 100644 index 0000000..b4b0503 --- /dev/null +++ b/dev/fin/4183_mapping_20260518.json @@ -0,0 +1,18 @@ +{ + "deposit_date": "2026-05-18", + "deposit_account_name": "Huntington 5424 - Primary", + "private_note_prefix": "FH batch 4183 / slip 000839", + "checks": [ + { "seq": 1, "check_num": "0000997294", "amount": 539.40, "customer_name": "Buffalo Glass Block", "invoices": [{ "doc_number": "29885800", "amount": 539.40 }] }, + { "seq": 2, "check_num": "039913", "amount": 1443.16, "customer_name": "HOSCH Company", "invoices": [{ "doc_number": "29885726", "amount": 1209.10 }, { "doc_number": "29885720", "amount": 234.06 }] }, + { "seq": 3, "check_num": "042074", "amount": 1059.51, "customer_name": "WPML West Penn Multi List", "invoices": [{ "doc_number": "29885794", "amount": 1059.51 }] }, + { "seq": 4, "check_num": "005198", "amount": 373.16, "customer_name": "ADM Signs", "invoices": [{ "doc_number": "29885798", "amount": 373.16 }] }, + { "seq": 5, "check_num": "126616", "amount": 427.00, "customer_name": "Superior Distributing Co", "invoices": [{ "doc_number": "29885705", "amount": 427.00 }] }, + { "seq": 6, "check_num": "033256", "amount": 959.57, "customer_name": "Attica Hub/Seneca Publishing", "invoices": [{ "doc_number": "29885767", "amount": 959.57 }] }, + { "seq": 7, "check_num": "005814", "amount": 481.43, "customer_name": "Cincinnati Glass Block", "invoices": [{ "doc_number": "29885801", "amount": 481.43 }] }, + { "seq": 8, "check_num": "0000996226", "amount": 2805.74, "customer_name": "Finn Chiropractic Group", "invoices": [{ "doc_number": "29885805", "amount": 2805.74 }] }, + { "seq": 9, "check_num": "0000995786", "amount": 1468.68, "customer_name": "Finn Chiropractic Group", "invoices": [{ "doc_number": "29885806", "amount": 1468.68 }] }, + { "seq": 10, "check_num": "103764", "amount": 7683.87, "customer_name": "Blackburn's Physicians Pharmacy, Inc.", "invoices": [{ "doc_number": "29885838", "amount": 7683.87 }] }, + { "seq": 11, "check_num": "000011297", "amount": 1720.60, "customer_name": "1 of 1 MotorSports", "invoices": [{ "doc_number": "29885797", "amount": 1720.60 }] } + ] +} diff --git a/scripts/apply-fh-deposit.ts b/scripts/apply-fh-deposit.ts new file mode 100644 index 0000000..a21ca0f --- /dev/null +++ b/scripts/apply-fh-deposit.ts @@ -0,0 +1,374 @@ +#!/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 + const client = getQboClient(); + 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 client.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 client.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); +});