Use Case: Wallet Implementation
This document outlines the architectural pattern for implementing a user wallet system with configurable transfer limits using the book-keeper service.
The core principle is to maintain the purity of the book-keeper service as a generic, atomic ledger. All business-specific logic (e.g., "is a beneficiary registered?", "which limits apply?") resides exclusively in the consuming application (e.g., a dedicated Wallet Service). This is achieved by leveraging book-keeper's powerful, generic endpoints to atomically execute complex financial operations.
The consuming application constructs a single, multi-leg journal entry that includes both the financial transfer and the "consumption" of the relevant limits. book-keeper guarantees that if any limit is breached, the entire transaction fails, ensuring the user's wallet balance is never incorrectly debited.
Benefits of this Architecture:
book-keeperRemains Pure: The ledger service has no knowledge of "wallets" or "users." It only processes balanced journal entries within its configured tenant.- Simplified Configuration: The Wallet service only needs to know one
tenant_idfor all its communication withbook-keeper. - Guaranteed Atomicity: The ledger ensures that a user's wallet transfer and their specific limit checks succeed or fail together, eliminating data consistency issues.
API Usage Examples
The following examples demonstrate how the wallet service would interact with the book-keeper REST API using a fixed tenant_id of wallet_service_v1.
(Note: All amounts are represented in the smallest currency unit, e.g., paise for INR.)
Account Naming: The book-keeper service supports flexible string-based account codes. You can use any naming convention that suits your domain (e.g., WALLET_USER_123, Wallet-Cust001, 1001). Each account must also specify a type field (asset, liability, equity, revenue, or expense) for proper categorization in the chart of accounts.
1. Setup: Creating Accounts for a New User
Make sure system accounts are in place:
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/accounts" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"accounts": [
{ "code": "PAYABLES_EXTERNAL", "name": "External Payables", "type": "liability" },
{ "code": "source_of_funds", "name": "Source of Funds", "type": "liability" },
{ "code": "max_balance_suspense", "name": "Max Balance Suspense", "type": "liability" },
{ "code": "sys_rate_limiter_credit", "name": "Rate Limiter Credit", "type": "liability" },
{ "code": "sys_rate_limiter_debit", "name": "Rate Limiter Debit", "type": "asset" },
{ "code": "BANK_SUSPENSE", "name": "Bank Suspense Account", "type": "asset" }
]
}'
When a new user (user-123) signs up, the wallet service creates a unique set of ledger accounts for them under the application's single tenant. The max_balance is set on the wallet account.
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/accounts" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"accounts": [
{ "code": "WALLET_USER_123", "name": "User 123 Main Wallet", "type": "liability", "max_balance": 20000000, "flags": 256 },
{ "code": "limit_non_reg_daily_amt_user_123", "name": "Limit - User 123 New Beneficiary Daily Amount", "type": "liability", "flags": 512 },
{ "code": "limit_non_reg_daily_count_user_123", "name": "Limit - User 123 New Beneficiary Daily Count", "type": "liability", "flags": 512 },
{ "code": "limit_reg_daily_amt_user_123", "name": "Limit - User 123 Registered Beneficiary Daily Amount", "type": "liability", "flags": 512 }
]
}'
type: "liability"categorizes the wallet as a liability (money owed to the user).type: "liability"categorizes limiters as liabilities (an obligation to provide transfer capacity).max_balance: 20000000andflags: 256(CREDITS_MUST_NOT_EXCEED_DEBITS) on the user's wallet account enforces the ₹2 Lakh maximum balance.flags: 512(DEBITS_MUST_NOT_EXCEED_CREDITS) on limiter accounts enforces the usage caps, preventing them from being overdrawn.
2. Admin: Refilling a User's Daily/Monthly Limits
A scheduled job in the wallet service would call this endpoint to reset the limits for a specific user (user-123).
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/admin/limiter-accounts/refill" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"source_of_funds_account_code": "sys_rate_limiter_credit",
"accounts_to_refill": [
{ "account_code": "limit_non_reg_daily_amt_user_123", "amount": 1000000, "currency": "INR" },
{ "account_code": "limit_non_reg_daily_count_user_123", "amount": 10, "currency": "QTY" }
]
}'
3. Use Case: ATOMIC Transfer to a NEW Beneficiary
The wallet service can perform a complex, multi-leg transfer atomically by using the dedicated POST /transfers/compound endpoint. This endpoint guarantees that the debit from the user's wallet and the consumption of their various limits all succeed or fail together.
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/transfers/compound" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"entry_date": "2024-08-01",
"narration": "Atomic payment from user 123 to new beneficiary example@upi",
"debit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 50000, "currency": "INR" },
{ "account_code": "limit_non_reg_daily_amt_user_123", "amount": 50000, "currency": "INR" },
{ "account_code": "limit_non_reg_daily_count_user_123", "amount": 1, "currency": "QTY" }
],
"credit_legs": [
{ "account_code": "PAYABLES_EXTERNAL", "amount": 50000, "currency": "INR" },
{ "account_code": "source_of_funds", "amount": 50000, "currency": "INR" },
{ "account_code": "source_of_funds", "amount": 1, "currency": "QTY" }
]
}'
Before using the limit for registered beneficiaries, it must also be refilled.
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/admin/limiter-accounts/refill" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"source_of_funds_account_code": "sys_rate_limiter_credit",
"accounts_to_refill": [
{ "account_code": "limit_reg_daily_amt_user_123", "amount": 10000000, "currency": "INR" }
]
}'
4. Use Case: ATOMIC Transfer to a REGISTERED Beneficiary
Similar to the new beneficiary transfer, this is a single, atomic transaction using the /transfers/compound endpoint.
curl -X POST "http://localhost:8000/api/book-keeper/v1/transfers/compound" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"entry_date": "2024-08-01",
"narration": "Consume limits for payment to registered beneficiary",
"debit_legs": [
{ "account_code": "limit_reg_daily_amt_user_123", "amount": 750000, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "source_of_funds", "amount": 750000, "currency": "INR" }
]
}'
5. Use Case: Enforcing Maximum Wallet Balance
The max_balance set during account creation is enforced automatically by the ledger on every credit.
A. Successful Top-Up
Assume the user's wallet balance is ₹190,000. A top-up of ₹10,000 is successful.
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/journal-entries" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"entry_date": "2024-08-01",
"narration": "Wallet top-up for user 123 via UPI",
"debit_legs": [
{ "account_code": "BANK_SUSPENSE", "amount": 1000000, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 1000000, "currency": "INR" }
]
}'
B. Failed Top-Up (Exceeds Limit)
Now the user's balance is ₹200,000. The next top-up of even ₹1 will fail.
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/journal-entries" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"entry_date": "2024-08-01",
"narration": "Attempted wallet top-up for user 123",
"debit_legs": [
{ "account_code": "BANK_SUSPENSE", "amount": 100, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 100, "currency": "INR" }
]
}'
Expected Response:
The API will return a 400 Bad Request or similar error with a message indicating the maximum balance was exceeded. The ledger atomically rejects the transfer, and the user's balance remains at ₹200,000.
6. Use Case: Managing Account Properties
A key architectural principle of this service is that account properties, like balances, are not updated directly. Instead, changes must be made by creating new, auditable transactions. This ensures a clear and immutable history, which is critical for a financial system. This pattern applies to all ledger backends to ensure domain consistency.
A. The max_balance_suspense Account
The max_balance feature works by creating a "virtual debit" on the user's wallet account at creation time. The other side of this entry is a credit to a single, global suspense account named max_balance_suspense. The balance of this suspense account represents the sum of all maximum balance ceilings granted to all wallets in the tenant. It is an accounting control and not an operational account.
B. Increasing max_balance
To increase the max_balance, you must increase the "virtual debit" on the wallet account. This is done by creating a journal entry that debits the wallet account and credits the global max_balance_suspense account.
Example: Increase max_balance from ₹200,000 to ₹250,000 (a ₹50,000 increase).
curl -X POST "http://localhost:8000/api/book-keeper/v1/journal-entries" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"entry_date": "2024-08-01",
"narration": "Increasing max_balance for user 123 to 250,000.",
"debit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 5000000, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "max_balance_suspense", "amount": 5000000, "currency": "INR" }
]
}'
C. Decreasing max_balance
Decreasing the limit is more complex as it requires a pre-condition check. The consuming application must first verify that the user's current balance is not greater than the proposed new limit.
- Check Balance: The consuming application calls
GET /api/book-keeper/v1/accounts/balancesforWALLET_USER_123. - Enforce Rule: If
current_balance > new_max_balance, abort the operation. - Create Reversing Transaction: If the check passes, create a journal entry that is the reverse of the increase pattern.
Example: Decrease max_balance from ₹200,000 to ₹150,000 (a ₹50,000 decrease).
curl -X POST "http://localhost:8000/api/book-keeper/v1/journal-entries" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"entry_date": "2024-08-01",
"narration": "Decreasing max_balance for user 123 to 150,000.",
"debit_legs": [
{ "account_code": "max_balance_suspense", "amount": 5000000, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 5000000, "currency": "INR" }
]
}'
This transaction reduces the total "virtual debit" on the wallet account, effectively lowering its credit ceiling.
7. Use Case: Querying Remaining Limits
A critical part of the wallet experience is showing users their available limits. This can be achieved by querying the balances of the special-purpose accounts created for each user.
A. Querying Remaining Rate Limits
The remaining number of transfers, or the remaining amount that can be transferred, is simply the current balance of the corresponding limiter account. You can get this by calling the GET /api/book-keeper/v1/accounts/balances endpoint.
Example: Get the remaining daily transfer count for user-123
curl -X GET "http://localhost:8000/api/book-keeper/v1/accounts/balances?tenant_id=wallet_service_v1&account_codes=limit_non_reg_daily_count_user_123"
Expected Response:
[
{
"account_code": "limit_non_reg_daily_count_user_123",
"balance": 9
}
]
The balance of 9 indicates that the user has 9 daily transfers remaining.
B. Querying Remaining Wallet Capacity
The remaining capacity before hitting the max_balance requires a simple calculation in the consuming application.
- Retrieve
max_balance: The consuming application should have the user'smax_balancevalue stored (e.g., 20,000,000). - Query Current Balance: Call
GET /api/book-keeper/v1/accounts/balancesfor the user's wallet account. - Calculate: The remaining capacity is
max_balance - current_balance.
Example: Get the remaining top-up capacity for WALLET_USER_123
Step 1: Get the current wallet balance
curl -X GET "http://localhost:8000/api/book-keeper/v1/accounts/balances?tenant_id=wallet_service_v1&account_codes=WALLET_USER_123"
Step 2: Interpret the response and calculate
Assuming the API returns a balance of 17500000 (₹175,000), the consuming application performs the calculation:
20000000 (max_balance) - 17500000 (current_balance) = 2500000
The user can be shown that they have a remaining top-up capacity of ₹25,000.
8. Use Case: Pending Transfers with Timeouts
For operations that require external confirmation (e.g., waiting for a payment gateway response), you can create a pending journal entry with a timeout. If the entry is not committed or voided within the specified duration, it automatically expires and can no longer be posted.
This prevents funds from being locked indefinitely.
Example: Create a pending transfer that expires in 60 seconds.
Request:
curl -X POST "http://localhost:8000/api/book-keeper/v1/pending-journal-entries" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"narration": "Payment to merchant, awaiting confirmation",
"entry_date": "2024-08-01",
"debit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 2500, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "PAYABLES_EXTERNAL", "amount": 2500, "currency": "INR" }
],
"timeout_seconds": 60
}'
timeout_seconds: 60: If this entry is not committed or voided within 60 seconds, any subsequent attempt to commit it will fail.
9. Use Case: Pending Compound Transfers
For the most complex scenarios, you can combine the two-phase commit pattern with a compound transfer. This is useful when a multi-leg atomic transaction requires external confirmation before being finalized.
Example: A complex wallet-to-wallet transfer that requires a third-party fraud check before committing. First, ensure the beneficiary account exists.
curl -X POST "http://localhost:8000/api/book-keeper/v1/accounts" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"accounts": [
{ "code": "WALLET_USER_456", "name": "User 456 Main Wallet", "type": "liability", "max_balance": 20000000, "flags": 256 }
]
}'
Then, create the pending transfer:
curl -X POST "http://localhost:8000/api/book-keeper/v1/pending-compound-transfers" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "wallet_service_v1",
"narration": "P2P transfer user-123 to user-456, pending fraud check",
"entry_date": "2024-08-01",
"debit_legs": [
{ "account_code": "WALLET_USER_123", "amount": 10000, "currency": "INR" }
],
"credit_legs": [
{ "account_code": "WALLET_USER_456", "amount": 10000, "currency": "INR" }
],
"timeout_seconds": 300
}'
Accountant's Guide: Understanding the Ledger Structure
This section explains how the wallet system maps to traditional accounting principles, helping accountants understand the atomic locks, balances, and how transaction counts are tracked as ledger accounts.
Chart of Accounts Overview
| Account Code | Account Name | Type | Normal Balance | Purpose |
|---|---|---|---|---|
WALLET_USER_123 | User 123 Main Wallet | Liability | Credit | Money owed to the user |
PAYABLES_EXTERNAL | External Payables | Liability | Credit | Money to be paid out to external parties |
BANK_SUSPENSE | Bank Suspense | Asset | Debit | Funds received but not yet allocated |
limit_non_reg_daily_amt_user_123 | Non-Reg Daily Amount Limit | Liability | Credit | Obligation to provide transfer capacity (amount) |
limit_non_reg_daily_count_user_123 | Non-Reg Daily Count Limit | Liability | Credit | Obligation to provide transfer capacity (count) |
limit_reg_daily_amt_user_123 | Reg Daily Amount Limit | Liability | Credit | Obligation to provide transfer capacity (amount) |
source_of_funds | Source of Funds | Liability | Credit | Contra-account for rate limiter refills |
max_balance_suspense | Max Balance Suspense | Liability | Credit | Virtual account for max balance tracking |
Key Accounting Principles
1. Wallets are Liabilities
User wallets represent money owed to users by the platform. When a user deposits ₹1,000:
- Debit: Bank Suspense (Asset ↑)
- Credit: Wallet User (Liability ↑)
This correctly reflects that the platform now owes ₹1,000 to the user.
2. Rate Limiters are Liabilities
Rate limiter accounts represent an obligation to provide capacity. When refilled with 10 transactions:
- Debit: Source of Funds (Asset ↓)
- Credit: Limit Count Account (Liability ↑)
This reflects that the platform now has an obligation to allow 10 transactions.
3. Transaction Counts as Ledger Accounts
The innovative aspect: transaction counts are tracked as account balances. A limiter account with a balance of 7 means "7 transactions remaining."
T-Account Examples
Example 1: User Wallet Top-Up (₹500)
┌─────────────────────────┐ ┌─────────────────────────┐
│ BANK_SUSPENSE (A) │ │ WALLET_USER_123 (L) │
├─────────────────────────┤ ├─────────────────────────┤
│ Dr: 50,000 │ │ │ │ Cr: 50,000 │
│ │ │ │ │ │
└─────────────────────────┘ └─────────────────────────┘
Example 2: Transfer to New Beneficiary (₹500, 1 transaction)
This is a single, atomic transaction from the client's perspective. The book-keeper service uses an internal control account to ensure all legs succeed or fail together. The net effect on the user-facing accounts is:
┌──────────────────────────┐ ┌──────────────────────────┐
│ WALLET_USER_123 (L) │ │ PAYABLES_EXTERNAL (L) │
├──────────────────────────┤ ├──────────────────────────┤
│ Dr: 50,000 │ │ │ │ Cr: 50,000 │
└──────────────────────────┘ └──────────────────────────┘
┌──────────────────────────────────────┐ ┌──────────────────────────┐
│ limit_non_reg_daily_amt_user_123(A) │ │ source_of_funds (L) │
├──────────────────────────────────────┤ ├──────────────────────────┤
│ Dr: 50,000 │ │ │ │ Cr: 50,000 │
└──────────────────────────────────────┘ └──────────────────────────┘
┌──────────────────────────────────────┐ ┌──────────────────────────┐
│ limit_non_reg_daily_count_user_123(A)│ │ source_of_funds (L) │
├──────────────────────────────────────┤ ├──────────────────────────┤
│ Dr: 1 │ │ │ │ Cr: 1 │
└──────────────────────────────────────┘ └──────────────────────────┘
Note: The transaction is atomic. If any account has insufficient balance, the entire entry is rejected.
Example 3: Daily Limit Refill (10 transactions, ₹10,000)
┌──────────────────────────────────────┐ ┌──────────────────────────┐
│ limit_non_reg_daily_count_user_123(A)│ │ source_of_funds (L) │
├──────────────────────────────────────┤ ├──────────────────────────┤
│ Dr: 10 │ │ │ │ Cr: 10 │
└──────────────────────────────────────┘ └──────────────────────────┘
┌──────────────────────────────────────┐ ┌──────────────────────────┐
│ limit_non_reg_daily_amt_user_123(A) │ │ source_of_funds (L) │
├──────────────────────────────────────┤ ├──────────────────────────┤
│ Dr: 1,000,000 │ │ │ │ Cr:1,000,000│
└──────────────────────────────────────┘ └──────────────────────────┘
How Atomic Locks Work
The ledger uses TigerBeetle's atomic transaction guarantees:
- All legs are validated before any are posted
- Insufficient balance on ANY account → entire transaction fails
- No partial transactions - it's all or nothing
- Concurrent transactions are serialized by TigerBeetle
Example: User tries to transfer ₹600 but only has ₹500:
- ❌ Wallet debit fails (insufficient balance)
- ❌ Entire transaction rejected
- ✅ Limiter accounts unchanged
- ✅ No orphaned records
Trial Balance Example
After the transactions above, a trial balance would show:
| Account Code | Account Name | Debits | Credits | Balance |
|---|---|---|---|---|
WALLET_USER_123 | User 123 Wallet | 50,000 | 0 | 50,000 Cr |
PAYABLES_EXTERNAL | External Payables | 0 | 50,000 | 50,000 Cr |
limit_non_reg_daily_amt_user_123 | Daily Amount Limit | 50,000 | 1,000,000 | 950,000 Cr |
limit_non_reg_daily_count_user_123 | Daily Count Limit | 1 | 10 | 9 Cr |
source_of_funds | Source of Funds | 0 | 1,050,001 | 1,050,001 Cr |
| TOTALS | 100,001 | 2,150,011 | Unbalanced |
Key Insights for Accountants
1. Mixed Units in Same Ledger
- Amount limiters use currency units (paise)
- Count limiters use quantity units (transactions)
- The
currencyfield differentiates them (INRvsQTY)
2. Contra-Account Pattern
source_of_funds is a contra-liability account that:
- Has no real-world meaning (it's a balancing account)
- Ensures double-entry integrity
- Its balance = total granted capacity across all users
3. Max Balance Enforcement
The max_balance feature uses a virtual debit pattern:
- When account is created with
max_balance: 200000, a virtual debit of 200,000 is applied - This reduces the account's credit capacity to 200,000
- The offsetting credit goes to
max_balance_suspense
4. Audit Trail
Every transaction is:
- ✅ Immutable (cannot be edited)
- ✅ Timestamped
- ✅ Includes narration
- ✅ Reversible (via correction entries, not deletion)
5. Reconciliation
To reconcile a user's wallet:
- Query
GET /accounts/balances?account_codes=WALLET_USER_123 - The
balancefield = current wallet balance - The
debits_postedandcredits_postedprovide full history - Cross-reference with external bank statements
Compliance Notes
- Immutability: All journal entries are permanent. Corrections are made via reversing entries.
- Audit Trail: Complete transaction history is maintained via event sourcing.
- Atomicity: Multi-leg transactions are guaranteed to be all-or-nothing.
- Consistency: Double-entry bookkeeping ensures debits always equal credits.
- Isolation: Concurrent transactions are properly serialized.
- Durability: TigerBeetle provides financial-grade persistence guarantees.
This architecture satisfies ACID properties and is suitable for regulated financial applications.