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.tsx → components/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¶
- Status —
PENDING,APPROVED,REJECTED,PROCESSING,FAILED. Default view: PENDING. - User ID — drill-in from a profile.
- Sort —
CREATED_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 underlyingWithdraw.networkfield. - Stats summary card at top: total pending count, total amount USD, average wait time. From
GET /admin/payments/withdraw/stats.
Common workflows¶
- 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, marksWithdraw.status=APPROVED. Telegram bot notifies the ops channel. - Reject suspected fraud. Operator opens row, sees identical wallet across 5 accounts. Clicks Reject → opens modal (see
WithdrawRejectModal.tsx) → fillsreason(free-text) → submits with OTP. Backend refunds balance to player, setsstatus=REJECTED. Telegram notify. - 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. - 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 viahandover/oncall-runbook.md. - 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 theWithdraw.id— re-approving an already-approved withdrawal is rejected withApiCode.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-otpheader. SeeOtpGuardinapps/api/src/auth/guards/otp.guard.tsand the FE wiring atqueries/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/:idis the only authoritative way to know what really happened. Don't trust the localWithdraw.statusafter 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 onUser.bannedAt. Always check the user state before approving. - Telegram channel echo for very large withdrawals (>5k USD) tags ops manually. Threshold is in
TelegramNotificationServiceconfig. - Skindeck withdrawals follow a separate BullMQ queue (
apps/api/src/payment/provider/integration/skindeck/); theirWithdraw.statustransitions are async and may take minutes.
Cross-links¶
- Per-user withdrawals block: user-profile.md, withdrawals-block sub-section
- KYC pre-condition for withdrawal: kyc-limits.md
- Deposit history (mirror screen): deposits-history.md
- Audit / who-approved-what: admin-logs.md
- Incident runbook for stuck payouts:
handover/oncall-runbook.md - Payment provider integration recipe:
recipes/add-payment-provider.md - Withdraw flow narrative (player perspective):
flows/→ withdraw
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