---
phase: 11-company-catalog-subscription-sync
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - migrations/092_pax8_subscription_costs.sql
  - lib/types/pax8.ts
  - lib/services/pax8-client.ts
  - lib/services/pax8-client.test.ts
autonomous: true
requirements: [PAX8-04, PAX8-05, PAX8-08]

must_haves:
  truths:
    - "pax8_subscriptions has price, partner_cost, and currency columns so both the customer price and the partner cost can be stored (D-03/D-04)"
    - "The PAX8 client can page through all companies, all subscriptions, and all products with read-only GET calls"
    - "No PUT/PATCH/DELETE call exists in the client; the only POST is the OAuth token exchange (PAX8-08)"
  artifacts:
    - path: "migrations/092_pax8_subscription_costs.sql"
      provides: "price + partner_cost + currency columns on pax8_subscriptions"
      contains: "ALTER TABLE pax8_subscriptions"
    - path: "lib/types/pax8.ts"
      provides: "cost fields on Pax8Subscription, vendorName on Pax8Product, Pax8SyncResult/Pax8EntitySyncResult"
      contains: "partnerCost"
    - path: "lib/services/pax8-client.ts"
      provides: "listAllCompanies / listAllSubscriptions / listAllProducts pagination helpers"
      exports: ["Pax8Client"]
    - path: "lib/services/pax8-client.test.ts"
      provides: "mocked-fetch coverage for the new pagination helpers"
  key_links:
    - from: "lib/services/pax8-client.ts"
      to: "lib/types/pax8.ts"
      via: "imports Pax8Company/Pax8Subscription/Pax8Product/Pax8PageEnvelope"
      pattern: "from '@/lib/types/pax8'"
---

<objective>
Lay down the data contract layer the Phase 11 sync service consumes: the missing
subscription cost columns (D-03/D-04), the extended TypeScript types, and the
read-only pagination helpers on the PAX8 client.

Purpose: The sync service (Plan 02) must not have to discover the schema, the
API response shapes, or invent pagination. This plan defines all three up front
(interface-first) so Plan 02 is pure orchestration against known contracts.

Output: migration 092 (applied to dev DB), extended `lib/types/pax8.ts`, new
read-only client methods with mocked-fetch 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/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-company-catalog-subscription-sync/11-CONTEXT.md
@.planning/phases/11-company-catalog-subscription-sync/11-PATTERNS.md

<interfaces>
<!-- Verified against PAX8 devx docs during Phase 11 planning (2026-07-10). -->
<!-- Subscription object fields (GET /subscriptions?page=&size=, max size 200): -->
<!--   id, parentSubscriptionId, companyId, productId, vendorSubscriptionId, -->
<!--   vendorSkuId, quantity, startDate, endDate, createdDate, updatedDate, -->
<!--   billingStart, status, price (customer price), currencyCode, -->
<!--   partnerCost (partner/reseller cost), productName, billingTerm, -->
<!--   provisioningDetails, commitmentTerm -->
<!-- Product object fields (GET /products?page=&size=, max size 200): -->
<!--   id, name, vendorName, shortDescription, sku, vendorSku, -->
<!--   altVendorSku (deprecated), requiresCommitment -->
<!--   NO `category` field; NO single-product-detail (GET /products/{id}) endpoint. -->
<!-- Company object: id, name, externalId, website, status, city, -->
<!--   stateOrProvince, postalCode, country, updatedDate -->
<!-- No endpoint (companies/subscriptions/products) supports a modified-since / -->
<!--   delta query param — full sync only for every entity type (resolves D-07). -->
<!-- All list endpoints return { content: T[], page: { size, totalElements, -->
<!--   totalPages, number } }; page.number is 0-indexed. -->
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add subscription cost columns (migration 092)</name>
  <files>migrations/092_pax8_subscription_costs.sql</files>
  <read_first>
    - migrations/091_pax8_tables.sql (existing pax8_subscriptions shape and the currency CHAR(3) DEFAULT 'USD' convention used on pax8_orders/pax8_order_items)
    - CLAUDE.md "Database" + "Migrations" sections (IF NOT EXISTS convention; migrations apply on Postgres init only — an existing volume needs a manual apply)
    - MEMORY note: "Postgres init applies migrations on first volume boot only" — dev DB needs manual apply via docker exec psql
  </read_first>
  <action>
    Create migration `092_pax8_subscription_costs.sql` that adds three columns to
    the existing `pax8_subscriptions` table using `ALTER TABLE pax8_subscriptions
    ADD COLUMN IF NOT EXISTS`:
    (1) `price NUMERIC(12,2)` — the customer/list price PAX8 returns per subscription (D-03),
    (2) `partner_cost NUMERIC(12,2)` — the partner/reseller cost PAX8 returns per subscription (D-03),
    (3) `currency CHAR(3) NOT NULL DEFAULT 'USD'` — matching the `currency` column convention already on pax8_orders/pax8_order_items in migration 091.
    Store the raw per-billing-period amounts exactly as PAX8 returns them, paired
    with the existing `billing_term` column — do NOT add any monthly-normalization
    columns (D-04 defers that to a read-time concern). Add a leading comment block
    citing that PAX8's Subscription object exposes both `price` and `partnerCost`
    (Phase 11 research, devx.pax8.com findsubscriptions). Do not alter any other
    column or table. After writing the file, apply it to the running dev database
    manually (the Postgres container will not re-run migrations on an existing
    volume): run the ALTER statements via `docker exec` against the pulse-postgres
    container per the MEMORY caveat.
  </action>
  <acceptance_criteria>
    - `migrations/092_pax8_subscription_costs.sql` exists and contains exactly three `ADD COLUMN IF NOT EXISTS` clauses for `price`, `partner_cost`, and `currency`
    - The file contains no `DROP`, no destructive statement, and touches only `pax8_subscriptions`
    - `grep -cE "ADD COLUMN IF NOT EXISTS" migrations/092_pax8_subscription_costs.sql` returns 3
    - After manual apply, `\d pax8_subscriptions` in the dev DB lists `price`, `partner_cost`, and `currency` columns
  </acceptance_criteria>
  <verify>
    <automated>test -f migrations/092_pax8_subscription_costs.sql && grep -cE "ADD COLUMN IF NOT EXISTS" migrations/092_pax8_subscription_costs.sql | grep -qx 3 && echo OK</automated>
  </verify>
  <done>Migration file created with price/partner_cost/currency columns and applied to the dev DB.</done>
</task>

<task type="auto">
  <name>Task 2: Extend PAX8 types with cost fields and sync-result shapes</name>
  <files>lib/types/pax8.ts</files>
  <read_first>
    - lib/types/pax8.ts (current Pax8Company/Pax8Subscription/Pax8Product/Pax8PageEnvelope; note the `[key: string]: unknown` escape hatch on each interface)
    - lib/types/appgate.ts (AppgateSyncResult / AppgateEntity result shape to mirror for Pax8SyncResult/Pax8EntitySyncResult)
  </read_first>
  <action>
    Extend `Pax8Subscription` with the pricing/lifecycle fields PAX8 actually
    returns: `price: number | null`, `partnerCost: number | null`,
    `currencyCode: string | null`, `productName: string | null`,
    `endDate: string | null`, `updatedDate: string | null` (keep the existing
    id/companyId/productId/quantity/billingTerm/status/startDate fields and the
    escape hatch). Extend `Pax8Product` with `vendorName: string | null` and
    `shortDescription: string | null` (keep existing fields; leave the existing
    `category` typing in place — the sync will populate it from `category ??
    vendorName`). Add two new exported interfaces mirroring the AppGate result
    shape: `Pax8EntitySyncResult` with fields `entity: string`, `success:
    boolean`, `upserted: number`, `tombstoned: number`, `durationMs: number`,
    `error?: string`; and `Pax8SyncResult` with fields `syncId: string`,
    `status: 'completed' | 'failed'`, `startedAt: Date`, `completedAt: Date |
    null`, `durationMs: number`, `entities: Pax8EntitySyncResult[]`, `errors:
    string[]`. Do not remove or rename any existing exported member.
  </action>
  <acceptance_criteria>
    - `Pax8Subscription` declares `price`, `partnerCost`, and `currencyCode` members
    - `Pax8Product` declares `vendorName`
    - `Pax8SyncResult` and `Pax8EntitySyncResult` are exported
    - `npx tsc --noEmit --pretty` passes with no new errors
    - `grep -c "partnerCost" lib/types/pax8.ts` is at least 1
  </acceptance_criteria>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -q "partnerCost" lib/types/pax8.ts && grep -q "Pax8SyncResult" lib/types/pax8.ts && echo OK</automated>
  </verify>
  <done>Types compile and expose the cost fields plus the sync-result shapes the service will consume.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Add read-only pagination helpers to the PAX8 client</name>
  <files>lib/services/pax8-client.ts, lib/services/pax8-client.test.ts</files>
  <read_first>
    - lib/services/pax8-client.ts (existing getToken, fetchJson with 429/Retry-After backoff, single-page listCompanies — do NOT change these signatures; verify-pax8-auth.ts from Phase 10 depends on listCompanies)
    - lib/services/pax8-client.test.ts (the vi.stubGlobal('fetch', ...) mocking convention keyed by URL substring, vi.unstubAllGlobals in beforeEach — follow this exact style, do not add nock/msw)
    - lib/types/pax8.ts (Pax8PageEnvelope shape: { content, page: { size, totalElements, totalPages, number } })
  </read_first>
  <behavior>
    - listAllCompanies() returns every company across all pages: given a mocked 2-page response (page 0 with content + totalPages:2, page 1 with content + totalPages:2), the returned array concatenates both pages' content in order
    - listAllSubscriptions() and listAllProducts() behave the same across pages
    - Each helper requests size=200 and stops once page.number >= page.totalPages - 1 (no infinite loop when totalPages is 1)
    - Every request the helpers issue is a GET with an `Authorization: Bearer` header (asserted via the fetch mock); no request sets method POST/PUT/PATCH/DELETE
  </behavior>
  <action>
    Add three async methods to `Pax8Client`, each looping pages via the existing
    private `fetchJson<T>()` (so 429 backoff is inherited) and requesting
    `size=200`: `listAllCompanies(): Promise<Pax8Company[]>`,
    `listAllSubscriptions(): Promise<Pax8Subscription[]>`, and
    `listAllProducts(): Promise<Pax8Product[]>`. Each fetches
    `/companies?page=N&size=200` (respectively `/subscriptions`, `/products`),
    accumulates `envelope.content`, and increments N until `envelope.page.number
    >= envelope.page.totalPages - 1`, then returns the accumulated array. Keep the
    existing single-page `listCompanies(page, size)` method unchanged. Do NOT add
    any method that issues a mutating HTTP verb — these are read-only list reads
    only (PAX8-08). Add tests in `pax8-client.test.ts` following the existing
    mocked-fetch style: assert multi-page concatenation for at least
    listAllSubscriptions, assert the request URLs carry `size=200`, and assert the
    fetch mock only ever sees GET requests (no method override) plus the bearer
    header.
  </action>
  <acceptance_criteria>
    - `Pax8Client` exposes `listAllCompanies`, `listAllSubscriptions`, `listAllProducts`
    - Existing `listCompanies(page, size)` signature is unchanged
    - No new method sets `method:` to POST/PUT/PATCH/DELETE
    - `grep -nE "method:\s*['\"](PUT|PATCH|DELETE)" lib/services/pax8-client.ts` returns nothing (exit 1)
    - `npx vitest run lib/services/pax8-client.test.ts` passes, including a multi-page concatenation test
  </acceptance_criteria>
  <verify>
    <automated>npx vitest run lib/services/pax8-client.test.ts && ! grep -nE "method:[[:space:]]*['\"](PUT|PATCH|DELETE)" lib/services/pax8-client.ts && echo OK</automated>
  </verify>
  <done>Client can enumerate all companies/subscriptions/products via read-only GET pagination, with passing mocked tests.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Pulse server → PAX8 API | Outbound OAuth2 client-credentials calls; client secret crosses here |
| Migration → Postgres | DDL applied to the pax8_subscriptions table |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-01 | Information Disclosure | pax8-client.ts token exchange | mitigate | Never log/interpolate `config.clientSecret`; existing getToken already errors with status text only, not the body echo of credentials — new pagination methods add no logging of secrets |
| T-11-02 | Elevation of Privilege | pax8-client.ts new read methods | mitigate | New methods issue GET only; grep gate asserts no PUT/PATCH/DELETE added; the only POST in the file remains the `/v1/token` auth handshake (not a data write) — upholds PAX8-08 at the client layer |
| T-11-03 | Tampering | migration 092 DDL | accept | Additive ADD COLUMN IF NOT EXISTS only, no destructive ops; idempotent on rerun; low risk |
| T-11-SC | Tampering | package installs | accept | This plan installs zero npm/pip/cargo packages (native fetch + existing pg) — package legitimacy gate not triggered |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` passes
- `npx vitest run lib/services/pax8-client.test.ts` passes
- Migration 092 present and applied to dev DB (`\d pax8_subscriptions` shows price/partner_cost/currency)
- No PUT/PATCH/DELETE method in pax8-client.ts
</verification>

<success_criteria>
pax8_subscriptions carries price/partner_cost/currency columns; lib/types/pax8.ts
exposes the cost fields and sync-result shapes; the PAX8 client can page through
all three entity types with read-only GET calls covered by mocked tests.
</success_criteria>

<output>
Create `.planning/phases/11-company-catalog-subscription-sync/11-01-SUMMARY.md` when done
</output>
