chore: merge executor worktree (worktree-agent-a5f863a8e23f3aa61)
This commit is contained in:
commit
3e2dd6c99a
5 changed files with 354 additions and 1 deletions
|
|
@ -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<T> 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<T> 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<T>` 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, string>)['Authorization'];
|
||||
expect(authHeader).toMatch(/^Bearer /);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Pax8PageEnvelope<Pax8Product>>(`/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<Pax8Invoice[]> {
|
||||
return this.paginateAll<Pax8Invoice>((page, size) =>
|
||||
this.fetchJson<Pax8PageEnvelope<Pax8Invoice>>(`/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<Pax8InvoiceItem[]> {
|
||||
return this.paginateAll<Pax8InvoiceItem>((page, size) =>
|
||||
this.fetchJson<Pax8PageEnvelope<Pax8InvoiceItem>>(
|
||||
`/invoices/${invoiceId}/items?page=${page}&size=${size}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
scripts/verify-pax8-invoice-items.ts
Normal file
105
scripts/verify-pax8-invoice-items.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue