Closes the data-refresh and money-correctness gaps found by driving the Ahad Loans flow end to end on a simulator against a live backend, then adds a socket push so a settled lending operation reaches the client on whatever screen the customer is on.
What was broken
Four defects were found live, all money-facing:
- Pay now offered on a settled loan. The client asked for a repay quote on a loan the server already considered closed and rendered the global error modal.
-
A fully repaid loan reported
LIQUIDATED. TheLIFECYCLE_BORROWquery ranked bykindacross the market's whole history, so a trailing dustWRITE_OFFdecided the verdict for a loan that had been repaid in full. - "Your balance" on the Supply screen showed the amount already supplied, not the wallet balance the MAX control pays from.
-
Withdraw Supply ignored in-flight debt. The first correction then over-corrected
and blocked on any pending transfer, so
REPAY_REFUNDdust would have locked the control permanently.
Plus: positions were never polled, the wallet balance was never invalidated by a lending mutation, and the detail screens did not refresh at all.
The near-miss worth reading
Fixing #2 introduced a Critical that only the whole-branch review caught. The
loan_cycle CTE that scopes the lifecycle decision also scoped the pending_repay
window aggregate, so BORROW(booked), REPAY(pending, full debt), BORROW(pending)
reported pendingRepayAmount: 0 — hasPendingRepay false, canPayNow true, and the
customer could pay the same debt twice. pending_repay is now an independent scalar
subquery over unscoped rows.
Rule for anyone touching that query: ordering may be cycle-scoped; a pending-transfer aggregate must not be.
Socket push
LendingSettlementRouter.onTerminal emits a domain event once the settlement handler has
resolved — after, never before, because the caller rolls its own transaction back and
the handler books in its own, so an early emit would announce money that has not moved. A
listener in socket-events turns that into lending.operation.settled / .failed,
addressed by the operation row's own user_id. Lending does not import the gateway; this
follows the existing Airwallex deposit pattern.
Failure isolation is three-deep: emitTerminal try/catches, Nest's @OnEvent wrapper
defaults to suppressErrors, and EventEmitter2's async: true defers via _setImmediate.
A socket failure can never fail a booking.
Polling stays as the floor — a dropped socket, a proxy or a backgrounded app all defeat the push. Do not remove it as redundant.
Trap pinned by tests
The aliases in onTerminal's SELECT are quoted (o."user_id" AS "userId"). Unquote one
and Postgres folds it to lowercase, the field reads undefined, every frame is addressed
to undefined, and the push stops for everyone with no log and no error. Two Postgres
tests pin the aliases against real database-generated ids; all 73 pre-existing tests
survived unquoting them, which is why those two exist.
Verification
Rebuilt the backend locally and ran the whole path against it, not just unit tests:
- Real 0.5 USDC supply from the app → local BE → Fireblocks sandbox tx. Settlement booked
at
15:32:01.230; the socket frame reached a real client at15:32:01.257carrying the correctoperationId,kindandmarketSymbol. - With the app parked on Home: 0 lending queries in a 60s baseline, 5 immediately after the frame. Home never polled lending, so that traffic can only have come from the push — which is exactly the gap polling never covered.
- Rate curve, health factor and all PRD-mandated copy verified verbatim against staging earlier in the same pass (U=0.2 → borrow 8.00% / supply 1.36%; HF 3.60/1.500002 = 2.39).
-
be: 6 suites / 102 tests pass.fe: tsc clean,src/lib/socket8/8, eslint clean. - Every new test was mutation-checked — including ordering (emit before the handler must fail) and the SQL aliases.
Two regression skills were added under .claude/skills/ (gitignored, local only):
moanapay-lending for the domain and moanapay-lending-e2e for the 33-assertion
simulator runbook.
Known, deliberately not in this MR
-
The Fireblocks webhook is still not wired into lending.
intent-recovery.schedulerpolls at*/2, so the end-to-end budget is up to ~2 min (cron) + effectively instant (socket). Cutting that 2 min changes how settlement is detected — money-path work that needs its own spec. - Margin calls still cannot arrive on screen (health scan is
*/5, reaches the customer only as a push notification). -
marketskeepsstaleTime: 30_000with no polling — a display lag on a rate the PRD already calls non-binding. -
pay-now.tsxstill uses bareuseLendingPositions(). - Backend lifecycle tests are
RUN_DB_TESTS-gated and.gitlab-ci.ymlhas no test stage, so CI pins none of this.
Reviewer, please look at
-
be/.../lending.controller.ts— theloan_cycleCTE and the independentpending_repaysubquery (the double-repay near-miss above). -
be/resp.json— a stray 401 response dump committed in12eb6f5; it should probably be deleted rather than merged into develop. -
fe/.env.development— this branch repoints the devAPI_URLfromlocalhost:3000tohttps://ahad.holadev.xyz/, which changes where every developer's dev build points. Intentional?