Ödeme sistemi
Problem
"Bir pazaryeri için ödeme sistemi tasarla. Müşteri sepetini kartla öder; kart işlemi bir ödeme hizmet sağlayıcısı (PSP) üzerinden yapılır. Satıcıların hakedişleri hesaplanır, iadeler desteklenir. Hiçbir koşulda müşteriden iki kez para çekilmemeli, alınan para kaybolmamalı ve her kuruşun nereden nereye gittiği açıklanabilmeli."
Bu soru, çoğu tasarım sorusunun tersine ölçekten çok doğruluk sorusudur. Mülakatçının dinlemek istediği kavramlar: idempotency'nin üç ayrı sınırda uygulanması, timeout'un "başarısız" değil "bilinmiyor" olması, ödemenin bir durum makinesi olması, webhook'ların tekrarlı ve sırasız gelmesi, double-entry ledger ve en önemlisi her şey ters gittiğinde son güvencenin mutabakat (reconciliation) olması.
Kitapta komşu iki konu zaten var: tek bir veritabanında bakiye, hareket kaydı ve race condition çözümleri Kitap Bölüm 22'de; flash sale'de provizyon → rezervasyon onayı → tahsilat sırası Kitap Bölüm 20'de. Bu case PSP entegrasyonuna, dağıtık belirsizliğe ve mutabakata odaklanır.
Netleştirme soruları ve varsayımlar
| Soru | Neden önemli | Varsayım |
|---|---|---|
| Kart işlemini kim yapıyor? Biz mi acquirer'ız, yoksa PSP mi kullanıyoruz? | Kapsam ve regülasyon | PSP kullanıyoruz (Stripe, Adyen, iyzico benzeri); birden fazla PSP olabilir |
| Kart numarası sistemimize giriyor mu? | PCI DSS kapsamı | Hayır: PSP'nin hosted ödeme alanları kartı tokenize eder, biz sadece token görürüz |
| 3D Secure var mı? | Akış senkron mu asenkron mu | Evet; müşteri bankasının doğrulama sayfasına yönlendirilir, sonuç dakikalar sürebilir |
| Tek adımda satış mı, provizyon + tahsilat mı? | Stok ve iptal akışı | Provizyon (authorize) siparişte, tahsilat (capture) stok onaylanınca |
| Kısmi iade ve kısmi tahsilat var mı? | Durum makinesi ve ledger | Kısmi iade var |
| Satıcıya ödeme (payout) ne zaman? | Ledger hesapları | Teslimattan N gün sonra, toplu |
| Para birimi? | Kur ve yuvarlama | Sadece TRY (çoklu para birimi kapsam dışı) |
| Kayıtlar ne kadar saklanır? | Depolama, değişmezlik | Mevzuat gereği uzun süre (ör. 10 yıl), değişmez |
| Erişilebilirlik mi tutarlılık mı? | Kritik karar | Ödeme durumunda tutarlılık; emin değilsek müşteriye "işleniyor" deriz, tahmin etmeyiz |
Kapasite tahmini
Sipariş : 5.000.000 /gün ≈ 58 ödeme/s ortalama
Kampanya tepesi : günlük ortalamanın ~35 katı kısa süreli ≈ 2.000 ödeme/s
PSP çağrısı : ödeme başına 2-3 (authorize, 3DS sonucu, capture) -> tepe ~5.000 çağrı/s
Webhook : ödeme başına ~3 event -> 15.000.000 /gün ≈ 175 /s ortalama
Ledger satırı : sipariş yaşam döngüsü başına ~8 satır (tahsilat 3, PSP takası 3, satıcı ödemesi 2)
5M x 8 = 40M satır/gün x ~200 B ≈ 8 GB/gün ≈ 2,9 TB/yıl
Ödeme + deneme : 5M x ~1 KB ≈ 5 GB/gün
PSP gecikmesi : authorize yüzlerce ms - birkaç saniye; 3DS dakikalar
Teşhis:
- Throughput orta düzeyde. Tepe 2.000 ödeme/s, iyi index'lenmiş ve ödeme id'si üzerinden bölünebilen bir PostgreSQL kurgusu için ulaşılabilir; asıl kısıt çoğu zaman PSP'nin kendi kotalarıdır.
- Asıl zorluk belirsizlik. Her ödeme, kontrol etmediğimiz bir sisteme (PSP, kart ağı, banka) en az bir ağ çağrısıdır. O çağrının sonucu bilinmeyebilir.
- Uzun yaşayan akış. 3DS ile bir ödeme dakikalarca açık kalabilir; thread tutan senkron bir akış değil, kalıcı durumu olan bir durum makinesi gerekir.
- Veri değişmez ve uzun ömürlü. Ledger yılda terabaytlar mertebesinde büyür; partitioning (aylık) ve arşivleme planı baştan konur.
API
POST /api/v1/payments
Idempotency-Key: 0b6f8c1e-... # istemci üretir, sipariş denemesi başına
{ "orderId": 98213, "amount": "1000.00", "currency": "TRY",
"paymentMethodToken": "tok_abc", "returnUrl": "https://app.ornek.com/odeme/sonuc" }
-> 201 { "paymentId": "pay_7f...", "status": "REQUIRES_ACTION", "redirectUrl": "https://3ds.banka..." }
-> 201 { "paymentId": "pay_7f...", "status": "AUTHORIZED" }
-> 201 { "paymentId": "pay_7f...", "status": "FAILED", "declineCode": "insufficient_funds" }
-> 409 aynı sipariş için aktif bir ödeme var
-> 422 aynı Idempotency-Key farklı gövdeyle
GET /api/v1/payments/{paymentId} # istemci 3DS dönüşünde durum sorar
POST /api/v1/payments/{paymentId}/refunds # Idempotency-Key zorunlu, {"amount": "250.00"}
POST /internal/v1/payments/{paymentId}/capture # sipariş servisi stok onayından sonra
POST /internal/v1/payments/{paymentId}/void # stok yoksa provizyon iptali
POST /webhooks/psp/{provider} # PSP'den gelen imzalı event'ler
Tasarım notları:
- Kart reddi bir HTTP hatası değildir.
201+status: FAILEDdöner; işlem başarıyla işlendi, sonucu "red"dir.5xxsadece bizim tarafımızdaki arızalar içindir. UNKNOWNdışarı sızmaz. İstemciyePROCESSINGolarak gösterilir; istemciGETile ya da push bildirimiyle sonucu öğrenir.- Capture ve void iç API'lerdir; müşteri tetiklemez. Her biri de idempotent olmalıdır (aynı capture iki kez gelirse ikinci etkisizdir).
Veri modeli
CREATE TABLE payments (
id UUID PRIMARY KEY,
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(19,4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
status VARCHAR(24) NOT NULL,
psp VARCHAR(32) NOT NULL,
psp_payment_id VARCHAR(128),
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT payments_status_valid CHECK (status IN
('CREATED','REQUIRES_ACTION','AUTHORIZED','CAPTURED','PARTIALLY_REFUNDED',
'REFUNDED','VOIDED','FAILED','UNKNOWN')),
CONSTRAINT payments_psp_ref_uq UNIQUE (psp, psp_payment_id)
);
-- Bir siparişin aynı anda tek bir canlı ödemesi olabilir; başarısız olan yeniden denenebilir
CREATE UNIQUE INDEX payments_one_active_per_order ON payments (order_id)
WHERE status NOT IN ('FAILED', 'VOIDED');
-- PSP'ye giden her çağrının kaydı: çağrıdan ÖNCE yazılır
CREATE TABLE payment_attempts (
id UUID PRIMARY KEY,
payment_id UUID NOT NULL REFERENCES payments(id),
operation VARCHAR(16) NOT NULL, -- AUTHORIZE, CAPTURE, VOID, REFUND
psp_idem_key VARCHAR(255) NOT NULL UNIQUE, -- PSP'ye gönderilen idempotency key
amount NUMERIC(19,4) NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING, SUCCEEDED, FAILED, UNKNOWN
psp_reference VARCHAR(128),
error_code VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Gelen webhook'ların tekilleştirilmesi (inbox)
CREATE TABLE psp_webhook_inbox (
provider VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
PRIMARY KEY (provider, event_id)
);
-- Double-entry ledger
CREATE TABLE ledger_transactions (
id UUID PRIMARY KEY,
payment_id UUID REFERENCES payments(id),
kind VARCHAR(32) NOT NULL, -- CAPTURE, PSP_SETTLEMENT, PAYOUT, REFUND
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ledger_entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES ledger_transactions(id),
account VARCHAR(64) NOT NULL, -- psp_receivable, seller_payable, commission_revenue ...
amount NUMERIC(19,4) NOT NULL CHECK (amount <> 0), -- borç +, alacak -
currency CHAR(3) NOT NULL
);
Bir ledger transaction'ının satırlarının toplamının her para birimi için sıfır olması, commit anında çalışan ertelenmiş bir constraint trigger ile zorlanır:
CREATE FUNCTION check_balanced() RETURNS trigger AS $$
BEGIN
IF EXISTS (SELECT 1 FROM ledger_entries
WHERE transaction_id = NEW.transaction_id
GROUP BY currency HAVING SUM(amount) <> 0) THEN
RAISE EXCEPTION 'unbalanced ledger transaction %', NEW.transaction_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER ledger_balanced
AFTER INSERT ON ledger_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_balanced();
Bu şema PostgreSQL 16'da denendi: 1.000 / −900 / −100'lük tahsilat kaydı commit oldu; −100'lük satırı eksik olan kayıt commit'te unbalanced ledger transaction hatasıyla reddedildi. Aynı sipariş için ikinci canlı ödeme payments_one_active_per_order ile reddedildi. Ledger satırları ayrıca append-only'dir (UPDATE/DELETE yetkisi yok, trigger ile de engelli; bkz. Kitap Bölüm 22).
Yüksek seviye tasarım
flowchart LR
APP[Mobil ve web] --> GW[API gateway]
APP -->|kart bilgisi| HF[PSP hosted alanları]
HF -->|token| APP
GW --> PS[Ödeme servisi]
OS[Sipariş servisi] -->|capture veya void| PS
PS --> DB[(PostgreSQL ödemeler, ledger, outbox)]
PS --> RT[PSP yönlendirici]
RT --> PSPA[PSP A]
RT --> PSPB[PSP B]
PSPA -->|imzalı webhook| WH[Webhook alıcısı]
WH --> DB
DB -->|outbox relay| K[(Kafka)]
K --> OS
K --> NS[Bildirim servisi]
RES[Belirsiz durum çözücü] --> DB
RES --> RT
REC[Mutabakat job'ı] --> DB
PSPA -->|günlük takas dosyası| REC
BANK[Banka ekstresi] --> RECBileşenlerin sorumlulukları:
- Ödeme servisi: Durum makinesinin sahibi. Her durum geçişi ve onun ledger kaydı ve outbox event'i tek bir veritabanı transaction'ında yazılır.
- PSP yönlendirici: PSP'ye özgü API'leri tek bir arayüzün arkasına saklar; timeout, circuit breaker ve bulkhead burada (Kitap Bölüm 14).
- Webhook alıcısı: İmzayı doğrular, event'i inbox'a yazar, hemen
2xxdöner; işleme asenkron. - Belirsiz durum çözücü:
UNKNOWNve uzun süredirPENDINGolan denemeleri PSP'ye sorarak sonuçlandırır. - Mutabakat job'ı: Günlük olarak iç kayıtları PSP takas dosyası ve banka ekstresiyle karşılaştırır.
Derinlemesine
Ödeme bir durum makinesidir
stateDiagram-v2
[*] --> CREATED
CREATED --> REQUIRES_ACTION : 3DS gerekli
CREATED --> AUTHORIZED : provizyon başarılı
CREATED --> FAILED : red
CREATED --> UNKNOWN : timeout
REQUIRES_ACTION --> AUTHORIZED : 3DS başarılı
REQUIRES_ACTION --> FAILED : 3DS başarısız veya süre doldu
UNKNOWN --> AUTHORIZED : PSP sorgusu
UNKNOWN --> FAILED : PSP sorgusu
AUTHORIZED --> CAPTURED : tahsilat
AUTHORIZED --> VOIDED : provizyon iptali
CAPTURED --> PARTIALLY_REFUNDED : kısmi iade
CAPTURED --> REFUNDED : tam iade
PARTIALLY_REFUNDED --> REFUNDED : kalan iade
FAILED --> [*]
VOIDED --> [*]
REFUNDED --> [*]Her geçiş koşullu bir UPDATE ile yapılır; beklenen kaynak durum WHERE koşulundadır:
UPDATE payments
SET status = 'CAPTURED', version = version + 1, updated_at = now()
WHERE id = :paymentId AND status = 'AUTHORIZED'
RETURNING version;
Etkilenen satır 0 ise geçiş zaten yapılmıştır ya da ödeme başka bir duruma gitmiştir; uygulama mevcut durumu okuyup karar verir (aynı capture tekrar geldiyse ilk sonucu döner, ödeme VOIDED ise hata). PostgreSQL 16'da aynı UPDATE ikinci kez çalıştırıldığında 0 satır döndü. Bu desen iki şeyi birden sağlar: eşzamanlı iki geçişten sadece biri kazanır ve sırasız gelen event'ler durumu geri götüremez (CAPTURED bir ödemeye geç gelen authorization.succeeded webhook'u hiçbir satırı etkilemez).
Exactly-once yanılgısı: üç sınırda idempotency
Ağ üzerinde "tam bir kez teslim" yoktur. İstemci cevabı alamadığında isteğin işlenip işlenmediğini bilemez ve tekrar dener. Kafka'nın exactly-once semantiği de sadece Kafka içindeki okuma-işleme-yazma döngüsünü kapsar; PSP'ye giden bir HTTP çağrısını kapsamaz (Kitap Bölüm 12). Gerçekleştirilebilen şey en az bir kez teslim + idempotent işleme = etkinin bir kez olmasıdır. Ödeme sisteminde bunun üç ayrı sınırı vardır:
| Sınır | Tekrar kaynağı | Çözüm |
|---|---|---|
| İstemci → ödeme servisi | Mobil timeout sonrası retry, çift tıklama | Idempotency-Key + (customer_id, key) unique; aynı anahtar farklı gövdeyle gelirse 422; sipariş başına tek canlı ödeme index'i |
| Ödeme servisi → PSP | Bizim retry'ımız, pod çökmesi sonrası yeniden deneme | Her PSP çağrısında deneme kaydından türetilmiş sabit bir PSP idempotency key |
| PSP → ödeme servisi (webhook) | PSP'nin retry'ları, aynı event'in iki kez üretilmesi | (provider, event_id) primary key'li inbox; durum makinesinin monoton geçişleri |
| Ödeme servisi → diğer servisler | Outbox relay ve Kafka'nın en az bir kez teslimi | Tüketicide event_id ile idempotent consumer (Kitap Bölüm 13) |
PSP tarafının davranışını bilmek önemlidir. Örnek olarak Stripe dokümantasyonu: idempotency key en fazla 255 karakterdir; API v1'de ilk isteğin status kodu ve gövdesi saklanır ve aynı anahtarla gelen tekrar, 500 dahil aynı cevabı alır; anahtarlar en az 24 saat sonra temizlenebilir ve temizlendikten sonra aynı anahtar yeni bir istek sayılır; aynı anahtar farklı parametrelerle gelirse hata döner. Buradan iki pratik sonuç çıkar: (1) belirsiz bir çağrıyı aynı anahtarla ve anahtarın saklanma süresi içinde tekrar etmek güvenlidir; (2) günler sonra yapılacak bir tekrar idempotency'ye değil, durum sorgusuna dayanmalıdır.
Timeout "başarısız" değil "bilinmiyor"dur
En pahalı hata şudur: PSP'ye provizyon isteği gider, 10 saniyelik timeout dolar, kod bunu FAILED sayar, kullanıcı tekrar dener ve yeni bir anahtarla ikinci provizyon gider. Bu arada ilk istek PSP'de başarılı olmuştur. Sonuç: müşteriden iki kez para çekilir.
Doğru akış:
sequenceDiagram
participant C as İstemci
participant P as Ödeme servisi
participant D as PostgreSQL
participant S as PSP
C->>P: POST /payments ve Idempotency-Key
P->>D: payments CREATED ve attempt PENDING tek transaction
P->>S: authorize ve psp_idem_key
S--xP: timeout
P->>D: attempt UNKNOWN, payment UNKNOWN
P-->>C: 201 status PROCESSING
Note over P,S: Çözücü job aynı anahtarla tekrar dener veya durum sorgular
P->>S: authorize ve aynı psp_idem_key
S-->>P: ilk isteğin sonucu, authorized
P->>D: payment AUTHORIZED, ledger ve outbox tek transactionKurallar:
- Niyet kaydı önce. PSP'yi çağırmadan önce deneme satırı (
PENDING, sabitpsp_idem_keyile) commit edilir. Pod çağrının ortasında ölse bile, kalıcı bir iz vardır: "şu anahtarla şu işlemi denedim." - Timeout ve 5xx
UNKNOWN'dır. Kesin red (card_declined)FAILED'dır; ağ hatası, timeout ve PSP'nin5xx'i belirsizdir. - Belirsizlik aynı anahtarla çözülür. Çözücü job,
UNKNOWNdenemeleri birkaç saniye ile birkaç dakika arasında artan aralıklarla aynı anahtarla tekrar eder ya da PSP'nin durum sorgulama API'sine referansla sorar. Asla yeni bir anahtarla "yeniden ödeme" başlatılmaz. - Müşteriye dürüst ol.
UNKNOWNsürerken kullanıcıya "ödemeniz işleniyor" gösterilir ve aynı sipariş için yeni ödeme başlatması engellenir (tek canlı ödeme index'i). Sipariş, sonuç kesinleşmeden kargoya verilmez. - Son güvence mutabakattır. Çözücü de sonuç alamazsa (PSP'de kayıt bulunamıyor, PSP kesintide), günlük takas dosyası gerçeği söyler.
Bu yaklaşım Airbnb gibi şirketlerin ödeme altyapısı yazılarında da anlattığı genel bir ilkedir: dış çağrıdan önce kalıcı kayıt, sonra çağrı, sonra sonucun kaydı; ve her adımın tekrar edilebilir olması.
Provizyon, tahsilat ve sipariş saga'sı
Pazaryerinde ödeme alındığı anda stok garanti değildir. Akış bir saga'dır (Kitap Bölüm 13):
- Sipariş
PENDING_PAYMENTolarak oluşur. - Ödeme servisi provizyon alır (para müşterinin kartında bloke edilir, çekilmez).
- Sipariş servisi stoğu onaylar.
- Stok varsa sipariş servisi capture çağırır; yoksa void çağırır (provizyon iptal, müşteriye hiçbir şey yansımaz).
Provizyonun bir ömrü vardır (kart şemasına ve bankaya göre günler mertebesinde); capture bu süre içinde yapılmalıdır, aksi halde provizyon düşer. Void, iadeden ucuz ve müşteri için daha temizdir: ekstrede hiç işlem görünmez. Bu sıra, flash sale'deki "para çekildi ama ürün başkasına gitti" durumunu da kapatır (Kitap Bölüm 20).
Double-entry ledger: her kuruşun hikayesi
payments tablosu bir ödemenin durumunu anlatır; ledger ise paranın hareketini. 1.000 TL'lik bir siparişte pazaryeri komisyonu %10, PSP komisyonu %2 olsun:
| Olay | Hesap | Tutar (borç +, alacak −) |
|---|---|---|
| Tahsilat (capture) | PSP alacakları | +1.000 |
| Satıcıya borç (satıcı 42) | −900 | |
| Komisyon geliri | −100 | |
| PSP takası (T+1) | Banka | +980 |
| PSP komisyon gideri | +20 | |
| PSP alacakları | −1.000 | |
| Satıcı ödemesi | Satıcıya borç (satıcı 42) | +900 |
| Banka | −900 |
Her olayın satırları sıfıra toplanır; para yoktan var olmaz, bir hesaptan diğerine geçer. Bakiye bir projeksiyondur: "satıcı 42'ye ne kadar borçluyuz?" sorusu, seller_payable:42 hesabının satırlarının toplamıdır (performans için ayrıca bir bakiye tablosu aynı transaction'da güncellenebilir; bkz. Kitap Bölüm 22). Faydaları:
- Denetlenebilirlik: Her kuruşun hangi olayla nereye gittiği bellidir.
- Hata yakalama: Dengesiz bir kayıt commit edilemez; tüm hesapların toplamı her zaman sıfırdır.
- Mutabakat kolaylığı: "PSP alacakları" hesabının bakiyesi, PSP'nin henüz bize ödemediği parayı gösterir; takas dosyasıyla doğrudan karşılaştırılır.
İade yeni bir ters kayıttır, eski satırlar değiştirilmez. 250 TL kısmi iade: PSP alacakları −250, satıcıya borç +225, komisyon geliri +25 (komisyon iadesi iş kuralıdır).
Hot account problemi: Her tahsilat "PSP alacakları" ve "komisyon geliri" gibi sistem hesaplarına yazar. Bu hesapların bakiye satırı her işlemde kilitlenirse saniyede binlerce işlem tek satırda sıraya girer. Çözüm: sistem hesapları için bakiye satırı tutmamak (bakiye satırların toplamıdır), ya da hesabı N alt hesaba bölmek (commission_revenue:shard_0..15) ve raporda toplamak.
Webhook'lar: tekrarlı, sırasız, sahte olabilir
PSP, asenkron sonuçları (3DS sonucu, chargeback, takas) webhook ile bildirir. Stripe dokümantasyonundaki davranış genel bir örnektir: canlı modda teslim edilemeyen event'ler üç güne kadar exponential backoff ile tekrar denenir; event'lerin üretildikleri sırayla teslimi garanti edilmez; aynı event birden fazla kez gelebilir; endpoint karmaşık iş mantığından önce hızlıca 2xx dönmelidir. Bundan çıkan tasarım:
- İmza doğrulama: PSP gövdeyi HMAC-SHA256 ile imzalar. İmza ham gövde üzerinden doğrulanır (JSON'u parse edip tekrar serialize etmek imzayı bozar); karşılaştırma sabit zamanlıdır; imzadaki zaman damgası tolerans dışındaysa (Stripe kütüphanelerinde varsayılan 5 dakika) replay saldırısı olarak reddedilir. Mümkünse PSP'nin IP aralıklarına izin listesi de konur.
- Önce kaydet, sonra işle: Event
psp_webhook_inbox'a(provider, event_id)primary key ile yazılır ve200dönülür. Aynı event ikinci kez gelirse insert çakışır (ON CONFLICT DO NOTHING) ve yine200dönülür. - Asenkron işleme: Bir worker inbox'taki işlenmemiş event'leri sırayla alır, durum makinesine uygular,
processed_at'i işaretler. Webhook trafiği ay başı gibi zamanlarda patladığında kuyruk bunu emer. - Sıraya güvenme: Event'in taşıdığı durum, koşullu geçişle uygulanır. Şüphede event'in içindeki veriye değil, PSP API'sinden ödemenin güncel haline bakılır.
- Webhook gelmezse: Webhook bir hızlandırıcıdır, tek doğruluk kaynağı değildir.
REQUIRES_ACTION'da 15 dakikayı aşan ödemeler çözücü job tarafından PSP'ye sorulur.
Senkron cevap ile webhook çelişirse (senkron cevap timeout oldu ama webhook succeeded dedi) durum makinesi karar verir: UNKNOWN → AUTHORIZED geçişi geçerlidir, uygulanır.
Outbox: ödeme sonucu diğer servislere nasıl gider?
Ödeme CAPTURED olduğunda sipariş servisi, bildirim servisi ve muhasebe bunu öğrenmelidir. Durum güncellemesi ve Kafka'ya yazma iki ayrı sisteme yazmadır (dual write); biri başarılı diğeri başarısız olabilir. Çözüm transactional outbox: durum geçişi, ledger satırları ve PaymentCaptured event'i aynı PostgreSQL transaction'ında yazılır; bir relay (polling ya da CDC) event'i Kafka'ya taşır. Event'in anahtarı paymentId'dir; aynı ödemenin event'leri aynı partition'da sıralı kalır. Tüketiciler event_id ile tekilleştirir (Kitap Bölüm 13).
Mutabakat: dağıtık sistemin son güvencesi
Ne kadar dikkatli tasarlarsan tasarla, kontrol etmediğin sistemlerle aranda tutarsızlık oluşur: webhook kaybolur, PSP'de manuel bir iade yapılır, takasta komisyon farklı çıkar, bir bug yanlış tutar yazar. Mutabakat bu farkları tespit eden ve sınıflandıran süreçtir.
Üç yönlü karşılaştırma, her gün:
- İç kayıtlar ↔ PSP takas dosyası: PSP her gün işlem bazında bir dosya yayınlar (referans, tutar, komisyon, net). Satırlar
psp_payment_idile eşlenir. - PSP takas dosyası ↔ banka ekstresi: PSP'nin "şu gün şu kadar net ödedim" dediği para bankaya gerçekten geldi mi?
- Ledger iç tutarlılığı: Her ledger transaction'ı dengeli mi,
paymentsdurumu ile ledger olayları uyumlu mu (herCAPTUREDödemenin birCAPTUREledger kaydı var mı)?
| Fark tipi | Örnek | Tipik aksiyon |
|---|---|---|
| Bizde yok, PSP'de var | Timeout sonrası FAILED sandığımız ödeme PSP'de tahsil edilmiş | Ödemeyi düzelt; sipariş yoksa otomatik iade |
| Bizde var, PSP'de yok | CAPTURED dediğimiz ödeme takasta yok | Çözücüyle PSP'ye sor; gerçekten yoksa siparişi incele |
| Tutar farkı | Kısmi capture yanlış yansımış | Manuel inceleme kuyruğu |
| Komisyon farkı | Sözleşmedeki oran ile takas farklı | Finans ekibine rapor |
| Tekrarlı kayıt | Aynı PSP referansı iki ödemede | Kritik alarm: idempotency hatası |
Eşleşme kuralları otomatikleşir; kural dışı kalanlar bir operasyon kuyruğuna düşer. Mutabakat sonuçları da ledger'a ayrı düzeltme kayıtlarıyla yansır; eski kayıtlar değiştirilmez. Senior cevabın ayırt edici cümlesi: "Dağıtık bir ödeme sisteminde her şeyin tutarlı olacağını iddia etmem; tutarsızlığın en geç bir gün içinde tespit edilip düzeltileceğini garanti ederim."
Birden fazla PSP ve yönlendirme
İki PSP ile çalışmak hem maliyet (komisyon pazarlığı) hem erişilebilirlik içindir. Yönlendirici, kart tipi, tutar ve PSP sağlığına (hata oranı, gecikme) göre seçim yapar. Dikkat:
UNKNOWNdurumdaki bir ödeme asla başka PSP'ye yönlendirilmez; ilk PSP'de başarılı olmuş olabilir, ikinci PSP çift çekim yaratır.- Kart token'ları genelde PSP'ye özgüdür; kayıtlı kartla failover için ağ tokenizasyonu veya PSP'ler arası token taşıma gerekir.
- Circuit breaker bir PSP için açıldığında sadece yeni ödemeler diğer PSP'ye gider.
Güvenlik ve uyumluluk
- Kart numarası (PAN) sistemimize hiç girmez; PSP'nin hosted alanları tokenize eder. Bu, PCI DSS kapsamını dramatik daraltır.
- Log'larda token, tutar ve id'ler olur; kart numarası, CVV ve tam ad olmaz.
- Webhook secret ve PSP API anahtarları secret yöneticisinde, düzenli rotasyonla.
- İade ve manuel düzeltme gibi işlemler iki kişi onayı (four-eyes) ve audit log ister.
Darboğazlar, hatalar ve trade-off'lar
| Senaryo | Etki | Önlem |
|---|---|---|
| PSP authorize çağrısında timeout | Ödeme durumu bilinmiyor | UNKNOWN + aynı anahtarla tekrar veya durum sorgusu; müşteriye "işleniyor"; yeni ödeme engeli |
| Pod PSP çağrısından sonra, sonucu yazmadan çöker | PSP'de başarılı, bizde PENDING | Niyet kaydı sayesinde çözücü bulur ve aynı anahtarla sonuçlandırır |
| Webhook hiç gelmez | 3DS sonucu bilinmiyor | Zaman aşımı sonrası PSP'ye aktif sorgu |
| Webhook iki kez veya sırasız gelir | Çift işlem, geri giden durum | Inbox primary key, koşullu durum geçişleri |
| Sahte webhook | Sahte "ödendi" ile ürün gönderimi | HMAC imza, zaman damgası toleransı, IP izin listesi |
| PSP tamamen düşer | Yeni ödemeler başarısız | Circuit breaker, yeni ödemeleri ikinci PSP'ye; belirsizleri bekletmek |
| Kafka/relay gecikir | Sipariş servisi ödemeyi geç görür | Outbox lag alarmı; sipariş servisi gerekirse ödeme API'sine sorar |
| Ledger sistem hesabı sıcaklaşır | Kilit beklemesi | Bakiye satırı tutmamak veya hesabı alt hesaplara bölmek |
| Mutabakat farkı büyür | Para kaybı veya müşteri şikayeti | Günlük çalışma, fark eşiği alarmı, operasyon kuyruğu |
Trade-off'lar:
- Tutarlılık ve erişilebilirlik: Belirsiz durumda "başarılı" ya da "başarısız" tahmin etmek yerine kullanıcıyı bekletmeyi seçtik.
- Senkron ve asenkron: 3DS ve webhook nedeniyle akış asenkron; bedeli istemci tarafında polling veya push ve daha karmaşık UX.
- Provizyon + tahsilat: Stok onayı ve iptal kolaylığı için iki adım; bedeli provizyon süresinin takibi ve fazladan PSP çağrısı.
- Mutabakat gecikmesi: Günlük mutabakat ucuz ve PSP dosyalarıyla uyumlu; bedeli tutarsızlığın en fazla bir gün yaşaması. Kritik farklar için gün içi kısmi kontroller eklenir.
Üç seviyede cevap
Soru: "PSP'ye provizyon isteği gönderdin ve timeout aldın. Ne yaparsın?"
seviye: İleri etiketler: [payments, timeout, idempotency] kaynak: Tahmini
İsteği birkaç kez retry ederim. Yine olmazsa ödemeyi başarısız sayar, kullanıcıya tekrar denemesini söylerim.
Timeout başarısız demek değil; PSP isteği almış ve işlemiş olabilir. Bu yüzden PSP'ye idempotency key gönderirim ve retry'ı aynı anahtarla yaparım; PSP ilk isteğin sonucunu döner. Retry'lar da sonuç vermezse ödemeyi belirsiz duruma alır, PSP'nin durum sorgulama API'siyle sonradan kontrol ederim. Kullanıcıya "işleniyor" derim.
Önce PSP'yi çağırmadan niyet kaydını commit etmiş olmam gerekiyor: deneme satırı PENDING, sabit bir PSP idempotency key ile. Timeout'ta ödeme UNKNOWN olur; istemciye PROCESSING dönerim ve sipariş başına tek canlı ödeme index'i yüzünden kullanıcı yeni bir ödeme başlatamaz, çünkü en tehlikeli hata yeni bir anahtarla ikinci provizyondur. Çözücü job aynı anahtarla, anahtarın PSP'de saklanma süresi içinde, artan aralıklarla tekrar dener ya da referansla durum sorgular; webhook önce gelirse durum makinesi UNKNOWN'dan geçişi kabul eder. Sonuç yine bilinmezse sipariş kargoya çıkmaz, ödeme günlük mutabakatta takas dosyasıyla kesinleşir; PSP'de tahsil edilmiş ama siparişi iptal olmuş bir ödeme otomatik iade edilir. Ayrıca UNKNOWN ödemeleri asla ikinci PSP'ye yönlendirmem.
Junior timeout'u başarısızlık sayıyor ve kullanıcıyı tekrar denemeye yönlendiriyor: bu, çift çekimin ders kitabı örneği. Mid'in cevabı doğru: aynı anahtarla retry ve durum sorgusu. Senior niyet kaydını çağrıdan önceye koyuyor (pod çökmesine dayanıklılık), kullanıcının yeni ödeme açmasını veriyle engelliyor, anahtar saklama süresini biliyor, webhook ile yarışı durum makinesine bırakıyor ve mutabakatı son güvence olarak konumlandırıyor. Ayrıca çoklu PSP'deki failover tuzağını kendiliğinden söylüyor.
Takip sorusu: PSP'nin idempotency anahtarlarını 24 saat sakladığını biliyorsun. Çözücü job 2 gün boyunca çalışmadı. Ne değişir?
Soru: "Kafka'da exactly-once var. Ödeme sistemimizde neden yine idempotency'ye ihtiyacımız var?"
seviye: İleri etiketler: [kafka, exactly-once, idempotency] kaynak: Tahmini
Kafka exactly-once ise mesajlar bir kez işlenir, ayrıca bir şey yapmaya gerek yok. Producer'da enable.idempotence açık olsun yeter.
Kafka'nın exactly-once'ı Kafka'dan okuyup Kafka'ya yazan akışlar içindir. Consumer bir mesajı işleyip veritabanına yazar ve offset commit'inden önce çökerse mesaj tekrar gelir. Bu yüzden consumer'da event id ile tekilleştirme yaparım. PSP çağrısı da Kafka transaction'ına dahil olmadığı için ayrıca idempotency key gerekir.
Exactly-once teslimat ağ üzerinde mümkün değil; mümkün olan, en az bir kez teslim artı idempotent işlemle etkinin bir kez olması. Kafka'nın garantisi sadece Kafka içindeki okuma-işleme-yazma döngüsünü kapsar; veritabanı yazması, PSP'ye HTTP çağrısı ve e-posta bunun dışında. Ödeme sisteminde dört sınır sayarım: istemciden bize Idempotency-Key ve unique constraint; bizden PSP'ye denemeden türetilmiş sabit anahtar; PSP'den bize webhook inbox'ı ve event id primary key; bizden diğer servislere outbox ve tüketicide inbox. Her birinde tekrarın kaynağı farklı, çözümün ortak noktası ise anahtarın iş verisiyle aynı transaction'da kaydedilmesi. Ve hepsine rağmen tutarsızlık olursa mutabakat yakalar.
Junior ürün özelliğinin kapsamını abartıyor; bu soru tam da bu yanılgıyı yakalamak için sorulur. Mid sınırı doğru çiziyor. Senior problemi bir ilkeye (en az bir kez + idempotent) indiriyor, sistemi sınırlarına ayırıp her sınırda tekrar kaynağını ve çözümü sayıyor, ortak mekanizmayı (aynı transaction) adlandırıyor. Bu cevap mülakatçıya adayın kavramı ezberlemediğini, modelini kurduğunu gösterir.
Takip sorusu: Idempotency kaydını Redis'te, ödeme kaydını PostgreSQL'de tutsak ne olur?
Soru: "Mutabakatı nasıl kurarsın, neden gerekli?"
seviye: Orta etiketler: [reconciliation, payments] kaynak: Tahmini
Gün sonunda bizim veritabanındaki ödemelerle PSP'nin raporunu karşılaştıran bir job yazarım, fark varsa e-posta atar.
Her gün PSP'nin takas dosyasını alır, işlemleri PSP referansıyla bizim kayıtlarla eşlerim. Bizde olup PSP'de olmayan, PSP'de olup bizde olmayan ve tutarı farklı olanları ayrı listelerim. Kurallı olanları otomatik düzeltir, kalanları operasyon ekibine iletirim. Bu gerekli çünkü webhook kaybı, timeout ve manuel işlemler yüzünden sistemler ayrışabilir.
Mutabakatı tasarımın parçası olarak görürüm, sonradan eklenen bir rapor olarak değil. Üç yönlü yaparım: iç ledger ile PSP takas dosyası, takas dosyası ile banka ekstresi, ve ledger'ın kendi içinde (her CAPTURED ödemenin dengeli bir tahsilat kaydı var mı). Farkları sınıflandırırım: bizde FAILED ama PSP'de tahsil edilmiş, bizde CAPTURED ama takasta yok, tutar farkı, komisyon farkı, aynı PSP referansının iki ödemede görünmesi. Her sınıfın bir aksiyonu var; örneğin siparişi iptal olmuş ama tahsil edilmiş ödeme otomatik iade edilir, tekrarlı referans ise idempotency hatası olduğu için kritik alarmdır. Düzeltmeler ledger'a yeni kayıt olarak girer. "PSP alacakları" hesabının bakiyesi, PSP'nin henüz ödemediği paraya eşit olmalı; bu tek sayı bile sistemin sağlığını gösterir. Fark sayısını ve yaşını metrik olarak izlerim.
Junior mutabakatı bir rapor olarak görüyor. Mid eşleme, sınıflandırma ve otomasyonu biliyor. Senior mutabakatı tasarımın bir bileşeni yapıyor: üç yön, fark sınıflarına bağlı aksiyonlar, düzeltmelerin değişmez ledger'a ters kayıt olarak girmesi ve ledger hesabının bakiyesini tek bir sağlık göstergesine çevirmesi. Bankacılık geçmişi olan bir aday bu soruda çok güçlü sinyal verebilir.
Takip sorusu: PSP takas dosyası bir gün gecikti ve iki günlük dosya birlikte geldi. Mutabakat job'ın neyi yanlış yapabilir?
Mülakatçı için
- PSP kullanımı, kart verisinin kapsamı (tokenizasyon/PCI), 3DS ve provizyon/tahsilat ayrımını netleştirme aşamasında soruyor.
- Asıl zorluğun ölçek değil doğruluk ve belirsizlik olduğunu kapasite hesabından sonra açıkça söylüyor.
- Ödemeyi açık durumları olan bir durum makinesi olarak modelliyor ve geçişleri koşullu güncellemeyle koruyor.
- Timeout'u
UNKNOWNolarak ele alıyor; aynı anahtarla tekrar veya durum sorgusu öneriyor, yeni anahtarla tekrarın çift çekim yarattığını söylüyor. - PSP çağrısından önce kalıcı bir niyet kaydı yazıyor.
- Idempotency'yi en az iki farklı sınırda (istemci → servis, servis → PSP, webhook) ayrı ayrı uyguluyor.
- Webhook'ların tekrarlı, sırasız ve sahte olabileceğini biliyor; imza, inbox ve hızlı
2xxkullanıyor. - Double-entry ledger'ı bir örnekle (satırların sıfıra toplanması) anlatıyor ve iadeyi ters kayıt olarak modelliyor.
- Servisler arası olay yayınında dual write problemini görüp outbox öneriyor.
- Mutabakatı (en az PSP takas dosyası ile) tasarımın parçası olarak öneriyor ve fark tiplerini sınıflandırıyor.
- Çoklu PSP'de belirsiz ödemelerin başka PSP'ye yönlendirilmemesi gerektiğini fark ediyor (bonus).
Takip soruları:
- Chargeback (müşterinin bankası üzerinden itiraz) geldiğinde durum makinesi ve ledger nasıl değişir?
- Satıcı ödemelerini (payout) toplu yaparken aynı satıcıya iki kez ödeme yapılmasını nasıl önlersin?
- Kısmi iade ile eşzamanlı ikinci kısmi iade gelirse toplam iadenin tahsil edilen tutarı aşmamasını nasıl garanti edersin?
- Çoklu para birimi eklenirse ledger ve yuvarlama nasıl değişir?
Seviye beklentisi: Mid seviyede idempotency key, durum makinesi, webhook tekilleştirme ve timeout'un belirsizliği beklenir. Senior'dan niyet kaydı, üç sınırda idempotency, double-entry ledger, outbox ve mutabakatın birlikte, gerekçeleriyle kurulması beklenir. "Exactly-once kullanırız" diyen ya da timeout'u başarısızlık sayan aday bu soruda elenir.
Hızlı tekrar
S: PSP çağrısında timeout ne anlama gelir, nasıl ele alınır?
C: Sonuç bilinmiyor. Ödeme UNKNOWN olur; aynı idempotency key ile tekrar edilir veya PSP'ye durum sorgulanır. Asla yeni anahtarla yeniden ödeme başlatılmaz.
S: Neden PSP'yi çağırmadan önce deneme kaydı yazılır?
C: Pod çağrıdan sonra çökerse "şu anahtarla şu işlemi denedim" izi kalır; çözücü job aynı anahtarla sonucu bulur.
S: Ödeme sisteminde idempotency hangi sınırlarda gerekir?
C: İstemci → servis (Idempotency-Key), servis → PSP (sabit PSP anahtarı), PSP → servis (webhook event id inbox), servis → diğer servisler (outbox + idempotent consumer).
S: Webhook işlerken hangi dört kural uygulanır?
C: Ham gövde üzerinden HMAC imza ve zaman damgası kontrolü; inbox'a event id ile kaydet; hızlıca 2xx dön; işlemeyi asenkron ve sıraya güvenmeden, koşullu durum geçişiyle yap.
S: Double-entry ledger'ın temel kuralı nedir?
C: Her işlemin satırlarının toplamı (her para birimi için) sıfırdır; satırlar değiştirilmez, düzeltme ve iade yeni ters kayıttır.
S: Provizyon + tahsilat ayrımı pazaryerinde ne kazandırır?
C: Stok onaylanmadan para çekilmez; stok yoksa provizyon iptal edilir (void), iade gerekmez ve müşterinin ekstresinde işlem görünmez.
S: Mutabakat hangi üç karşılaştırmayı yapar?
C: İç ledger ↔ PSP takas dosyası, PSP takas dosyası ↔ banka ekstresi, ledger'ın iç tutarlılığı (ödeme durumu ↔ ledger kayıtları).
S: Çoklu PSP'de hangi ödemeler ikinci PSP'ye yönlendirilmez?
C: UNKNOWN durumdakiler; ilk PSP'de başarılı olmuş olabilirler, ikinci PSP çift çekim yaratır. Failover sadece yeni ödemeler içindir.
Kaynaklar
- Alex Xu, Sahn Lam, System Design Interview: An Insider's Guide (Cilt 2): "Payment System" ve "Digital Wallet" bölümleri.
- Martin Kleppmann, Designing Data-Intensive Applications: Transaction'lar, "exactly-once" tartışması ve stream işleme bölümleri.
- Stripe API: Idempotent requests: Anahtar uzunluğu, saklama süresi, parametre uyuşmazlığı ve
500cevaplarının saklanması. - Stripe: Receive events in your webhook endpoint: Retry süresi, sıra garantisinin olmaması, tekrarlı event'ler, imza doğrulama ve replay koruması.