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)
| Table | Rows | Role |
|---|---|---|
wallet_accounts | 6 (was 7 earlier the same day) | one balance per wallet-user |
wallet_transactions | 161 (was 142) | every credit/debit (the movement log) |
wallet_ledger | 30 | off-platform / settlement ledger entries |
wallet_holds | 0 | reserved/hold amounts (dormant) |
wallet_user_memberships | 280 (was 257) | membership rows funded via wallet |
wallet_membership_plans | 3 | wallet-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 memorywallet-orphans-and-fk-type-mismatch): wallet tables historically had no FK tocustomer_masterbecauserowIdisint(11)signed vsuser_idint(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)
| Method | Line | What |
|---|---|---|
getOrCreateAccount(userId) | 603 | ensure a wallet_accounts row |
getSummary(userId, limit) | 633 | balance + recent txns |
listTransactions(userId,โฆ) | 670 | paged wallet_transactions |
credit(...) | 985 | add funds (topup/cashback/refund/referral). Tag distinguishes spendable vs earned. |
debit(...) | 1245 | spend funds; enforces earned-credit cap and non-withdrawable rules |
createCreditsOrder(userId, amount) | 1136 | start a Razorpay topup order |
confirmCreditsPurchase(...) | 1173 | verify Razorpay signature โ credit() |
creditVerifiedPurchase(...) | 1228 | credit after a verified external purchase |
prepareWalletPayment(userId, amount) | 1710 | pre-check for a wallet-funded order (used by module checkouts) |
transferToUser(from,to,amount,โฆ) | 1757 | P2P wallet transfer |
sweepDeletedAccountBalance(...) | 1868 | move a deleted account's balance to a holding user (orphan-safe) |
recordOffplatformLedger(...) | 1655 | write a wallet_ledger entry (settlements) |
getPlatformFeePercentForUser(userId) | 2022 | membership-aware platform fee % |
How a module uses the wallet (the pattern)
prepareWalletPayment($userId, $amount)โ confirms funds / returns a payment intent.debit($userId, $amount, โฆ)โ moves money, writeswallet_transactions, returns the txn id.- 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.
- 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 calldebit()/prepareWalletPayment.BR-WAL-002โ resolve the wallet-user (resolveWalletUserId) before any balance op.BR-WAL-003โ earned credit redemption capped atmin(15%, โน25)per bill (debit).BR-WAL-004โ earned credit expires afterearnedCreditExpiryDays(); topup does not.
Errors (prefix WAL-E)
WAL-E001insufficient balance โdebit()/prepareWalletPaymentreturns failure; inspect
wallet_accounts.balance vs amount.
WAL-E002Razorpay signature mismatch โconfirmCreditsPurchase()rejects; no credit written.WAL-E003wrong-wallet read โ caller skippedresolveWalletUserId; balance looks wrong.WAL-E004orphaned wallet after user delete โ see FK caution; usesweepDeletedAccountBalance.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.