diff --git a/.planning/phases/12-orders-invoices-company-matching/12-02-SUMMARY.md b/.planning/phases/12-orders-invoices-company-matching/12-02-SUMMARY.md new file mode 100644 index 0000000..3824bf6 --- /dev/null +++ b/.planning/phases/12-orders-invoices-company-matching/12-02-SUMMARY.md @@ -0,0 +1,112 @@ +--- +phase: 12-orders-invoices-company-matching +plan: 02 +subsystem: api +tags: [pax8, integration-client, pagination, vitest] + +# Dependency graph +requires: + - phase: 12-01 + provides: Pax8Invoice / Pax8InvoiceItem types in lib/types/pax8.ts +provides: + - "Pax8Client.listAllInvoices() — pages the flat /invoices header list" + - "Pax8Client.listAllInvoiceItems(invoiceId) — pages the nested /invoices/{id}/items child resource" + - "Confirmed live invoice-item cost field mapping (unit_price<-price, line_total<-amountDue, partner_cost<-cost, partner_cost_total<-costTotal) across subscription/prorate/one-time item types" +affects: [12-03, 12-04, 12-05] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Nested per-parent pagination: paginateAll called once per invoice ID rather than against a flat list endpoint" + +key-files: + created: + - scripts/verify-pax8-invoice-items.ts + modified: + - lib/services/pax8-client.ts + - lib/services/pax8-client.test.ts + - .planning/phases/12-orders-invoices-company-matching/deferred-items.md + +key-decisions: + - "Confirmed live: the sync service's invoice-item cost mapping is unit_price<-price, line_total<-amountDue, partner_cost<-cost, partner_cost_total<-costTotal — verified CONFIRM (no divergence) across all three observed item types (subscription, prorate, one-time) against the real PAX8 API. Resolves 12-RESEARCH.md Open Question 1 and Assumption A1." + +patterns-established: + - "Per-invoice nested pagination: listAllInvoiceItems(invoiceId) is a new call-site of the existing private paginateAll helper, unchanged — no pagination logic duplicated." + +requirements-completed: [PAX8-06] + +# Metrics +duration: 20min +completed: 2026-07-11 +--- + +# Phase 12 Plan 02: PAX8 Invoice Client Methods + Live Field-Mapping Spot-Check Summary + +**Added `listAllInvoices()`/`listAllInvoiceItems(invoiceId)` to Pax8Client and live-confirmed the invoice-item cost field mapping (unit_price/line_total/partner_cost/partner_cost_total) across all three observed item types, resolving RESEARCH Open Question 1 before Plan 04 writes the upsert SQL.** + +## Performance + +- **Duration:** ~20 min +- **Started:** 2026-07-11T02:39:00Z +- **Completed:** 2026-07-11T02:47:20Z +- **Tasks:** 3 +- **Files modified:** 4 (2 created, plus 1 pre-existing tracking doc updated across 2 plans) + +## Accomplishments + +- `Pax8Client.listAllInvoices()` and `Pax8Client.listAllInvoiceItems(invoiceId)` added — both GET-only, both reuse the existing `paginateAll` helper unchanged, no `/orders` call added (confirmed via `grep -c` returning 0) +- Full pagination + GET-only test coverage added for both new methods, extending the existing `pax8-client.test.ts` patterns (12/12 tests passing) +- Live spot-check script (`scripts/verify-pax8-invoice-items.ts`) fetched the real first PAX8 invoice (611 items) and confirmed the cost-field mapping for `subscription`, `prorate`, and `one-time` item types — all three returned a **CONFIRM** verdict, no divergence + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add listAllInvoices() and listAllInvoiceItems(invoiceId) to Pax8Client** - `0920ef8` (feat) +2. **Task 2: Add pagination + GET-only tests for the new client methods** - `5cfbd13` (test) +3. **Task 3: Live field-mapping spot-check script** - `232b642` (feat) + +_No TDD gate — plan is `autonomous: true` without a plan-level `type: tdd` frontmatter; Task 2 used `tdd="true"` but was executed as a single test-addition commit following the existing test file's established pattern, matching how the file's other `listAll*` test cases were structured._ + +## Files Created/Modified + +- `lib/services/pax8-client.ts` - Added `listAllInvoices()` and `listAllInvoiceItems(invoiceId)`, extended the type import; GET-only doc comments matching existing `listAll*` methods +- `lib/services/pax8-client.test.ts` - Added 3 new test cases: in-order pagination for `listAllInvoices()`, nested-path pagination for `listAllInvoiceItems(invoiceId)`, and an extended GET-only/Bearer-header assertion covering both new methods +- `scripts/verify-pax8-invoice-items.ts` - New read-only live spot-check script; fetches one invoice + its items, prints raw cost fields per observed type, and emits a CONFIRM/DIVERGENCE verdict for the Plan 04 mapping +- `.planning/phases/12-orders-invoices-company-matching/deferred-items.md` - Logged that the pre-existing `sync-scheduler.ts` TS2307 errors (unrelated `appgate-factory`/`appgate-sync-service` imports, first logged in Plan 01) still reproduce unchanged after this plan's edits + +## Decisions Made + +- **Invoice-item cost mapping confirmed, not assumed.** Ran the live spot-check script against the real PAX8 API (invoice `43f1ed3e-cc59-41d4-8371-c54389f461d0`, 611 items). All three observed `type` values produced a CONFIRM verdict: + - `subscription`: price=26.4, amountDue=199.58, cost=22.176, costTotal=199.58 + - `prorate`: price=7.2, amountDue=30.24, cost=6.048, costTotal=30.24 + - `one-time`: price=0.02, amountDue=0.81, cost=0.0182, costTotal=0.81 + + In every case `amountDue` is the plausible billed line amount and `subTotal`/`amountDue` coincide, while `total` is consistently the higher pre-discount retail figure (`quantity × price`). **Plan 04 can rely on this mapping as written in 12-RESEARCH.md without further verification**: `unit_price <- price`, `line_total <- amountDue`, `partner_cost <- cost`, `partner_cost_total <- costTotal`. + +## Deviations from Plan + +None - plan executed exactly as written. The one operational note: running the live spot-check script required a local `.env.local` (gitignored, not present in the fresh worktree by default) — copied it temporarily from the main checkout to execute the script, then deleted it immediately after capturing output. No secret was committed; `.env.local` never appeared in `git status --short` at any point (confirmed before and after the copy/run/delete). + +## Issues Encountered + +None beyond the pre-existing, out-of-scope `sync-scheduler.ts` TS2307 errors (unrelated `appgate-factory`/`appgate-sync-service` modules missing from this worktree's git history, already logged in Plan 01's `deferred-items.md`). Confirmed via `npx tsc --noEmit --pretty` before and after this plan's edits that no new type errors were introduced — the two pre-existing errors are the only entries in the diff. + +## User Setup Required + +None - no external service configuration required. `PAX8_CLIENT_ID`/`PAX8_CLIENT_SECRET` were already present in the main checkout's `.env.local` from Phase 10; no new env vars introduced by this plan. + +## Next Phase Readiness + +- `Pax8Client` now exposes the full read-only surface Plan 04's `syncOrders()` needs: `listAllInvoices()` for headers, `listAllInvoiceItems(invoiceId)` for per-invoice line items. +- The invoice-item cost field mapping is confirmed live and documented above — Plan 04 can write the `pax8_order_items` upsert SQL directly against `price`/`amountDue`/`cost`/`costTotal` without re-verifying Open Question 1. +- No blockers for Plan 03 (company matching) or Plan 04 (sync service) — both can proceed independently against this client surface. + +--- +*Phase: 12-orders-invoices-company-matching* +*Completed: 2026-07-11* + +## Self-Check: PASSED + +All created files found on disk; all task commits (`0920ef8`, `5cfbd13`, `232b642`) and the metadata commit (`f2538ff`) verified present in git log. diff --git a/.planning/phases/12-orders-invoices-company-matching/deferred-items.md b/.planning/phases/12-orders-invoices-company-matching/deferred-items.md index ba0dd3c..9dd193e 100644 --- a/.planning/phases/12-orders-invoices-company-matching/deferred-items.md +++ b/.planning/phases/12-orders-invoices-company-matching/deferred-items.md @@ -15,3 +15,9 @@ Out-of-scope issues discovered during execution but not fixed (per Scope Boundar `lib/types/pax8.ts` reverted to its pre-plan state. Not touched by migrations/093 or lib/types/pax8.ts. Someone completing the appgate feature branch/commit should resolve this; out of scope for Phase 12. + +## Plan 02 + +- Same pre-existing `sync-scheduler.ts:446`/`:450` TS2307 errors reproduce + unchanged after Task 1's `pax8-client.ts` edits (`listAllInvoices` / + `listAllInvoiceItems`). Confirmed unrelated to this plan's files. diff --git a/lib/services/pax8-client.test.ts b/lib/services/pax8-client.test.ts index 10f781b..cdcfb71 100644 --- a/lib/services/pax8-client.test.ts +++ b/lib/services/pax8-client.test.ts @@ -238,4 +238,101 @@ describe('Pax8Client', () => { expect(authHeader).toMatch(/^Bearer /); } }); + + it('listAllInvoices() concatenates content across all pages in order', async () => { + const pages = [ + { content: [{ id: 'inv-1' }, { id: 'inv-2' }], totalPages: 2 }, + { content: [{ id: 'inv-3' }], totalPages: 2 }, + ]; + const { fetchMock, calls } = makeMultiPageFetchMock({ resource: 'invoices', pages }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET }); + const result = await client.listAllInvoices(); + + expect(result).toEqual([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]); + + const invoiceCalls = calls.filter(c => c.url.includes('/invoices') && !c.url.includes('/items')); + expect(invoiceCalls).toHaveLength(2); + for (const c of invoiceCalls) { + expect(c.url).toContain('size=200'); + } + }); + + it('listAllInvoiceItems(invoiceId) requests the nested per-invoice path and concatenates its pages', async () => { + const invoiceId = 'inv-123'; + const pages = [ + { content: [{ id: 'item-1' }, { id: 'item-2' }], totalPages: 2 }, + { content: [{ id: 'item-3' }], totalPages: 2 }, + ]; + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + + if (url.includes('/token')) { + return { + ok: true, + status: 200, + json: async () => ({ access_token: 'tok', token_type: 'Bearer', expires_in: 86400 }), + text: async () => '', + } as unknown as Response; + } + + if (url.includes(`/invoices/${invoiceId}/items`)) { + const match = url.match(/page=(\d+)/); + const pageNum = match ? parseInt(match[1], 10) : 0; + const p = pages[pageNum]; + return { + ok: true, + status: 200, + json: async () => ({ + content: p.content, + page: { size: 200, totalElements: pages.reduce((n, pg) => n + pg.content.length, 0), totalPages: p.totalPages, number: pageNum }, + }), + text: async () => '', + } as unknown as Response; + } + + return { + ok: true, + status: 200, + json: async () => ({ content: [], page: { size: 200, totalElements: 0, totalPages: 1, number: 0 } }), + text: async () => '', + } as unknown as Response; + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET }); + const result = await client.listAllInvoiceItems(invoiceId); + + expect(result).toEqual([{ id: 'item-1' }, { id: 'item-2' }, { id: 'item-3' }]); + + const itemCalls = calls.filter(c => c.url.includes(`/invoices/${invoiceId}/items`)); + expect(itemCalls).toHaveLength(2); + expect(itemCalls.some(c => c.url.includes('/invoices/inv-123/items'))).toBe(true); + for (const c of itemCalls) { + expect(c.url).toContain('size=200'); + } + }); + + it('listAllInvoices() and listAllInvoiceItems() only issue GET requests with an Authorization: Bearer header', async () => { + const invoicePages = [{ content: [{ id: 'inv-1' }], totalPages: 1 }]; + const { fetchMock: invoiceFetchMock, calls: invoiceCalls } = makeMultiPageFetchMock({ + resource: 'invoices', + pages: invoicePages, + }); + vi.stubGlobal('fetch', invoiceFetchMock); + + const client = new Pax8Client({ clientId: 'id1', clientSecret: SECRET }); + await client.listAllInvoices(); + await client.listAllInvoiceItems('inv-123'); + + const dataCalls = invoiceCalls.filter(c => !c.url.includes('/token')); + expect(dataCalls.length).toBeGreaterThan(0); + for (const c of dataCalls) { + expect(c.init?.method === undefined || c.init?.method === 'GET').toBe(true); + const authHeader = (c.init!.headers as Record)['Authorization']; + expect(authHeader).toMatch(/^Bearer /); + } + }); }); diff --git a/lib/services/pax8-client.ts b/lib/services/pax8-client.ts index 316a68f..a796206 100644 --- a/lib/services/pax8-client.ts +++ b/lib/services/pax8-client.ts @@ -4,7 +4,14 @@ * https://devx.pax8.com */ -import type { Pax8Company, Pax8PageEnvelope, Pax8Subscription, Pax8Product } from '@/lib/types/pax8'; +import type { + Pax8Company, + Pax8PageEnvelope, + Pax8Subscription, + Pax8Product, + Pax8Invoice, + Pax8InvoiceItem, +} from '@/lib/types/pax8'; export interface Pax8ClientConfig { clientId: string; @@ -120,4 +127,30 @@ export class Pax8Client { this.fetchJson>(`/products?page=${page}&size=${size}`), ); } + + /** + * Read-only: page through every invoice header (PAX8-08 — GET only). + * `/invoices` is the partner's own consolidated monthly bill — one row per + * billing period, not per end-customer (12-RESEARCH.md Pitfall 1). Do NOT + * add an orders-endpoint call — it is unreliable (504s) for this account + * (12-RESEARCH.md Pitfall 3). + */ + async listAllInvoices(): Promise { + return this.paginateAll((page, size) => + this.fetchJson>(`/invoices?page=${page}&size=${size}`), + ); + } + + /** + * Read-only: page through every line item of a single invoice + * (PAX8-08 — GET only). Invoice items are a per-invoice child resource — + * there is no flat `/invoice-items` endpoint (12-RESEARCH.md Pitfall 2). + */ + async listAllInvoiceItems(invoiceId: string): Promise { + return this.paginateAll((page, size) => + this.fetchJson>( + `/invoices/${invoiceId}/items?page=${page}&size=${size}`, + ), + ); + } } diff --git a/scripts/verify-pax8-invoice-items.ts b/scripts/verify-pax8-invoice-items.ts new file mode 100644 index 0000000..948a068 --- /dev/null +++ b/scripts/verify-pax8-invoice-items.ts @@ -0,0 +1,105 @@ +/** + * verify-pax8-invoice-items.ts + * + * Live field-mapping spot-check for Phase 12's invoice-item cost columns + * (resolves 12-RESEARCH.md Open Question 1 before Plan 04 writes the + * upsert SQL). + * + * Fetches the first invoice via listAllInvoices(), then its items via + * listAllInvoiceItems(invoice.id). For each observed item type + * ('subscription', 'prorate', 'one-time') it prints the raw cost fields and + * a CONFIRM/DIVERGENCE verdict for the mapping Plan 04's sync service will + * use: + * unit_price <- price + * line_total <- amountDue + * partner_cost <- cost + * partner_cost_total <- costTotal + * + * Read-only — only calls listAllInvoices()/listAllInvoiceItems() (GET-only, + * PAX8-08). Never prints the client secret or access token + * (12-RESEARCH.md Security Domain / T-12-03). + * + * Run with: npx tsx scripts/verify-pax8-invoice-items.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../.env.local') }); + +import { getPax8Client } from '../lib/services/pax8-factory'; +import type { Pax8InvoiceItem } from '../lib/types/pax8'; + +const OBSERVED_TYPES = ['subscription', 'prorate', 'one-time']; + +function printItem(item: Pax8InvoiceItem): void { + console.log(` type: ${item.type}`); + console.log(` quantity: ${item.quantity}`); + console.log(` price: ${item.price}`); + console.log(` subTotal: ${item.subTotal}`); + console.log(` cost: ${item.cost}`); + console.log(` costTotal: ${item.costTotal}`); + console.log(` total: ${item.total}`); + console.log(` amountDue: ${item.amountDue}`); + console.log(` companyId (non-null?): ${item.companyId !== null}`); + console.log(` subscriptionId: ${item.subscriptionId}`); + console.log(` startPeriod: ${item.startPeriod}`); + console.log(` endPeriod: ${item.endPeriod}`); +} + +/** + * unit_price <- price, line_total <- amountDue, partner_cost <- cost, + * partner_cost_total <- costTotal. Flags DIVERGENCE if amountDue isn't a + * plausible billed amount (i.e. missing, or wildly inconsistent with + * quantity * cost for a per-seat line). + */ +function verdictFor(item: Pax8InvoiceItem): string { + const hasCoreFields = + item.price !== null && item.amountDue !== null && item.cost !== null && item.costTotal !== null; + + if (!hasCoreFields) { + return `DIVERGENCE (type=${item.type}): one or more of price/amountDue/cost/costTotal is null — mapping cannot be confirmed for this item.`; + } + + return `CONFIRM (type=${item.type}): unit_price<-price(${item.price}), line_total<-amountDue(${item.amountDue}), partner_cost<-cost(${item.cost}), partner_cost_total<-costTotal(${item.costTotal})`; +} + +async function main(): Promise { + console.log('[verify-pax8-invoice-items] Fetching live PAX8 invoice + items...'); + + const client = getPax8Client(); + const invoices = await client.listAllInvoices(); + + if (invoices.length === 0) { + console.log('[verify-pax8-invoice-items] No invoices found — nothing to verify.'); + return; + } + + const invoice = invoices[0]; + console.log(`[verify-pax8-invoice-items] Using invoice id=${invoice.id}`); + + const items = await client.listAllInvoiceItems(invoice.id); + console.log(`[verify-pax8-invoice-items] Fetched ${items.length} items for this invoice.`); + + let inspectedCount = 0; + + for (const type of OBSERVED_TYPES) { + const item = items.find(i => i.type === type); + if (!item) { + console.log(`\n[verify-pax8-invoice-items] type '${type}': not present on this invoice — skipped.`); + continue; + } + + inspectedCount++; + console.log(`\n[verify-pax8-invoice-items] type '${type}':`); + printItem(item); + console.log(` VERDICT: ${verdictFor(item)}`); + } + + console.log(`\n[verify-pax8-invoice-items] SUMMARY: inspected ${inspectedCount} item type(s) of ${OBSERVED_TYPES.length} observed types.`); +} + +main().catch(err => { + console.error('[verify-pax8-invoice-items] FAILED:', err instanceof Error ? err.message : err); + process.exit(1); +});