Tantangan Vendor Lock-In dan Mitigasi Ketergantungan Gateway

Ketergantungan penuh pada satu penyedia pembayaran (payment gateway) menciptakan titik kegagalan tunggal (Single Point of Failure/SPOF) dan eksposur risiko regulasi atau komersial, sebagaimana terlihat pada sengketa antitrust antara agregator pembayaran independen dan platform marketplace besar. Ketika penyedia tunggal mengalami gangguan layanan, tingkat konversi transaksi langsung anjlok.

Arsitektur multi-provider payment menyelesaikan tantangan ini dengan mengisolasi logika domain aplikasi dari spesifikasi API pihak ketiga. Pendekatan ini membutuhkan lapisan abstraksi terpadu, mekanisme failover adaptif, serta sistem sinkronisasi asinkron yang konsisten.

1. Desain Abstraksi: Unified Interface dan Adapter Pattern

Tiap payment gateway (misalnya Stripe, Adyen, atau gateway lokal seperti Midtrans dan Xendit) menerapkan struktur penamaan, status kode, dan format payload yang berbeda. Lapisan domain aplikasi internal tidak boleh terekspos pada perbedaan ini.

Solusinya adalah menerapkan Adapter Pattern yang menormalisasi permintaan (request) dan respons (response) ke dalam kontrak data internal (Domain Transfer Object/DTO).

// Kontrak Domain Internal
interface PaymentGatewayInterface {
    public function charge(PaymentRequest $request): PaymentResponse;
    public function parseWebhook(array $payload, array $headers): WebhookEvent;
}

readonly class PaymentRequest {
    public function __construct(
        public string $transactionId,
        public int $amountInCents,
        public string $currency,
        public string $paymentMethodToken,
        public string $idempotencyKey,
    ) {}
}

readonly class PaymentResponse {
    public function __construct(
        public bool $isSuccessful,
        public string $providerTransactionId,
        public string $status, // PENDING, SUCCESS, FAILED
        public ?string $errorCode = null,
        public ?string $rawResponse = null,
    ) {}
}

Implementasi adapter konkret menerjemahkan DTO internal menjadi pemanggilan API spesifik vendor:

class StripeAdapter implements PaymentGatewayInterface {
    public function __construct(private StripeClient $client) {}

    public function charge(PaymentRequest $request): PaymentResponse {
        try {
            $intent = $this->client->paymentIntents->create([
                'amount' => $request->amountInCents,
                'currency' => strtolower($request->currency),
                'payment_method' => $request->paymentMethodToken,
                'confirm' => true,
            ], [
                'idempotency_key' => $request->idempotencyKey,
            ]);

            return new PaymentResponse(
                isSuccessful: $intent->status === 'succeeded',
                providerTransactionId: $intent->id,
                status: $this->mapStatus($intent->status),
                rawResponse: json_encode($intent)
            );
        } catch (StripeApiErrorException $e) {
            return new PaymentResponse(
                isSuccessful: false,
                providerTransactionId: '',
                status: 'FAILED',
                errorCode: $e->getStripeCode(),
                rawResponse: $e->getMessage()
            );
        }
    }

    private function mapStatus(string $status): string {
        return match($status) {
            'succeeded' => 'SUCCESS',
            'requires_action', 'processing' => 'PENDING',
            default => 'FAILED',
        };
    }

    public function parseWebhook(array $payload, array $headers): WebhookEvent {
        // Verifikasi signature dan parsing ke WebhookEvent domain
    }
}

2. Strategi Failover: Circuit Breaker dan Idempotent Routing

Mekanisme failover otomatis tidak boleh langsung mengalihkan transaksi jika kegagalan disebabkan oleh validasi klien (misal: saldo tidak cukup atau kartu kedaluwarsa). Failover hanya dieksekusi saat terjadi galat infrastruktur (HTTP 5xx, connection timeout, atau rate limit HTTP 429).

Circuit Breaker Pattern

Circuit Breaker memantau metrik kegagalan gateway. Pola ini mencegah latensi berlebih akibat menunggu timeout pada gateway yang sedang down:

  • Closed: Permintaan dialirkan normal ke gateway utama.
  • Open: Ambang batas error tercapai (misal error rate > 25% dalam 1 menit). Permintaan langsung dialihkan ke gateway sekunder tanpa memanggil gateway utama.
  • Half-Open: Setelah periode pendinginan (misal 30 detik), sebagian kecil traffic dialirkan ke gateway utama untuk menguji pemulihan status sistem.

Idempotency Key pada Routing Ulang

Saat transaksi dialihkan ke gateway cadangan, sistem harus menghasilkan idempotency_key baru yang dipetakan secara relasional ke transaksi internal yang sama di database. Hal ini memastikan tidak terjadi penagihan ganda (double-charge) jika request pertama pada gateway utama ternyata sempat diproses sebelum koneksi terputus.

class PaymentRouter {
    public function __construct(
        private CircuitBreakerRegistry $breakerRegistry,
        private GatewayRegistry $gatewayRegistry
    ) {}

    public function execute(PaymentRequest $request): PaymentResponse {
        $providers = ['primary' => 'stripe', 'secondary' => 'adyen'];
        
        foreach ($providers as $role => $gatewayName) {
            $breaker = $this->breakerRegistry->get($gatewayName);
            
            if (!$breaker->isAvailable()) {
                continue; // Circuit terbuka, lewati ke cadangan
            }

            try {
                $gateway = $this->gatewayRegistry->get($gatewayName);
                $response = $breaker->call(fn() => $gateway->charge($request));

                if ($response->isSuccessful || $this->isTerminalUserError($response->errorCode)) {
                    return $response;
                }
                // Jika galat teknis, loop lanjut ke gateway berikutnya
            } catch (NetworkException | TimeoutException $e) {
                $breaker->recordFailure();
                // Lanjut ke fallback gateway
            }
        }

        throw new AllGatewaysUnavailableException('Seluruh payment gateway gagal dijangkau.');
    }

    private function isTerminalUserError(?string $code): bool {
        return in_array($code, ['insufficient_funds', 'card_expired', 'incorrect_cvc']);
    }
}

3. Rekonsiliasi Asinkron: Outbox Pattern dan Webhook Workers

Koneksi HTTP saat eksekusi charge sinkron dapat gagal di tengah jalan. Webhook dan pekerja background menjadi satu-satunya sumber kebenaran (source of truth) untuk mencapai eventual consistency.

Database Transactional Outbox Pattern

Status transaksi dan event pembayaran harus disimpan dalam unit transaksi database yang sama (ACID). Hal ini mencegah status transaksi tersimpan sukses di DB internal namun event audit gagal dipublikasikan ke message broker (Kafka/RabbitMQ).

BEGIN TRANSACTION;

INSERT INTO payments (id, order_id, amount, status, gateway_used, provider_ref)
VALUES ('pay_123', 'ord_999', 50000, 'PENDING', 'stripe', 'pi_stripe_abc');

INSERT INTO transactional_outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (
    'out_001', 
    'PAYMENT', 
    'pay_123', 
    'PaymentInitiated', 
    '{"order_id":"ord_999","amount":50000}', 
    NOW()
);

COMMIT;

Relay background worker kemudian membaca tabel transactional_outbox dan mempublikasikannya ke message broker dengan jaminan at-least-once delivery.

Webhook Processing Worker

Webhook dari gateway diproses secara asinkron menggunakan antrean (queue) terdistribusi dengan penanganan idempotensi:

  1. Endpoint webhook menerima payload, memvalidasi signature kriptografis, lalu memasukkan raw payload ke Redis/SQS dalam waktu < 200ms (mengembalikan HTTP 200 OK langsung ke gateway).
  2. Worker background membaca event dari queue.
  3. Worker memeriksa tabel processed_webhooks untuk mencegah eksekusi duplikat (idempotent consumer).
  4. Worker memperbarui state transaksi internal dan memicu pemenuhan order (order fulfillment).

4. Trade-Off dan Kompleksitas Sistem

Menerapkan arsitektur multi-provider payment memberikan fleksibilitas tinggi, namun disertai trade-off operasional yang signifikan:

  • State Machine Complexity: Setiap gateway memiliki tahapan lifecycle pembayaran yang berbeda. Stripe mendukung authorize terpisah dengan capture otomatis, sementara metode lokal berbasis VA atau QRIS bergantung pada status pending dengan TTL ketat. State machine internal harus merepresentasikan seluruh irisan variasi status ini tanpa kehilangan integritas data.
  • Beban Pemeliharaan Schema Data: Skema database harus cukup generik untuk menampung field spesifik masing-masing gateway (misalnya URL redirect 3DS, QR string, virtual account number) tanpa memicu sparse table anti-pattern. Penggunaan kolom JSONB untuk field metadata provider menjadi solusi kompromi.
  • Biaya Operasional dan Kontrak Finansial: Tiap vendor memerlukan deposit, alur rekonsiliasi settlement bank harian, dan fee pemrosesan yang bervariasi. Settlement period yang berbeda (T+1 vs T+3) mempersulit rekonsiliasi akuntansi otomatis.

Adopsi multi-provider tepat diterapkan ketika volume transaksi harian cukup tinggi untuk menjustifikasi biaya perawatan dependensi API ganda, atau ketika ketersediaan sistem pembayaran merupakan parameter kritikal bisnis.