docs(quick-260519-0oz): Add QBO createPayment + createDeposit + .FH reconciliation script

This commit is contained in:
lorentz 2026-05-19 00:42:32 -04:00
parent 54974584b1
commit 1a488e5d1d
3 changed files with 1004 additions and 1 deletions

View file

@ -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

View file

@ -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 <fh-file> <mapping-json> [--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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@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
<interfaces>
<!-- Key existing exports the executor will build against. -->
From lib/services/qbo-client.ts (existing):
```typescript
export class QboClient {
// private request<T>(path: string, options: RequestInit = {}): Promise<T>
// ^ 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<QboInvoice[]>;
async getPayments(updatedSince?: Date): Promise<QboPayment[]>;
async getDeposits(updatedSince?: Date): Promise<QboDeposit[]>;
async loadToken(): Promise<QboTokenRecord | null>;
}
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<T>(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": "<customer_ref_id from qbo_invoices>" },
"TotalAmt": 539.40,
"TxnDate": "2026-05-18",
"DepositToAccountRef": { "value": "<account id resolved from qbo_deposits>" },
"PaymentRefNum": "0000997294",
"PrivateNote": "FH batch 4183 / slip 000839 / seq 0001",
"Line": [
{ "Amount": 539.40, "LinkedTxn": [{ "TxnId": "<qbo_invoices.id>", "TxnType": "Invoice" }] }
]
}
```
QBO Deposit payload shape (endpoint `POST /v3/company/{realmId}/deposit?minorversion=65`):
```json
{
"TxnDate": "2026-05-18",
"DepositToAccountRef": { "value": "<account id>" },
"PrivateNote": "FH batch 4183 / slip 000839 / 2026-05-18",
"Line": [
{
"Amount": 539.40,
"DetailType": "DepositLineDetail",
"LinkedTxn": [{ "TxnId": "<payment id from createPayment>", "TxnType": "Payment" }],
"DepositLineDetail": {}
}
]
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add payload types + createPayment/createDeposit to QboClient</name>
<files>lib/types/qbo.ts, lib/services/qbo-client.ts</files>
<action>
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<string, unknown>;
}>;
}
```
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<QboPayment> {
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<QboDeposit> {
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<T>()` (already has Authorization + Content-Type), throw on empty response.
DO NOT modify the existing `private async request<T>()` 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).
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -E "qbo-client|qbo\.ts" || echo "TS clean for qbo files"</automated>
</verify>
<done>
- `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
</done>
</task>
<task type="auto">
<name>Task 2: Build apply-fh-deposit.ts CLI + write the 5/18 mapping JSON</name>
<files>scripts/apply-fh-deposit.ts, dev/fin/4183_mapping_20260518.json</files>
<action>
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 <fh-file> <mapping-json> [--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 `<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];
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);
});
```
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 `<mapping>.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).
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -E "apply-fh-deposit" || echo "TS clean for apply-fh-deposit.ts"</automated>
</verify>
<done>
- `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 `<mapping>.applied.json` after each successful post (not in dry-run)
</done>
</task>
<task type="auto">
<name>Task 3: Dry-run against 5/18 batch and capture expected output</name>
<files>.planning/quick/260519-0oz-add-qbo-createpayment-createdeposit-fh-r/260519-0oz-SUMMARY.md</files>
<action>
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)
</action>
<verify>
<automated>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"</automated>
</verify>
<done>
- 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
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- `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)
</success_criteria>
<output>
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)
</output>

View file

@ -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<QboPayment>` — POSTs to `/payment?minorversion=65`, throws if no `Id` returned
- `async createDeposit(payload: QboDepositCreatePayload): Promise<QboDeposit>` — POSTs to `/deposit?minorversion=65`, throws if no `Id` returned
- Both use the existing private `this.request<T>()` 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 <fh-file> <mapping.json> [--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 `<mapping>.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