docs(quick-260519-0oz): Add QBO createPayment + createDeposit + .FH reconciliation script
This commit is contained in:
parent
54974584b1
commit
1a488e5d1d
3 changed files with 1004 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue