wulf-pulse/scripts/apply-fh-deposit.ts

375 lines
15 KiB
TypeScript
Raw Normal View History

#!/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);
});