Better Payment
Rehberler

Özel Sağlayıcılar

better-payment'in henüz desteklemediği bir ödeme sağlayıcısını, yerleşik olanlarla aynı API ile ekleyin.

Sağlayıcı, PaymentProvider sınıfını genişleten bir sınıftır. Yerleşik sağlayıcılar da aynı şekilde yazılır; bu yüzden özel bir sağlayıcı diğer her şeyle çalışır: plugin'ler ve olaylar, HTTP handler (/api/pay/<id>/... altında), tarayıcı client'ı (client.use('<id>')) ve istek doğrulama.

Sağlayıcı başkalarının da işine yarayacaksa (bir Türk bankası ya da ödeme kuruluşu), onu better-payment'e eklemeyi düşünün. Depodaki katkı rehberine bakın.

Sağlayıcıyı yazmak

Altı soyut metodu yazın: createPayment, initThreeDSPayment, completeThreeDSPayment, refund, cancel ve getPayment. Diğerleri (authorize, capture, saveCard, binCheck, installmentInfo, …) siz yazana kadar NOT_SUPPORTED fırlatır.

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,
    };
  }
}

Temel sınıfın sağladıkları:

ÜyeKullanımı
this.configConfig; varsayılan olarak locale: 'tr' ile
validateConfig()Kimlik bilgilerini kontrol etmek için yazın; constructor onu çağırır
createHttpClient(name, { timeout })Yapılandırılmış baseUrl, logger, fetch ve yeniden deneme politikasıyla fetch tabanlı bir client. Yalnızca idempotent istekler yeniden denenir
validatePayment(request, rules), validateRefund(request)İstek doğrulama. Geçersiz bir istek ValidationError fırlatır: bunu başarısız bir sonuç olarak döndürün; withErrorCode ona INVALID_REQUEST kodunu verir
errorCodeTable() ve withErrorCode(result)Sağlayıcının hata kodlarını normalize kodlara eşler; withErrorCode başarısız sonuçlara code ekler
cardOf(request)İsteğin kartı ya da bir ValidationError
notSupported(feature)NOT_SUPPORTED hatası; handler buna 400 ile yanıt verir

better-payment'daki hmac, digest, safeEqual, toHex ve toBase64, istekleri WebCrypto ile imzalar ve callback'leri doğrular; böylece sağlayıcı edge ortamlarında da çalışır. İmzaları === ile değil, her zaman safeEqual ile karşılaştırın.

Bir ödemeyi yalnızca sağlayıcı reddettiğinde failure olarak bildirin. Yanıt yoksa errorCode: 'NETWORK_ERROR' ile pending döndürün: ödeme gerçekleşmiş olabilir. Ödeme, iade ve iptal isteklerini asla otomatik olarak yeniden denemeyin. completeThreeDSPayment içinde callback'e güvenmeden önce imzasını doğrulayın ve sahte bir callback için errorCode: 'INVALID_HASH' döndürün.

Kaydetmek

Bir örnek ya da daha iyisi defineProvider() ile yapılmış bir tanım verin. Tanım, yerleşik sağlayıcılarda olduğu gibi, ödeme nesnesi oluşturulurken ortak ayarları (mode, logger, retry, fetch, validate) alır:

/** `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/...

providers içindeki anahtar sağlayıcının kimliğidir. payment.mypos, eklediğiniz üyeler dahil MyPos tipindedir.

Test etmek

Sağlayıcıyı API'si olmadan test etmek için fetch'i taklit edin:

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

Bu sayfada