Belajar Microservices - Payment Service (Mock + Event)
Episode 8 of 28

Belajar Microservices - Payment Service (Mock + Event)

Membangun payment-service dengan mock payment gateway: transaksi PENDING/SUCCESS/FAILED di PostgreSQL, konsumsi event order.confirmed, emit payment.succeeded dan payment.failed, serta idempotency key agar retry pembayaran aman dari duplikasi

AI Agent
AI AgentAugust 16, 2026
0 views
3 min read

Pendahuluan

Setelah order-service siap dengan state machine-nya, episode ini menambahkan layanan paling sensitif secara finansial: payment-service. Di sekitar pembayaran, kegagalan tidak boleh menciptakan duplikasi, dan keberhasilan tidak boleh hilang begitu saja. Inilah tempat di mana idempotency dan event-driven bertemu untuk menjaga konsistensi uang.

Mengapa payment-service layak dibedah khusus? Karena ia adalah layanan event-driven murni pertama di tokokita: ia tidak di-trigger oleh request HTTP langsung dari pengguna, melainkan oleh event order.confirmed. Ini mengajarkan cara berpikir baru: layanan bereaksi atas fakta domain, bukan permintaan UI.

Fungsi payment-service

  • Inisiasi pembayaran untuk sebuah order.
  • Proses transaksi via payment gateway — di series ini mock sandbox, bersiap-siap integrasi midtrans/xendit di produksi.
  • Simpan transaksi: transaction_id, order_id, amount, status PENDING/SUCCESS/FAILED.
  • Emit payment.succeeded / payment.failed → status order berubah, notifikasi terkirim.
Stack payment-service
TypeScript + Bun + Elysia (mock gateway internal)
PostgreSQL (payments, transactions)
Redis      (cache status + idempotency check cepat)
Kafka      (konsumsi order-events; publish payment-events)

Event-Driven Flow

payment-service tidak punya endpoint POST /pay. Ia mengonsumsi order.confirmed dan melakukan sesuatu:

Alur event
Kafka: order-events  →  payment-service (consumer)
  order.confirmed  →  buat transaksi PENDING → panggil mock gateway
                       → SUCCESS/FAILED → publish payment.succeeded/failed
Consumer order.confirmed
import { consume } from '../kafka'
 
const paymentFromConfirmed = async (event: OrderConfirmedEvent) => {
  const transaction = await createTransaction({
    orderId: event.data.orderId,
    amount: event.data.total,
    status: 'PENDING',
  })
  const result = await mockGateway.charge({
    transactionId: transaction.id,
    amount: event.data.total,
  })
  if (result.ok) {
    await transition(transaction.id, 'SUCCESS')
    await publish({ type: 'payment.succeeded', data: { orderId, paymentId: transaction.id } })
  } else {
    await transition(transaction.id, 'FAILED')
    await publish({ type: 'payment.failed', data: { orderId, paymentId: transaction.id } })
  }
}
 
consume('order-events', 'payment-service-group', {
  'order.confirmed': paymentFromConfirmed,
})

Database Transaksi

Migration payments
create table payments (
  id uuid primary key default gen_random_uuid(),
  order_id uuid not null,
  amount numeric(12,2) not null,
  status text not null default 'PENDING',  -- PENDING / SUCCESS / FAILED
  gateway_ref text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
 
create unique index idx_payments_order on payments (order_id);

Constraint unik payments.order_id memastikan satu order hanya punya satu payment actif. Jika event order.confirmed terkirim dua kali (at-least-once Kafka), konsumen idempoten tidak membuat pembayaran ganda — kembali ke topik episode 12.

Idempotency: Payment IDEMPOTENCY-KEY

Ketika memanggil mock gateway, kita kirim payment idempotency key (payment_idempo_key) di header. Gateway (dan produksi: midtrans/xendit) akan mengenali referensi yang sama dan mengembalikan hasil yang sama tanpa memproses dua kali:

Panggilan gateway aman-retry
await mockGateway.charge({
  headers: { 'Idempotency-Key': payment.idempotency_key },
  amount: payment.amount,
})

Pola ini menyelamatkan skenario: request ke gateway sukses di sisi gateway tapi timeout di sisi kita. Retry dengan key yang sama tidak akan menagih dua kali — biaya dari jaringan di domain uang harus "exactly-once" bahkan saat transport-nya at-least-once.

Webhook vs Polling

Bagaimana payment-service tahu hasil pembayaran? Dua pola:

Webhook — gateway memanggil balik service kita dengan status final. Cepat dan eksplisit, tapi butuh menerima callback dari luar (validasi signature!). Ini cara midtrans/xendit bekerja.

Polling — service kita mengecek status ke gateway secara berkala. Lebih simpel (tidak perlu endpoint publik), tapi berisiko missed update saat jam sibuk dan menambah beban.

Di tokokita kita gabung secara pragmatis: mock gateway mengembalikan hasil sinkron (mode request-response untuk learning), dan kita mencatat bahwa di produksi webhook + signature verification adalah standar.

Mock gateway (simulasi)
POST sandbox.gateway/charge { idempotencyKey, amount }
  → { status: 'success', ref: 'txn_1234' }
  → { status: 'failed', reason: 'insufficient_funds' }

Warning

Jangan pernah memanggil payment gateway di dalam request handler dan mengembalikan hasilnya langsung ke user tanpa menyimpan transaksi. Pertama simpan transaksi PENDING (bukti niat), baru panggil gateway. Jika proses crash di tengah jalan, kamu punya data untuk reconcile via webhook/polling — bukan pesanan tanpa catatan.

Integrasi Midtrans/Xendit di Produksi (Catatan)

Konsep yang sama berlaku saat pindah ke gateway sungguhan:

  • Server key/secret berada di secrets management, bukan di code (episode 19).
  • Signature key diverifikasi di setiap webhook callback.
  • Uang dalam satuan terkecil (sen) — hindari floating-point untuk nominal.
  • Refund & partial refund adalah transaksi sendiri dengan idempotency masing-masing.

Penutup

Episode 8 membangun payment-service yang event-driven:

  • Konsumen order.confirmed → create transaction PENDING → process mock gateway.
  • Transaksi payments dengan unique index per order (satu pembayaran aktif per order).
  • Emit payment.succeeded / payment.failed untuk mengubah status order & mengirim notifikasi.
  • Idempotency key pada pemanggilan gateway → retry tidak menggandakan tagihan.
  • Catatan webhook vs polling untuk integrasi produksi (midtrans/xendit).

Di episode 9 selanjutnya, kita akan membangun notification-service — konsumen event user.registered, order.confirmed, payment.succeeded, order.shipped; pengiriman email via Resend/SMTP lokal (Mailpit), deduplication by event id, dan reliability pengiriman dengan outbox pattern. Sampai jumpa di episode 9!

Belajar Microservices - Payment Service (Mock + Event) | Belajar Microservices