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
This commit is contained in:
parent
ef9b31e7c2
commit
4745de1bce
2 changed files with 392 additions and 0 deletions
374
scripts/apply-fh-deposit.ts
Normal file
374
scripts/apply-fh-deposit.ts
Normal file
|
|
@ -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 `<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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue