Better Payment

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:

MemberUse
this.configThe 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());
});

On this page