Case studyLive
Tahsil
Receivables and collections for Turkish SMBs: customer ledgers, invoices, polite reminder sequences, pay-by-link and reconciliation — on a double-entry ledger that always balances.
- Role
- Solo — product, design implementation, backend, infra
- When
- 2026
- Backend
- Full-stack
“Demoyu aç” opens the demo company, Ege Tekstil, without signing up. Pay the link with test card 4242 4242 4242 4242, expiry 12/30, CVC 123, 3-D Secure code 123456. The data resets every night at 04:00 Istanbul time; the source is private.

Who it’s for
- A textile wholesaler in İzmir with about 50 B2B customers and ₺2.4 million of open receivables. Today: an Excel aging sheet, a finance manager phoning customers, cheques in a drawer and bank statements matched by eye.
- They need one ledger per customer that always reconciles, reminders that go out on time and stop by themselves when the money arrives, and a link customers can pay by card in instalments.
- Their customers, who open the link and pay all or part by card — with taksit, through 3-D Secure — or by EFT with the invoice number in the description.
What I built
- Built it alone as one Worker on Cloudflare’s free plan: a Hono API, D1 as the system of record, and three SQLite-backed Durable Object classes — a reminder scheduler per company, an outbox that delivers the payment provider’s webhooks, and a maintenance object for the chunked nightly reset.
- A double-entry ledger on the Turkish chart of accounts: every money event — invoice, payment, refund, credit note, write-off, credit application — is one balanced journal entry in one transaction. Balances, aging, statements and DSO are sums over the journal lines, and amounts are integer kuruş end to end.
- The back office in React + Vite, ported from an approved design: customer ledgers and statements, e-Fatura-style invoices with an event history, a reminder-sequence editor (email, SMS, WhatsApp), payments and a reconciliation review queue, reports with Excel export, roles and settings — plus the public pay-by-link page with instalments and 3-D Secure.
- Multi-tenancy enforced in one place, PBKDF2 auth with five roles, a PaymentProvider interface with an iyzico-like fake PSP that sends signed webhooks two or three times and out of order — and the property test, webhook replay and load test that prove the invariants.
How it works
1Invoice
An invoice is imported or drafted and sent: one balanced journal entry — receivables debited, sales and VAT credited — and the customer gets a pay link.
2Remind
The customer’s sequence (3 days before, the due date, +7, +14 with escalation) runs at the company’s hour in Europe/Istanbul — never at night, at weekends or on Turkish public holidays; escalation steps create a call task.
3Pay
The customer opens the link and pays all or part by card with instalments through a 3-D Secure step — or by EFT with the invoice number in the description.
4Reconcile
Webhooks (delivered two or three times, out of order) and bank lines are matched to invoices: exact references apply automatically, fuzzy ones wait in a review queue with a confidence score. Overpayment becomes customer credit.
5Stop & report
A fully paid invoice cancels its pending reminders; aging, collection rate and DSO come straight from the journal and export to Excel.
Key decisions
01
Balances are never stored
Instead of: A balance column on each invoice and customer, updated as money moves.
Why: Every money event is one D1 batch — one SQLite transaction — of a journal header and its lines. Outstanding amounts, customer credit, aging and statements are sums over the lines. The journal is append-only, and a guard row inside every batch makes the database itself refuse an entry whose debits don’t equal its credits.
02
Exactly-once by constraint, allocation inside the transaction
Instead of: Check whether the payment was seen, then write; lock the invoice while applying.
Why: Each entry carries the business identity of its event under a unique constraint, so a replay fails the whole batch atomically and is reported as a duplicate. The amount applied to an invoice is the smaller of the payment and what’s outstanding, computed in SQL in the same transaction — the rest becomes customer credit — so two concurrent payments can never over-apply an invoice, with no locks and no read-then-write window.
03
Durable Objects for time, D1 for truth
Instead of: One Workflow instance per invoice.
Why: One reminder scheduler per company holds the multi-day schedule and a single alarm at the earliest job; when it fires it re-reads D1 — paid? paused? — before sending, and a payment also pokes it to cancel. On the free plan, a Workflow per invoice buys nothing over one alarm per company and is harder to cancel.
04
Tenancy in one place
Instead of: Remember to add the tenant filter to every query.
Why: Every row carries the company id, and tenant SQL is only reachable through a data layer that refuses any statement not scoped to it. Tests prove cross-tenant reads return nothing and cross-tenant writes are rejected inside the transaction; the property test threw 50,226 of them at it.
Architecture
D1 is the only source of truth for money; Durable Objects only keep time. Everything here is mine.
Numbers
- random operations — the ledger stayed balanced, every balance matched an independent replay
- 1,040,000
- signed webhook deliveries for 388 card payments — each applied exactly once
- 11,619
- duplicates under load — 616 payments, 0 missing, 0 wrong amounts
- 0
- a month — Cloudflare’s free plan
- €0
Tech stack
Backend
- Cloudflare Workers
- Durable Objects
- D1 (SQLite)
- Hono
- TypeScript
- Cron Triggers
- Rate Limiting
- PBKDF2
Frontend
- React
- React Router
- Vite
Payments & accounting
- Double-entry ledger
- Idempotency keys
- HMAC-SHA256 webhooks
- 3-D Secure flow
Quality
- Vitest
- vitest-pool-workers
- node:sqlite property tests
- Load test
Gallery

Reported four times, recorded once. 
Fuzzy matches wait for a person, with a confidence score. 
A customer statement — every balance is a sum over the journal. 
Reminder sequences — polite, on time, and they stop when the money arrives. 
On a phone: the overview, the pay link and the receipt.
Want the longer version — the code, the pipeline, the trade-offs?
Email me