---
phase: 12-orders-invoices-company-matching
plan: 04
type: execute
wave: 3
depends_on: ["12-02", "12-03"]
files_modified:
  - lib/services/pax8-sync-service.ts
  - lib/services/pax8-sync-service.test.ts
autonomous: true
requirements: [PAX8-06, PAX8-10, PAX8-11]

must_haves:
  truths:
    - "fullSync() runs the invoice/line-item sync and the company matcher as two additional entity steps"
    - "syncOrders() upserts invoice headers to pax8_orders and line items to pax8_order_items with per-company id, billing period, and dual-cost columns populated"
    - "syncOrders() tombstones headers and items no longer returned by PAX8 using the id <> ALL($1) soft-delete pattern"
    - "syncCompanyMatches() delegates to matchPax8Companies() and reports a Pax8EntitySyncResult"
  artifacts:
    - path: "lib/services/pax8-sync-service.ts"
      provides: "syncOrders + syncCompanyMatches steps wired into fullSync"
      contains: "syncOrders"
    - path: "lib/services/pax8-sync-service.test.ts"
      provides: "upsert/tombstone + matcher-delegation unit tests"
      contains: "syncOrders"
  key_links:
    - from: "lib/services/pax8-sync-service.ts"
      to: "pax8-client listAllInvoices/listAllInvoiceItems"
      via: "nested per-invoice fetch loop"
      pattern: "listAllInvoiceItems"
    - from: "lib/services/pax8-sync-service.ts"
      to: "pax8-company-matcher matchPax8Companies"
      via: "syncCompanyMatches wrapper"
      pattern: "matchPax8Companies"
    - from: "lib/services/pax8-sync-service.ts"
      to: "fullSync entities array"
      via: "entities.push(ordersResult) and entities.push(matchResult)"
      pattern: "entities.push"
---

<objective>
Wire both halves of Phase 12 into the existing Pax8SyncService.fullSync()
orchestration: a new syncOrders() step (historical invoice headers + per-company
line items, PAX8-06) and a new syncCompanyMatches() step delegating to the Plan
03 matcher (PAX8-10, PAX8-11). Both conform to the existing Pax8EntitySyncResult
shape and slot into fullSync's entities.push accumulation unchanged.

Invoice → items is a nested per-parent fetch (12-RESEARCH.md Pattern 1): page all
94 headers once, then for each header page its ~500-700 items. Per-company cost
lives ONLY on the item (`companyId` is always NULL on the header — Pitfall 1), so
pax8_order_items.pax8_company_id is populated from the item; pax8_orders stays
header-only. Plan 02 Task 3 spot-checked the cost mapping live and resolved
RESEARCH.md Open Question 1; the DEFAULT mapping is
unit_price←price, line_total←amountDue, partner_cost←cost,
partner_cost_total←costTotal. **12-02-SUMMARY.md is authoritative and records a
CONFIRM or DIVERGENCE verdict *per item type* ('subscription', 'prorate',
'one-time', and any others found live). Where it records a DIVERGENCE for a type,
Task 1 branches the column mapping on `item.type` instead of using the default —
this is an explicit switch, not a prose reconciliation.**

Purpose: The integration point that makes the phase's data actually populate on a
sync run.
Output: syncOrders + syncCompanyMatches in pax8-sync-service.ts, wired into
fullSync, plus the service's first unit tests.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/12-orders-invoices-company-matching/12-RESEARCH.md
@.planning/phases/12-orders-invoices-company-matching/12-PATTERNS.md
@.planning/phases/12-orders-invoices-company-matching/12-02-SUMMARY.md

<interfaces>
Existing pax8-sync-service.ts:
- fullSync(triggeredBy): builds entities: Pax8EntitySyncResult[]; currently pushes
  syncCompanies(), syncSubscriptions() (returns { result, referencedProductIds }),
  syncProducts(referencedProductIds). Add ordersResult then matchResult after
  productsResult, before the rollup that computes success/status/totals.
- syncSubscriptions() (lines 175-253) is the closest upsert+tombstone analog:
  `seen: string[]`, INSERT ... ON CONFLICT (id) DO UPDATE SET ..., synced_at=NOW(),
  is_deleted=false, deleted_at=NULL; then tombstone UPDATE ... WHERE is_deleted=false
  AND id <> ALL($1::uuid[]). Per-method try/catch returning a failed
  Pax8EntitySyncResult on error.

New client methods (Plan 02): this.client.listAllInvoices(): Promise<Pax8Invoice[]>;
this.client.listAllInvoiceItems(invoiceId: string): Promise<Pax8InvoiceItem[]>.

Matcher (Plan 03): import { matchPax8Companies, Pax8CompanyMatchResult } from
'./pax8-company-matcher'. matchPax8Companies(opts?: { limit?; dryRun? }).

pax8_orders columns (migration 091): id UUID, pax8_company_id UUID (stays NULL),
order_date TIMESTAMPTZ, total NUMERIC(12,2), status TEXT, currency CHAR(3),
raw_payload JSONB, synced_at, is_deleted, deleted_at.
pax8_order_items columns (091 + 093): id UUID, order_id UUID NOT NULL (FK CASCADE),
product_id UUID, quantity INTEGER, unit_price NUMERIC(12,2), line_total
NUMERIC(12,2), currency CHAR(3), raw_payload, synced_at, is_deleted, deleted_at,
+ pax8_company_id UUID, subscription_id UUID, item_type TEXT, sku TEXT,
description TEXT, start_period TIMESTAMPTZ, end_period TIMESTAMPTZ, partner_cost
NUMERIC(12,2), partner_cost_total NUMERIC(12,2).
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add syncOrders() (nested invoice → item upsert + tombstone)</name>
  <files>lib/services/pax8-sync-service.ts</files>
  <read_first>
    - lib/services/pax8-sync-service.ts (syncSubscriptions lines 175-253 for the upsert+tombstone shape; syncCompanies lines 113-173 for the single-table baseline; the import block lines 12-21; the per-method try/catch error shape lines 168-172)
    - lib/services/itglue-sync-service.ts (the syncModels() per-parent nested-fetch iteration referenced by 12-PATTERNS.md Pattern 1 — parent loop calling a child endpoint per id)
    - .planning/phases/12-orders-invoices-company-matching/12-02-SUMMARY.md — AUTHORITATIVE for the invoice-item cost mapping. It records a CONFIRM or DIVERGENCE verdict PER item type ('subscription', 'prorate', 'one-time', and any others the live spot-check found). Read the per-type verdicts BEFORE writing the item upsert: any type marked DIVERGENCE has a corrected field mapping you MUST branch on (see the branching instruction in the action); any type marked CONFIRM uses the default mapping.
    - .planning/phases/12-orders-invoices-company-matching/12-RESEARCH.md (Pitfall 1 header companyId always NULL; Pitfall 2 field mapping; Pattern 1 nested pagination; Open Question 1 — the mapping question Plan 02 resolved)
  </read_first>
  <action>
    Extend the type import block to add Pax8Invoice, Pax8InvoiceItem from
    '@/lib/types/pax8'. Add a private async syncOrders(): Promise&lt;Pax8EntitySyncResult&gt;
    modeled on syncSubscriptions.

    Fetch headers: const invoices = await this.client.listAllInvoices(). Track
    seenOrderIds: string[] and seenItemIds: string[]; count ordersUpserted and
    itemsUpserted. For each invoice with an id: push id to seenOrderIds; upsert
    pax8_orders — INSERT (id, pax8_company_id, order_date, total, status, currency,
    raw_payload, synced_at, is_deleted, deleted_at) VALUES ($1, NULL, $2..., NOW(),
    false, NULL) ON CONFLICT (id) DO UPDATE SET the same fields + synced_at=NOW(),
    is_deleted=false, deleted_at=NULL. Map order_date←invoice.invoiceDate,
    total←invoice.total, status←invoice.status, currency←invoice.currencyCode ??
    'USD'. Keep pax8_company_id NULL (Pitfall 1 — comment why).

    Then fetch that invoice's items: const items = await
    this.client.listAllInvoiceItems(invoice.id). For each item with an id: push id
    to seenItemIds; upsert pax8_order_items — INSERT (id, order_id, product_id,
    quantity, unit_price, line_total, currency, raw_payload, synced_at, is_deleted,
    deleted_at, pax8_company_id, subscription_id, item_type, sku, description,
    start_period, end_period, partner_cost, partner_cost_total) with order_id =
    invoice.id. Non-cost columns map directly: pax8_company_id←companyId,
    subscription_id←subscriptionId, item_type←type, sku←sku,
    description←description, start_period←startPeriod, end_period←endPeriod,
    product_id←productId, quantity←quantity, currency←currencyCode ?? 'USD',
    raw_payload←JSON.stringify(item). ON CONFLICT (id) DO UPDATE SET all the above
    + synced_at=NOW(), is_deleted=false, deleted_at=NULL.

    COST-COLUMN MAPPING — branch on item.type per 12-02-SUMMARY.md:
    The four cost columns (unit_price, line_total, partner_cost,
    partner_cost_total) are the ones Plan 02 Task 3 spot-checked per item type.
    Before wiring them, read 12-02-SUMMARY.md's per-type verdict table and encode
    it EXPLICITLY — do not reconcile a prose note against a hardcoded mapping:

      1. DEFAULT mapping (applies to every item type 12-02-SUMMARY.md marks
         CONFIRM): unit_price←price, line_total←amountDue, partner_cost←cost,
         partner_cost_total←costTotal.
      2. For any item type 12-02-SUMMARY.md marks DIVERGENCE, do NOT use the
         default. Implement an explicit `switch (item.type)` (or equivalent
         if/else on item.type) that, for that specific type, selects the corrected
         source fields exactly as 12-02-SUMMARY.md specifies for it, and falls
         through to the DEFAULT for all CONFIRM types (and unknown/new types).
      3. If 12-02-SUMMARY.md marks CONFIRM for ALL types, the switch collapses to
         a single default branch — that is expected and acceptable. Still write it
         as a resolveCostColumns(item) helper returning { unitPrice, lineTotal,
         partnerCost, partnerCostTotal } so the per-type branch point is a named,
         reviewable seam rather than inline ternaries.
      Add a short comment on the helper citing 12-02-SUMMARY.md as the source of
      the per-type verdicts.

    After all invoices processed, tombstone in child-then-parent order to respect
    the FK: first UPDATE pax8_order_items SET is_deleted=true, deleted_at=NOW()
    WHERE is_deleted=false AND id <> ALL($1::uuid[]) with seenItemIds; then the
    same on pax8_orders with seenOrderIds. Guard each with the seen.length === 0 ?
    0 : (...).rowCount ?? 0 pattern from syncSubscriptions. Sum tombstoned across
    both tables.

    Return { entity: 'orders', success: true, upserted: ordersUpserted +
    itemsUpserted, tombstoned, durationMs: Date.now() - start }. Wrap in the
    standard try/catch returning a failed result with the error message
    ('[Pax8Sync] Order sync failed:').
  </action>
  <acceptance_criteria>
    - syncOrders() upserts into both `pax8_orders` and `pax8_order_items`
    - Cost columns are resolved through a `resolveCostColumns(item)` helper that branches on `item.type`; for every type 12-02-SUMMARY.md marks CONFIRM the helper uses the default mapping (unit_price←price, line_total←amountDue, partner_cost←cost, partner_cost_total←costTotal), and for any type it marks DIVERGENCE the helper uses that type's corrected mapping from 12-02-SUMMARY.md
    - The header insert leaves pax8_orders.pax8_company_id NULL (Pitfall 1)
    - Tombstone runs on pax8_order_items before pax8_orders, both using `id <> ALL($1::uuid[])`
    - Returns a Pax8EntitySyncResult with `entity: 'orders'`
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <verify>
    <automated>npx tsc --noEmit --pretty 2>&1 | tail -5; grep -c "listAllInvoiceItems\|resolveCostColumns" lib/services/pax8-sync-service.ts</automated>
  </verify>
  <done>syncOrders performs the nested invoice→item sync with the per-type cost mapping resolved through resolveCostColumns() (branching on item.type where 12-02-SUMMARY.md records a divergence), per-company id on items, and child-then-parent tombstoning; type-check passes.</done>
</task>

<task type="auto">
  <name>Task 2: Add syncCompanyMatches() and wire both steps into fullSync()</name>
  <files>lib/services/pax8-sync-service.ts</files>
  <read_first>
    - lib/services/pax8-sync-service.ts (fullSync lines 42-109 — the entities.push accumulation and rollup)
    - lib/services/pax8-company-matcher.ts (matchPax8Companies signature + Pax8CompanyMatchResult from Plan 03)
    - lib/types/pax8.ts (Pax8EntitySyncResult)
  </read_first>
  <action>
    Import { matchPax8Companies } from './pax8-company-matcher' (and the
    Pax8CompanyMatchResult type). Add a private async syncCompanyMatches():
    Promise&lt;Pax8EntitySyncResult&gt; that calls matchPax8Companies() (no dryRun),
    then maps its result into a Pax8EntitySyncResult: entity: 'company_matches',
    success: true, upserted: result.autoLinked, tombstoned: result.flaggedAmbiguous
    + result.flaggedNoCandidate (repurposed here as the flagged-for-review count —
    document this in a comment), durationMs: result.durationMs. Wrap in try/catch
    returning a failed result ('[Pax8Sync] Company match failed:').

    In fullSync(), after `entities.push(productsResult);` add: const ordersResult =
    await this.syncOrders(); entities.push(ordersResult); then const matchResult =
    await this.syncCompanyMatches(); entities.push(matchResult); Matching runs
    AFTER orders and companies so pax8_companies is fully populated first. The
    existing rollup (success = entities.every, totals = reduce) picks both up
    unchanged.
  </action>
  <acceptance_criteria>
    - fullSync() contains `entities.push(ordersResult)` and `entities.push(matchResult)` after the products step
    - syncCompanyMatches() calls `matchPax8Companies()` and returns a Pax8EntitySyncResult with `entity: 'company_matches'`
    - The matcher step runs after syncCompanies (companies populated before matching)
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <verify>
    <automated>npx tsc --noEmit --pretty 2>&1 | tail -5; grep -c "matchPax8Companies\|entities.push" lib/services/pax8-sync-service.ts</automated>
  </verify>
  <done>Both new steps are wired into fullSync and roll up into the sync result and sync_history counts.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Create lib/services/pax8-sync-service.test.ts</name>
  <files>lib/services/pax8-sync-service.test.ts</files>
  <behavior>
    - syncOrders (via fullSync or a directly-invoked instance with a mock client): a two-invoice fixture with items produces upserts into both pax8_orders and pax8_order_items; the item upsert binds companyId into the pax8_company_id parameter position
    - unseen headers/items are tombstoned via id <> ALL (the tombstone UPDATE runs)
    - the cost-column resolution honors item.type: a 'subscription'-type fixture item maps line_total←amountDue (default); if 12-02-SUMMARY.md recorded a DIVERGENCE for a type, add a fixture item of that type asserting the corrected mapping
    - syncCompanyMatches delegates to the mocked matchPax8Companies and returns entity 'company_matches'
    - fullSync's entities array includes an 'orders' and a 'company_matches' result
  </behavior>
  <read_first>
    - lib/services/pax8-client.test.ts (vi.fn / vi.mock discipline to mirror)
    - lib/services/pax8-sync-service.ts (the constructor accepts an optional client: `constructor(client?: Pax8Client)` — inject a mock client; the query shapes to assert; the resolveCostColumns helper to exercise per type)
    - .planning/phases/12-orders-invoices-company-matching/12-02-SUMMARY.md (which item types are CONFIRM vs DIVERGENCE — drive the fixture item types from this)
    - .planning/phases/12-orders-invoices-company-matching/12-RESEARCH.md (Validation Architecture — Wave 0 note that no prior Pax8SyncService test exists; mock postgresClient.query)
  </read_first>
  <action>
    Create the service's first unit test. Mock '@/lib/services/postgres-client'
    (default: { query: vi.fn() }) and mock './pax8-company-matcher' so
    matchPax8Companies returns a canned Pax8CompanyMatchResult ({ scanned: 2,
    autoLinked: 1, flaggedAmbiguous: 1, flaggedNoCandidate: 0, durationMs: 1 }).
    Construct the service with an injected mock Pax8Client exposing
    listAllCompanies/listAllSubscriptions/listAllProducts (return []) and
    listAllInvoices (two headers) + listAllInvoiceItems (items per header, one with
    a companyId + subscriptionId + amountDue/cost/costTotal). Give at least one
    fixture item type='subscription'; if 12-02-SUMMARY.md marked any type
    DIVERGENCE, also add a fixture item of that type so the per-type branch is
    exercised.

    Assert: after fullSync (or a direct syncOrders call if you expose it via the
    instance), the mocked postgresClient.query received an INSERT INTO pax8_orders
    and an INSERT INTO pax8_order_items call; for the item insert, the params array
    includes the fixture's companyId value (pax8_company_id) and its amountDue value
    (line_total) for the default-mapped ('subscription') item. If a DIVERGENCE-type
    fixture item was added, assert its line_total/partner_cost params reflect the
    corrected per-type mapping from 12-02-SUMMARY.md rather than the default. Assert
    a tombstone UPDATE against pax8_order_items and one against pax8_orders were
    issued. Assert the returned Pax8SyncResult.entities contains an object with
    entity 'orders' and one with entity 'company_matches'. Keep DB-free (all
    mocked), environment node.
  </action>
  <acceptance_criteria>
    - Test asserts INSERT calls against both `pax8_orders` and `pax8_order_items`
    - Test asserts the item insert params include the fixture companyId (pax8_company_id) and amountDue (line_total) for a CONFIRM/default-mapped item
    - If 12-02-SUMMARY.md recorded a DIVERGENCE type, a fixture item of that type asserts its corrected cost-column mapping
    - Test asserts tombstone UPDATEs against both tables
    - Test asserts fullSync entities include `entity: 'orders'` and `entity: 'company_matches'`
    - `npx vitest run lib/services/pax8-sync-service.test.ts` passes
  </acceptance_criteria>
  <verify>
    <automated>npx vitest run lib/services/pax8-sync-service.test.ts</automated>
  </verify>
  <done>The sync service's first unit tests prove the nested order upsert (with per-type cost mapping), tombstoning, and matcher delegation.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| PAX8 API → sync service | External invoice/item JSON persisted to Postgres |
| sync writes → Postgres | Upserts/tombstones on billing-history tables |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-12-01 | Tampering | syncOrders upsert SQL | mitigate | All PAX8 field values bound as `$n` params via postgresClient.query (no interpolation), matching the existing syncSubscriptions convention |
| T-12-07 | Denial of Service | nested invoice→item fetch | accept | ~94 headers × ~500-700 items (≈56k rows), well within the client's 1000/min handling and normal Postgres sizes (12-RESEARCH.md Assumption A3); full sync only, no unbounded growth path |
| T-12-08 | Repudiation | sync_history rollup | mitigate | orders + company_matches counts fold into the existing sync_history record via fullSync's rollup, preserving the audit trail of what each run changed |
| T-12-05 | Elevation of Privilege | client calls | mitigate | Only read (GET) client methods are called (listAll*); PAX8-08 read-only invariant preserved |
| T-12-SC | Tampering | package installs | accept | No new packages |
</threat_model>

<verification>
- `npx vitest run lib/services/pax8-sync-service.test.ts` and `npx vitest run lib/services/pax8-client.test.ts lib/services/pax8-company-matcher.test.ts` green.
- `npx tsc --noEmit --pretty` passes.
- `npm test` (full suite) green before the wave merges.
</verification>

<success_criteria>
fullSync runs orders sync + company matching as first-class entity steps; invoice items land with per-company id, billing period, and dual-cost columns (cost columns resolved per item.type per 12-02-SUMMARY.md); unseen rows tombstone; matcher delegation reports through the sync result. Unit tests prove it.
</success_criteria>

<output>
Create `.planning/phases/12-orders-invoices-company-matching/12-04-SUMMARY.md` when done.
</output>
