Engineering Notes
Building Kinko: a double-entry ledger, the hard way
Why I refused to let Kinko's balances be a mutable integer column — and what it took to build a financial core where the domain logic doesn't know Active Record exists.
View the repository on GitHub
The easiest way to build "an account with a balance" is a single integer column you increment and decrement. It works, right up until two requests race each other, or a refund needs reconciling, or someone asks "why does this balance say $340?" and there's no trail to answer with.
Kinko is a digital-bank simulation I built to avoid that trap on purpose — modeled after how real ledger systems work: every movement of money is an immutable record, and the balance is just the sum of history, computed on demand.
01The domain layer doesn't know Active Record exists
The core financial logic lives in app/domain, in plain Ruby classes with zero references to ApplicationRecord, SQL, or Rails callbacks. Accounts::Account is a pure object that knows how to credit, debit, and compute a balance — nothing else:
app/domain/accounts/account.rbmodule Accounts
class Account
def balance
all_entries.sum(&:signed_amount)
end
def debit!(money, transaction_id: nil, description: nil)
validate_amount!(money)
raise InsufficientFunds if insufficient?(money)
register_entry(amount_cents: money.amount_cents, currency: money.currency,
entry_type: :debit, reference: description || transaction_id)
end
end
endAn ActiveRecord::Base-backed model called Accountexists too — but it's a separate class, living one layer up, whose only job is persistence. A dedicated AccountRepository is the translator between the two: it loads database rows into plain domain objects, and later persists only the entries the domain object says are new.
app/repositories/account_repository.rbdef self.save(domain_account)
record = Account.find_by!(uuid: domain_account.uuid)
ActiveRecord::Base.transaction do
domain_account.new_entries.each do |entry|
record.ledger_entries.create!(
amount_cents: entry.amount_cents,
entry_type: entry.entry_type.to_s,
reference: entry.reference
)
end
end
endThis separation is what makes Accounts::Account#balance, #credit!, and #debit!trivially unit-testable — no database, no fixtures, no Rails boot time. The business rule "you can't debit more than you have" lives in a plain Ruby object I can test in milliseconds.
02Money as a value object, not a raw integer
Every amount in the system is wrapped in a small immutable Money value object instead of a bare integer floating around the codebase:
app/domain/value_object/money.rbclass Money
def initialize(amount_cents, currency = 'USD')
@amount_cents = amount_cents
@currency = currency
end
def positive?
amount_cents > 0
end
def ==(other)
other.is_a?(Money) && amount_cents == other.amount_cents && currency == other.currency
end
endIt's a small thing, but it closes off an entire category of bugs: you can't accidentally add a BRL amount to a USD one, and credit! and debit! both validate through Money#positive? before anything touches the ledger — so a negative or zero-amount entry never gets a chance to exist.
03A transfer is two entries, one transaction, or none at all
Every transfer between two users is really two ledger entries — a debit on one account, a credit on the other — sharing a single transaction_id so they can always be traced back to each other. Both entries are persisted inside one database transaction:
app/domain/transfers/transfer_service.rbtransaction_id = SecureRandom.uuid
from_account.debit!(money, transaction_id: transaction_id, description: from_description)
to_account.credit!(money, transaction_id: transaction_id, description: to_description)
persist!(from_account, to_account) # wraps both saves in ActiveRecord::Base.transactionIf the debit succeeds but the credit fails for any reason, the whole transaction rolls back — there's no window where money can disappear from one account without appearing in the other. That's the entire point of double-entry accounting, enforced at the code level instead of just the spreadsheet level.
04Making Stripe webhook retries harmless
Stripe retries webhook deliveries — sometimes the same payment_intent.succeededevent arrives more than once. Processing a deposit twice would mean crediting a user's account twice for one real-world payment, so idempotency isn't optional here.
I enforce it at the database level, not just in application logic, using a unique constraint on the event ID and letting the race condition resolve itself through the constraint:
app/models/webhook_event.rbclass WebhookEvent < ApplicationRecord
validates :event_id, presence: true, uniqueness: true
def self.process_once(event_id)
create(event_id: event_id, status: 'processing')
true
rescue ActiveRecord::RecordNotUnique
false
end
endWhy this matters: a uniqueness validationalone isn't safe under concurrency — two simultaneous requests can both pass the check before either commits. The unique index at the database level is what actually closes the race; the rescuejust turns that guaranteed failure into a clean "already handled" response instead of a 500.
Only after process_once returns true does the webhook controller call into Payments::DepositService, which credits both the user's account and a system account, keeping the ledger double-entry even for money entering from outside the platform.
05Real-time balance updates over ActionCable
Once a deposit lands, the mobile app doesn't need to poll for a new balance — the backend broadcasts the update directly to the user's channel the moment the ledger entries are persisted:
app/domain/payments/deposit_service.rbActionCable.server.broadcast(
"notifications:user:#{user.id}",
{ type: "balance_updated", account_uuid: account_uuid, amount_cents: amount_cents }
)The notification failing never rolls back the deposit — it's wrapped in its own rescue, because a missed push notification is an inconvenience, but a reverted deposit for a payment Stripe already confirmed would be a real financial bug.
06One API, two clients
The Rails API is the single source of truth for balances and transaction history, consumed by a React Native (Expo) mobile app that handles auth, deposits, transfers, and a live transaction feed against the same endpoints — no duplicated business logic on the client side. JWT-based auth keeps the mobile app stateless against the backend, with token verification isolated into a single Authenticatable concern shared across every authenticated controller.
07Why build a toy bank this carefully
Kinko is a simulation, not a production fintech product — but I built the financial core with the same discipline I'd want in one: an append-only ledger, a domain layer that doesn't leak persistence concerns, transactional integrity on every transfer, and idempotency enforced where a retry could otherwise cost someone real money (even simulated money).
That discipline is the actual point of the project. It's a lot easier to demonstrate architecture on a system where correctness is optional. I wanted one where it wasn't.
Read the code yourself
The full Kinko monorepo — Rails API and React Native client — is open on GitHub.