From 1a81f942f39986589028a5d069fffac3579e13de Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 14 Sep 2026 14:07:51 +0300 Subject: [PATCH] Handle prepaid payment errors with the reviewed Platform SDK --- README.md | 19 +++++++++++++ src/errors/present.ts | 16 +++++++++++ test/commands/api.test.ts | 58 +++++++++++++++++++++++++++++++++++++++ test/sdk/client.test.ts | 1 + 4 files changed, 94 insertions(+) diff --git a/README.md b/README.md index 45e8a8fe..18ff1cb3 100644 --- a/README.md +++ b/README.md @@ -686,3 +686,22 @@ The file contains `messages`, with up to 100 entries specifying `id`, `senderPhone`, `text`, `timestamp` (Unix seconds), and `fromMe`. Senders must be existing simulated numbers in the selected project. This command does not enable hosted message storage. + +## Prepaid billing responses + +`polymorfa api /platform/billing --surface platform --json` preserves fractional +credit quantities. Fields ending in `Cents` in the prepaid balance and usage +responses are credits, not cash minor units. + +A request denied for insufficient funding returns `payment_required`, preserves +the API error code and request ID, and exits with code 4. This is not a retryable +server error. Add credit or resolve the billing restriction in the Console before +retrying. HTTP 409 from an expired tier quote also requires reviewing a new quote; +the CLI never creates or confirms a replacement purchase automatically. + +For a tier change, use the raw API command to create a quote with +`POST /platform/sessions/{sessionId}/tier-quotes`, review the returned charge, +then explicitly submit its `quoteId` with `PATCH /platform/sessions/{sessionId}`. +Read `GET /platform/sessions/{sessionId}/tier-quotes/{quoteId}` until the result is +applied or rejected. Store the full JSON request in a file and pass `--input` when +it contains numeric values or `null`; `--field` creates string values. diff --git a/src/errors/present.ts b/src/errors/present.ts index 8291a0e9..66a0da09 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -7,6 +7,7 @@ import { PolymorfaConnectionError, PolymorfaError, PolymorfaNotFoundError, + PolymorfaPaymentRequiredError, PolymorfaRateLimitError, PolymorfaServerError, PolymorfaTimeoutError, @@ -22,6 +23,7 @@ export type ErrorCategory = | 'internal' | 'network' | 'not_found' + | 'payment_required' | 'rate_limit' | 'server' | 'timeout' @@ -32,6 +34,8 @@ export interface PresentedError { readonly exitCode: number readonly message: string readonly requestId?: string + readonly code?: string + readonly retryable?: boolean } export function presentError( @@ -45,6 +49,14 @@ export function presentError( return { ...classified, + ...(error instanceof PolymorfaPaymentRequiredError + ? { + retryable: false, + ...(error.code === undefined + ? {} + : {code: redact(error.code, secrets)}), + } + : {}), message, ...(requestId === undefined ? {} : {requestId}), } @@ -53,6 +65,10 @@ export function presentError( function classifyError( error: unknown, ): Pick { + if (error instanceof PolymorfaPaymentRequiredError) { + return {category: 'payment_required', exitCode: 4} + } + if (error instanceof PolymorfaConfigurationError) { return {category: 'configuration', exitCode: 2} } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 2b6c0601..602e06a0 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -14,6 +14,64 @@ describe('api', () => { let server: TestHttpServer | undefined const tempDirectories: string[] = [] + test('preserves fractional prepaid balances in JSON output', async () => { + server = await startHttpServer({ + body: {data: {balanceCents: 17.500001, preferredCurrency: 'USD'}}, + }) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand('api /platform/billing --surface platform --json'), + ) + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: {data: {balanceCents: 17.500001}}, + }) + expect(server.requests).toHaveLength(1) + }) + + test('reports insufficient credit as payment required without retrying the operation', async () => { + server = await startHttpServer({ + status: 402, + headers: {'request-id': 'req_billing'}, + body: { + error: { + type: 'permission_error', + code: 'entitlement_required', + message: + 'The account needs the required entitlement for this action.', + param: null, + }, + data: null, + docs: 'https://docs.polymorfa.com/api/errors#entitlement-required', + }, + }) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => + runCommand( + 'api /platform/sessions/number/start --surface platform --method POST --json', + ), + ) + expect(exitCode(result.error)).toBe(4) + expect(JSON.parse(result.stderr)).toMatchObject({ + category: 'payment_required', + code: 'entitlement_required', + retryable: false, + requestId: 'req_billing', + }) + expect(server.requests).toHaveLength(1) + }) + afterEach(async () => { await server?.close() server = undefined diff --git a/test/sdk/client.test.ts b/test/sdk/client.test.ts index df245913..29ab2877 100644 --- a/test/sdk/client.test.ts +++ b/test/sdk/client.test.ts @@ -83,6 +83,7 @@ describe('createRawClient', () => { expect(client.sessions).toHaveProperty('retrieve') expect(client.sessions).toHaveProperty('update') expect(client.sessions).toHaveProperty('quoteTierChange') + expect(client.sessions).toHaveProperty('retrieveTierChange') expect(client.organizations).not.toHaveProperty('update') expect(() =>