Skip to main content

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-keeper Remains 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_id for all its communication with book-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: 20000000 and flags: 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.

  1. Check Balance: The consuming application calls GET /api/book-keeper/v1/accounts/balances for WALLET_USER_123.
  2. Enforce Rule: If current_balance > new_max_balance, abort the operation.
  3. 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.

  1. Retrieve max_balance: The consuming application should have the user's max_balance value stored (e.g., 20,000,000).
  2. Query Current Balance: Call GET /api/book-keeper/v1/accounts/balances for the user's wallet account.
  3. 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 CodeAccount NameTypeNormal BalancePurpose
WALLET_USER_123User 123 Main WalletLiabilityCreditMoney owed to the user
PAYABLES_EXTERNALExternal PayablesLiabilityCreditMoney to be paid out to external parties
BANK_SUSPENSEBank SuspenseAssetDebitFunds received but not yet allocated
limit_non_reg_daily_amt_user_123Non-Reg Daily Amount LimitLiabilityCreditObligation to provide transfer capacity (amount)
limit_non_reg_daily_count_user_123Non-Reg Daily Count LimitLiabilityCreditObligation to provide transfer capacity (count)
limit_reg_daily_amt_user_123Reg Daily Amount LimitLiabilityCreditObligation to provide transfer capacity (amount)
source_of_fundsSource of FundsLiabilityCreditContra-account for rate limiter refills
max_balance_suspenseMax Balance SuspenseLiabilityCreditVirtual 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:

  1. All legs are validated before any are posted
  2. Insufficient balance on ANY account → entire transaction fails
  3. No partial transactions - it's all or nothing
  4. 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 CodeAccount NameDebitsCreditsBalance
WALLET_USER_123User 123 Wallet50,000050,000 Cr
PAYABLES_EXTERNALExternal Payables050,00050,000 Cr
limit_non_reg_daily_amt_user_123Daily Amount Limit50,0001,000,000950,000 Cr
limit_non_reg_daily_count_user_123Daily Count Limit1109 Cr
source_of_fundsSource of Funds01,050,0011,050,001 Cr
TOTALS100,0012,150,011Unbalanced

Key Insights for Accountants

1. Mixed Units in Same Ledger

  • Amount limiters use currency units (paise)
  • Count limiters use quantity units (transactions)
  • The currency field differentiates them (INR vs QTY)

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:

  1. Query GET /accounts/balances?account_codes=WALLET_USER_123
  2. The balance field = current wallet balance
  3. The debits_posted and credits_posted provide full history
  4. 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.