Sistem tasarımı

Ödeme sistemi

Zor~60 dkAmazonMetaGoogleMicrosoftStripe
Cevaplar:

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

SoruNeden önemliVarsayım
Kart işlemini kim yapıyor? Biz mi acquirer'ız, yoksa PSP mi kullanıyoruz?Kapsam ve regülasyonPSP 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 muEvet; 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 ledgerKısmi iade var
Satıcıya ödeme (payout) ne zaman?Ledger hesaplarıTeslimattan N gün sonra, toplu
Para birimi?Kur ve yuvarlamaSadece TRY (çoklu para birimi kapsam dışı)
Kayıtlar ne kadar saklanır?Depolama, değişmezlikMevzuat 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

text
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

http
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: FAILED döner; işlem başarıyla işlendi, sonucu "red"dir. 5xx sadece bizim tarafımızdaki arızalar içindir.
  • UNKNOWN dışarı sızmaz. İstemciye PROCESSING olarak gösterilir; istemci GET ile 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

sql
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:

sql
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

diyagram yükleniyor…
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] --> REC

Bileş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 2xx döner; işleme asenkron.
  • Belirsiz durum çözücü: UNKNOWN ve uzun süredir PENDING olan 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

diyagram yükleniyor…
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:

sql
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ırTekrar kaynağıÇözüm
İstemci → ödeme servisiMobil timeout sonrası retry, çift tıklamaIdempotency-Key + (customer_id, key) unique; aynı anahtar farklı gövdeyle gelirse 422; sipariş başına tek canlı ödeme index'i
Ödeme servisi → PSPBizim retry'ımız, pod çökmesi sonrası yeniden denemeHer 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 servislerOutbox relay ve Kafka'nın en az bir kez teslimiTü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ış:

diyagram yükleniyor…
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 transaction

Kurallar:

  1. Niyet kaydı önce. PSP'yi çağırmadan önce deneme satırı (PENDING, sabit psp_idem_key ile) commit edilir. Pod çağrının ortasında ölse bile, kalıcı bir iz vardır: "şu anahtarla şu işlemi denedim."
  2. Timeout ve 5xx UNKNOWN'dır. Kesin red (card_declined) FAILED'dır; ağ hatası, timeout ve PSP'nin 5xx'i belirsizdir.
  3. Belirsizlik aynı anahtarla çözülür. Çözücü job, UNKNOWN denemeleri 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.
  4. Müşteriye dürüst ol. UNKNOWN sü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.
  5. 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):

  1. Sipariş PENDING_PAYMENT olarak oluşur.
  2. Ödeme servisi provizyon alır (para müşterinin kartında bloke edilir, çekilmez).
  3. Sipariş servisi stoğu onaylar.
  4. 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:

OlayHesapTutar (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ı ödemesiSatı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:

  1. İ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.
  2. Önce kaydet, sonra işle: Event psp_webhook_inbox'a (provider, event_id) primary key ile yazılır ve 200 dönülür. Aynı event ikinci kez gelirse insert çakışır (ON CONFLICT DO NOTHING) ve yine 200 dönülür.
  3. 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.
  4. 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.
  5. 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:

  1. İç 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_id ile eşlenir.
  2. PSP takas dosyası ↔ banka ekstresi: PSP'nin "şu gün şu kadar net ödedim" dediği para bankaya gerçekten geldi mi?
  3. Ledger iç tutarlılığı: Her ledger transaction'ı dengeli mi, payments durumu ile ledger olayları uyumlu mu (her CAPTURED ödemenin bir CAPTURE ledger kaydı var mı)?
Fark tipiÖrnekTipik aksiyon
Bizde yok, PSP'de varTimeout sonrası FAILED sandığımız ödeme PSP'de tahsil edilmişÖdemeyi düzelt; sipariş yoksa otomatik iade
Bizde var, PSP'de yokCAPTURED 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ıtAynı PSP referansı iki ödemedeKritik 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:

  • UNKNOWN durumdaki 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

SenaryoEtkiÖnlem
PSP authorize çağrısında timeoutÖdeme durumu bilinmiyorUNKNOWN + aynı anahtarla tekrar veya durum sorgusu; müşteriye "işleniyor"; yeni ödeme engeli
Pod PSP çağrısından sonra, sonucu yazmadan çökerPSP'de başarılı, bizde PENDINGNiyet kaydı sayesinde çözücü bulur ve aynı anahtarla sonuçlandırır
Webhook hiç gelmez3DS sonucu bilinmiyorZaman aşımı sonrası PSP'ye aktif sorgu
Webhook iki kez veya sırasız gelirÇift işlem, geri giden durumInbox primary key, koşullu durum geçişleri
Sahte webhookSahte "ödendi" ile ürün gönderimiHMAC imza, zaman damgası toleransı, IP izin listesi
PSP tamamen düşerYeni ödemeler başarısızCircuit breaker, yeni ödemeleri ikinci PSP'ye; belirsizleri bekletmek
Kafka/relay gecikirSipariş servisi ödemeyi geç görürOutbox lag alarmı; sipariş servisi gerekirse ödeme API'sine sorar
Ledger sistem hesabı sıcaklaşırKilit beklemesiBakiye satırı tutmamak veya hesabı alt hesaplara bölmek
Mutabakat farkı büyürPara kaybı veya müşteri şikayetiGü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?"

İ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?"

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?"

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 UNKNOWN olarak 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ı 2xx kullanı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 500 cevapları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ı.