diff --git a/.planning/STATE.md b/.planning/STATE.md index 1c34678..2d55ac6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-05-03) Phase: 09.1 (ntfy-backend-fix) — EXECUTING Plan: 1 of 1 Status: Executing Phase 09.1 -Last activity: 2026-05-11 -- Phase 09.1 execution started +Last activity: 2026-05-19 - Completed quick task 260519-0oz: Add QBO createPayment + createDeposit + .FH reconciliation script Progress: [░░░░░░░░░░] 0% @@ -89,6 +89,12 @@ None yet. None yet. +### Quick Tasks Completed + +| # | Description | Date | Commit | Directory | +|---|-------------|------|--------|-----------| +| 260519-0oz | Add QBO createPayment + createDeposit + .FH reconciliation script | 2026-05-19 | 5497458 | [260519-0oz-add-qbo-createpayment-createdeposit-fh-r](./quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/) | + ## Session Continuity Last session: 2026-05-10T02:09:48.834Z diff --git a/.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-PLAN.md b/.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-PLAN.md new file mode 100644 index 0000000..8c56454 --- /dev/null +++ b/.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-PLAN.md @@ -0,0 +1,777 @@ +--- +phase: 260519-0oz-add-qbo-createpayment-createdeposit-fh-r +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lib/types/qbo.ts + - lib/services/qbo-client.ts + - scripts/apply-fh-deposit.ts + - dev/fin/4183_mapping_20260518.json +autonomous: true +requirements: + - QBO-FH-01 # createPayment + createDeposit added to qbo-client + - QBO-FH-02 # apply-fh-deposit.ts script consumes .FH + mapping JSON + - QBO-FH-03 # dry-run + idempotency markers + - QBO-FH-04 # first production batch (2026-05-18, 11 checks, $18,962.12) reconciled + +must_haves: + truths: + - "QboClient exposes createPayment(payload) and createDeposit(payload) that POST to QBO and return the created entity" + - "scripts/apply-fh-deposit.ts can be invoked: npx tsx scripts/apply-fh-deposit.ts [--dry-run]" + - "Running the script with --dry-run prints the per-check Payment payloads and the aggregate Deposit payload but makes ZERO QBO writes" + - "Running the script live (no --dry-run) writes one Payment per check applied to the mapped invoice(s), then one Deposit grouping all payments" + - "Re-running the script against an already-applied mapping skips payments/deposits that already have QBO IDs recorded (idempotency)" + - "Per-check amounts and the deposit total reconcile to the .FH file totals before any QBO writes happen; mismatch aborts the run" + - "CustomerRef.value for each Payment is resolved from qbo_invoices.customer_ref_id via the mapped invoice's doc_number (no name-fuzzy-match)" + artifacts: + - path: "lib/types/qbo.ts" + provides: "QboPaymentCreatePayload, QboDepositCreatePayload payload types" + contains: "QboPaymentCreatePayload" + - path: "lib/services/qbo-client.ts" + provides: "createPayment + createDeposit methods on QboClient" + contains: "async createPayment" + - path: "scripts/apply-fh-deposit.ts" + provides: "CLI: parse .FH, validate against mapping, post payments + deposit, write back QBO IDs" + min_lines: 150 + - path: "dev/fin/4183_mapping_20260518.json" + provides: "Mapping JSON for the 2026-05-18 batch (11 checks, $18,962.12)" + contains: "deposit_date" + key_links: + - from: "scripts/apply-fh-deposit.ts" + to: "lib/services/qbo-client.ts" + via: "getQboClient().createPayment + createDeposit" + pattern: "createPayment|createDeposit" + - from: "scripts/apply-fh-deposit.ts" + to: "qbo_invoices (Postgres)" + via: "SELECT id, customer_ref_id, balance FROM qbo_invoices WHERE doc_number = ANY($1)" + pattern: "qbo_invoices" + - from: "scripts/apply-fh-deposit.ts" + to: "qbo_deposits (Postgres)" + via: "SELECT deposit_to_account_ref_id FROM qbo_deposits WHERE deposit_to_account_ref_name = $1 ORDER BY txn_date DESC LIMIT 1" + pattern: "deposit_to_account_ref" + - from: "scripts/apply-fh-deposit.ts" + to: "dev/fin/4183_mapping_20260518.applied.json (idempotency marker)" + via: "fs.writeFileSync after each successful QBO post" + pattern: "\\.applied\\.json" +--- + + +Add `createPayment` and `createDeposit` methods to `QboClient`, then build a single CLI script that reconciles a bank `.FH` deposit file against a JSON invoice-mapping file and posts Receive Payments + a grouping Deposit to QuickBooks Online. First production run is the 2026-05-18 batch: 11 checks, $18,962.12 total, deposited to "Huntington 5424 - Primary". + +Purpose: Eliminate manual data entry of weekly remittance batches into QBO. Each Payment must be applied to the correct invoice(s) per the user-supplied mapping; the Deposit groups all payments so QBO's bank-feed reconciliation matches the bank's actual deposit slip. + +Output: Two new methods on QboClient, payload types in lib/types/qbo.ts, a runnable CLI script in scripts/, and a verified dry-run against the 5/18 batch with QBO payloads documented in the SUMMARY. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@CLAUDE.md +@lib/services/qbo-client.ts +@lib/types/qbo.ts +@app/api/qbo/sync/route.ts +@app/api/qbo/diagnose-ar/route.ts +@dev/fin/4183_Data_20260518193531.FH + + + + +From lib/services/qbo-client.ts (existing): +```typescript +export class QboClient { + // private request(path: string, options: RequestInit = {}): Promise + // ^ private. Executor must either: + // (a) add createPayment/createDeposit as METHODS on QboClient so they can call this.request(), OR + // (b) leave request() private and add the new methods inside the class (preferred — matches existing pattern). + async getInvoices(updatedSince?: Date): Promise; + async getPayments(updatedSince?: Date): Promise; + async getDeposits(updatedSince?: Date): Promise; + async loadToken(): Promise; +} +export function getQboClient(): QboClient; +``` + +From lib/types/qbo.ts (existing — payload types must be ADDED, response types already exist): +```typescript +export interface QboRef { value: string; name?: string; } +export interface QboLinkedTxn { TxnId: string; TxnType: string; TxnLineId?: string; } +export interface QboPayment { Id: string; SyncToken: string; /* ... full response shape exists */ } +export interface QboDeposit { Id: string; SyncToken: string; /* ... full response shape exists */ } +``` + +From lib/services/postgres-client.ts (existing default export): +```typescript +const postgresClient: { query(sql: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }> }; +export default postgresClient; +``` + +From dev/fin/4183_Data_20260518193531.FH (input format — confirmed): +``` +date(MMDDYYYY),slipNum,seq,checkNum,amount,payerAcct,payerRouting +05182026,000839,0001,0000997294,539.40,3336040195,243374218 +``` +Note: `checkNum` is a string (preserve leading zeros — the mapping JSON's check_num must match this exactly). + +QBO Payment payload shape (Intuit v3 API — endpoint `POST /v3/company/{realmId}/payment?minorversion=65`): +```json +{ + "CustomerRef": { "value": "" }, + "TotalAmt": 539.40, + "TxnDate": "2026-05-18", + "DepositToAccountRef": { "value": "" }, + "PaymentRefNum": "0000997294", + "PrivateNote": "FH batch 4183 / slip 000839 / seq 0001", + "Line": [ + { "Amount": 539.40, "LinkedTxn": [{ "TxnId": "", "TxnType": "Invoice" }] } + ] +} +``` + +QBO Deposit payload shape (endpoint `POST /v3/company/{realmId}/deposit?minorversion=65`): +```json +{ + "TxnDate": "2026-05-18", + "DepositToAccountRef": { "value": "" }, + "PrivateNote": "FH batch 4183 / slip 000839 / 2026-05-18", + "Line": [ + { + "Amount": 539.40, + "DetailType": "DepositLineDetail", + "LinkedTxn": [{ "TxnId": "", "TxnType": "Payment" }], + "DepositLineDetail": {} + } + ] +} +``` + + + + + + + Task 1: Add payload types + createPayment/createDeposit to QboClient + lib/types/qbo.ts, lib/services/qbo-client.ts + +1. In `lib/types/qbo.ts`, add two new exported payload interfaces (place after existing `QboDeposit` interface, before `QboPurchase`): + +```typescript +// Payload for creating a Payment via POST /v3/company/{realmId}/payment +// Only the fields we actually send — QBO accepts many more but we keep the +// surface area small and explicit. +export interface QboPaymentCreatePayload { + CustomerRef: QboRef; // { value: customer_ref_id } + TotalAmt: number; + TxnDate?: string; // YYYY-MM-DD + DepositToAccountRef?: QboRef; // { value: account id } + PaymentRefNum?: string; // check number (max 21 chars per QBO) + PrivateNote?: string; + Line?: Array<{ + Amount: number; + LinkedTxn?: QboLinkedTxn[]; // one entry per invoice being paid + }>; +} + +// Payload for creating a Deposit via POST /v3/company/{realmId}/deposit +// Each Line links to a previously-created Payment so QBO groups them as a +// single bank deposit (matches the bank slip). +export interface QboDepositCreatePayload { + TxnDate?: string; + DepositToAccountRef: QboRef; // required for deposit + PrivateNote?: string; + Line: Array<{ + Amount: number; + DetailType: 'DepositLineDetail'; + LinkedTxn?: QboLinkedTxn[]; // [{ TxnId: payment_id, TxnType: 'Payment' }] + DepositLineDetail?: Record; + }>; +} +``` + +2. In `lib/services/qbo-client.ts`: + - Add the new payload types to the import from `@/lib/types/qbo`: + ```typescript + import { + QboTokenRecord, QboTokenResponse, QboInvoice, QboPayment, QboDeposit, + QboPurchase, QboJournalEntry, QboReport, QboQueryResponse, + QboPaymentCreatePayload, QboDepositCreatePayload, + } from '@/lib/types/qbo'; + ``` + - Add two new methods to the `QboClient` class, placed AFTER `getDeposits()` (line ~226) and BEFORE `getPurchases()` so the create/read methods sit together by entity: + +```typescript +// ─── Entity Creators ───────────────────────────────────────────────────────── + +/** + * Create a Receive Payment in QBO and apply it to one or more invoices. + * Returns the created Payment as QBO echoes it back (includes Id, SyncToken). + */ +async createPayment(payload: QboPaymentCreatePayload): Promise { + const data = await this.request<{ Payment: QboPayment }>( + `/payment?minorversion=65`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); + if (!data?.Payment?.Id) { + throw new Error(`QBO createPayment returned no Payment: ${JSON.stringify(data).slice(0, 500)}`); + } + return data.Payment; +} + +/** + * Create a Deposit in QBO that groups one or more existing Payments into a + * single bank-deposit line (so QBO's bank-feed reconciliation matches the + * bank's actual deposit slip). + */ +async createDeposit(payload: QboDepositCreatePayload): Promise { + const data = await this.request<{ Deposit: QboDeposit }>( + `/deposit?minorversion=65`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); + if (!data?.Deposit?.Id) { + throw new Error(`QBO createDeposit returned no Deposit: ${JSON.stringify(data).slice(0, 500)}`); + } + return data.Deposit; +} +``` + +Match existing style: 2-space indent, no semicolons at top of class methods (existing code uses them), use `this.request()` (already has Authorization + Content-Type), throw on empty response. + +DO NOT modify the existing `private async request()` method — it already handles auth, error wrapping, and `intuit_tid` logging correctly. + +DO NOT introduce Zod validation here. Payload shape is enforced by TypeScript at the call site (the script). + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -E "qbo-client|qbo\.ts" || echo "TS clean for qbo files" + + +- `lib/types/qbo.ts` exports `QboPaymentCreatePayload` and `QboDepositCreatePayload` +- `lib/services/qbo-client.ts` has public `createPayment(payload)` and `createDeposit(payload)` methods on `QboClient` +- `npx tsc --noEmit --pretty` shows zero errors in either file +- Existing methods unchanged; no new dependencies added; no Zod + + + + + Task 2: Build apply-fh-deposit.ts CLI + write the 5/18 mapping JSON + scripts/apply-fh-deposit.ts, dev/fin/4183_mapping_20260518.json + +1. **Write the mapping JSON first** at `dev/fin/4183_mapping_20260518.json` with this exact content (this is the first production input — bake it in so the dry-run in Task 3 can consume it): + +```json +{ + "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 }] } + ] +} +``` + +NOTE on check 8 and 9: the `.FH` file has `996226` and `995786` (the bank strips leading zeros), but the mapping uses `0000996226` and `0000995786` as they appear on the physical checks. The matcher must normalize by **stripping leading zeros on both sides** before comparing. + +2. **Write the script** at `scripts/apply-fh-deposit.ts`. Must run via `npx tsx scripts/apply-fh-deposit.ts [--dry-run]`. + +Structure (single file, ~250 lines, no external deps beyond what's already in package.json): + +```typescript +#!/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]; + + 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); +}); +``` + +Key constraints (do NOT deviate): +- No ORM / no Zod (per CLAUDE.md). Type assertions on JSON.parse are acceptable. +- Use `postgresClient` singleton (default export). +- Use `getQboClient()`, not `new QboClient()` — matches the factory pattern. +- Idempotency marker is `.applied.json` sibling. Save AFTER each successful POST so a mid-run failure leaves accurate state on disk. +- `process.exit(0)` at the end so the Postgres pool doesn't hold the process open. +- Dry-run path MUST NOT call `client.createPayment` or `client.createDeposit` (verified by reading the code — both calls are inside `if (!dryRun)` branches). +- Customer-per-check guard: enforces one CustomerRef per Payment (QBO requires this). +- Normalize check numbers (strip leading zeros) when comparing FH vs mapping — bank strips them, remittance preserves them. + +Do NOT add CLI flags beyond `--dry-run` (no --verbose, --json, etc — out of scope for v1). + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -E "apply-fh-deposit" || echo "TS clean for apply-fh-deposit.ts" + + +- `scripts/apply-fh-deposit.ts` exists, runnable via `npx tsx` +- `dev/fin/4183_mapping_20260518.json` exists with all 11 checks totaling $18,962.12 +- `npx tsc --noEmit --pretty` shows zero errors in either file +- Dry-run code path verifiably skips both `createPayment` and `createDeposit` (grep "if (dryRun)" finds the guards) +- Idempotency markers written to `.applied.json` after each successful post (not in dry-run) + + + + + Task 3: Dry-run against 5/18 batch and capture expected output + .planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md + +1. Execute the dry-run: +```bash +cd /opt/stacks/pulse && npx tsx scripts/apply-fh-deposit.ts dev/fin/4183_Data_20260518193531.FH dev/fin/4183_mapping_20260518.json --dry-run 2>&1 | tee /tmp/fh-dryrun.log +``` + +2. Verify the dry-run output shows: + - Validation passed: 11 checks, $18,962.12 total + - Deposit account "Huntington 5424 - Primary" resolved to a numeric ID (from `qbo_deposits` history) + - 11 PAYLOAD lines printed (one per check) with correctly resolved `CustomerRef.value` (real QBO customer IDs) and `LinkedTxn.TxnId` (real qbo_invoices.id values, NOT doc_numbers) + - 1 Deposit PAYLOAD with 11 lines, each LinkedTxn pointing to `DRY-RUN-seq-N` + - Exit code 0 + - `dev/fin/4183_mapping_20260518.applied.json` was NOT created (no writes in dry-run) + +3. Verify NO QBO API calls were made: +```bash +grep -c "client.createPayment\|client.createDeposit" /tmp/fh-dryrun.log +# Expected: 0 (the strings only appear if QBO call was made; dry-run goes through the dryRun branch) +``` + +4. Write `.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md` with: + - **What was built**: bullet list of (a) types, (b) client methods, (c) script, (d) mapping JSON + - **Dry-run output capture**: paste the full `[fh-deposit]` log lines + the 11 payment payloads + the deposit payload + - **How to run live**: + ``` + cd /opt/stacks/pulse + npx tsx scripts/apply-fh-deposit.ts dev/fin/4183_Data_20260518193531.FH dev/fin/4183_mapping_20260518.json + # After success: + curl -X POST http://localhost:3100/api/qbo/sync -H 'content-type: application/json' -d '{"syncType":"incremental","triggeredBy":"fh-deposit-cli"}' + ``` + - **Idempotency contract**: explain `.applied.json` sibling file, what happens on re-run, recovery instructions if the Deposit step fails after Payments succeed + - **First-batch reconciliation expectation**: 11 Payments + 1 Deposit posted, $18,962.12 deposited to "Huntington 5424 - Primary", all 12 invoices (29885800, 29885726, 29885720, 29885794, 29885798, 29885705, 29885767, 29885801, 29885805, 29885806, 29885838, 29885797) move from Open to Paid after next incremental QBO sync + - **Known limitations**: no auto-extraction of mapping from PDF remittance, no admin UI (one-off script), `qbo_payments`/`qbo_deposits` Pulse tables update via re-running existing sync (not direct writes) + + + cd /opt/stacks/pulse && test -f dev/fin/4183_mapping_20260518.json && test -f scripts/apply-fh-deposit.ts && test ! -f dev/fin/4183_mapping_20260518.applied.json && test -f .planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md && echo "ALL ARTIFACTS PRESENT, NO DRY-RUN SIDE EFFECTS" + + +- Dry-run ran end-to-end with exit code 0 against the real 5/18 .FH file +- All 11 payment payloads and 1 deposit payload printed and captured in SUMMARY.md +- No `.applied.json` file was created (proves dry-run had no side effects) +- SUMMARY.md documents the live-run command, idempotency contract, and reconciliation expectation +- User can run the live command in a follow-up session with confidence + + + + + + +After all three tasks complete: + +1. **Type check clean**: `cd /opt/stacks/pulse && npx tsc --noEmit --pretty` shows zero errors +2. **Files exist**: + - `lib/types/qbo.ts` exports `QboPaymentCreatePayload` + `QboDepositCreatePayload` + - `lib/services/qbo-client.ts` has `createPayment` + `createDeposit` methods + - `scripts/apply-fh-deposit.ts` runnable via `npx tsx` + - `dev/fin/4183_mapping_20260518.json` (11 checks, $18,962.12) + - `.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md` +3. **Dry-run succeeds**: produces 11 payment payloads + 1 deposit payload, NO `.applied.json` written, NO QBO API calls made +4. **Reconciliation arithmetic holds**: sum of FH amounts = sum of mapping amounts = sum of invoice splits = $18,962.12 +5. **No new dependencies**: package.json unchanged + + + +- `QboClient.createPayment()` and `QboClient.createDeposit()` exist, typed, use `this.request()`, throw on empty response +- `scripts/apply-fh-deposit.ts` validates the .FH file against the mapping JSON before any QBO writes +- Dry-run is provably side-effect-free (no `.applied.json`, no QBO POST calls) +- Live run is idempotent: re-running after a partial success resumes from where it left off (skips already-posted payments by seq, skips deposit if posted) +- The 2026-05-18 batch (`dev/fin/4183_Data_20260518193531.FH` + `dev/fin/4183_mapping_20260518.json`) successfully dry-runs and the SUMMARY captures the expected payloads +- Zero violations of CLAUDE.md constraints (no ORM, no Zod, no server actions, no new state libs, snake_case DB / camelCase API, postgresClient singleton) + + + +After completion, create `.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md` documenting: +- What was built (types, methods, script, mapping JSON) +- The captured dry-run output for the 5/18 batch (full payloads) +- How to run the live command + the follow-up `qbo/sync` call +- Idempotency contract and partial-failure recovery +- Known limitations (no PDF OCR, no admin UI, sync-after-write) + diff --git a/.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md b/.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md new file mode 100644 index 0000000..afd2550 --- /dev/null +++ b/.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md @@ -0,0 +1,220 @@ +--- +phase: 260519-0oz-add-qbo-createpayment-createdeposit-fh-r +plan: 01 +subsystem: qbo +tags: [qbo, payments, deposits, financial, cli-script] +key-decisions: + - Lazy QBO client construction — getQboClient() deferred until first live POST so dry-run works without QBO credentials configured + - Check number normalization (strip leading zeros) handles bank-strips-zeros vs remittance-preserves-zeros mismatch + - Idempotency via sibling .applied.json file rather than DB table — keeps the script self-contained with no migration required + - process.exit(0) at end to drain Postgres pool so script terminates cleanly +key-files: + created: + - lib/types/qbo.ts (QboPaymentCreatePayload + QboDepositCreatePayload added) + - lib/services/qbo-client.ts (createPayment + createDeposit methods added) + - scripts/apply-fh-deposit.ts (full CLI script, ~270 lines) + - dev/fin/4183_mapping_20260518.json (2026-05-18 batch mapping) + modified: [] +metrics: + duration: ~25min + completed: 2026-05-19 + tasks: 3 + files: 4 +--- + +# QBO createPayment + createDeposit / FH Deposit Script + +**One-liner:** JWT-free QBO Payment+Deposit CLI that reconciles bank .FH remittance files against a JSON mapping, posts per-check Payments applied to invoice(s), and groups them under a single Deposit — with dry-run, idempotency, and per-check amount validation baked in. + +--- + +## What Was Built + +1. **`lib/types/qbo.ts` — two new payload interfaces** + - `QboPaymentCreatePayload` — fields sent when POSTing a Receive Payment: `CustomerRef`, `TotalAmt`, `TxnDate`, `DepositToAccountRef`, `PaymentRefNum`, `PrivateNote`, `Line[]` + - `QboDepositCreatePayload` — fields sent when POSTing a Deposit: `TxnDate`, `DepositToAccountRef`, `PrivateNote`, `Line[]` (each line has `DetailType: 'DepositLineDetail'` and links to a Payment) + +2. **`lib/services/qbo-client.ts` — two new public methods on `QboClient`** + - `async createPayment(payload: QboPaymentCreatePayload): Promise` — POSTs to `/payment?minorversion=65`, throws if no `Id` returned + - `async createDeposit(payload: QboDepositCreatePayload): Promise` — POSTs to `/deposit?minorversion=65`, throws if no `Id` returned + - Both use the existing private `this.request()` which handles Bearer auth, Content-Type, `intuit_tid` logging, and error wrapping + +3. **`scripts/apply-fh-deposit.ts` — CLI script** + - Usage: `npx tsx scripts/apply-fh-deposit.ts [--dry-run]` + - Parses `.FH` (comma-delimited, MMDDYYYY date, one check per row) + - Validates per-check: check number match (normalized), amount match, invoice splits sum + - Validates deposit total: FH total == mapping total + - Resolves invoice IDs and `customer_ref_id` from `qbo_invoices` (no name fuzzy-match) + - Resolves deposit account ID from `qbo_deposits` most-recent-row-by-name + - Posts one `Payment` per check (applied to its invoice(s) via `Line[].LinkedTxn`) + - Posts one `Deposit` grouping all payments + - Idempotency: writes `.applied.json` after each successful POST; re-run skips already-posted entries + - Dry-run: prints all payloads, makes zero QBO writes, creates no `.applied.json` + +4. **`dev/fin/4183_mapping_20260518.json` — 2026-05-18 batch mapping** + - 11 checks, $18,962.12 total + - Deposit to "Huntington 5424 - Primary" + - seq 2 splits across two invoices (29885726 + 29885720) + - seq 8 + 9: physical check numbers preserve leading zeros (0000996226, 0000995786); bank strips them to 996226, 995786 — normalizer handles both sides + +--- + +## Dry-Run Output — 2026-05-18 Batch + +Run command: +``` +POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=pulse_autotask \ + POSTGRES_USER=pulse_user POSTGRES_PASSWORD='...' \ + npx tsx scripts/apply-fh-deposit.ts \ + dev/fin/4183_Data_20260518193531.FH \ + dev/fin/4183_mapping_20260518.json \ + --dry-run +``` + +Full output: +``` +[fh-deposit] FH file: /opt/stacks/pulse/dev/fin/4183_Data_20260518193531.FH +[fh-deposit] Mapping: /opt/stacks/pulse/dev/fin/4183_mapping_20260518.json +[fh-deposit] Mode: DRY-RUN (no QBO writes) +[fh-deposit] Validation OK — 11 checks, $18962.12 total +[fh-deposit] Deposit account "Huntington 5424 - Primary" -> 58 +[fh-deposit] seq 1: Buffalo Glass Block — check 0000997294 $539.40 -> invoice(s) 29885800 + PAYLOAD: {"CustomerRef":{"value":"12"},"TotalAmt":539.4,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"0000997294","PrivateNote":"FH batch 4183 / slip 000839 / seq 0001 / check 0000997294","Line":[{"Amount":539.4,"LinkedTxn":[{"TxnId":"31619","TxnType":"Invoice"}]}]} +[fh-deposit] seq 2: HOSCH Company — check 039913 $1443.16 -> invoice(s) 29885726, 29885720 + PAYLOAD: {"CustomerRef":{"value":"666"},"TotalAmt":1443.16,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"039913","PrivateNote":"FH batch 4183 / slip 000839 / seq 0002 / check 039913","Line":[{"Amount":1209.1,"LinkedTxn":[{"TxnId":"31539","TxnType":"Invoice"}]},{"Amount":234.06,"LinkedTxn":[{"TxnId":"31538","TxnType":"Invoice"}]}]} +[fh-deposit] seq 3: WPML West Penn Multi List — check 042074 $1059.51 -> invoice(s) 29885794 + PAYLOAD: {"CustomerRef":{"value":"57"},"TotalAmt":1059.51,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"042074","PrivateNote":"FH batch 4183 / slip 000839 / seq 0003 / check 042074","Line":[{"Amount":1059.51,"LinkedTxn":[{"TxnId":"31614","TxnType":"Invoice"}]}]} +[fh-deposit] seq 4: ADM Signs — check 005198 $373.16 -> invoice(s) 29885798 + PAYLOAD: {"CustomerRef":{"value":"1"},"TotalAmt":373.16,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"005198","PrivateNote":"FH batch 4183 / slip 000839 / seq 0004 / check 005198","Line":[{"Amount":373.16,"LinkedTxn":[{"TxnId":"31617","TxnType":"Invoice"}]}]} +[fh-deposit] seq 5: Superior Distributing Co — check 126616 $427.00 -> invoice(s) 29885705 + PAYLOAD: {"CustomerRef":{"value":"45"},"TotalAmt":427,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"126616","PrivateNote":"FH batch 4183 / slip 000839 / seq 0005 / check 126616","Line":[{"Amount":427,"LinkedTxn":[{"TxnId":"31317","TxnType":"Invoice"}]}]} +[fh-deposit] seq 6: Attica Hub/Seneca Publishing — check 033256 $959.57 -> invoice(s) 29885767 + PAYLOAD: {"CustomerRef":{"value":"4"},"TotalAmt":959.57,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"033256","PrivateNote":"FH batch 4183 / slip 000839 / seq 0006 / check 033256","Line":[{"Amount":959.57,"LinkedTxn":[{"TxnId":"31588","TxnType":"Invoice"}]}]} +[fh-deposit] seq 7: Cincinnati Glass Block — check 005814 $481.43 -> invoice(s) 29885801 + PAYLOAD: {"CustomerRef":{"value":"14"},"TotalAmt":481.43,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"005814","PrivateNote":"FH batch 4183 / slip 000839 / seq 0007 / check 005814","Line":[{"Amount":481.43,"LinkedTxn":[{"TxnId":"31620","TxnType":"Invoice"}]}]} +[fh-deposit] seq 8: Finn Chiropractic Group — check 0000996226 $2805.74 -> invoice(s) 29885805 + PAYLOAD: {"CustomerRef":{"value":"21"},"TotalAmt":2805.74,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"0000996226","PrivateNote":"FH batch 4183 / slip 000839 / seq 0008 / check 0000996226","Line":[{"Amount":2805.74,"LinkedTxn":[{"TxnId":"31624","TxnType":"Invoice"}]}]} +[fh-deposit] seq 9: Finn Chiropractic Group — check 0000995786 $1468.68 -> invoice(s) 29885806 + PAYLOAD: {"CustomerRef":{"value":"21"},"TotalAmt":1468.68,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"0000995786","PrivateNote":"FH batch 4183 / slip 000839 / seq 0009 / check 0000995786","Line":[{"Amount":1468.68,"LinkedTxn":[{"TxnId":"31625","TxnType":"Invoice"}]}]} +[fh-deposit] seq 10: Blackburn's Physicians Pharmacy, Inc. — check 103764 $7683.87 -> invoice(s) 29885838 + PAYLOAD: {"CustomerRef":{"value":"6"},"TotalAmt":7683.87,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"103764","PrivateNote":"FH batch 4183 / slip 000839 / seq 0010 / check 103764","Line":[{"Amount":7683.87,"LinkedTxn":[{"TxnId":"31650","TxnType":"Invoice"}]}]} +[fh-deposit] seq 11: 1 of 1 MotorSports — check 000011297 $1720.60 -> invoice(s) 29885797 + PAYLOAD: {"CustomerRef":{"value":"295"},"TotalAmt":1720.6,"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PaymentRefNum":"000011297","PrivateNote":"FH batch 4183 / slip 000839 / seq 0011 / check 000011297","Line":[{"Amount":1720.6,"LinkedTxn":[{"TxnId":"31616","TxnType":"Invoice"}]}]} +[fh-deposit] Deposit: 11 lines, total $18962.12 + PAYLOAD: {"TxnDate":"2026-05-18","DepositToAccountRef":{"value":"58"},"PrivateNote":"FH batch 4183 / slip 000839 / 2026-05-18","Line":[{"Amount":539.4,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-1","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":1443.16,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-2","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":1059.51,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-3","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":373.16,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-4","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":427,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-5","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":959.57,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-6","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":481.43,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-7","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":2805.74,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-8","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":1468.68,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-9","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":7683.87,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-10","TxnType":"Payment"}],"DepositLineDetail":{}},{"Amount":1720.6,"DetailType":"DepositLineDetail","LinkedTxn":[{"TxnId":"DRY-RUN-seq-11","TxnType":"Payment"}],"DepositLineDetail":{}}]} +[fh-deposit] Dry-run complete. +``` + +### Resolved IDs (from Postgres qbo_invoices / qbo_deposits) + +| seq | Customer | CustomerRef.value | Check # | Invoice(s) | qbo_invoices.id(s) | Amount | +|-----|----------|-------------------|---------|------------|---------------------|--------| +| 1 | Buffalo Glass Block | 12 | 0000997294 | 29885800 | 31619 | $539.40 | +| 2 | HOSCH Company | 666 | 039913 | 29885726, 29885720 | 31539, 31538 | $1,443.16 | +| 3 | WPML West Penn Multi List | 57 | 042074 | 29885794 | 31614 | $1,059.51 | +| 4 | ADM Signs | 1 | 005198 | 29885798 | 31617 | $373.16 | +| 5 | Superior Distributing Co | 45 | 126616 | 29885705 | 31317 | $427.00 | +| 6 | Attica Hub/Seneca Publishing | 4 | 033256 | 29885767 | 31588 | $959.57 | +| 7 | Cincinnati Glass Block | 14 | 005814 | 29885801 | 31620 | $481.43 | +| 8 | Finn Chiropractic Group | 21 | 0000996226 | 29885805 | 31624 | $2,805.74 | +| 9 | Finn Chiropractic Group | 21 | 0000995786 | 29885806 | 31625 | $1,468.68 | +| 10 | Blackburn's Physicians Pharmacy | 6 | 103764 | 29885838 | 31650 | $7,683.87 | +| 11 | 1 of 1 MotorSports | 295 | 000011297 | 29885797 | 31616 | $1,720.60 | + +**Deposit account:** "Huntington 5424 - Primary" -> account ref ID `58` + +**Total: $18,962.12** (matches FH file and mapping JSON) + +--- + +## How to Run Live + +```bash +cd /opt/stacks/pulse +npx tsx scripts/apply-fh-deposit.ts \ + dev/fin/4183_Data_20260518193531.FH \ + dev/fin/4183_mapping_20260518.json +``` + +After the script completes successfully, trigger an incremental QBO sync so `qbo_payments` and `qbo_deposits` tables in Pulse reflect the new records: + +```bash +curl -X POST http://localhost:3100/api/qbo/sync \ + -H 'content-type: application/json' \ + -d '{"syncType":"incremental","triggeredBy":"fh-deposit-cli"}' +``` + +--- + +## Idempotency Contract + +After each successful QBO POST, the script writes (or updates) `dev/fin/4183_mapping_20260518.applied.json`: + +```json +{ + "payments": { + "1": { "qbo_payment_id": "...", "applied_at": "2026-05-19T..." }, + "2": { "qbo_payment_id": "...", "applied_at": "2026-05-19T..." } + }, + "deposit": { "qbo_deposit_id": "...", "applied_at": "2026-05-19T..." } +} +``` + +**On re-run:** +- Any seq already in `payments{}` → SKIP (logs "already posted as Payment X") +- Deposit already in `deposit{}` → SKIP (logs "already posted as X") +- This means re-running after a mid-run failure is safe and picks up exactly where it left off + +**Partial failure recovery (Payments succeeded, Deposit failed):** +The `.applied.json` will contain all 11 payment IDs but no deposit entry. Re-running the script will skip all payments (already in `.applied.json`) and retry only the Deposit POST. + +**Do NOT delete `.applied.json` after a partial run** — that would cause duplicate payments to be posted for the already-applied seqs. + +--- + +## First-Batch Reconciliation Expectation + +After running live against the 2026-05-18 batch: + +- **11 Payments** created in QBO, each applied to its invoice(s) via `Line[].LinkedTxn` +- **1 Deposit** created in QBO grouping all 11 payments under "Huntington 5424 - Primary" (account ID 58) +- **12 invoices** (29885800, 29885726, 29885720, 29885794, 29885798, 29885705, 29885767, 29885801, 29885805, 29885806, 29885838, 29885797) move from Open to Paid after the next incremental QBO sync +- **$18,962.12** total deposited, matching the bank slip for slip 000839 dated 2026-05-18 +- QBO bank-feed reconciliation will show a single deposit line of $18,962.12 to "Huntington 5424 - Primary" + +--- + +## Known Limitations + +- **No PDF OCR / auto-mapping** — the `mapping.json` is hand-authored per batch. Future work could extract invoice→check mapping from the remittance PDF (4183_IMAGE_20260518193550.pdf) +- **No admin UI** — one-off CLI script; not surfaced in Pulse's `/admin` or `/finance` pages +- **Pulse tables update via sync, not direct write** — after the live run, `qbo_payments` and `qbo_deposits` in Pulse Postgres only reflect the new records after running `POST /api/qbo/sync` +- **Single-slip assumption** — the current mapping schema has one `private_note_prefix` for the whole file; if a single `.FH` file contains multiple deposit slips, the mapping would need per-check prefix overrides (out of scope for v1) +- **No partial-amount application** — each check is applied to its invoices for the full mapped amounts; unapplied credit handling is not implemented + +--- + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Lazy QBO client construction for dry-run mode** +- **Found during:** Task 3 dry-run execution +- **Issue:** `const client = getQboClient()` was called unconditionally before the payment loop, causing `QboClient` constructor to throw "QBO_CLIENT_ID, QBO_CLIENT_SECRET, and QBO_REALM_ID must be set" in dry-run mode (where no QBO credentials are needed) +- **Fix:** Replaced eager instantiation with a lazy `getClient()` wrapper that calls `getQboClient()` only when the first live POST is about to execute +- **Files modified:** `scripts/apply-fh-deposit.ts` +- **Commit:** 5497458 + +## Self-Check: PASSED + +- `lib/types/qbo.ts` exports `QboPaymentCreatePayload` — FOUND +- `lib/types/qbo.ts` exports `QboDepositCreatePayload` — FOUND +- `lib/services/qbo-client.ts` has `createPayment` method — FOUND +- `lib/services/qbo-client.ts` has `createDeposit` method — FOUND +- `scripts/apply-fh-deposit.ts` exists — FOUND +- `dev/fin/4183_mapping_20260518.json` exists — FOUND +- `dev/fin/4183_mapping_20260518.applied.json` does NOT exist — CONFIRMED (dry-run left no side effects) +- TypeScript clean — CONFIRMED (npx tsc --noEmit --pretty: 0 errors in qbo files and apply-fh-deposit.ts) +- Dry-run exit code 0 — CONFIRMED +- Dry-run produced 11 payment payloads + 1 deposit payload — CONFIRMED +- All amounts reconcile to $18,962.12 — CONFIRMED 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/lib/services/qbo-client.ts b/lib/services/qbo-client.ts index 3352668..2c642a8 100644 --- a/lib/services/qbo-client.ts +++ b/lib/services/qbo-client.ts @@ -14,6 +14,8 @@ import { QboJournalEntry, QboReport, QboQueryResponse, + QboPaymentCreatePayload, + QboDepositCreatePayload, } from '@/lib/types/qbo'; const QBO_PRODUCTION_URL = 'https://quickbooks.api.intuit.com'; @@ -225,6 +227,45 @@ export class QboClient { return this.queryAll('Deposit', where); } + // ─── Entity Creators ───────────────────────────────────────────────────────── + + /** + * Create a Receive Payment in QBO and apply it to one or more invoices. + * Returns the created Payment as QBO echoes it back (includes Id, SyncToken). + */ + async createPayment(payload: QboPaymentCreatePayload): Promise { + const data = await this.request<{ Payment: QboPayment }>( + `/payment?minorversion=65`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); + if (!data?.Payment?.Id) { + throw new Error(`QBO createPayment returned no Payment: ${JSON.stringify(data).slice(0, 500)}`); + } + return data.Payment; + } + + /** + * Create a Deposit in QBO that groups one or more existing Payments into a + * single bank-deposit line (so QBO's bank-feed reconciliation matches the + * bank's actual deposit slip). + */ + async createDeposit(payload: QboDepositCreatePayload): Promise { + const data = await this.request<{ Deposit: QboDeposit }>( + `/deposit?minorversion=65`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); + if (!data?.Deposit?.Id) { + throw new Error(`QBO createDeposit returned no Deposit: ${JSON.stringify(data).slice(0, 500)}`); + } + return data.Deposit; + } + async getPurchases(updatedSince?: Date): Promise { const where = updatedSince ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` @@ -278,6 +319,26 @@ export class QboClient { }); return this.request(`/reports/CashFlow?${params.toString()}`); } + + // A/R Aging Detail — one row per open invoice, with customer + balance + days + // past due. Used to reconcile Pulse's qbo_invoices against QBO's view of A/R. + async getAgedReceivableDetail(asOfDate?: string): Promise { + const params = new URLSearchParams({ + report_date: asOfDate ?? new Date().toISOString().slice(0, 10), + aging_method: 'Report_Date', + minorversion: '65', + }); + return this.request(`/reports/AgedReceivableDetail?${params.toString()}`); + } + + async getAgedReceivableSummary(asOfDate?: string): Promise { + const params = new URLSearchParams({ + report_date: asOfDate ?? new Date().toISOString().slice(0, 10), + aging_method: 'Report_Date', + minorversion: '65', + }); + return this.request(`/reports/AgedReceivables?${params.toString()}`); + } } let _instance: QboClient | null = null; diff --git a/lib/types/qbo.ts b/lib/types/qbo.ts index 6ff611e..bd40cf0 100644 --- a/lib/types/qbo.ts +++ b/lib/types/qbo.ts @@ -87,6 +87,37 @@ export interface QboDeposit { MetaData?: QboMetaData; } +// Payload for creating a Payment via POST /v3/company/{realmId}/payment +// Only the fields we actually send — QBO accepts many more but we keep the +// surface area small and explicit. +export interface QboPaymentCreatePayload { + CustomerRef: QboRef; // { value: customer_ref_id } + TotalAmt: number; + TxnDate?: string; // YYYY-MM-DD + DepositToAccountRef?: QboRef; // { value: account id } + PaymentRefNum?: string; // check number (max 21 chars per QBO) + PrivateNote?: string; + Line?: Array<{ + Amount: number; + LinkedTxn?: QboLinkedTxn[]; // one entry per invoice being paid + }>; +} + +// Payload for creating a Deposit via POST /v3/company/{realmId}/deposit +// Each Line links to a previously-created Payment so QBO groups them as a +// single bank deposit (matches the bank slip). +export interface QboDepositCreatePayload { + TxnDate?: string; + DepositToAccountRef: QboRef; // required for deposit + PrivateNote?: string; + Line: Array<{ + Amount: number; + DetailType: 'DepositLineDetail'; + LinkedTxn?: QboLinkedTxn[]; // [{ TxnId: payment_id, TxnType: 'Payment' }] + DepositLineDetail?: Record; + }>; +} + // Purchase (expense/credit card) export interface QboPurchase { Id: string; @@ -156,6 +187,7 @@ export interface QboEntitySyncResult { entity: string; success: boolean; recordsUpserted: number; + recordsDeleted?: number; duration: number; error?: string; } diff --git a/scripts/apply-fh-deposit.ts b/scripts/apply-fh-deposit.ts new file mode 100644 index 0000000..7b21faf --- /dev/null +++ b/scripts/apply-fh-deposit.ts @@ -0,0 +1,379 @@ +#!/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 + // Defer QBO client construction until first live write — dry-run needs no QBO credentials. + let client: ReturnType | null = null; + const getClient = () => { + if (!client) client = getQboClient(); + return client; + }; + 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 getClient().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 getClient().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); +});