Skip to content
GitLab
Projects Groups Topics Snippets
  • /
  • Help
    • Help
    • Support
    • Community forum
    • Submit feedback
    • Contribute to GitLab
  • Register
  • Sign in
  • A Ahad Mobile
  • Project information
    • Project information
    • Activity
    • Labels
    • Members
  • Repository
    • Repository
    • Files
    • Commits
    • Branches
    • Tags
    • Contributor statistics
    • Graph
    • Compare revisions
  • Issues 0
    • Issues 0
    • List
    • Boards
    • Service Desk
    • Milestones
  • Merge requests 0
    • Merge requests 0
  • CI/CD
    • CI/CD
    • Pipelines
    • Jobs
    • Schedules
  • Deployments
    • Deployments
    • Environments
    • Releases
  • Packages and registries
    • Packages and registries
    • Package Registry
    • Terraform modules
  • Monitor
    • Monitor
    • Incidents
  • Analytics
    • Analytics
    • Value stream
    • CI/CD
    • Repository
  • Wiki
    • Wiki
  • Snippets
    • Snippets
  • Activity
  • Graph
  • Create a new issue
  • Jobs
  • Commits
  • Issue Boards
Collapse sidebar
  • holatech
  • Ahad Mobile
  • Merge requests
  • !67

fix(lending): close the money-correctness and refresh gaps, push settlement over the socket

  • Review changes

  • Download
  • Patches
  • Plain diff
Merged Khanh Pham requested to merge lending-local into develop Sep 15, 2026
  • Overview 0
  • Commits 28
  • Pipelines 2
  • Changes 107

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:

  1. 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.
  2. A fully repaid loan reported LIQUIDATED. The LIFECYCLE_BORROW query ranked by kind across the market's whole history, so a trailing dust WRITE_OFF decided the verdict for a loan that had been repaid in full.
  3. "Your balance" on the Supply screen showed the amount already supplied, not the wallet balance the MAX control pays from.
  4. Withdraw Supply ignored in-flight debt. The first correction then over-corrected and blocked on any pending transfer, so REPAY_REFUND dust 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 at 15:32:01.257 carrying the correct operationId, kind and marketSymbol.
  • 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/socket 8/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.scheduler polls 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).
  • markets keeps staleTime: 30_000 with no polling — a display lag on a rate the PRD already calls non-binding.
  • pay-now.tsx still uses bare useLendingPositions().
  • Backend lifecycle tests are RUN_DB_TESTS-gated and .gitlab-ci.yml has no test stage, so CI pins none of this.

Reviewer, please look at

  • be/.../lending.controller.ts — the loan_cycle CTE and the independent pending_repay subquery (the double-repay near-miss above).
  • be/resp.json — a stray 401 response dump committed in 12eb6f5; it should probably be deleted rather than merged into develop.
  • fe/.env.development — this branch repoints the dev API_URL from localhost:3000 to https://ahad.holadev.xyz/, which changes where every developer's dev build points. Intentional?

🤖 Generated with Claude Code

Assignee
Assign to
Reviewers
Request review from
Time tracking
Source branch: lending-local