Payments Service: Fake Ödeme Sağlayıcısı, Idempotent Charge ve Webhook Callback'i
Payments, ödeme sağlayıcısına siparişten türetilen bir Idempotency-Key ile charge gönderiyor, POST'u HTTP katmanında tekrar etmiyor ve zaman aşımını başarısızlık değil bilinmeyen sonuç sayıyor; sonucu imzalı ve replay korumalı bir webhook ya da reconciliation job'ı belirliyor. Sağlayıcının modeli bir anti-corruption layer'da kalıyor; iptal edilmiş siparişe geç gelen ödeme uçtan uca iadeye dönüşüyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, WolverineFx 6.64, Microsoft.Extensions.Http.Resilience 10.11 ve Entity Framework Core 10 ile derlenip denenmiştir.
Önce Tahmin Et
Payments, ödeme sağlayıcısına bir charge isteği gönderiyor ve 2 saniye içinde cevap alamıyor. Oysa sağlayıcı isteği almış ve kartı çekmiş; yalnız cevabı geç kalmış. Payments bu durumda checkout saga'sına PaymentFailed göndermeli mi? İkinci soru: sağlayıcı aynı "ödeme başarılı" webhook'unu iki kez gönderirse sipariş için kaç PaymentSucceeded event'i yayınlanır? Cevaplar gerçek çalıştırmalarda.
Sahte Bir Ödeme Sağlayıcısı: Neden ve Nerede?
07'de Payments bir if'ten ibaretti: 5.000 TRY'nin (Türk lirası) üstünü reddediyordu. Gerçek bir ödeme entegrasyonunun zor kısımları ise if'te değil, ağda: zaman aşımı, tekrar, geç gelen sonuç, sahte istek. Bunları lokalde görmek için sitedeki serilerin ortak örnek uygulaması Bookstore'a (bir online kitapçı) küçük bir sağlayıcı simülatörü ekledik. Gerçek bir markayı taklit etmiyor; çoğu kart ödeme API'sinin ortak davranışını veriyor: Idempotency-Key header'ı zorunlu charge, sonucun webhook ile asenkron gelmesi, merchant referansıyla sorgu ve iade.
Proje src/Simulators/Bookstore.FakePaymentProvider'da, src/Services'ta değil. Bizim service'imiz değil, dış dünyanın yerini tutuyor: ServiceDefaults kullanmıyor, contract paylaşmıyor, Payments ile yalnız HTTP konuşuyor. 01'deki architecture test de onu service saymıyor. AppHost'ta ikisinin arasında iki bağ var: Payments sağlayıcıyı service discovery ile buluyor, sağlayıcı da webhook'u Payments'ın adresine gönderiyor. İkisi webhook imzası için bir secret paylaşıyor:
// Sağlayıcının webhook imzaları için paylaşılan secret (Standard Webhooks: "whsec_" + base64).
// 32 harf ve rakam geçerli bir base64 (24 byte); Aspire onu bir kez üretip user secrets'ta saklıyor.
IResourceBuilder<ParameterResource> webhookSecret = builder.AddResource(ParameterResourceBuilderExtensions.CreateGeneratedParameter(
builder, "payment-webhook-secret", secret: true,
new GenerateParameterDefault { MinLength = 32, Lower = true, Upper = true, Numeric = true, Special = false }));
// Dış kart ödeme sağlayıcısının yerini tutuyor. Production'da bu, bizim çalıştırdığımız bir resource değil, sağlayıcının URL'i.
IResourceBuilder<ProjectResource> paymentProvider = builder.AddProject<Projects.Bookstore_FakePaymentProvider>("payment-provider")
.WithEnvironment("Webhooks__Secret", ReferenceExpression.Create($"whsec_{webhookSecret}"));
Payments'ın da artık kendi veritabanı tablosu var: Payments (sipariş başına bir ödeme, durumu ve sağlayıcının charge id'si) ve webhook'lar için bir inbox tablosu. Migration'ları diğer service'ler gibi payments-migrator resource'u çalıştırıyor.
Anti-Corruption Layer: Sağlayıcının Modeli Sınırda Kalıyor
Sağlayıcının API'si snake_case JSON konuşuyor ve durumları kendi string'leriyle veriyor ("pending", "succeeded", "failed"). Bu kelimelerin Payments'ın domain'ine, oradan da saga'ya sızmasını istemiyoruz; yarın sağlayıcı değişirse yalnız bir klasör değişmeli. PaymentProviderClient bu çeviriyi yapan tek yer ve dışarıya kendi tipini veriyor:
// Anti-corruption layer: sağlayıcının JSON'u, durum string'leri ve hata kodları burada kalıyor.
// Payments'ın geri kalanı yalnız ChargeOutcome'u görüyor.
public abstract record ChargeOutcome
{
// Sağlayıcı cevap verdi. Pending "kabul edildi, sonuç webhook ile gelecek" demek.
public sealed record Answered(string ChargeId, ChargeState State, string? FailureReason) : ChargeOutcome;
// Timeout, 5xx ya da kopan bağlantı: kart çekilmiş de olabilir, çekilmemiş de.
public sealed record Unknown(string Reason) : ChargeOutcome;
}
Sağlayıcının DTO'ları (Data Transfer Object; yalnız veri taşıyan tipler: ProviderCharge, ProviderWebhook) internal ve Provider klasörünün dışında kullanılmıyor. Kavramın kendisi DDD (Domain-Driven Design; iş alanını modelin merkezine koyan tasarım yaklaşımı) kaynaklı; Microservices serisinde de geçti. Burada önemli olan Unknown'ın ayrı bir sonuç olması: "başarısız" ile "bilmiyorum" aynı şey değil.
Idempotency-Key ile Charge ve POST'ta Retry'ı Kapatmak
Idempotency-Key sağlayıcıya şunu söylüyor: bu anahtarla daha önce bir istek geldiyse yeni bir charge açma, ilkinin sonucunu dön. Anahtarı rastgele üretmiyoruz, siparişten türetiyoruz (charge-{OrderId}). Böylece aynı sipariş için yapılan her deneme, ister ilk handler'dan ister günler sonra reconciliation job'ından gelsin, aynı anahtarı taşıyor. Simülatörü doğrudan iki kez çağırdık:
HTTP/1.1 202 Accepted
{"id":"ch_c6179d1e0aa6430a906586132e99e6c8","reference":"0f8e2c6a-1111-4c3b-9d1e-000000000001","amount":249.90,"currency":"TRY","status":"pending","failure_code":null}
HTTP/1.1 200 OK
Idempotent-Replayed: true
{"id":"ch_c6179d1e0aa6430a906586132e99e6c8","reference":"0f8e2c6a-1111-4c3b-9d1e-000000000001","amount":249.90,"currency":"TRY","status":"pending","failure_code":null}
İkinci istek aynı charge'ı döndü; kart iki kez çekilmedi. Peki tekrarı kim yapmalı? ServiceDefaults her HttpClient'a standard resilience handler ekliyor ve onun retry'ı bir timeout'tan sonra POST'u da tekrar ediyor. Charge için bu kararı HTTP katmanına bırakmak istemiyoruz: tekrarın sahibi mesaj handler'ı ve reconciliation job'ı olmalı, ikisi de durumu veritabanına yazarak. Payments'ın client'ında varsayılan handler'ı kaldırıp yerine POST'u tekrar etmeyen bir tane koyduk:
// ServiceDefaults her client'a standard handler veriyor; onun retry'ı bir timeout'tan sonra POST'u tekrar ediyor.
// Charge'ı tekrar etmek HTTP katmanının değil, mesaj handler'ının kararı (aynı Idempotency-Key ile).
// RemoveAllResilienceHandlers, Microsoft.Extensions.Http.Resilience 10.11'de hâlâ experimental işaretli.
#pragma warning disable EXTEXP0001
builder.Services.AddHttpClient<PaymentProviderClient>(client => client.BaseAddress = new Uri("https+http://payment-provider"))
.RemoveAllResilienceHandlers()
.AddStandardResilienceHandler(options =>
{
options.Retry.DisableForUnsafeHttpMethods();
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(5);
});
#pragma warning restore EXTEXP0001
İlk siparişte AuthorizePayment handler'ı hiç çalışmadı ve mesaj payments.dead-letter'a düştü:
[payments] Wolverine.Configuration.InvalidServiceLocationException: Found service locations while generating code for Message Handler for
Bookstore.Contracts.Payments.AuthorizePayment, but ServiceLocationPolicy.NotAllowed is in effect (it is the default).
[payments] Service Bookstore.Payments.Provider.PaymentProviderClient: The service registration for Bookstore.Payments.Provider.PaymentProviderClient is an
'opaque' lambda factory with the Transient lifetime and requires service location
Wolverine 6 handler kodunu üretirken bağımlılıkları kendisi kuruyor ve bir factory lambda'sının arkasını göremeyince varsayılan olarak durmayı seçiyor. Typed HttpClient tam olarak böyle kaydediliyor. Çözüm, bu tip için service location'a izin vermek: opts.CodeGeneration.AlwaysUseServiceLocationFor<PaymentProviderClient>(). DLQ'daki (Dead Letter Queue; işlenemeyen mesajların ayrıldığı queue) mesajı management API'si ile payments.commands'a geri taşıdık ve 06'daki ★★★ alıştırmasının cevabı log'a düştü: Discarding a duplicate delivery of envelope ... which the inbox already holds. Wolverine'in inbox'ı dead letter'a düşen mesajın id'sini de saklıyor; mesaj ancak yeni bir message id ile gönderilince işlendi.
Bilinmeyen Sonuç ve Reconciliation Job'ı
Handler'ın kendisi kısa: ödeme yoksa oluştur, sağlayıcıyı çağır, sonucu kaydet. Önemli olan Unknown dalı:
ChargeOutcome outcome = await provider.ChargeAsync(payment.IdempotencyKey, command.OrderId, command.Amount, command.Currency, ct);
switch (outcome)
{
case ChargeOutcome.Answered { State: ChargeState.Pending } answered:
payment.MarkSubmitted(answered.ChargeId, clock);
return null;
case ChargeOutcome.Answered answered:
return payment.Complete(answered.State == ChargeState.Succeeded, answered.ChargeId, answered.FailureReason, clock);
case ChargeOutcome.Unknown unknown:
// Başarısızlık değil: kart çekilmiş olabilir. Reconciliation öğreniyor; saga beklemeye devam ediyor.
payment.MarkUnknown(unknown.Reason, clock);
return null;
default:
throw new InvalidOperationException($"Unexpected outcome {outcome}");
}
Payments'ta Wolverine'in Entity Framework Core (EF Core) transaction'ını Lightweight modda kullanıyoruz: handler sağlayıcının cevabını beklerken veritabanında açık bir transaction tutulmuyor, sondaki SaveChanges ödemeyi ve giden mesajları birlikte yazıyor.
Önce Tahmin Et'teki durumu simülatörde kurduk: cevabı 3 saniye geciktirdik ve webhook'ları düşürdük. Sağlayıcı charge'ı kabul edip başarıyla sonuçlandırdı ama Payments bunu ne cevaptan ne webhook'tan öğrenebildi:
[payment-provider] Charge ch_1a068030b95249d48ce9f59f19cde670 for 01a1da89-dc16-79d5-8b68-834d27b5ce70: 219.90 TRY accepted (key charge-01a1da89-dc16-79d5-8b68-834d27b5ce70)
[payments] Charge charge-01a1da89-dc16-79d5-8b68-834d27b5ce70 has an unknown result: TimeoutRejectedException
[payment-provider] Webhook evt_7c184bbe3ec2464281460b5ab11985ee for ch_1a068030b95249d48ce9f59f19cde670 dropped (simulation)
[payments] Reconciling order 01a1da89-dc16-79d5-8b68-834d27b5ce70 (Unknown): provider says Answered { ChargeId = ch_1a068030b95249d48ce9f59f19cde670, State = Succeeded, FailureReason = }
14:50:33 order=StockReserved payment=Unknown
14:50:37 order=StockReserved payment=Unknown
...
14:50:47 order=StockReserved payment=Unknown
14:50:50 order=Confirmed payment=Succeeded
Önce Tahmin Et'in ilk cevabı: PaymentFailed göndermek yanlış olurdu. Saga siparişi iptal edip stoğu serbest bırakırdı, müşterinin kartından ise para çekilmiş olurdu. Unknown saga'ya hiçbir şey söylemiyor; saga 15 dakikalık timeout'una kadar beklemeye devam ediyor. Reconciliation job'ı periyodik olarak Unknown ve uzun süredir Submitted olan ödemeleri alıyor ve sağlayıcıya sipariş referansıyla soruyor. Sağlayıcı charge'ı tanıyorsa sonucunu uyguluyor; tanımıyorsa (istek sağlayıcıya hiç ulaşmamışsa) charge'ı aynı Idempotency-Key ile yeniden gönderiyor. Lokalde aralık 10 saniye; appsettings.json'daki varsayılan bir dakika. Mekanizmanın genel hâli External Service Integration ve Resilience serisinin Idempotent Dış Çağrılar yazısında.
Webhook: İmza, Replay Koruması ve Inbox
Webhook endpoint'i internete açık bir kapı: herkes ona "ödeme başarılı" yazan bir JSON gönderebilir. Simülatör Standard Webhooks spesifikasyonunun formatını kullanıyor: webhook-id, webhook-timestamp ve webhook-signature header'ları, imza da id.timestamp.body üzerinde hesaplanan bir HMAC (Hash-based Message Authentication Code; paylaşılan anahtarla üretilen imza), algoritması HMAC-SHA256. Doğrulamanın kalbi:
// Eski bir timestamp, yakalanmış bir isteğin tekrarı demek; imzası doğru olsa bile
if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out long seconds) ||
(clock.GetUtcNow() - DateTimeOffset.FromUnixTimeSeconds(seconds)).Duration() > Tolerance)
{
error = "timestamp outside tolerance";
return false;
}
string secret = configuration["PaymentProvider:WebhookSecret"]
?? throw new InvalidOperationException("PaymentProvider:WebhookSecret is not configured.");
byte[] key = Convert.FromBase64String(secret["whsec_".Length..]);
byte[] expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"));
// Secret değiştirilirken header birden fazla imza taşıyabilir ("v1,abc v1,def")
foreach (string candidate in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (candidate.StartsWith("v1,", StringComparison.Ordinal) &&
TryFromBase64(candidate[3..], out byte[] actual) &&
CryptographicOperations.FixedTimeEquals(actual, expected))
{
error = "";
return true;
}
}
Karşılaştırma FixedTimeEquals ile, çünkü sıradan bir eşitlik kontrolü ilk farklı byte'ta durur ve süre farkından imza tahmin edilebilir. İmza ham body üzerinde, bu yüzden endpoint body'yi JSON'a çevirmeden önce string olarak okuyor. Doğrulamadan sonra endpoint webhook id'sini ProcessedWebhooks tablosuna, ödemenin yeni durumunu ve saga'ya gidecek event'i aynı transaction'da outbox ile yazıyor. Dört istek gönderdik; imzalar AppHost'un ürettiği secret ile hesaplandı:
tampered signature
HTTP 401
valid signature, timestamp 10 minutes old
HTTP 401
valid signature, first delivery
HTTP 200
valid signature, same webhook-id again
HTTP 200
[payments] Rejected webhook evt_manual_1: signature mismatch
[payments] Rejected webhook evt_manual_2: timestamp outside tolerance
[payments] Webhook evt_manual_3: payment for order 01a1da88-0849-7911-9bb1-f0cbbd3ea21c is Succeeded
[payments] Webhook evt_manual_3 already processed
Önce Tahmin Et'in ikinci cevabı: tek bir PaymentSucceeded. Aynı webhook-id ikinci kez geldiğinde inbox tablosu onu tanıdı ve 200 döndü; sağlayıcılar 2xx görene kadar tekrar gönderdiği için 200 doğru cevap. Farklı id'li ama aynı ödemeye ait bir webhook gelse bile (üçüncü istek bu siparişin ikinci "başarılı" bildirimiydi) Payment.Complete ödeme zaten sonuçlandığı için null dönüyor ve yeni bir event yayınlanmıyor. Webhook almanın ayrıntıları External Service Integration ve Resilience serisinin Webhook Almak yazısında.
Uçtan Uca: Geç Gelen Ödeme ve İade
Son deneyde 07'deki zaman aşımı senaryosunu gerçek sağlayıcı akışıyla birleştirdik: Ordering'in ödeme timeout'unu 5 saniyeye indirdik, simülatörü yine yavaş ve webhook'suz bıraktık:
14:53:11 order=StockReserved payment=Unknown
14:53:14 order=Cancelled payment=Unknown
14:53:17 order=Cancelled payment=Unknown
...
14:53:27 order=Cancelled payment=Refunded
[payments] Charge charge-01a1da8c-4371-7776-ad13-7cceaac4b095 has an unknown result: TimeoutRejectedException
[payments] Reconciling order 01a1da8c-4371-7776-ad13-7cceaac4b095 (Unknown): provider says Answered { ChargeId = ch_c19a4102926b4b56b141bc8aea4594d0, ...
[payments] Refunded 219.90 TRY for order 01a1da8c-4371-7776-ad13-7cceaac4b095: order-cancelled
[payment-provider] Refund re_7be787c21f194399aa28389a554b0e9a for charge ch_c19a4102926b4b56b141bc8aea4594d0 (key refund-01a1da8c-4371-7776-ad13-7cceaac4b095)
Saga 5 saniyede siparişi iptal etti. Reconciliation ödemenin aslında başarılı olduğunu buldu ve PaymentSucceeded yayınladı. Saga artık yoktu; 07'deki NotFound siparişin iptal edildiğini görüp RefundPayment gönderdi ve Payments iadeyi kendi Idempotency-Key'iyle sağlayıcıya iletti. Bu zinciri ilk denediğimizde iade 500 aldı: simülatör iade isteğinin charge_id alanını snake_case olarak okumuyordu. Mesaj hiç tekrar denenmeden DLQ'ya düştü, çünkü Payments'ta HttpRequestException için bir kural yoktu. Simülatörü düzelttik ve Payments'a bir kural ekledik: sağlayıcı kapalıysa ya da 5xx dönüyorsa mesaj 1, 5 ve 30 saniye beklenerek tekrar deneniyor. Bu güvenli, çünkü her çağrı siparişten türetilen bir Idempotency-Key taşıyor.
Üretimde Dikkat
Kart verisi bizde yok. Payments yalnız tutarı, sipariş referansını ve sağlayıcının charge id'sini tutuyor. Kart numarası tarayıcıdan doğrudan sağlayıcıya gider; bu, PCI'ın (Payment Card Industry; kart verisi işleyen sistemlerin uyması gereken güvenlik standartları) kapsamını küçük tutmanın en etkili yolu.
Unknown ödemelere alarm koy. Reconciliation'ın çözemediği, bir saatten eski Unknown ya da Submitted ödeme, sağlayıcıyla aranızda bir şeylerin bozulduğu anlamına gelir ve bir insanın bakması gerekir.
Secret'ı değiştirmeyi baştan planla. Doğrulayıcı header'daki birden fazla imzayı kabul ediyor; sağlayıcı bir süre eski ve yeni secret ile birlikte imzalar, sonra eskisi kaldırılır. Webhook endpoint'i production'da yalnız HTTPS üzerinden açılmalı.
İade bir para hareketi. Kimin, ne zaman, hangi sebeple iade yaptığı audit log'da durmalı; burada RefundPayment mesajındaki Reason alanı bunun başlangıcı.
Gerçek sağlayıcının sandbox ortamıyla da test et. Simülatör davranışı öğretmek ve hata senaryolarını kurmak için iyi, ama gerçek API'nin hata kodlarını ve webhook tiplerini ancak gerçeği gösterir.
Sık Düşülen Tuzaklar
Timeout'u "başarısız" saymak. Bilinmeyen sonucu başarısız sayıp siparişi iptal etmek, parası çekilmiş müşteriye ürün göndermemek demek.
Idempotency-Key'i her denemede yeniden üretmek. Rastgele bir anahtar, retry'da yeni bir charge açar. Anahtar işten (siparişten) türemeli ve denemeler arasında değişmemeli.
HTTP retry'ı ve mesaj retry'ını üst üste koymak. İki katman da kendi denemelerini yaparsa istek sayısı çarpılır ve hangisinin tekrar ettiği log'dan da anlaşılmaz.
Webhook body'sini önce JSON'a çevirip sonra imzayı kontrol etmek. Yeniden serialize edilen JSON'un byte'ları farklıdır ve imza hiç tutmaz; daha kötüsü, imzasız alanlar okunmuş olur.
Sağlayıcının modelini domain'e taşımak. "succeeded" string'ini Payments'ın dört yerinde karşılaştırmak, sağlayıcı değiştiğinde dört yeri değiştirmek demek.
Küçük Bir Deneme
★☆☆ payment-provider resource'unu durdur ve bir sipariş gönder. Payments'ta ödemenin durumu ne oluyor? Sağlayıcıyı başlattıktan sonra reconciliation job'ının log'unda no charge, submitted again satırını bul.
★★☆ Simülatörün webhook'u ilk iki denemede 500 alacak şekilde Payments'a geçici bir hata ekle (ör. bir ayarla ilk iki isteği reddet). Sağlayıcının tekrarları ve ProcessedWebhooks tablosu birlikte ne gösteriyor?
★★★ Kısmi iade ekle: RefundPayment'a tutar alanı koy, Payments'ta iadelerin toplamının charge'ı geçmesini engelle ve her iadenin kendi Idempotency-Key'ini nasıl üreteceğine karar ver. Aynı iade mesajı iki kez gelirse ne olmalı?
Checkout artık gerçek bir ödeme akışının hatalarına dayanıklı. Ama service'lerin hepsi hâlâ ayrı port'larda, ayrı adreslerde ve kimlik doğrulamasız duruyor. Bir sonraki yazıda Angular'ın tek giriş noktasını kuruyoruz: YARP (Yet Another Reverse Proxy; Microsoft'un .NET için reverse proxy library'si) ile Bookstore.Bff projesi, /api/catalog ve /api/orders route'ları, kitap sayfası için Catalog ile Inventory'yi birleştiren ve biri cevap vermediğinde de çalışan bir aggregation endpoint'i, route bazlı rate limit ve timeout.