Wallet & Ledger
The client money layer every order and payout debits — wallets, the single-entry ledger, the atomic debit stored procedure, the refund paths, charges/FX, and prepaid voucher-burning. Includes precision and double-refund hazards.
The money layer: client wallets, transactions, ledgers, currencies. Every voucher/topup/eSIM order debits it; every cancel/refund credits it. (vendor_wallets is a separate vendor-side balance and is not part of this path.)
Two things to internalise first
- The ledger is single-entry, not double-entry. Each movement writes one
transactionsrow + oneledgersrow against one wallet — there is no contra account, so the ledger alone can't be reconciled against company/vendor cash. - Balance is a running column (
wallets.amount), not derived from the ledger. The ledger runs alongside as an audit trail; nothing reconcilesSUM(ledgers)againstwallets.amount.
Data model
| Model | Table | Notes |
|---|---|---|
Wallet (wallet.go:35) | wallets | amount NUMERIC(24,2), type PREPAID/POSTPAID. One wallet per (client, currency) (unique constraint). A client has many wallets, one per currency. |
Transaction (transaction.go) | transactions | CREDIT/DEBIT, amount NUMERIC(24,2), FX cols (forex_rate NUMERIC(24,12), conversion_charges), created_by. status always hardcoded COMPLETED. |
Ledger (ledger.go) | ledgers | transaction_id (FK commented out in the migration — no DB integrity), ref_number, forex_rate NUMERIC(8,2). Append-only by convention. |
Currency | currencies | has a Precision column that is never consulted — all math hardcodes 2 dp. |
Client-facing handlers (wallet.go, transaction.go) are read/list only — no client mutation endpoints. All wallet writes go through admin or the order/refund paths.
POSTPAID is effectively dead — the debit stored procedure hard-blocks negative balances for all wallet types, so no overdraft path exists. The type column is stored but unusable.
The ledger
- Only two transaction types:
CREDIT/DEBIT. There is no dedicated refund/topup/adjustment type — refunds are justCREDITs distinguished by free-textremarksand bycreated_by(system id vs admin id). - Order↔ledger linkage is loose and reversed: the order/recharge/payout row holds the FK to the transaction (
orders.transaction_id); the transaction/ledger does not point back. The only correlation is a sharedreference_numberstring (voucherreference_codeUUID; topup KSUID). reference_numberis not unique and not a ledger-level idempotency key. CallingUpdateWalletBalancetwice with the same reference creates two ledger rows and doubles the balance change — nothing at the ledger layer stops it. Idempotency lives at the order layer (dedup onclient_reference, atomic voucher claim).
Debit path
Entry DebitWalletForOrder (services/wallet_debit.go:64): call UpdateWalletBalance in the caller's tx → fetch the created transaction to stamp orders.transaction_id → fire the wallet webhook async. The real work is a plpgsql stored procedure UpdateWalletBalance (current def in …extend_update_wallet_balance_for_fx.sql).
Atomicity / concurrency (the good part): the proc does SELECT amount FROM wallets WHERE id=? FOR UPDATE — a row lock that serializes concurrent debits/credits on the same wallet — then IF v_new_balance < 0 THEN RAISE EXCEPTION 'Insufficient funds'. Overdraw is impossible at the DB level. Two orders racing the same wallet queue on the lock; the second reads the post-first balance.
App pre-checks are advisory, and the race-loser error is raw
The app-level balance checks (topup_order.go:179, create_voucher_order.go:710) read a stale wallet loaded before the tx — under concurrency both can pass, then the proc's FOR UPDATE + exception catches the loser. But the loser gets a raw Postgres error string ("error executing UpdateWalletBalance: … Insufficient funds"), not the ErrInsufficientBalance sentinel — so callers can't cleanly map it to a 402/400. Poor error contract.
User attribution is silently dropped
The proc accepts p_user_id and the Go layer passes it, but the INSERT INTO transactions never writes created_by — so transactions.created_by (a column added specifically for this) is always NULL from the money path. The attribution plumbed through the whole stack dies at the SQL boundary.
Refund / credit path
services/wallet_refund.go is dead — prod uses a parallel inline copy
RefundRechargeToWallet / RefundEsimOrderToWallet / RefundVoucherOrderToWallet (services/wallet_refund.go) have no production callers — only FX tests reference them. The real refund logic is inlined in handlers: creditWalletForRechargeRefund (admin_recharge_actions.go:55), autoRefundWalletForFailedRecharge (topup.go:1910), autoRefundWalletForFailedEsimOrder (esim_order.go:945), and voucher cancel/partial-refund in admin_ui.go:2809/3042. Two parallel refund implementations to keep in sync by hand — and the tests pin the unused one. Consolidate.
When refunds fire: vendor terminal FAILED (recharge/eSIM auto-refund), admin manual cancel/refund, voucher order admin cancel. Linkage to the original debit is only via shared reference_number — no explicit FK from the refund ledger row to the original debit.
Double-refund protection is inconsistent across paths:
| Path | Guard | Robustness |
|---|---|---|
| Recharge manual | [REFUNDED] string prefix in StatusText | Fragile — rides on free text; no sub_status column |
| Auto-refund (recharge/eSIM) | SELECT … FOR UPDATE + terminal-status early-return in applyTopupVendorStatus | Correct if callers always use the locked transition |
| Prepaid (voucher-funded) | refund is a no-op that releases the voucher instead | Correct |
| Voucher partial | refundAmount = Amount − RefundedAmount, reject ≤0 | OK; full-cancel relies on status transition |
There is no unified idempotency key for refunds — each path invented its own guard.
Wallet resolution
ResolveWalletForOrder (services/wallet_resolver.go:57): explicit WalletID → client-scoped lookup (currency-mismatch → error if RequireCurrencyMatch); otherwise prefer a wallet in the product currency, else fall back to the client's default-currency wallet.
Cross-currency: yes — a client can pay for a USD product from an INR wallet; FX happens in the charges computation via admin-managed forex_values.
Stale docstring trap
The resolver's doc says topup/eSIM "don't support FX" and set RequireCurrencyMatch=true — but topup_order.go:146 no longer sets it (defaults false) and comments "voucher-parity: downstream FX bridges variant↔wallet currency." So topup now does FX, contradicting the resolver's own docstring. Don't trust the comment.
Funding a wallet (crediting money in)
"Topup order" ≠ "wallet topup." services/topup_order.go is DIRECT-TOPUP (mobile recharge) — a product the client buys, which debits the wallet. It does not fund it.
Wallets are credited (CREDIT ledger entry) only via admin operations: manual top-up (admin_ui.go:2063, ref TOPUP-{clientID}-{unix}), wallet-create-with-initial-balance (:2418), manual adjust-down (:2229), and refunds. There is no client-facing self-funding (no invoice / payment-gateway / prepaid-deposit endpoint in this repo) — funding is entirely admin-driven.
Charges & fees
Two overlapping implementations of the same discount+FX math — ComputeOrderChargeBreakdown (order_charges.go:85, used by topup/eSIM) and CalculateCharges (utils/charges.go:15, used by the voucher charges endpoint). Canonical formula (discount before FX, single round at the end):
amount = round(face × qty × (1 − discount/100) × rate, 2)Both produce NonDiscountedTotal, DiscountAmount, TotalAmount, and — if wallet currency ≠ product currency — NetAmount = convert(total, rate), HandlingFeeAmount = NetAmount × conversionFee%, TotalPayable.
- Conversion/handling fee is hardcoded 0 today (
DefaultConversionFeePercent = 0.0) — the plumbing exists to switch it on in one place. - GST is unimplemented —
GSTAmountis referenced in a log line but never computed or set. - FX source: admin-managed
forex_values(GetForexValue); missing rate → 400. - Caching: the charges response is cached 5 min, and the forex rate is part of the cache key so an admin rate change auto-invalidates. Wallet is resolved before cache lookup so unauthorized wallets can't be served from cache.
- CPD (Client Product Detail) per-client discount overrides applied via
applyClientProductDetailDiscounts. See Products & Catalog for the discount model.
Prepaid — burn-a-voucher
The redeem_voucher_code path pays for an order by consuming a previously delivered voucher instead of debiting the wallet:
ValidateVoucherCodeForRedemptionhashes the plaintext with SHA3-256 and matchesorder_items.code_hashed; the item must be delivered/completed withredeem_resource_idNULL.- When prepaid, the wallet is not debited (
if !prepaidguards the debit),TransactionIDstays null,IsPrepaid=true. - Double-spend guard is a DB-atomic conditional UPDATE —
ClaimOrderItemForRedemption(repo/order_item.go:1590,WHERE redeem_resource_id IS NULL) inside the order tx. Exactly one concurrent caller wins; the loser getsErrVoucherAlreadyRedeemed. Correct. - Refund releases the voucher via
ReleaseOrderItemRedemption, making it redeemable again.
Wallet webhooks
wallet.credited / wallet.debited fire from DebitWalletForOrder unconditionally in a detached go func() with context.Background(). Payload: wallet_id, ledger_id, amount, currency, reference_number, remarks. See Notifications.
The webhook fires from a goroutine that captures state before the caller commits — if the surrounding tx rolls back, a wallet.debited webhook can still be sent for a movement that never persisted (no post-commit hook). Admin manual top-up, by contrast, fires after commit — inconsistent patterns.
Precision & rounding hazards
| Hazard | Detail |
|---|---|
| Fixed 2 dp for every currency | wallets.amount / transactions.amount are NUMERIC(24,2); currencies.precision is never used. KWD/BHD (3 dp) and JPY (0 dp) are silently mis-rounded. |
| Ledger FX truncated | ledgers.forex_rate NUMERIC(8,2) vs transactions.forex_rate NUMERIC(24,12) — same rate stored at two precisions; the ledger copy truncates to 2 dp and can overflow for high-rate pairs (e.g. USD→VND). |
Recharge amount is raw float64 | models.Recharge.Amount isn't types.Decimal — topup money round-trips through float, and refunds credit that float back, persisting any drift. |
| Lossy display read | GetPrimaryWalletBalances scans amount into float64 for the admin dashboard. |
Findings summary
| Severity | Finding | Where |
|---|---|---|
| High | services/wallet_refund.go dead; prod uses parallel inline refunds — two impls to keep in sync. | wallet_refund.go vs handlers |
| High | Per-currency precision bug — all money forced to 2 dp; currencies.precision ignored. | utils/money.go, migrations |
| Medium | User attribution (created_by) silently dropped in the debit proc. | …for_fx.sql INSERT |
| Medium | Race-loser insufficient-funds surfaces as raw pg error, not ErrInsufficientBalance. | repo/wallet.go:465 |
| Medium | Inconsistent double-refund guards; no unified idempotency key. | recharge/eSIM/voucher paths |
| Medium | Wallet webhook fires pre-commit from a goroutine → can emit for rolled-back movements. | wallet_debit.go:129 |
| Low | Single-entry ledger, no contra account → not reconcilable from ledger alone. | model |
| Low | Ledger→transaction FK commented out; reference_number not unique. | ledgers.sql:6 |
| Low | POSTPAID dead; transaction status always COMPLETED; GSTAmount unimplemented. | various |
| Low | Stale wallet_resolver.go docstring claims topup has no FX (it does). | wallet_resolver.go:40 |
Key files
- Models:
database/models/{wallet,transaction,ledger,currency,recharge}.go - Debit/refund/resolve:
services/{wallet_debit,wallet_refund,wallet_resolver,voucher_redemption}.go - Stored proc:
database/migration/20260519033357_extend_update_wallet_balance_for_fx.sql; wrapperdatabase/repo/wallet.go:382 - Charges:
services/{order_charges,discount_calculator,vendor_discount_service}.go,utils/{charges,money}.go,http/handler/charges.go - Redemption claim:
database/repo/order_item.go:1590 - Admin:
http/handler/{admin_ui,admin_recharge_actions,admin_esim_actions}.go - Read handlers:
http/handler/{wallet,transaction}.go
Inventory Flow
How pre-loaded voucher codes move DB → Valkey pools → orders. The exact pool key formats, the ZSET FEFO (first-expiry) allocation, the two-phase reserved/general algorithm, prefetch, import, tagging, and the ALLOCATED leak.
Jobs, Queue, Cache & Observability
The cron scheduler and full job inventory, the (dormant) RabbitMQ queue, the Valkey cache strategy, and the OpenTelemetry → SigNoz pipeline.