164 lines
5.8 KiB
TypeScript
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}`,
|
|
),
|
|
);
|
|
}
|
|
}
|