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