- 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
374 lines
15 KiB
JavaScript
374 lines
15 KiB
JavaScript
#!/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 `<mapping>.applied.json` after each successful
|
|
* QBO post; re-running skips entries already present in that file.
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/apply-fh-deposit.ts <fh-file> <mapping.json> [--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<string, { qbo_payment_id: string; applied_at: string }>; // 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<number, FhRow>(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<Map<string, { qbo_invoice_id: string; customer_ref_id: string; balance: number; customer_ref_name: string }>> {
|
|
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<string, { qbo_invoice_id: string; customer_ref_id: string; balance: number; customer_ref_name: string }>();
|
|
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<string> {
|
|
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<string, { qbo_invoice_id: string; customer_ref_id: string; customer_ref_name: string }>,
|
|
): QboPaymentCreatePayload {
|
|
// All invoices for a single check must belong to the same customer.
|
|
const customerIds = new Set<string>();
|
|
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 <fh-file> <mapping.json> [--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);
|
|
});
|