/** * PAX8 REST API Client (v1) * OAuth2 client-credentials flow for partner/reseller reads. * https://devx.pax8.com */ import type { Pax8Company, Pax8PageEnvelope, Pax8Subscription, Pax8Product, Pax8Invoice, Pax8InvoiceItem, } from '@/lib/types/pax8'; export interface Pax8ClientConfig { clientId: string; clientSecret: string; } export class Pax8Client { private config: Pax8ClientConfig; private accessToken: string | null = null; private tokenExpiry: number = 0; constructor(config: Pax8ClientConfig) { this.config = config; } private async getToken(): Promise { if (this.accessToken && Date.now() < this.tokenExpiry - 60000) { return this.accessToken; } // PAX8 deviation from msgraph-client.ts — JSON body + audience field, // NOT application/x-www-form-urlencoded + URLSearchParams. const res = await fetch('https://api.pax8.com/v1/token', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: this.config.clientId, client_secret: this.config.clientSecret, audience: 'https://api.pax8.com', // partner/reseller audience — NOT api://provisioning }), }); if (!res.ok) { const text = await res.text(); throw new Error(`PAX8 token request failed: ${res.status} ${text}`); } const data = await res.json(); // WR-03: a 200 response with an unexpected body shape (proxy/CDN error // page reshaped as JSON, a future API version renaming the field, etc.) // must not silently set accessToken to undefined and mask the failure // behind a confusing 401 from the data endpoint later. if (!data.access_token || typeof data.expires_in !== 'number') { throw new Error('PAX8 token response missing access_token/expires_in'); } const token: string = data.access_token; this.accessToken = token; this.tokenExpiry = Date.now() + data.expires_in * 1000; return token; } // Phase 11/12 will extend this with 429-aware Retry-After backoff for the // account-wide 1000/min rate limit (10-RESEARCH.md Pitfall 4) — not needed // for this phase's single auth-proof call. private async fetchJson(path: string, retryCount = 0): Promise { const token = await this.getToken(); const res = await fetch(`https://api.pax8.com/v1${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }); if (res.status === 429 && retryCount < 4) { const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10)); await new Promise(r => setTimeout(r, retryAfter * 1000)); return this.fetchJson(path, retryCount + 1); } if (!res.ok) { const text = await res.text(); throw new Error(`PAX8 API error ${res.status} for ${path}: ${text}`); } return res.json(); } /** Auth-proof read: list a page of companies. */ async listCompanies(page = 0, size = 10): Promise> { return this.fetchJson>(`/companies?page=${page}&size=${size}`); } /** * Generic read-only paginate-until-exhausted helper. Loops the given * page-fetcher (each call inherits fetchJson's 429/Retry-After backoff), * requesting size=200, and stops once page.number >= page.totalPages - 1. * GET-only — no method override is ever set, satisfying PAX8-08. */ private async paginateAll( fetchPage: (page: number, size: number) => Promise>, ): Promise { const size = 200; const items: T[] = []; let page = 0; while (true) { const envelope = await fetchPage(page, size); items.push(...envelope.content); if (envelope.page.number >= envelope.page.totalPages - 1) break; page++; } return items; } /** Read-only: page through every company (PAX8-08 — GET only). */ async listAllCompanies(): Promise { return this.paginateAll((page, size) => this.fetchJson>(`/companies?page=${page}&size=${size}`), ); } /** Read-only: page through every subscription (PAX8-08 — GET only). */ async listAllSubscriptions(): Promise { return this.paginateAll((page, size) => this.fetchJson>(`/subscriptions?page=${page}&size=${size}`), ); } /** Read-only: page through every product (PAX8-08 — GET only). */ async listAllProducts(): Promise { return this.paginateAll((page, size) => 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}`, ), ); } }