8 Farklı Ödeme Sağlayıcısını Tek Muhasebe Sisteminde Birleştirmek: Mutabakat Mimarisi
Sanal (uygulama içi) coin bayileri için geliştirdiğim muhasebe sisteminde en zor problem SignalR ya da UI değildi — sekiz farklı dış servisten (banka makbuz tarayıcıları, birden fazla ödeme sağlayıcısı, e-fatura) gelen veriyi tek, tutarlı bir mutabakat kaydına dönüştürmekti. Her servisin kendi formatı, kendi gecikme profili ve kendi hata modları vardı. Bu yazıda o mimariyi nasıl kurduğumu anlatıyorum.
Neden tek bir "senkronizasyon job'ı" yeterli olmadı
İlk yaklaşımım tek bir zamanlanmış job'ın sırayla sekiz servisi dolaşmasıydı. İki sorun çıktı:
- Domino etkisi: Servislerden biri (örneğin bir ödeme sağlayıcısının API'si) yavaşladığında, sıradaki yedi servisin işlenmesi de gecikiyordu.
- Kısmi başarısızlık takibi zor: Job ortasında hata verirse, hangi servislerin işlendiği hangi servislerin işlenmediği belirsizleşiyordu.
Çözüm, her dış servis için bağımsız bir BackgroundService worker'ı yazmaktı — her biri kendi polling aralığında, kendi hata yönetimiyle, birbirinden habersiz çalışıyor:
public class BankReceiptWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var receipts = await _bankClient.FetchNewReceiptsAsync(stoppingToken);
foreach (var receipt in receipts)
{
await _reconciliationService.ProcessReceiptAsync(receipt, stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Banka makbuz senkronizasyonu başarısız");
// Bu worker'ın hatası diğer worker'ları etkilemiyor.
}
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
}
}Mutabakat: aynı ödemeyi iki kez işlememek
Sekiz servisten gelen veriler bazen aynı gerçek ödemeyi farklı zamanlarda, farklı kimliklerle bildiriyordu (örneğin banka makbuzu ödemeyi saat 14:03'te, ödeme sağlayıcısı webhook'u 14:05'te görüyor). Aynı ödemeyi iki kez işlemek, bayinin bakiyesine çift ekleme yapmak demekti — muhasebe sisteminde bu affedilemez bir hata sınıfı.
Çözüm, her gelen kaydı tutar + zaman penceresi + gönderen bilgisi üçlüsünden türetilen bir idempotency anahtarıyla işlemek oldu:
var idempotencyKey = $"{amount:F2}_{senderIdentifier}_{timestamp:yyyyMMddHH}";
var exists = await _db.ReconciliationRecords
.AnyAsync(r => r.IdempotencyKey == idempotencyKey, stoppingToken);
if (exists)
{
continue; // Zaten işlenmiş, aynı ödemenin farklı kaynaktan tekrarı.
}Saat bazlı pencere (yyyyMMddHH) kullanmamın sebebi, aynı kişiden aynı tutarda ama gerçekten farklı iki ödemeyi (örneğin bir saat arayla) yanlışlıkla aynı ödeme sanmamaktı — dakika hassasiyeti çok katı olurdu (banka/sağlayıcı arasındaki saat senkron sapması yüzünden gerçek eşleşmeleri kaçırabilirdi), saat hassasiyeti pratikte yeterli ayrım sağladı.
Kısmi eşleşme: insan onayına düşen vakalar
Her ödeme otomatik eşleşmiyor — tutar birebir uymadığında (örneğin banka komisyonu düşülmüş bir tutar geldiğinde) sistem bunu otomatik işlemek yerine bir "beklemede" kuyruğuna atıyor ve muhasebe ekibine SignalR üzerinden gerçek zamanlı bildirim gönderiyordu. Otomasyonun amacı %100 insan müdahalesini sıfırlamak değil, %90'ı otomatikleştirip kalan %10'u insanın hızlıca karar verebileceği net bir kuyruğa indirgemekti.
E-fatura entegrasyonu: senkron değil, kuyruklu
E-fatura sağlayıcısının API'si zaman zaman 30 saniyeyi bulan yanıt süreleri veriyordu. Bunu diğer worker'ları bloklamadan işlemek için ayrı bir kuyruğa (basit bir veritabanı tablosu + FOR UPDATE SKIP LOCKED benzeri bir claim deseni) aldım; e-fatura worker'ı kendi hızında kuyruğu tüketiyor, diğer mutabakat akışı onu beklemiyor.
Sonuç
Sekiz bağımsız worker, saat penceresi bazlı idempotency anahtarı ve insan onayına düşen kısmi eşleşme kuyruğu — bu üç karar, 67'den fazla migration'la uzun süre üretimde kalan bir sistemin temelini oluşturdu. Sistemin BCrypt/rol tabanlı yetkilendirme ve raporlama tarafını proje sayfasında bulabilirsin.