wulf-pulse/lib/services/pax8-client.ts

164 lines
5.8 KiB
TypeScript

/**
* 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<string> {
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<T>(path: string, retryCount = 0): Promise<T> {
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<Pax8PageEnvelope<Pax8Company>> {
return this.fetchJson<Pax8PageEnvelope<Pax8Company>>(`/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<T>(
fetchPage: (page: number, size: number) => Promise<Pax8PageEnvelope<T>>,
): Promise<T[]> {
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<Pax8Company[]> {
return this.paginateAll<Pax8Company>((page, size) =>
this.fetchJson<Pax8PageEnvelope<Pax8Company>>(`/companies?page=${page}&size=${size}`),
);
}
/** Read-only: page through every subscription (PAX8-08 — GET only). */
async listAllSubscriptions(): Promise<Pax8Subscription[]> {
return this.paginateAll<Pax8Subscription>((page, size) =>
this.fetchJson<Pax8PageEnvelope<Pax8Subscription>>(`/subscriptions?page=${page}&size=${size}`),
);
}
/** Read-only: page through every product (PAX8-08 — GET only). */
async listAllProducts(): Promise<Pax8Product[]> {
return this.paginateAll<Pax8Product>((page, size) =>
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}`,
),
);
}
}