Wallet — Two Wallets, Do Not Confuse Them¶
Validation legend: ✅ verified in code · 📄 Excel/spec-only · 💻 code-only · ♻️ duplicate · ⚰️ dead code · ⚠️ risk/known-issue
[!IMPORTANT] Prism has TWO different "wallets". Confusing them is the #1 cause of wallet bugs and support confusion.
Customer Loyalty Wallet Business Messaging Wallet Belongs to the diner (guest) the restaurant client Holds loyalty points prepaid rupees for SMS/WhatsApp/OTP sends Store MySQL wallet/wallet_historyMongoDB business_configs.wallet_balance+wallet_transactionsledgerDebited when customer redeems points a message/OTP is sent Owned by Loyalty this doc ( walletController)
walletControllerand this module are primarily about the business messaging wallet. The customer points ledger (wallet/wallet_historyin MySQL) is documented in Loyalty; it appears here only where the two intersect (loyalty notifications debit the business wallet).
1. Overview¶
The business messaging wallet is the restaurant client's prepaid rupee balance. Every campaign/journey/OTP send debits it; the client recharges it. Prism computes wallet health (Healthy / Low / Critical / Services Halted) from an exponential moving average of daily spend, alerts the client before it runs dry, snapshots monthly opening/closing balances for invoicing, and refunds failed WhatsApp sends.
walletController exports (✅): addWalletBalance, getWalletTransactions, getWalletServiceSummary, getWalletBalance, setWalletPointsSource, walletFlag, recomputeWalletHealth.
2. Business Flow¶
2.1 Example journey (business wallet)¶
flowchart LR
A[Client recharges<br/>addWalletBalance] --> B[business_configs.wallet_balance ↑<br/>wallet_transactions credit]
B --> C[Campaign/Journey/OTP send]
C --> D[deductBalance<br/>cost = base × units + GST]
D --> E[wallet_balance ↓<br/>wallet_transactions debit]
E --> F[Nightly EMA of spend<br/>walletAvgConsumption 05:00]
F --> G{wallet_health}
G -->|Low/Critical/Halted| H[Alert email 17:00]
E --> I[Month-end snapshot<br/>monthlyBalanceCron]
C -->|WhatsApp failures| J[whatsappRefund 00:00 → credit back]
2.2 Edge cases¶
| Case | Rule | Validation |
|---|---|---|
| Balance < ₹1 | wallet_health = "Services Halted", wallet_days_left = 0 — all sends stop |
✅ |
| No consumption history | wallet_health = "Healthy", days_left = null |
✅ |
| Last txn > 90 days ago | health fields cleared (stale business) | ✅ |
| Campaign cost cap | hard cap 10,000 per campaign (MAX_CAMPAIGN_AMOUNT) |
✅ |
| Failed WhatsApp sends | refunded 2+ days later (whatsappRefund) |
✅ |
| Loyalty OTP/notification | debits business wallet (not customer points) | ✅ |
| Month-end | snapshot only on last day of month | ✅ |
3. Technical Flow¶
RECHARGE: addWalletBalance → business_configs.wallet_balance += amt ; wallet_transactions {credit}
SEND: deductBalance(getCustomers, data, walletConfigData, businessData, campaignTypeData, templateCode)
SMS cost = sms_cost(0.145) × length-multiplier(1..7+) × units + sms_gst(18%)
WA cost = whatsapp_cost (MARKETING) | whatsapp_cost_utility(0.17) (UTILITY) × units + whatsapp_gst
cap total at 10000
→ business_configs.wallet_balance -= final ; wallet_transactions {debit, service_id 1=SMS/4=WA, units, sub_total, gst, total}
HEALTH: walletAvgConsumption (05:00) EMA α=0.065 → business_configs.avg_consumption, wallet_health, wallet_days_left
ALERT: walletHealthAlertEmail (17:00) → email if health in [Low, Critical, Services Halted]
SNAPSHOT: monthlyBalanceCron (00:00, month-end) → last_monthly_balance + Excel report
REFUND: whatsappRefund (00:00) → credit back failed WA sends
LOYALTY MIS: loyaltyPointMIS (04:03) → loyalty_mis {total_credited, total_redeemed, total_expired}
Cost calculation detail (✅ automatedCampaignWalletTransaction.deductBalance):
- SMS length multiplier: 1–160 chars = 1×, 161–306 = 2×, … up to 7× at 918 chars; beyond 1071 chars: ceil((len−1071)/153)+7.
- WhatsApp: template category decides rate — whatsapp_cost (marketing) vs whatsapp_cost_utility (default 0.17, utility/transactional); GST per category.
4. Architecture Diagram¶
4.1 Business wallet flowchart¶
flowchart TB
RC[Recharge / addWalletBalance] --> BC[(business_configs.wallet_balance)]
SEND[Campaign/Journey/OTP] --> DB[deductBalance]
DB -->|debit| BC
DB --> WT[(wallet_transactions ledger<br/>service_id 1=SMS / 4=WA)]
BC --> AVG[walletAvgConsumption 05:00<br/>EMA α=0.065]
AVG --> HEALTH{wallet_health}
HEALTH --> ALERT[walletHealthAlertEmail 17:00]
BC --> MONTH[monthlyBalanceCron month-end<br/>last_monthly_balance + Excel]
WT --> REFUND[whatsappRefund 00:00<br/>credit failed WA]
REFUND --> BC
LOY[Loyalty notifications] -.->|debit business wallet| BC
4.2 Wallet-health state machine¶
stateDiagram-v2
[*] --> Healthy: days_left ≥ 4 OR no consumption
Healthy --> LowBalance: 2 ≤ days_left < 4
LowBalance --> CriticalBalance: days_left < 2
CriticalBalance --> ServicesHalted: balance < ₹1 (days_left=0)
ServicesHalted --> Healthy: recharge
LowBalance --> Healthy: recharge
5. Folder Structure (real files)¶
uengage-crm/
├── controllers/
│ └── walletController.js ✅ recharge, ledger, service summary, balance, health, source flag
├── commonFunctions/
│ ├── walletMonthlyBalance.js ✅ opening/closing balance math (MySQL wallet_history)
│ └── automatedCampaignWalletTransaction.js ✅ deductBalance (SMS/WA cost + debit)
├── crons/
│ ├── monthlyBalanceCron.js ✅ 00:00 month-end snapshot + Excel email
│ ├── walletAvgConsumption.js ✅ 05:00 EMA consumption + health
│ ├── walletHealthAlertEmail.js ✅ 17:00 low-balance alerts
│ └── loyaltyPointMIS.js ✅ 04:03 loyalty points MIS
└── models/
└── wallet_transactions.js ✅ ledger schema (collection: wallet_transaction)
6. Database¶
Business messaging wallet¶
| Store | Object | Key fields | Purpose |
|---|---|---|---|
| MongoDB | business_configs |
wallet_balance, last_wallet_balance, last_monthly_balance, avg_consumption, avg_consumption_updated_at, wallet_health, wallet_days_left, wallet_points_source, alert thresholds |
live balance + health |
| MongoDB | wallet_transactions (collection wallet_transaction) |
transaction_type (credit/debit), amount, parent_business_id, child_business_id, campaign_id, inserted_date, service_id (1=SMS,4=WA), units, sub_total, gst, total, invoice_generated, refund |
ledger |
| MongoDB | wallet_config |
sms_cost (0.145), sms_gst, whatsapp_cost, whatsapp_gst, whatsapp_cost_utility (0.17) |
per-business rate card |
| MySQL | business_wallet_balance |
business-level credit balance | summary (see Database map) |
wallet_transactions schema (✅ models/wallet_transactions.js): transaction_order_id, payment_id, transaction_type enum[credit,debit], amount, description, updated_by, inserted_date, parent_business_id, child_business_id, campaign_id, invoice_generated (def false), invoice_url, sms_count, per_sms_price, total_sms_amount (+ runtime-added service_id, units, sub_total, gst, total, refund).
Customer loyalty wallet (cross-ref — owned by Loyalty)¶
| Store | Object | Note |
|---|---|---|
| MySQL | wallet, wallet_history |
points ledger; wallet_history.txn_type 1=credit/2=debit, remarks='Expired' marks expiry |
| MongoDB | loyalty_mis |
daily total_credited/redeemed/expired |
Monthly balance math (✅ walletMonthlyBalance.js):
txn_type=1, redeemed = txn_type=2 AND remarks!='Expired', expired = txn_type=2 AND remarks='Expired', all status=1.
Indexes: ⚠️ no dedicated wallet-transaction index — ledger reads scan by business+date. Add (parent_business_id, inserted_date).
Common query (business ledger):
db.collection("wallet_transaction").find({
parent_business_id: pid, inserted_date: { $gte: from, $lte: to }
}).sort({ inserted_date: -1 });
7. APIs¶
walletControllerendpoints — verify route file & auth inprism_routes/routes(grep the export names). Business-wallet actions are Admin/Finance role.
| Handler | Purpose | Touches |
|---|---|---|
addWalletBalance |
recharge business wallet | business_configs.wallet_balance +, wallet_transactions credit |
getWalletBalance |
current balance + health | business_configs |
getWalletTransactions |
ledger (paginated) | wallet_transactions |
getWalletServiceSummary |
spend by service (SMS/WA) | wallet_transactions group by service_id |
setWalletPointsSource |
set wallet_points_source (edge/dashboard/pos) |
business_configs |
walletFlag |
toggle wallet feature flags | business_configs |
recomputeWalletHealth |
force health recompute | reuses computeWalletHealth(balance, avg) |
Example — getWalletBalance response (verify):
{ "status": true, "wallet_balance": 4200.5, "wallet_health": "Low Balance",
"wallet_days_left": 3, "avg_consumption": 1350.2 }
deductBalance); recharge with non-positive amount rejected.
8. Code Walkthrough¶
deductBalance(getCustomers, data, walletConfigData, businessData, campaignTypeData, templateCode) [automatedCampaignWalletTransaction.js]
├─ SMS branch → [total, getCustomers] (base × length-multiplier × units + GST, cap 10000)
└─ WA branch → {status, total, customers} (marketing vs utility rate + GST)
→ business_configs.wallet_balance -= final (findOneAndUpdate $inc)
→ wallet_transactions.insert({debit, service_id, units, sub_total, gst, total, campaign_id})
computeWalletHealth(walletBalance, avgConsumption) [walletAvgConsumption.js export]
balance<1 → Services Halted (0) ; avg≤0 → Healthy(null)
days = balance/avg ; ≥4 Healthy · 2–4 Low · <2 Critical
walletAvgConsumption cron (05:00, α=0.065)
newAvg = α×yesterdaySpend + (1−α)×oldAvg ; recompute health if last txn ≤90d ; bulk write 1000/chunk
monthlyBalanceCron (00:00, month-end only)
snapshot business_configs → set last_monthly_balance ; Excel → email anshu/prabhpreet/hani@uengage.in
whatsappRefund cron (00:00)
find campaignType "4", status 1, ≥2 days old, whatsapp_refund=1
refund = failed × whatsapp_cost (+18% GST) → business_configs.wallet_balance += ; wallet_transactions {credit, refund:1}
Dependencies: MongoDB (business_configs, wallet_transactions, wallet_config), MySQL (wallet_history for loyalty MIS/monthly), xlsx + Nodemailer (reports/alerts), campaign/journey callers of deductBalance.
9. Business Rules¶
| Rule | Value | Validation |
|---|---|---|
| Services Halted threshold | balance < ₹1 → days_left 0 | ✅ |
| Health bands | ≥4 Healthy · 2–4 Low · <2 Critical | ✅ |
| EMA smoothing α | 0.065 (~30-day) | ✅ |
| Stale window | health ignored if last txn > 90d | ✅ |
| Campaign cost cap | 10,000 | ✅ |
| SMS base cost | 0.145 / segment; GST 18% (configurable) | ✅ |
| WA utility cost | 0.17 fixed; marketing per config | ✅ |
| WA refund | failed sends, ≥2 days old, whatsapp_refund=1 |
✅ |
| Monthly snapshot | last day of month only | ✅ |
| Alert cadence | 17:00 active; 09:00 disabled (no .start()) |
✅ |
Feature flags: whatsapp_refund, wallet_points_source, business_status, gst_deduction, refund_processed, whatsapp_refund_enabled_date.
Hidden logic (💻): hardcoded report/alert recipients (anshu@, prabhpreet@, hani@uengage.in); the 09:00 alert cron exists but is not started; refund only fires for the last retry attempt.
10. Performance¶
- Ledger reads scan
wallet_transactionsby business+date (no index) ⚠️ — add(parent_business_id, inserted_date). walletAvgConsumptionbulk-writes in 1000 chunks — fine; but aggregates all businesses nightly, watch runtime as tenant count grows.deductBalanceuses atomicfindOneAndUpdate $inc— safe under concurrency (no lost-update).
11. Logging¶
- Winston across crons; recharge/debit go through
wallet_transactions(the ledger is the audit log). - Refund entries carry
refund:1+whatsapp_campaign_id— filter these to audit refunds. - Health recompute logs skip reasons (stale >90d, no avg).
12. Monitoring¶
- Low-balance fleet:
db.business_configs.find({ wallet_health: { $in: ["Low Balance","Critical Balance","Services Halted"] } }). - Ledger reconciliation: sum credits − debits over a period should equal balance delta.
- Refund volume: count
wallet_transactions {refund:1}— a spike signals WhatsApp delivery failures upstream. - Cron health:
cron_status/cron_statusesflags for the four wallet crons.
13. Troubleshooting¶
| Issue | Cause | Fix | Commands |
|---|---|---|---|
| Sends stopped for a client | balance < ₹1 → Services Halted | recharge via addWalletBalance |
db.business_configs.findOne({parent_business_id:pid},{wallet_balance:1,wallet_health:1}) |
| No low-balance alert received | health not in alert set, or 09:00 cron disabled, or missing recipient | check wallet_health, business_email/reportsEmail |
grep -n "0 17\|0 9" crons/walletHealthAlertEmail.js |
| Wrong campaign charge | length multiplier or WA category rate | recompute cost from wallet_config |
grep -n "sms_cost\|whatsapp_cost" commonFunctions/automatedCampaignWalletTransaction.js |
| Balance drifts from ledger | non-atomic external write / missing debit | reconcile credits−debits vs balance | aggregate wallet_transaction by type |
| WhatsApp refund not applied | whatsapp_refund≠1 / <2 days / already refund_processed |
verify flags | grep -n "whatsapp_refund\|refund_processed" crons/whatsappRefund.js |
| Health shows null | no consumption or stale >90d | expected; recharge/activity resets | grep -n "90\|avg_consumption" crons/walletAvgConsumption.js |
14. FAQs¶
Q: Which wallet does a campaign debit? The business messaging wallet (business_configs.wallet_balance).
Q: Which wallet does a loyalty redemption debit? The customer points wallet (MySQL wallet). But the loyalty notification about it debits the business wallet.
Q: How is wallet_days_left computed? balance / avg_consumption, where avg is an EMA (α=0.065) of daily debit spend.
Q: Why did a client's health go null? No consumption yet, or no transactions in 90 days.
Q: Are failed WhatsApp charges refunded automatically? Yes, by whatsappRefund (00:00) for campaigns ≥2 days old with whatsapp_refund=1.
15. Cheat Sheet¶
TWO WALLETS: customer points (MySQL wallet) vs business rupees (business_configs.wallet_balance)
Recharge: addWalletBalance → +wallet_balance, wallet_transactions{credit}
Debit: deductBalance → SMS 0.145×mult×units+GST | WA marketing/utility(0.17)+GST, cap 10000
Ledger: MongoDB wallet_transactions (service_id 1=SMS,4=WA)
Health: walletAvgConsumption 05:00 EMA α=0.065 ; <₹1 Halted · <2d Critical · 2-4d Low · ≥4d Healthy
Alerts: walletHealthAlertEmail 17:00 (09:00 disabled)
Snapshot: monthlyBalanceCron 00:00 month-end → last_monthly_balance + Excel
Refund: whatsappRefund 00:00 → credit failed WA (≥2d, whatsapp_refund=1)
Monthly: closing = opening + credited − redeemed − expired
Related Modules¶
- Loyalty — customer points ledger (MySQL
wallet); its notifications debit this business wallet. - Campaigns / SMS-Push / WhatsApp — every send calls
deductBalance. - Fraud-Prevention — fraud alerts also debit the business wallet per send.
- Database · Queues · Roadmap B3.2 wallet-balance campaign
Knowledge Tests¶
Level 1 — MCQs¶
-
A WhatsApp campaign send debits which wallet? a) MySQL
walletb)business_configs.wallet_balancec)wallet_historyd) Redis Answer: b. Business messaging wallet. -
wallet_health = "Services Halted"means… a) days_left < 2 b) balance < ₹1 c) avg=0 d) stale > 90d Answer: b. Balance under ₹1, days_left=0. -
The EMA smoothing factor for average consumption is… a) 0.5 b) 0.18 c) 0.065 d) 0.145 Answer: c. α=0.065 in
walletAvgConsumption. -
WhatsApp utility template cost defaults to… a) 0.145 b) 0.17 c) 0.91 d) 0 Answer: b.
whatsapp_cost_utility= 0.17. -
Failed WhatsApp sends are refunded… a) instantly b) never c) by
whatsappRefundfor campaigns ≥2 days old d) at month-end Answer: c.
Level 2 — Scenarios¶
-
A client complains "my balance dropped ₹2000 overnight but I ran no campaign." Expected: check
wallet_transactionsfor that window — likely automated journeys, OTP/loyalty notifications, or fraud alerts (all debit the business wallet). Group byservice_id/campaign_idto attribute. -
Two outlets have identical balances but one shows "Critical" and the other "Healthy". Expected: health depends on
avg_consumption(spend rate), not balance alone. The Critical outlet spends faster (fewer days_left). Check eachavg_consumption.
Level 3 — Code Reading¶
computeWalletHealth(balance=300, avg=100): what are wallet_health and wallet_days_left? Expected: days = 300/100 = 3 → "Low Balance", wallet_days_left ≈ 3 (2–4 band).
Level 4 — Architecture¶
The business wallet balance lives in business_configs while the immutable ledger is wallet_transactions. Discuss the risk of these disagreeing and how to guarantee the balance equals ledger(credits) − ledger(debits). Expected: single-writer via atomic $inc, periodic reconciliation job, or make balance a derived read from the ledger; note the missing index and lack of double-entry constraints.
Level 5 — Production Debugging¶
Ticket: "Low-balance alerts stopped going out last week." Expected reasoning: (a) the 09:00 cron is intentionally disabled — only 17:00 runs; (b) check the 17:00 cron_status flag (stuck?); (c) recipients: business_email/reportsEmail/account-manager mapping missing; (d) query requires avg_consumption to exist and business_status:1. Walk the query filters, confirm at least one business is in an alert state, and check mailer errors.
Practical Assignments¶
- Cost audit. Given a 350-char SMS campaign to 1,000 customers with
sms_cost=0.145, GST 18%, compute the expected debit and verify against a realwallet_transactionsentry (mind the 3× length multiplier and 10,000 cap). - Health simulator. Write a script that, for every parent business, prints
wallet_balance,avg_consumption, and the derived health band usingcomputeWalletHealth, and flags any mismatch vs the storedwallet_health. - Reconciliation job. Build a daily reconciliation that sums
wallet_transactionscredits−debits over the month and compares towallet_balance − last_monthly_balance; report drift per business.