Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,19 @@ export class PayBotClient {

// Don't retry on 4xx (client errors). Map to the most specific subclass
// (auth/policy) while keeping `instanceof PayBotApiError` true for callers.
// For 402 specifically, preserve the full response body in `details` so that
// `_payOnce` can extract an embedded `settlementToken` when the facilitator
// pre-authorizes a payment inside the 402 response.
if (response.status >= 400 && response.status < 500) {
const errorData = await response.json().catch(() => ({})) as Record<string, unknown>;
const details = response.status === 402
? errorData
: (errorData.details as Record<string, unknown> | undefined);
Comment on lines +228 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Preserving the entire errorData as details for HTTP 402 responses introduces inconsistency. For all other 4xx errors, error.details maps to errorData.details. If a 402 response contains its own details field, it would end up nested as error.details.details, which is confusing and inconsistent for SDK consumers.

Instead of passing the entire errorData as details, we can merge the top-level properties of errorData (like settlementToken, commission, and modifiedRequirements) into the details object, while keeping the original errorData.details structure intact.

        const details = response.status === 402
          ? {
              ...(errorData.details as Record<string, unknown> | undefined),
              settlementToken: errorData.settlementToken,
              commission: errorData.commission,
              modifiedRequirements: errorData.modifiedRequirements,
            }
          : (errorData.details as Record<string, unknown> | undefined);

throw mapHttpError(
(errorData.error as string) ?? `HTTP ${response.status}`,
(errorData.code as string) ?? 'HTTP_ERROR',
response.status,
errorData.details as Record<string, unknown> | undefined
details
);
}

Expand Down Expand Up @@ -412,19 +418,33 @@ export class PayBotClient {
);
} catch (error: unknown) {
if (error instanceof PayBotApiError) {
paySpan?.setAttribute('success', false);
return {
success: false,
grossAmount: '0',
netAmount: '0',
commissionAmount: '0',
commissionRate: 0,
error: error.message,
errorCode: error.code,
errorDetails: error.details,
};
// 402 with an embedded settlementToken means the facilitator
// pre-authorized the payment in the verify response — proceed to settle.
const embeddedToken = error.statusCode === 402
? (error.details?.settlementToken as string | undefined)
: undefined;
if (embeddedToken) {
verifyData = {
settlementToken: embeddedToken,
commission: error.details?.commission,
modifiedRequirements: error.details?.modifiedRequirements,
};
} else {
paySpan?.setAttribute('success', false);
return {
success: false,
grossAmount: '0',
netAmount: '0',
commissionAmount: '0',
commissionRate: 0,
error: error.message,
errorCode: error.code,
errorDetails: error.details,
};
}
} else {
throw error;
}
throw error;
}

// A5 HITL: when the server places the payment in the approval band,
Expand Down
63 changes: 63 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,69 @@ describe('PayBotClient', () => {
const verifyCall = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(verifyCall.requirements.amount).toBe('1');
});

it('should return failure on ETIMEDOUT (both attempts)', async () => {
mockFetch.mockRejectedValueOnce(new Error('ETIMEDOUT'));
mockFetch.mockRejectedValueOnce(new Error('ETIMEDOUT'));

const result = await client.pay({
resource: 'https://example.com',
amount: '0.05',
payTo: '0x0000000000000000000000000000000000000001',
});

expect(result.success).toBe(false);
expect(result.error).toContain('ETIMEDOUT');
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('should succeed when verify 402 includes embedded settlementToken', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
error: 'Payment required',
code: 'PAYMENT_REQUIRED',
settlementToken: 'st_from_402',
commission: { grossAmount: '51250', netAmount: '50000', commissionAmount: '1250', commissionRate: 0.025 },
}, 402)
);
mockFetch.mockResolvedValueOnce(
jsonResponse({ success: true, transaction: '0x402Tx', network: 'eip155:84532' })
);

const result = await client.pay({
resource: 'https://example.com',
amount: '0.05',
payTo: '0x0000000000000000000000000000000000000001',
});

expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][0]).toContain('/verify');
expect(mockFetch.mock.calls[1][0]).toContain('/settle');
});

it('should return failure with INSUFFICIENT_FUNDS when settle returns 402', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
valid: true,
settlementToken: 'st_abc',
commission: { grossAmount: '51250', netAmount: '50000', commissionAmount: '1250', commissionRate: 0.025 },
})
);
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: 'Insufficient balance', code: 'INSUFFICIENT_FUNDS' }, 402)
);

const result = await client.pay({
resource: 'https://example.com',
amount: '0.05',
payTo: '0x0000000000000000000000000000000000000001',
});

expect(result.success).toBe(false);
expect(result.errorCode).toBe('INSUFFICIENT_FUNDS');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});

describe('commissionSummary()', () => {
Expand Down
Loading