Skip to content

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_history MongoDB business_configs.wallet_balance + wallet_transactions ledger
Debited when customer redeems points a message/OTP is sent
Owned by Loyalty this doc (walletController)

walletController and this module are primarily about the business messaging wallet. The customer points ledger (wallet/wallet_history in 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):

closing_balance = opening_balance + (total_credited − total_redeemed − total_expired)
where credits = 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

walletController endpoints — verify route file & auth in prism_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 }
Failures: insufficient balance blocks sends upstream (in 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_transactions by business+date (no index) ⚠️ — add (parent_business_id, inserted_date).
  • walletAvgConsumption bulk-writes in 1000 chunks — fine; but aggregates all businesses nightly, watch runtime as tenant count grows.
  • deductBalance uses atomic findOneAndUpdate $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_statuses flags 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


Knowledge Tests

Level 1 — MCQs

  1. A WhatsApp campaign send debits which wallet? a) MySQL wallet b) business_configs.wallet_balance c) wallet_history d) Redis Answer: b. Business messaging wallet.

  2. 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.

  3. 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.

  4. WhatsApp utility template cost defaults to… a) 0.145 b) 0.17 c) 0.91 d) 0 Answer: b. whatsapp_cost_utility = 0.17.

  5. Failed WhatsApp sends are refunded… a) instantly b) never c) by whatsappRefund for campaigns ≥2 days old d) at month-end Answer: c.

Level 2 — Scenarios

  1. A client complains "my balance dropped ₹2000 overnight but I ran no campaign." Expected: check wallet_transactions for that window — likely automated journeys, OTP/loyalty notifications, or fraud alerts (all debit the business wallet). Group by service_id/campaign_id to attribute.

  2. 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 each avg_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

  1. 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 real wallet_transactions entry (mind the 3× length multiplier and 10,000 cap).
  2. Health simulator. Write a script that, for every parent business, prints wallet_balance, avg_consumption, and the derived health band using computeWalletHealth, and flags any mismatch vs the stored wallet_health.
  3. Reconciliation job. Build a daily reconciliation that sums wallet_transactions credits−debits over the month and compares to wallet_balance − last_monthly_balance; report drift per business.