Skip to content

Withdrawals

Purpose

The Withdrawals screen is the daily approval / reject queue for player cashouts. Every action requires a permission and a fresh OTP, and every action sends a Telegram notification. This is the most-watched financial control surface in the admin panel — the screen where a wrong click moves real money.

Audience

Finance / payments operations (primary). Risk / fraud uses it to reject suspicious cashouts pre-payout.

Path in admin-fe

Screen URL Page
Withdrawals queue /withdrawals ebit-admin-fe/src/app/(dashboard)/withdrawals/page.tsxcomponents/pages/withdrawals/index.tsx
Reject reason modal (in-page) components/pages/withdrawals/WithdrawRejectModal.tsx
Per-row drill-in (in-page expand) components/pages/withdrawals/Table/

Backing API endpoints

Endpoint Source
GET /admin/payments/withdraw (list) apps/api/src/payment/withdraw/admin.withdraw.controller.ts:34
GET /admin/payments/withdraw/stats apps/api/src/payment/withdraw/admin.withdraw.controller.ts:48
GET /admin/payments/withdraw/stats/:userId apps/api/src/payment/withdraw/admin.withdraw.controller.ts:54
GET /admin/payments/withdraw/:id apps/api/src/payment/withdraw/admin.withdraw.controller.ts:62
GET /admin/payments/withdraw/fetch/:id (force-refresh from provider) apps/api/src/payment/withdraw/admin.withdraw.controller.ts:68
POST /admin/payments/withdraw/approve (OTP) apps/api/src/payment/withdraw/admin.withdraw.controller.ts:74
POST /admin/payments/withdraw/reject (OTP) apps/api/src/payment/withdraw/admin.withdraw.controller.ts:88
POST /admin/payments/withdraw/retry (OTP) apps/api/src/payment/withdraw/admin.withdraw.controller.ts:102

Frontend wiring: ebit-admin-fe/src/queries/withdrawals/index.ts.

Key actions

Action Required permission API call DB tables touched Side effects Audit-logged?
List pending withdrawals withdrawals.view GET /admin/payments/withdraw?status=PENDING Withdraw, User (joined) none yes
Get aggregated stats withdrawals.view GET /admin/payments/withdraw/stats[/:userId] Withdraw aggregate none yes
Force-refresh from payment provider withdrawals.view GET /admin/payments/withdraw/fetch/:id provider HTTP call → Withdraw.status updated calls NowPayments / CCPayment / Skindeck yes
Approve withdrawals.approve + OTP POST /admin/payments/withdraw/approve Withdraw.status=APPROVED, Transaction provider payout API call; Telegram notify yes
Reject withdrawals.reject + OTP POST /admin/payments/withdraw/reject Withdraw.status=REJECTED, balance refunded Telegram notify yes
Retry (after provider failure) withdrawals.retry + OTP POST /admin/payments/withdraw/retry provider re-call; Withdraw.status updated new provider attempt yes

AdminActionLog rows: every approve/reject/retry writes an entry with requestBody containing the withdrawal id and player id.

Filters and views

  • StatusPENDING, APPROVED, REJECTED, PROCESSING, FAILED. Default view: PENDING.
  • User ID — drill-in from a profile.
  • SortCREATED_AT (default DESC), AMOUNT_USD, PROVIDER.
  • Currency / network — server returns the network slug (btc, eth-erc20, …); UI groups visually but server-side filter is via the underlying Withdraw.network field.
  • Stats summary card at top: total pending count, total amount USD, average wait time. From GET /admin/payments/withdraw/stats.

Common workflows

  1. Daily approval queue. Operator opens /withdrawals. Default filter PENDING. Sorts by oldest. Clicks first row → reviews the user (profile drawer; cross-links to user-profile.md — KYC level, withdrawals-block status, recent deposits). If clean, clicks Approve → enters OTP → confirms. Backend calls payment provider, marks Withdraw.status=APPROVED. Telegram bot notifies the ops channel.
  2. Reject suspected fraud. Operator opens row, sees identical wallet across 5 accounts. Clicks Reject → opens modal (see WithdrawRejectModal.tsx) → fills reason (free-text) → submits with OTP. Backend refunds balance to player, sets status=REJECTED. Telegram notify.
  3. Retry a failed payout. Provider returned a transient error (e.g., rate limit). Withdraw.status=FAILED. Operator opens the row, clicks Retry → OTP → backend re-calls provider with same parameters. Common with NowPayments during congestion.
  4. Investigate a stuck withdrawal. Player complains "I haven't received my BTC." Operator opens row, clicks Force-Refresh (GET /admin/payments/withdraw/fetch/:id). Server pulls fresh state from provider. If still stuck, escalate via handover/oncall-runbook.md.
  5. Monthly reconciliation. Finance filters by APPROVED + last month, exports CSV (browser download). Cross-checks sum against on-chain settlement records.

Edge cases / gotchas

  • Approve and Reject both write balance changes. Approve subtracts from Account (held balance); Reject refunds. If the queue gets reordered mid-approval (e.g., a duplicate request), you can race. The endpoint is idempotent on the Withdraw.id — re-approving an already-approved withdrawal is rejected with ApiCode.WITHDRAWAL_NOT_PENDING.
  • OTP is enforced server-side, not just UI-side. Even if the UI hides the OTP prompt, the backend will reject without x-otp header. See OtpGuard in apps/api/src/auth/guards/otp.guard.ts and the FE wiring at queries/withdrawals/index.ts:86.
  • Telegram notifications can lag or fail. The notify runs after the DB write; if Telegram is down, the action is still committed. Audit log is the source of truth, not the channel.
  • Provider-side state drift. fetch/:id is the only authoritative way to know what really happened. Don't trust the local Withdraw.status after a known provider outage.
  • Reject reason is free-text. Not enumerated. For consistency, follow the operator playbook in handover/customer-comms/.
  • Withdrawals from banned users still show up here — the queue filter is on Withdraw.status=PENDING, not on User.bannedAt. Always check the user state before approving.
  • Telegram channel echo for very large withdrawals (>5k USD) tags ops manually. Threshold is in TelegramNotificationService config.
  • Skindeck withdrawals follow a separate BullMQ queue (apps/api/src/payment/provider/integration/skindeck/); their Withdraw.status transitions are async and may take minutes.

Sequence — approving a withdrawal

sequenceDiagram
    actor admin
    participant admin-fe
    participant api
    participant provider as Payment provider
    participant pg as Postgres
    participant tg as Telegram bot
    admin->>admin-fe: open /withdrawals (PENDING)
    admin-fe->>api: GET /admin/payments/withdraw?status=PENDING
    api->>api: PermissionGuard('withdrawals.view')
    api->>pg: SELECT Withdraw WHERE status=PENDING JOIN User
    pg-->>api: rows
    api-->>admin-fe: PaginatedDto<WithdrawDto>
    admin->>admin-fe: click Approve, enter OTP
    admin-fe->>api: POST /admin/payments/withdraw/approve { id }, x-otp: 123456
    api->>api: PermissionGuard('withdrawals.approve') + OtpGuard
    api->>pg: UPDATE Withdraw SET status=APPROVED, adminUserId=...
    api->>provider: payout request (NowPayments / CCPayment / Skindeck)
    provider-->>api: tx submitted
    api->>pg: INSERT Transaction(type=WITHDRAW_APPROVED), AdminActionLog
    api->>tg: TelegramNotificationService.sendWithdrawApprovedNotify
    api-->>admin-fe: 200 OK
    admin-fe-->>admin: row removed from PENDING list