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