Docs/Use casesv0.0.1-preview
Wallets
Stored balances with an engine-enforced overdraft policy — top-ups, spends, and holds where the "can't go negative" rule is not application code.
A wallet is a stored balance with one non-negotiable rule: it must never go below what the user actually has. In most stacks that rule lives in application code — which means it lives in every service that can write, and fails the day one of them forgets. In TulaDB it’s a property of the account.
Account topology
A treasury account funds wallets; each user wallet is an account whose limit_low = 0 makes overdraft impossible at the engine; merchant accounts receive spends.
W = c.create_ledger(LedgerInstance(ledger_id=0, currency=840)).ledger_id
TREASURY, USER_WALLET, MERCHANT, APP = 100, 5001, 6001, 1 # integer ids
c.create_account(AccountCreate(id=TREASURY, ledger_id=W, currency=840,
limit_low=-10**15, limit_high=10**15))
c.create_account(AccountCreate(id=USER_WALLET, ledger_id=W, currency=840,
limit_low=0, # the overdraft policy
limit_high=10**9)) # optional balance cap
c.create_account(AccountCreate(id=MERCHANT, ledger_id=W, currency=840,
limit_low=0, limit_high=10**15))
The treasury’s deeply negative floor is deliberate: in double-entry, funding wallets drives the treasury negative by exactly the amount in circulation — its balance is your float, auditable at any moment.
Top-up, spend, decline
c.transfer(TransferCreate(source_id=APP, ledger_id=W, currency=840,
debit_account_id=TREASURY, credit_account_id=USER_WALLET,
amount=1_000, txn_id="topup-1")) # wallet: 1000
c.transfer(TransferCreate(...,
debit_account_id=USER_WALLET, credit_account_id=MERCHANT,
amount=400, txn_id="spend-1")) # wallet: 600
c.transfer(TransferCreate(..., amount=700, txn_id="spend-2"))
# → declined: ERR_LIMIT (in Python, raises LedgerError with status_code 2).
# The engine checked 600 − 700 < limit_low and refused —
# atomically, at write time, no matter which service submitted it.
The decline is a durable record like any other: it appears in the rejected listing, and redelivering spend-2 replays the decline rather than re-deciding it. In the Python SDK a declined single-op transfer raises LedgerError with status_code == 2 (ERR_LIMIT) — the batch path returns per-item statuses instead — so your spend handler catches it and shows “insufficient balance.”
Holds count against the balance
A pending debit is unavailable money: with 600 posted in the wallet and 500 held, a further 200 spend is declined — held funds cannot be double-spent.
c.reserve(TransferCreate(..., amount=500, txn_id="hold-1")) # e.g. pending withdrawal
c.transfer(TransferCreate(..., amount=200, txn_id="spend-3")) # → ERR_LIMIT (raises LedgerError)
This gives you two-step wallet flows (withdrawal requests, pending purchases) with the same safety as instant spends — see Holds for the lifecycle, and add a TTL so an abandoned hold releases itself.
The invariants you get for free
- Conservation: treasury outflow always equals the sum of wallet balances plus spends — debits equal credits on every transfer, so float reconciliation is a query, not a nightly job.
- Idempotent top-ups: a redelivered
topup-1(webhook retry, queue redelivery) returnsOK_DUPLICATE— the user is credited exactly once, for as long as history is retained (forever by default). - Auditability: every wallet’s history — including its declines — is immutable and queryable by account key,
AccountKey(ledger_id=W, currency=840, id=USER_WALLET).
Related
Accounts and limits · Card payments · Rewards and closed-loop economies · Spend controls