Toplam bir kuruş tutmuyor: para, yuvarlama ve döviz kuru yazılımda nasıl saklanır?
Muhasebenin bulduğu bir kuruşluk farkın kaynağı neredeyse her zaman aynı dört yerden biridir: tutar kayan noktalı sayı olarak saklanmıştır, para birimi tutardan ayrı dolaşmaktadır, yuvarlama koda birden çok yere dağılmıştır ya da kur işlemin kendisiyle birlikte kaydedilmemiştir. Doğru kurgu kısa. Tutarı tam sayı kuruş ya da decimal tipiyle saklayın, para birimini tutarın yanından ayırmayın, yuvarlamayı önceden yazılmış tek bir kuralla ve tek bir noktada yapın, kullandığınız kuru tarihi ve kaynağıyla birlikte işlem kaydına yazın. Aşağıdaki başlıklar bu dördünün nerede ve nasıl bozulduğunu anlatıyor.
Kayan noktalı sayı parayı taşıyamaz
0.1 + 0.2 çoğu dilde 0.30000000000000004 verir. Sebep dilin hatası değil, IEEE 754 ikilik gösterimidir: 0,1 sayısı ikilik tabanda devirli bir kesirdir ve tam olarak saklanamaz. Tek bir satırda fark görünmez. Yüz bin satırlık bir ay sonu mutabakatında görünür, üstelik her çalıştırmada aynı yerde değil.
İki doğru seçenek var. Birincisi tutarı en küçük birimde tam sayı olarak saklamak: 1.299,90 TL yerine 129990 kuruş. İkincisi dilin ve veritabanının kesin ondalık tipini kullanmak: PostgreSQL'de numeric, MySQL'de DECIMAL, Java'da BigDecimal, .NET'te decimal, Python'da decimal.Decimal.
JavaScript'in dil seviyesinde ondalık tipi yok. Tam sayı kuruşla çalışırsanız güvenli aralık Number.MAX_SAFE_INTEGER, yani 9.007.199.254.740.991 kuruş; yaklaşık 90 trilyon TL. Toplama ve çıkarma için fazlasıyla yeter. Sorun bölme yaptığınız anda başlar, çünkü sonuç artık tam sayı değildir. O noktada ya BigInt ile çalışın ya da bir ondalık kütüphanesi kullanın.
Veritabanı tarafında iki tuzak var. Birincisi para sütununu FLOAT ya da DOUBLE PRECISION yapmak; yukarıdaki sorunu kalıcı hale getirir. İkincisi PostgreSQL'in money tipi. PostgreSQL wiki'sinin "Don't Do This" sayfası bu tipi adıyla sayar: değerle birlikte para birimi saklamaz, bunun yerine veritabanının lc_monetary ayarını varsayar. Aynı satırı farklı bir yerel ayarla okuduğunuzda farklı bir sembolle görürsünüz. Doğrusu numeric, yanında ayrı bir para birimi sütunuyla.
Tutar ile para birimi tek parça halinde dolaşmalı
"Toplam 1.250" cümlesi bir bilgi taşımaz. Para bir sayı değil, tutar ve birim çiftidir. Şemada bunu numeric(19,4) ve char(3) sütun çifti olarak kurun; kodda ise ikisini bir arada tutan bir Money tipi tanımlayın.
Bu tipin asıl faydası toplamayı engellemesidir. Farklı para birimindeki iki tutarı toplamak bir hesap hatası değil, bir tasarım hatasıdır ve tip sistemi bunu derleme ya da çalışma zamanında yakalayabilir. Elinizde yalın decimal değerler dolaşıyorsa, 100 EUR ile 100 TL'yi toplayan kodu kimse fark etmeden birleştirir.
Her para biriminde ondalık iki hane değildir
ISO 4217 her para birimi için bir "minor unit" üssü tanımlar. Çoğu birimde bu 2'dir, ama Japon yeninde (JPY) 0'dır; yen tam sayı olarak işlem görür. Kuveyt dinarı (KWD), Bahreyn dinarı (BHD), Ürdün dinarı (JOD), Umman riyali (OMR) ve Tunus dinarında (TND) ise 3'tür.
Ondalık hane sayısını 2 olarak sabitleyen bir kod bu birimlerde sessizce yanlış hesaplar. Ödeme sağlayıcıları bu yüzden tutarları en küçük birimde ister: Stripe'ta 10,99 USD 1099, 10 JPY ise 10 olarak gönderilir. Aynı alan, farklı ölçek. Para birimi tablonuzda ondalık hane sayısını veri olarak tutun, koda gömmeyin.
Bir ayrım daha gerekiyor: hesap hassasiyeti ile gösterim hassasiyeti aynı şey değildir. Kilogram, metre ya da bin adet üzerinden fiyatlanan ürünlerde birim fiyat dört ya da altı haneye ihtiyaç duyabilir. Birim fiyatı dört haneyle saklayıp satır toplamını iki haneye yuvarlamak doğru; birim fiyatı da iki haneye kırpmak, miktar büyüdükçe büyüyen bir hata üretir.
Yuvarlama kuralını siz seçin, kütüphane seçmesin
Yarıda kalan değerlerde iki yaygın kural var: yukarı yuvarlama (half-up, 2,345 → 2,35) ve çift sayıya yuvarlama (half-even ya da bankacı yuvarlaması, 2,345 → 2,34). İkincisi büyük veri kümelerinde sistematik yukarı kaymayı engeller, ama fatura okuyan insan için sezgisel değildir.
Önemli olan hangisini seçtiğiniz değil, seçimin bilinçli olmasıdır. Platformlar aynı davranmaz: .NET'te Math.Round varsayılan olarak MidpointRounding.ToEven uygular, yani bankacı yuvarlaması yapar. Java'da Math.round yukarı yuvarlar, BigDecimal.setScale ise yuvarlama modunu açıkça vermezseniz gerektiğinde istisna fırlatır. Üç farklı serviste üç farklı varsayılanla çalışıyorsanız, farkı yalnızca mutabakatta görürsünüz.
Pratik kural: yuvarlamayı tek bir yardımcı fonksiyona indirin, kuralı şartnamenin içine yazın ve ara sonuçları yuvarlamayın. Her adımda yuvarlanan bir hesap, adım sayısı kadar hata biriktirir.
Satır satır yuvarlarsanız fatura toplamı tutmaz
KDV hesabında en sık yapılan hata her satırın vergisini ayrı ayrı yuvarlayıp bunları toplamaktır. Doğrusu, aynı orana tabi satırları gruplayıp vergiyi grup matrahı üzerinden bir kez hesaplamaktır. Avrupa e-fatura standardı EN 16931 bunu kural haline getirmiş durumda: BR-CO-17 maddesi, vergi grubunun tutarının grup matrahı çarpı oran olarak ve iki haneye yuvarlanarak hesaplanmasını şart koşar; BR-CO-14 de fatura toplam KDV'sinin grup tutarlarının toplamına eşit olmasını ister. Doğrulayıcılar küçük bir tolerans tanır, ama hesabınızı o toleransın üzerine kurmayın.
Türkiye'de aynı fatura üzerinde birden fazla oran görmek olağandır; 10 Temmuz 2023'ten bu yana genel oran %20, indirimli oranlar %10 ve %1. Yani gruplama kodu bir kenar durumu değil, normal akıştır.
Buna rağmen bir kuruşluk fark kalabiliyorsa UBL'nin PayableRoundingAmount alanı bunun içindir: ödenecek tutara eklenen düzeltme kalemi. Alanın varlığı, farkı kapatmanın normal yöntem olduğu anlamına gelmiyor. Önce grup bazlı hesabı doğru kurun, bu alanı son çare olarak bırakın.
Bölüştürmede kaybolan kuruş
100,00 TL'yi üçe bölün: 33,33 + 33,33 + 33,33 = 99,99. Bir kuruş kayboldu. Martin Fowler'ın Money deseninde bu işin adı "allocate"tir: bölüştürmeyi yaparken kalanı deterministik bir sırayla ilk alıcılara dağıtırsınız, böylece parçaların toplamı her zaman girdiye eşit kalır.
Bu problem düşündüğünüzden çok yerde karşınıza çıkar. Sipariş toplamına uygulanan indirimin satırlara dağıtılması, kargo bedelinin kalemlere paylaştırılması, taksit planının oluşturulması, bayi komisyonunun hesaplanması, paylaşımlı ödemelerin bölünmesi. Hepsinde aynı değişmez geçerli: parçaların toplamı bütüne eşit olmalı. Bunu bir test olarak yazın, bir daha unutmayın.
Kur bir alan değil, bir kayıttır
Dövizli bir işlemi doğru saklamak için beş şey gerekir: tutar, para birimi, kullanılan kur, kurun yönü (hangi birim başına hangi birim) ve kurun tarihi ile kaynağı. Bunların hepsi işlem kaydının üzerinde durmalı. Kuru ayrı bir tabloda tutup rapor anında yeniden okumak, geçmiş işlemlerin değerini her gün değiştirir.
En sık görülen hata kurun ters çevrilmesidir. 1 USD = 49 TL ile 1 TL = 49 USD arasındaki fark koda bakınca görünmez, sonuca bakınca anında görünür. Dönüşüm fonksiyonuna kaynak ve hedef para birimini zorunlu parametre yapın, sonuç için makul aralık kontrolü koyun.
Türkiye'de referans kaynak TCMB'nin günlük bülteni. Kurlar her iş günü saat 15.30'da belirlenip aynı gün yayımlanıyor, ertesi gün Resmî Gazete'de çıkıyor. Teknik tarafta iki ayrıntı önemli. Birincisi, bültende hem döviz (ForexBuying, ForexSelling) hem efektif (BanknoteBuying, BanknoteSelling) kurları var; hangisini kullanacağınız iş kuralıdır, varsayılan olarak seçilecek bir şey değil. İkincisi, her kayıttaki Unit alanı: Japon yeni 100 birim üzerinden kote edilir, yani ForexBuying değerini Unit'e bölmeden kullanırsanız kur yüz kat şişer.
Bülten yalnızca iş günlerinde yayımlanır. today.xml adresinin yanında kurlar/YYYYAA/GGAAYYYY.xml biçiminde tarih bazlı arşiv var, ama hafta sonu ve resmî tatil için dosya yoktur. Entegrasyonun bir önceki iş gününe düşmesi ve kuru kendi tarafında önbelleğe alması gerekir. Bu, dış servis kesintilerine dayanıklılık yazısında anlattığımız desenin küçük ama somut bir örneği.
Dövizli faturada Türk lirası karşılığı
Vergi Usul Kanunu'nun 215. maddesi kayıt ve belgelerde Türk para biriminin esas olduğunu, belgelerin Türk parası karşılığı gösterilmek şartıyla yabancı para birimine göre düzenlenebileceğini söyler. 385 sıra numaralı genel tebliğ bunu netleştirir: yurt içindeki müşteriye düzenlenen dövizli faturada hem döviz tutarı hem Türk lirası karşılığı bulunmalıdır. Yurt dışındaki müşteriye kesilen ihracat faturasında bu şart aranmaz. Bu hukuki tarafı; sizin tarafınızdan bakıldığında şu anlama gelir: fatura modelinizin belge para birimi, kur ve kurun tarihi alanlarını taşıması gerekir.
e-Fatura XML'inde bunların karşılığı var. Belge para birimi DocumentCurrencyCode alanında, kur bilgisi ise cac:PricingExchangeRate bloğunda duruyor: kaynak para birimi, hedef para birimi, hesaplanan kur ve kurun tarihi.
Dövizli satışın tahsilatı farklı bir günde ve farklı bir kurla yapıldığında ortaya çıkan fark, muhasebe tarafında ayrıca belgelenir. Yazılım tarafında bunun tek bir gereksinimi var: tahsilat kaydı hangi faturayı kapattığını ve kendi kurunu bilmeli. Faturanın orijinal kurunu tahsilat anında güncellemek, farkı hesaplanamaz hale getirir. Ödeme entegrasyonu ve mutabakat yazısında bu eşleştirmenin nasıl kurulduğunu ayrıntılandırmıştık.
Raporlamada iki tutarı birden saklayın
Çok para birimli bir sistemde her işlemin iki tutarı vardır: işlemin kendi para birimindeki tutar ve şirketin raporlama para birimindeki karşılığı. İkincisini işlem anındaki kurla hesaplayıp kaydedin. Rapor anında bugünün kuruyla çevirmek, geçen ayın cirosunu her gün değiştirir ve kimse hangi rakamın doğru olduğunu söyleyemez hale gelir.
Muhasebe standartları da bu ayrımı yapar. TFRS/IAS 21 uyarınca parasal kalemler (alacak, borç, nakit) dönem sonu kuruyla yeniden değerlenir, parasal olmayan kalemler ise işlem tarihindeki kurla taşınmaya devam eder. Yani "okurken çevir" yaklaşımı yalnızca pratikte sorun çıkarmakla kalmaz, muhasebenin beklediği davranışla da örtüşmez. Rakamların nerede üretilip nerede tüketileceğine dair genel yaklaşımı raporlar neden tutmuyor yazısında anlatmıştık.
Bu hataların hepsi testle yakalanır
Para kodunun iyi tarafı, doğruluğunun değişmezlerle ifade edilebilmesidir. Bölüştürmenin parçaları toplamı girdiye eşit olmalı. Satır toplamları artı vergi, ödenecek tutara eşit olmalı. TL'ye çevirip geri çevirdiğinizde makul bir toleransın içinde kalmalısınız. Bunlar örnek bazlı testten çok, rastgele girdi üreten özellik tabanlı (property-based) testlere uygun ifadelerdir.
Test verisine kenar durumlarını mutlaka koyun: sıfır ondalıklı bir para birimi (JPY), üç ondalıklı bir para birimi (KWD), negatif tutarlar (iade), çok büyük tutarlar ve tam yarıda biten değerler. Bu değerler üretim veritabanınızda yoksa test verisi yönetimi tarafında üretmeniz gerekir; gerçek veriyi kopyalamak burada işinizi görmez.
Bugün yapılabilecek beş kontrol
- Şemanızda para tutan sütunları listeleyin.
FLOAT,DOUBLEveya PostgreSQLmoneytipinde olan var mı? - Her para sütununun yanında bir para birimi sütunu var mı? Yoksa o tutarın birimi nerede yazıyor?
- Kodda kaç ayrı yerde yuvarlama yapılıyor? Tek bir fonksiyona indirin ve kuralı yazılı hale getirin.
- İşlem kayıtları kullandıkları kuru, kurun tarihini ve kaynağını taşıyor mu? Yoksa geçmiş raporlar bugün yeniden hesaplanıyor demektir.
- İndirim dağıtımı, taksit planı, komisyon ve kargo paylaştırması yapan kod parçalarını bulun. Parçaların toplamının bütüne eşitliğini test ediyor musunuz?
Bu maddelerden birinde cevap veremiyorsanız ya da mutabakatta çıkan farkın nereden geldiğini bulamıyorsanız, şemayı ve hesap akışını birlikte gözden geçirip somut bir düzeltme planı çıkarabiliriz.
Bu konuda yardıma mı ihtiyacınız var?