Konut projelerinde satış sonrası süreç, CRM tarafında genellikle en az konuşulan ama en çok hata üreten kısımdır. Ben Koray Yalçın; işim çoğunlukla lead yönetimi, CRM mimarisi, dijital pazarlama ve yapay zekâ destekli otomasyonların kesiştiği yerde geçiyor. Kıbrıs'taki konut projeleriyle çalışırken sık karşılaştığım bir durum var: Sözleşme İngiliz Sterlini (GBP) üzerinden imzalanıyor, ödeme planı onlarca aya yayılıyor, alıcı ise parayı Euro, Dolar ya da Türk Lirası olarak gönderiyor. Bir de bazı taksitler takvim tarihine değil, şantiyedeki inşaat aşamasına bağlı.

Bu yazıda bu yapıyı bir tablo yığını olarak değil, tutarlı bir veri modeli olarak nasıl kurduğumu anlatacağım. Konu finansal tavsiye değil, tamamen yazılım tasarımı: para nasıl saklanır, kur farkı nereye yazılır, kısmi ödeme hangi taksite dağıtılır, aşamaya bağlı vade nasıl tetiklenir ve aracı komisyonu tahsilatla nasıl senkron tutulur.

Neden bir tabloda "kalan borç" sütunu yetmez

İlk sürümlerde en sık gördüğüm tasarım şudur: contracts tablosunda total_price, paid_amount ve remaining sütunları, payments tablosunda da her havalenin tutarı. Bir süre sonra şu sorular cevapsız kalır:

  • Alıcı 5.000 Euro gönderdi. Bu, sözleşme para birimi olan Sterlin'de kaç ediyor ve hangi günün kuruyla?
  • Ödeme iki taksiti kısmen kapatıyorsa hangisine ne kadar düşüldü?
  • Muhasebe bir ödemeyi sonradan iptal etti; remaining sütunu neye geri dönecek?
  • Karkas aşaması iki hafta erken bitti; bu aşamaya bağlı taksitin vadesi ne zaman başladı?

Bu soruların ortak noktası şu: "Kalan borç" türetilmiş bir değerdir. Saklanması gereken şey olaylardır; bakiye o olaylardan hesaplanır. Bu nedenle modeli küçük bir defter (ledger) mantığıyla kuruyorum.

Para tipi: kayan nokta yok, para birimi her zaman yanında

İlk kural basit ama sürekli ihlal edilir: Para değerleri float olarak saklanmaz. Ya en küçük birim cinsinden tam sayı (peni, sent) ya da sabit hassasiyetli ondalık (NUMERIC(14,2)) kullanılır. İkinci kural: Tutar hiçbir zaman para birimi bilgisinden ayrı dolaşmaz.

from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_EVEN @dataclass(frozen=True) class Money: amount_minor: int # örn. peni cinsinden  currency: str # ISO 4217: GBP, EUR, USD, TRY  def __add__(self, other: "Money") -> "Money": if self.currency != other.currency: raise ValueError("Farklı para birimleri doğrudan toplanamaz") return Money(self.amount_minor + other.amount_minor, self.currency) def convert(m: Money, rate: Decimal, target: str) -> Money: value = (Decimal(m.amount_minor) * rate).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN) return Money(int(value), target) 

Money nesnesinin farklı para birimleriyle toplanmayı reddetmesi küçük bir detay gibi görünür, ama kur dönüşümünü unutan her kod yolunu çalışma anında yakalar. Yuvarlama kuralını da tek bir yerde tanımlamak gerekir; banka yuvarlaması mı, yukarı yuvarlama mı, bunu finans ekibiyle bir kez konuşup koda gömmek, her raporda farklı bir kuruş farkı çıkmasını engeller.

Kur anlık görüntüsü: dönüşüm bir olaydır

Yabancı para birimiyle gelen her ödeme için dönüşümü ödemenin kendisiyle birlikte saklıyorum. Sorgu anında güncel kurla yeniden hesaplamak, geçmiş bakiyelerin her gün değişmesi demektir.

CREATE TABLE payment ( id BIGSERIAL PRIMARY KEY, contract_id BIGINT NOT NULL REFERENCES contract(id), received_amount NUMERIC(14,2) NOT NULL, received_ccy CHAR(3) NOT NULL, value_date DATE NOT NULL, -- paranın hesaba geçtiği gün fx_rate NUMERIC(18,8) NOT NULL, -- received_ccy -> sözleşme para birimi fx_source TEXT NOT NULL, -- örn. banka dekontu, merkez bankası settled_amount NUMERIC(14,2) NOT NULL, -- sözleşme para biriminde bank_reference TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending, matched, reversed UNIQUE (bank_reference, contract_id) ); 

Burada üç alan kritik. value_date, hangi günün kurunun kullanılacağını belirler; dekontun yüklendiği gün ile paranın hesaba geçtiği gün sık sık farklıdır. fx_source, kurun nereden geldiğini kayıt altına alır; banka uyguladığı kuru dekontta belirtiyorsa o, belirtmiyorsa şirketin seçtiği referans kaynak. Hangisinin geçerli olacağı bir iş kararıdır, ama kararın her ödemede görünür olması denetimi kolaylaştırır. UNIQUE (bank_reference, contract_id) ise aynı dekontun iki kez işlenmesini veritabanı seviyesinde engeller.

Kur farkını taksit tutarına gömmek yerine ayrı bir satır olarak yazmak da işe yarar. Alıcıdan beklenen Sterlin tutarı ile fiilen oluşan Sterlin karşılığı arasındaki küçük farklar, "yuvarlama ve kur farkı" türünde ayrı bir defter kaydı olarak tutulursa hem raporlamada görünür kalır hem de taksit tutarları bozulmaz.

Ödeme planı: taksitler ve tetikleyiciler

Ödeme planını iki tür satırdan oluşan bir liste olarak modelliyorum: tarihe bağlı taksitler ve aşamaya bağlı taksitler.

CREATE TABLE installment ( id BIGSERIAL PRIMARY KEY, contract_id BIGINT NOT NULL REFERENCES contract(id), seq INT NOT NULL, amount NUMERIC(14,2) NOT NULL, -- sözleşme para biriminde trigger_type TEXT NOT NULL CHECK (trigger_type IN ('date', 'milestone')), due_date DATE, -- trigger_type = 'date' ise dolu milestone_key TEXT, -- örn. 'block_a.frame_complete' grace_days INT NOT NULL DEFAULT 0, UNIQUE (contract_id, seq) ); CREATE TABLE milestone_event ( id BIGSERIAL PRIMARY KEY, milestone_key TEXT NOT NULL, reached_on DATE NOT NULL, approved_by TEXT NOT NULL, evidence_url TEXT ); 

Aşamaya bağlı bir taksitin vadesi, ilgili milestone_event kaydı oluştuğunda hesaplanır: reached_on + grace_days. Burada önemli olan, aşama bilgisinin satış ekibine e-postayla değil, onaylı bir olay olarak sisteme girmesidir. Şantiye tarafında bir sorumlu aşamayı onayladığında, aynı bloktaki tüm sözleşmelerin ilgili taksitleri tek seferde vadeye girer ve hatırlatma akışı başlar. Elle yapılan süreçlerde aşama bilgisinin finans ekibine geç ulaşması, tahsilatın da aynı süre gecikmesi anlamına gelir; olay tabanlı tasarım bu gecikmeyi yapısal olarak ortadan kaldırır.

approved_by ve evidence_url alanları sonradan çıkan anlaşmazlıklarda işe yarar. Alıcı "bu aşama henüz tamamlanmadı" dediğinde, kimin hangi gün hangi kanıtla onay verdiği tek sorguyla görülebilir.

Dağıtım: kısmi ödeme hangi taksite düşer

Ödemeyi doğrudan bir taksite bağlamak yerine ara bir dağıtım tablosu kullanıyorum. Böylece bir ödeme birden çok taksiti kapatabilir, bir taksit de birden çok ödemeyle kapanabilir.

CREATE TABLE allocation ( payment_id BIGINT NOT NULL REFERENCES payment(id), installment_id BIGINT NOT NULL REFERENCES installment(id), amount NUMERIC(14,2) NOT NULL CHECK (amount > 0), PRIMARY KEY (payment_id, installment_id) ); 

Dağıtım kuralı varsayılan olarak "en eski vadesi gelmiş taksitten başla" şeklindedir; ama bazı sözleşmelerde alıcı fazla ödemeyi son taksitlerden düşmek ister. Kuralı sözleşme düzeyinde bir ayar olarak tutup dağıtımı saf bir fonksiyon olarak yazmak, test edilebilirliği ciddi biçimde artırır:

def allocate(payment_amount: int, open_items: list[tuple[int, int]]) -> list[tuple[int, int]]: """open_items: (installment_id, kalan_tutar) listesi, istenen sırada.""" result, left = [], payment_amount for inst_id, remaining in open_items: if left <= 0: break take = min(left, remaining) result.append((inst_id, take)) left -= take if left > 0: result.append((None, left)) # avans / fazla ödeme olarak ayrı tutulur  return result 

Fazla ödemeyi sessizce bir sonraki taksite yazmak yerine None ile ayrı bir avans kaydına çevirmek, muhasebe ekibinin bunu bilinçli olarak yönetmesini sağlar.

İptal durumunda ise hiçbir satırı silmiyorum. Ödeme reversed durumuna alınır, dağıtım satırları ters işaretli yeni kayıtlarla dengelenir. Bakiye sorgusu her zaman aynı formülle çalışır ve geçmiş, denetim için olduğu gibi kalır.

Bakiye ve gecikme: türetilmiş görünümler

Bakiye artık bir sütun değil, bir görünümdür:

CREATE VIEW installment_status AS SELECT i.id, i.contract_id, i.seq, i.amount, COALESCE(SUM(a.amount), 0) AS paid, i.amount - COALESCE(SUM(a.amount), 0) AS open_amount, COALESCE(i.due_date, m.reached_on + i.grace_days) AS effective_due FROM installment i LEFT JOIN allocation a ON a.installment_id = i.id LEFT JOIN milestone_event m ON m.milestone_key = i.milestone_key GROUP BY i.id, m.reached_on; 

Gecikme takibi, hatırlatma mesajları ve satış temsilcisinin CRM ekranındaki "vadesi geçen" listesi bu görünümden beslenir. Hatırlatma akışını burada ayrıntılandırmayacağım, ama bir noktayı vurgulamak isterim: Mesaj tetikleyicisi effective_due ve open_amount üzerinden çalışmalı, elle işaretlenen bir "gecikti" bayrağından değil. Aksi hâlde ödeme yapılmış bir alıcıya hatırlatma gitmesi kaçınılmaz olur.

Aracı komisyonu: tahsilata endeksli serbest bırakma

Dış acenteyle çalışılan projelerde komisyonun tamamını ilk ödemede serbest bırakmak, sonraki taksitler aksadığında nakit açığı yaratır. Bunu modelde, komisyonu da bir plan olarak tutup her dilimi bir tahsilat koşuluna bağlayarak çözüyorum:

CREATE TABLE commission_tranche ( id BIGSERIAL PRIMARY KEY, contract_id BIGINT NOT NULL REFERENCES contract(id), share_pct NUMERIC(5,2) NOT NULL, release_rule TEXT NOT NULL, -- örn. 'paid_ratio>=0.35' ya da 'milestone:handover' released_at TIMESTAMPTZ ); 

Dilimlerin oranları tamamen ticari bir karardır ve projeden projeye değişir. Teknik tarafta önemli olan, serbest bırakma kuralının bir değerlendirme fonksiyonu tarafından her ödeme ve her aşama olayından sonra kontrol edilmesi, released_at alanının da yalnızca bu fonksiyon tarafından doldurulmasıdır. Böylece "komisyon neden ödendi" sorusunun cevabı her zaman bir kurala ve bir olaya dayanır.

Test ve doğrulama

Bu tür bir modelde en değerli testler senaryo testleridir. Benim standart setim şunları içeriyor: farklı para biriminde kısmi ödeme, iki taksiti kapatan tek ödeme, fazla ödeme ve avans oluşumu, ödeme iptali ve bakiyenin geri dönmesi, aşamanın erken onaylanması ve vadenin kayması, aynı dekontun iki kez yüklenmesi. Her senaryonun sonunda bir değişmez (invariant) kontrol edilir: Bir sözleşmedeki tüm dağıtımların toplamı ile avansların toplamı, eşleşmiş ödemelerin sözleşme para birimindeki toplamına eşit olmalı. Bu tek kontrol, gözden kaçan hataların büyük kısmını yakalıyor.

Sonuç

Sterlin bazlı, uzun vadeli ve aşamaya bağlı taksit planları ilk bakışta bir muhasebe sorunu gibi görünür, ama aslında bir veri modelleme sorunudur. Parayı para birimiyle birlikte ve tam sayı olarak saklamak, kur dönüşümünü ödeme anında kayda geçirmek, ödemeyi taksite bir dağıtım tablosu üzerinden bağlamak, aşamaları onaylı olaylar olarak tutmak ve bakiyeyi her zaman türetmek, sistemin hem doğru hem de denetlenebilir kalmasını sağlıyor. CRM ekranındaki her rakamın arkasında, tek sorguyla izlenebilen bir olay zinciri olmalı.

SSS

Para değerlerini tam sayı olarak mı, NUMERIC olarak mı saklamalıyım?
İkisi de doğru seçim olabilir; önemli olan float kullanmamak. Uygulama katmanında en küçük birim cinsinden tam sayı, veritabanında NUMERIC kullanan ekipler de var. Tutarlılık için tek bir yaklaşım seçip dönüşümü tek bir yerde yapmak yeterli.

Kur için hangi kaynağı kullanmalıyım?
Bu bir iş ve muhasebe kararı. Teknik açıdan önerim, seçilen kaynağın her ödemede fx_source alanında kayıt altına alınması ve kurun ödeme anında sabitlenmesi; sonradan güncel kurla yeniden hesaplama yapılmaması.

Aşamaya bağlı taksitlerde vade ne zaman başlar?
Modelde vade, aşamanın onaylandığı tarih artı sözleşmedeki ek süre olarak hesaplanır. Onayın kim tarafından ve hangi kanıtla verildiği ayrı alanlarda tutulursa olası anlaşmazlıklarda referans noktası oluşur.

Hazır bir CRM kullanıyorsam bu tabloları nereye koyacağım?
Çoğu CRM özel nesne (custom object) desteği sunuyor; taksit, ödeme ve dağıtım ayrı nesneler olarak kurulabilir. Kur dönüşümü ve dağıtım mantığı ise genellikle CRM dışındaki küçük bir servis ya da otomasyon adımı olarak daha sağlıklı çalışıyor.

Ödeme iptal edildiğinde kayıtları silmek neden kötü bir fikir?
Silme işlemi geçmişi yok eder ve bakiyenin neden değiştiğini açıklanamaz hale getirir. Ters kayıtla dengeleme hem denetim izi bırakır hem de bakiye formülünü değiştirmeden doğru sonucu üretir.

Koray Yalçın — lead yönetimi, CRM mimarisi, dijital pazarlama ve gayrimenkul teknolojileri (PropTech) üzerine çalışıyorum. Benzer bir tahsilat modeli kurduysanız, sizi en çok hangi senaryonun zorladığını yorumlarda paylaşın.