2026-05-19 00:37:31 -04:00
# ! / u s r / b i n / e n v n o d e
/ * *
* 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
2026-05-19 00:38:47 -04:00
// Defer QBO client construction until first live write — dry-run needs no QBO credentials.
let client : ReturnType < typeof getQboClient > | null = null ;
const getClient = ( ) = > {
if ( ! client ) client = getQboClient ( ) ;
return client ;
} ;
2026-05-19 00:37:31 -04:00
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 {
2026-05-19 00:38:47 -04:00
const created = await getClient ( ) . createPayment ( payload ) ;
2026-05-19 00:37:31 -04:00
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 {
2026-05-19 00:38:47 -04:00
const created = await getClient ( ) . createDeposit ( depositPayload ) ;
2026-05-19 00:37:31 -04:00
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 ) ;
} ) ;