Custom Providers
Add a payment provider that better-payment does not support yet, with the same API as the built-in ones.
A provider is a class that extends PaymentProvider. The built-in providers are written the same way, so a custom provider works with everything else: plugins and events, the HTTP handler (at /api/pay/<id>/...), the browser client (client.use('<id>')) and request validation.
If the provider is useful to others (a Turkish bank or payment institution), consider contributing it to better-payment. See the contributing guide in the repository.
Implement the provider
Implement the six abstract methods: createPayment, initThreeDSPayment, completeThreeDSPayment, refund, cancel and getPayment. The others (authorize, capture, saveCard, binCheck, installmentInfo, …) throw NOT_SUPPORTED until you override them.
import {
betterPayment,
defineProvider,
hmac,
toHex,
PaymentProvider,
PaymentStatus,
PaymentErrorCode,
ConfigurationError,
ValidationError,
HttpError,
type PaymentProviderConfig,
type PaymentRequest,
type PaymentResponse,
type ThreeDSPaymentRequest,
type ThreeDSInitResponse,
type RefundRequest,
type RefundResponse,
type CancelRequest,
type CancelResponse,
} from 'better-payment';
export interface MyPosConfig extends PaymentProviderConfig {
terminalId: string;
secretKey: string;
}
interface MyPosResult {
approved: boolean;
transactionId?: string;
responseCode?: string;
message?: string;
}
export class MyPos extends PaymentProvider<MyPosConfig> {
private readonly http = this.createHttpClient('MyPos', { timeout: 30_000 });
// Called by the base constructor: throw on missing credentials
protected validateConfig(): void {
if (!this.config.terminalId || !this.config.secretKey) {
throw new ConfigurationError('MyPos: terminalId and secretKey are required', 'mypos');
}
}
// The provider's error codes, mapped to the normalized ones
protected errorCodeTable(): Record<string, PaymentErrorCode> {
return { '51': PaymentErrorCode.INSUFFICIENT_FUNDS, '54': PaymentErrorCode.EXPIRED_CARD };
}
async createPayment(request: PaymentRequest): Promise<PaymentResponse> {
try {
// Card, amounts and required fields: throws a ValidationError before any API call
this.validatePayment(request, { card: true, required: ['buyer.ip'] });
const body = JSON.stringify({
terminalId: this.config.terminalId,
orderId: request.conversationId,
amount: request.paidPrice,
cardNumber: this.cardOf(request).cardNumber,
});
const { data } = await this.http.post<MyPosResult>('/payments', body, {
headers: { 'x-signature': toHex(await hmac('SHA-256', this.config.secretKey, body)) },
});
return this.withErrorCode({
status: data.approved ? PaymentStatus.SUCCESS : PaymentStatus.FAILURE,
paymentId: data.transactionId,
conversationId: request.conversationId,
errorCode: data.responseCode,
errorMessage: data.message,
rawResponse: data,
});
} catch (error) {
if (error instanceof ValidationError) {
return this.withErrorCode({
status: PaymentStatus.FAILURE,
errorCode: 'VALIDATION_ERROR',
errorMessage: error.message,
});
}
if (error instanceof HttpError && error.isNetworkError) {
// No response: the payment may have gone through. Never report it as failed.
return this.withErrorCode({
status: PaymentStatus.PENDING,
errorCode: 'NETWORK_ERROR',
conversationId: request.conversationId,
});
}
throw error;
}
}
async initThreeDSPayment(_request: ThreeDSPaymentRequest): Promise<ThreeDSInitResponse> {
throw this.notSupported('3D Secure');
}
async completeThreeDSPayment(_callbackData: unknown): Promise<PaymentResponse> {
throw this.notSupported('3D Secure');
}
async refund(request: RefundRequest): Promise<RefundResponse> {
this.validateRefund(request);
const { data } = await this.http.post<MyPosResult>(
'/refunds',
JSON.stringify({ transactionId: request.paymentId, amount: request.price })
);
return this.withErrorCode({
status: data.approved ? PaymentStatus.SUCCESS : PaymentStatus.FAILURE,
refundId: data.transactionId,
errorCode: data.responseCode,
});
}
async cancel(_request: CancelRequest): Promise<CancelResponse> {
throw this.notSupported('Cancel');
}
async getPayment(paymentId: string): Promise<PaymentResponse> {
const { data } = await this.http.get<MyPosResult>(`/payments/${encodeURIComponent(paymentId)}`);
return {
status: data.approved ? PaymentStatus.SUCCESS : PaymentStatus.FAILURE,
paymentId,
rawResponse: data,
};
}
}The base class provides:
| Member | Use |
|---|---|
this.config | The config, with locale: 'tr' as default |
validateConfig() | Override it to check credentials; the constructor calls it |
createHttpClient(name, { timeout }) | A fetch-based client with the configured baseUrl, logger, fetch and retry policy. Only idempotent requests are retried |
validatePayment(request, rules), validateRefund(request) | Request validation. An invalid request throws a ValidationError: return it as a failure, which withErrorCode gives the INVALID_REQUEST code |
errorCodeTable() and withErrorCode(result) | Map the provider's error codes to the normalized codes; withErrorCode adds code to failed results |
cardOf(request) | The request's card, or a ValidationError |
notSupported(feature) | The NOT_SUPPORTED error; the handler answers it with 400 |
hmac, digest, safeEqual, toHex and toBase64 from better-payment sign requests and verify callbacks with WebCrypto, so the provider also runs on edge runtimes. Compare signatures with safeEqual, never with ===.
Report a payment as failure only when the provider rejected it. When there is no response, return pending with errorCode: 'NETWORK_ERROR': the payment may have gone through. Never retry payment, refund or cancel requests automatically. In completeThreeDSPayment, verify the callback's signature before trusting it, and return errorCode: 'INVALID_HASH' for a forged one.
Register it
Pass an instance, or better, a definition made with defineProvider(). A definition receives the shared settings (mode, logger, retry, fetch, validate) when the payment object is created, like the built-in providers:
/** `providers: { mypos: myPos({ ... }) }`: gets the mode, logger, retry and fetch settings */
export const myPos = (config: MyPosConfig) =>
defineProvider(
(ctx) =>
new MyPos({
...config,
baseUrl:
config.baseUrl ??
(ctx.mode === 'sandbox' ? 'https://test.mypos.example' : 'https://api.mypos.example'),
logger: config.logger ?? ctx.logger,
retry: config.retry ?? ctx.retry,
fetch: config.fetch ?? ctx.fetch,
validate: config.validate ?? ctx.validate,
})
);
export const payment = betterPayment({
providers: {
mypos: myPos({ terminalId: 'T1', secretKey: 'secret' }),
},
mode: 'sandbox',
});
// payment.mypos is a MyPos; routes are /api/pay/mypos/...The key in providers is the provider's id. payment.mypos is typed as MyPos, including the members you add.
Test it
Stub fetch to test the provider without its API:
import { it, expect, vi } from 'vitest';
import { betterPayment } from 'better-payment';
import { myPos } from './my-pos';
it('charges through MyPos', async () => {
const fetch = vi.fn(async () => Response.json({ approved: true, transactionId: 'T-1' }));
const payment = betterPayment({
providers: { mypos: myPos({ terminalId: 'T1', secretKey: 'secret', fetch }) },
});
const result = await payment.createPayment(order);
expect(result).toMatchObject({ status: 'success', paymentId: 'T-1' });
expect(fetch).toHaveBeenCalledWith('https://api.mypos.example/payments', expect.anything());
});