Koovira Dev Docs
40-financial/wallet.md

Wallet

Status: ๐ŸŸข Production (core credit/debit mechanics) ยท ๐ŸŸก accounting-tag defect confirmed (see Known gaps) ยท Last validated: 2026-09-06

The single money account per user/company. Wallet-first is a platform invariant: no module touches a payment gateway directly โ€” money flows Gateway โ†’ Wallet โ†’ module. Validated against classs/WalletModule.php and the live wallet_* schema.


Tables (2026-09-06, re-counted same day during the money-validation pass โ€” see note below)

TableRowsRole
wallet_accounts6 (was 7 earlier the same day)one balance per wallet-user
wallet_transactions161 (was 142)every credit/debit (the movement log)
wallet_ledger30off-platform / settlement ledger entries
wallet_holds0reserved/hold amounts (dormant)
wallet_user_memberships280 (was 257)membership rows funded via wallet
wallet_membership_plans3wallet-tier plans

All counts are live snapshots and move within the same day under active QA/money testing โ€” re-run SELECT COUNT(*) FROM wallet_accounts (etc., via _tools/kv_docs_db.php::kv_docs_row_count()) rather than trusting either number verbatim.

FK type mismatch caution (validated in memory wallet-orphans-and-fk-type-mismatch): wallet tables historically had no FK to customer_master because rowId is int(11) signed vs user_id int(10) unsigned; a purge stranded wallets holding real money. RESTRICT FKs were added 2026-09-03. Confirm before any user-deletion path.

Key concept: wallet-user resolution

resolveWalletUserId(int $userId) (:95) maps a caller to the wallet-owning id (a sub-profile resolves to its parent person; a company may resolve to its owner). Always resolve before reading/writing a balance โ€” using the raw $userId can hit the wrong wallet.

Balance buckets (KCredits)

KCredit = wallet amount, 1 โ‚น = 1 KCredit, closed-loop store credit (India PPI):

  • Withdrawable vs non-withdrawable: getWithdrawableBalance() (:2183) /

getNonWithdrawableBalance() (:2133).

  • Topup (spendable) vs earned (capped): earned credit (cashback/rewards) is capped on

redemption. getEarnedCreditBalance() (:2733), walletEarnedCapEnabled() (:2687), earnedCreditExpiryDays() (:2658). Redemption cap: earned points redeemable per bill = min(15%, โ‚น25), enforced in debit() (see memory earned-credit-redemption-cap).

Core operations (method contract)

MethodLineWhat
getOrCreateAccount(userId)603ensure a wallet_accounts row
getSummary(userId, limit)633balance + recent txns
listTransactions(userId,โ€ฆ)670paged wallet_transactions
credit(...)985add funds (topup/cashback/refund/referral). Tag distinguishes spendable vs earned.
debit(...)1245spend funds; enforces earned-credit cap and non-withdrawable rules
createCreditsOrder(userId, amount)1136start a Razorpay topup order
confirmCreditsPurchase(...)1173verify Razorpay signature โ†’ credit()
creditVerifiedPurchase(...)1228credit after a verified external purchase
prepareWalletPayment(userId, amount)1710pre-check for a wallet-funded order (used by module checkouts)
transferToUser(from,to,amount,โ€ฆ)1757P2P wallet transfer
sweepDeletedAccountBalance(...)1868move a deleted account's balance to a holding user (orphan-safe)
recordOffplatformLedger(...)1655write a wallet_ledger entry (settlements)
getPlatformFeePercentForUser(userId)2022membership-aware platform fee %

How a module uses the wallet (the pattern)

  1. prepareWalletPayment($userId, $amount) โ†’ confirms funds / returns a payment intent.
  2. debit($userId, $amount, โ€ฆ) โ†’ moves money, writes wallet_transactions, returns the txn id.
  3. The module threads that txn id into the central finance ledger

(kv_order_finance_master.wallet_payment_txn_id, kv_order_finance_entries.wallet_txn_id) โ€” see central-finance-ledger.md.

  1. Topups come the other way: gateway โ†’ confirmCreditsPurchase() โ†’ credit().

Known gaps / cautions โ€” CONFIRMED DEFECT (money-validation pass, 2026-09-06)

Wallet movements are mis-booked as platform revenue in accounting_journal by default.

WalletModule::journalAccountsFor() (classs/WalletModule.php:952-983, called from credit() at :1101 and debit() at :1429) picks the debit/credit account pair for a wallet movement from $meta['acct_purpose']. It correctly special-cases escrow_hold, escrow_release/escrow_refund, topup, revenue/fee/commission, and transfer โ€” but every existing call site as of 2026-09-05 omits acct_purpose, so the default: branch fires (:977-981), which maps a debit to ['USER_WALLET_LIABILITY', 'REVENUE_GENERAL'] โ€” i.e. any un-tagged spend is booked as platform revenue, whether it was actually an escrow funding, a refund, or a P2P transfer.

Verified against the live accounting_journal table (2026-09-06): 995 of 2,422 rows (41%) have credit_account = 'REVENUE_GENERAL'; module_key breakdown for the table is wallet (2,393), ecommerce (24), referral (5) โ€” the overwhelming majority of journal volume is wallet-sourced and therefore exposed to this default. This is not hypothetical: it is the current state of the books. Cross-reference: central-finance-ledger.md for the related AccountingEngine::deriveWallet() gap (escrow money also never reaches the ESCROW_HELD account in the derived ledger used for reporting).

Fix shape (not implemented โ€” documentation-only finding): every wallet call site that funds escrow, releases escrow, or processes a refund/transfer needs to pass the matching acct_purpose in $meta; until then, accounting_journal.credit_account = REVENUE_GENERAL cannot be trusted as "platform revenue" without cross-checking the underlying wallet_transactions.type/meta_source.

Business rules

  • BR-WAL-001 โ€” wallet-first: modules never call a gateway; they call debit()/prepareWalletPayment.
  • BR-WAL-002 โ€” resolve the wallet-user (resolveWalletUserId) before any balance op.
  • BR-WAL-003 โ€” earned credit redemption capped at min(15%, โ‚น25) per bill (debit).
  • BR-WAL-004 โ€” earned credit expires after earnedCreditExpiryDays(); topup does not.

Errors (prefix WAL-E)

  • WAL-E001 insufficient balance โ€” debit()/prepareWalletPayment returns failure; inspect

wallet_accounts.balance vs amount.

  • WAL-E002 Razorpay signature mismatch โ€” confirmCreditsPurchase() rejects; no credit written.
  • WAL-E003 wrong-wallet read โ€” caller skipped resolveWalletUserId; balance looks wrong.
  • WAL-E004 orphaned wallet after user delete โ€” see FK caution; use sweepDeletedAccountBalance.
  • WAL-E005 (new, 2026-09-06) wallet movement booked as revenue โ€” journalAccountsFor()

defaulted because the caller passed no meta['acct_purpose'] (WalletModule.php:977-981); inspect accounting_journal.credit_account = 'REVENUE_GENERAL' against the source wallet_transactions.type/meta_source to find the true nature of the movement.

Change-impact

Central to every paid flow. Changing credit/debit affects all module checkouts, the finance ledger, commission, cashback, referral payout, and settlement. Re-test: topup, a module checkout, earned-cap enforcement, refund credit, transfer, and the deleted-account sweep.