OctoWiki

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

  1. The ledger is single-entry, not double-entry. Each movement writes one transactions row + one ledgers row against one wallet — there is no contra account, so the ledger alone can't be reconciled against company/vendor cash.
  2. Balance is a running column (wallets.amount), not derived from the ledger. The ledger runs alongside as an audit trail; nothing reconciles SUM(ledgers) against wallets.amount.

Data model

ModelTableNotes
Wallet (wallet.go:35)walletsamount NUMERIC(24,2), type PREPAID/POSTPAID. One wallet per (client, currency) (unique constraint). A client has many wallets, one per currency.
Transaction (transaction.go)transactionsCREDIT/DEBIT, amount NUMERIC(24,2), FX cols (forex_rate NUMERIC(24,12), conversion_charges), created_by. status always hardcoded COMPLETED.
Ledger (ledger.go)ledgerstransaction_id (FK commented out in the migration — no DB integrity), ref_number, forex_rate NUMERIC(8,2). Append-only by convention.
Currencycurrencieshas 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 just CREDITs distinguished by free-text remarks and by created_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 shared reference_number string (voucher reference_code UUID; topup KSUID).
  • reference_number is not unique and not a ledger-level idempotency key. Calling UpdateWalletBalance twice 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 on client_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_numberno explicit FK from the refund ledger row to the original debit.

Double-refund protection is inconsistent across paths:

PathGuardRobustness
Recharge manual[REFUNDED] string prefix in StatusTextFragile — rides on free text; no sub_status column
Auto-refund (recharge/eSIM)SELECT … FOR UPDATE + terminal-status early-return in applyTopupVendorStatusCorrect if callers always use the locked transition
Prepaid (voucher-funded)refund is a no-op that releases the voucher insteadCorrect
Voucher partialrefundAmount = Amount − RefundedAmount, reject ≤0OK; 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 unimplementedGSTAmount is 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:

  • ValidateVoucherCodeForRedemption hashes the plaintext with SHA3-256 and matches order_items.code_hashed; the item must be delivered/completed with redeem_resource_id NULL.
  • When prepaid, the wallet is not debited (if !prepaid guards the debit), TransactionID stays null, IsPrepaid=true.
  • Double-spend guard is a DB-atomic conditional UPDATEClaimOrderItemForRedemption (repo/order_item.go:1590, WHERE redeem_resource_id IS NULL) inside the order tx. Exactly one concurrent caller wins; the loser gets ErrVoucherAlreadyRedeemed. 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

HazardDetail
Fixed 2 dp for every currencywallets.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 truncatedledgers.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 float64models.Recharge.Amount isn't types.Decimal — topup money round-trips through float, and refunds credit that float back, persisting any drift.
Lossy display readGetPrimaryWalletBalances scans amount into float64 for the admin dashboard.

Findings summary

SeverityFindingWhere
Highservices/wallet_refund.go dead; prod uses parallel inline refunds — two impls to keep in sync.wallet_refund.go vs handlers
HighPer-currency precision bug — all money forced to 2 dp; currencies.precision ignored.utils/money.go, migrations
MediumUser attribution (created_by) silently dropped in the debit proc.…for_fx.sql INSERT
MediumRace-loser insufficient-funds surfaces as raw pg error, not ErrInsufficientBalance.repo/wallet.go:465
MediumInconsistent double-refund guards; no unified idempotency key.recharge/eSIM/voucher paths
MediumWallet webhook fires pre-commit from a goroutine → can emit for rolled-back movements.wallet_debit.go:129
LowSingle-entry ledger, no contra account → not reconcilable from ledger alone.model
LowLedger→transaction FK commented out; reference_number not unique.ledgers.sql:6
LowPOSTPAID dead; transaction status always COMPLETED; GSTAmount unimplemented.various
LowStale 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; wrapper database/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

On this page