What
Users learned about money moving in/out of their wallet only by opening History. This MR makes every user-visible +/- transaction create a notification (persisted row in the bell list + FCM push), and live-refreshes the app for P2P transfers.
Bug record: https://hola-tech.sg.larksuite.com/record/MzE9rVZ6ae5hBPcB9zklGqsmgQX
Coverage (11 new NotificationTypes)
| Flow | + / - | Mechanism |
|---|---|---|
| P2P transfer | sender -, receiver +
|
new TransferCompletedEvent after the outer commit; both sides notified |
| Crypto deposit (Fireblocks) | + |
listener on existing DEPOSIT_RECORDED; net amount, legacy asset id -> display code |
| Crypto withdrawal (Fireblocks) | - |
new WITHDRAWAL_COMPLETED on the wasNotCompleted status edge (fires exactly once) |
| Card top-up |
- wallet |
new event after the stage-1 commit (both legs settled) |
| Card purchase |
- card |
Reap: emitted at clearing (booking), not the auth hold; KeySecure: on posted positive delta; both replay-gated |
| Card withdrawal settle |
+ wallet |
existing socket event now also persists a row |
| Lending supply / close |
- / +
|
listener on existing lending.operation.settled, resolves the operation row; customer kinds only |
| Lending borrow / repay / repay-refund |
+ / - / +
|
same listener |
| Fiat payout (bank) |
- on PAID, + on RETURN |
collector filled inside the DB transaction, emitted after commit; failed-before-paid releases emit nothing |
| Conversion (ramp USD/USDC) | both legs | emitted only when the COMPLETED verdict committed; exactly once across serialization retries |
Dead paths verified and left untouched: legacy cards.service.ts top-up (400s before any ledger write since 0515fd34), legacy conversions.service.ts (no caller). Liquidation/write-off keep their existing dedicated notifications.
Live refresh
New transfer.completed socket frame to BOTH sides (be socket-events listener; fe socket handler invalidates balance + history + notifications queries) — the receiver previously had no live update at all. Other flows keep their existing socket frames; all notifications also refetch on FCM foreground.
Invariants
- Every emit is after the booking commits and wrapped in try/catch — a listener can never roll back settled money.
- Fires once per movement (status edges / verdict gating / replay guards), verified by dedicated specs.
Verification
- BE: 3443 unit tests pass (only failures are the 75 pre-existing env-dependent ones in
treasury.config/admin-lending.risk, identical on develop); ~150 new assertions across 13 new/updated suites, including emit-after-commit ordering and no-emit-on-rollback. - FE: socket suite 10/10; tsc clean; eslint clean.
- Maestro on the iOS simulator against the repo fixture (
.omx/artifacts/tx-notifications/): full flow green — home bell shows the unread badge -> notification list renders + and - rows -> Mark all as read clears the badge. Screenshots:home-bell-unread-badge.png,notifications-list.png,notifications-all-read.png,home-bell-cleared.pngunder~/.maestro/tests/2026-10-26_082756/.
Deploy notes — sau khi resolve conflict với develop (fb2396a5)
- Chạy migration
1790600000000trước khi bật code mới; giữDB_SYNCHRONIZE=false. IndexUQ_notifications_lending_bookingphải tồn tại để cơ chế dedupe lending có hiệu lực. - Nếu môi trường từng chạy bản round 2, chạy SQL chỉ đọc dưới đây trước khi tạo unique index để kiểm tra thông báo trùng. Nguồn:
.omx/artifacts/mr144-round3/deploy-preflight.sql. Nếu truy vấn đầu trả về dữ liệu, cần reconcile trước migration; không tự động xoá lịch sử.
-- Read-only checks for an environment where an earlier build of MR !144 may have run.
-- This review did NOT run these against staging or production.
-- Before migration: this result should be empty. If not, reconcile deliberately; do not
-- delete financial/notification history automatically.
SELECT type, relation_id, params, COUNT(*) AS copies
FROM notifications
WHERE type IN (
'LENDING_SUPPLY_COMPLETED', 'LENDING_BORROW_COMPLETED',
'LENDING_REPAY_COMPLETED', 'LENDING_REPAY_REFUND_COMPLETED',
'LENDING_WITHDRAW_COMPLETED'
) AND params IS NOT NULL
GROUP BY type, relation_id, params
HAVING COUNT(*) > 1;
-- After migration, before the new writer is activated: the expected index must exist.
SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE indexname = 'UQ_notifications_lending_booking';