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());
});