Skip to content

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.

Tahsil’s overview for the demo company Ege Tekstil: ₺2,318,695.40 of receivables, ₺746,460 overdue, a 90-day collection rate of 89.9%, an aging chart and today’s tasks.
The overview: receivables, overdue, aging and today’s tasks.

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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

CLIENTSBack officeReact · five rolesPay link/ode/:token · 3-DSecure · taksitBank linesEFT · havale · FASTHTTPS /api/* · signed webhooksEDGECloudflare Workerstatic assets · Worker only for /api/*APPLICATIONHono API + Ledgerorg-scoped DAL ·refuses unscoped SQLReminder DO1 per company · onealarmPSP outbox DOwebhooks 2–3× · retriesone batch = one transactionDATAD1double-entry journal ·system of recordDO SQLitereminder schedulesOUTSIDEPaymentProviderfake PSP · iyzico-likeChannelsemail · SMS · WhatsApp· log-onlyCron 04:00chunked nightly demoreset↑ ships everything aboveDELIVERYProperty test1,040,000 ops ·production SQLWebhook replay11,619 deliveriesLoad test16 users · wrangler devVitest31 tests · unit +Workers runtime

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

Want the longer version — the code, the pipeline, the trade-offs?

Email me
Next case studyMasaQR menu, table ordering, a kitchen display, a waiter tablet and an owner admin for independent cafés and restaurants in Türkiye.