# Withdrawal System Operations

## Scope and safety boundary

Phase 14 implements controlled, ledger-backed withdrawal requests. It supports dynamic manual methods, server-calculated quotes, eligibility rules, encrypted destination details, available-to-held balance movement, one- or two-person approval, rejection refunds, and evidence-backed manual payout confirmation.

The runtime does **not** contact a bank, mobile-wallet network, cryptocurrency network, or other payout provider. The only registered provider is `manual`; confirming `paid` records an external transfer that an authorized operator has already verified. Automatic submission must remain unavailable until a separately reviewed provider adapter, signed callback validation, reconciliation, retry, and incident procedure are implemented.

Both the `withdrawals` feature flag and `withdrawals.enabled` setting default to disabled. An operator must configure a valid method and destination fields, migrate the database, complete staging tests, and explicitly enable both gates.

## Money model

All values are integer minor-unit decimal strings at JavaScript and API boundaries. The server never accepts a client-selected fee, net amount, approval count, status, ledger identifier, or provider reference during request creation.

| Event                   | Balanced movement                                                |            Amount |
| ----------------------- | ---------------------------------------------------------------- | ----------------: |
| Request accepted        | User available debit → user held credit                          |             Gross |
| Request rejected        | User held debit → user available credit                          |             Gross |
| Manual payout confirmed | User held debit → payout clearing credit + withdrawal fee credit | Gross = net + fee |

The quote endpoint creates no ledger entry. A quote is a five-minute, user- and method-bound calculation snapshot. The request transaction recalculates the current rule result before moving funds. A stale, consumed, expired, or method-version-mismatched quote fails closed.

## Request state model

| State                 | Meaning                                                                  | Permitted Phase 14 transition  |
| --------------------- | ------------------------------------------------------------------------ | ------------------------------ |
| `requested`           | Funds are held and awaiting review                                       | approve, hold, reject          |
| `under_review`        | More approval evidence is required                                       | approve, hold, reject          |
| `held`                | An operator intentionally paused review                                  | approve, hold, reject          |
| `approved`            | Required approvals are complete                                          | confirm paid or reject         |
| `paid`                | External manual transfer evidence and payout ledger settlement committed | terminal                       |
| `rejected`            | Gross held amount was atomically refunded                                | terminal                       |
| `processing`          | Reserved for a future automatic provider workflow                        | not entered in Phase 14        |
| `cancelled`, `failed` | Reserved terminal states                                                 | not entered by Phase 14 routes |

Every transition uses the submitted optimistic `version`. Status history and approval rows are append-only. Database triggers also block deletion of quotes, requests, status history, approvals, and payout transactions, and protect immutable request/payout evidence during updates.

## Public API

Every endpoint requires the verified Telegram user session. Unsafe methods also require the signed session-bound CSRF token. Sensitive mutations use the configured distributed rate limiter.

| Method | Path                                | Purpose                                                              |
| ------ | ----------------------------------- | -------------------------------------------------------------------- |
| GET    | `/api/v1/withdrawal-methods`        | Active methods, destination field schema, currency, fees, and limits |
| POST   | `/api/v1/withdrawals/quote`         | Server calculation and current eligibility check                     |
| POST   | `/api/v1/withdrawals`               | Idempotent request and atomic gross hold                             |
| GET    | `/api/v1/withdrawals`               | Paginated history belonging to the authenticated user                |
| GET    | `/api/v1/withdrawals/:withdrawalId` | User-owned request and status timeline                               |

`POST /api/v1/withdrawals` requires an `Idempotency-Key` header. Raw keys are never stored: the key is HMAC-digested under the withdrawal scope, and a canonical request fingerprint prevents the same key from being reused for different quote or destination input.

## Eligibility evaluation

The server evaluates all of these from authoritative database state, both while quoting and again under transaction locks while creating a request:

- account status, deletion state, account ban, and withdrawal-specific ban;
- account age;
- posted task-completion count;
- active referral count when configured;
- authoritative available ledger balance;
- method/global minimum and maximum;
- timezone-aware daily request and amount limits;
- cooldown anchored to the last committed request;
- method status/version, settlement currency, and current rule configuration.

Method and global rules combine conservatively: the larger minimum/age/task/referral/cooldown requirement and the smaller non-zero maximum/daily limit win. Fixed fees add together; percentage basis points add and cannot exceed 10,000. Percentage fees round upward to a whole minor unit, and total fee must remain below gross.

## Destination details

Admins define a bounded field schema while a method is `draft` or `inactive`. Supported field types are text, telephone, email, and select, with allowlisted format/length rules. Unknown fields, control characters, oversized payloads, invalid formats, and unapproved select values are rejected.

Normalized details are encrypted with AES-256-GCM using authenticated context bound to both user public ID and withdrawal public ID. A separate SHA-256 hash of the canonical normalized details detects corruption before disclosure. Public responses expose only a masked display value. Full details require `withdrawals.account_details.view`, and the read is audit-context aware.

Key rotation must preserve the older key version until all open requests using it are terminal or have been safely re-encrypted through a separately designed migration. Never log destination plaintext or include it in exports by default.

## Admin API and permissions

Admin routes require verified 2FA-backed Admin identity, configured Admin IP policy, CSRF for writes, rate limiting, and exact permission checks.

| Capability                          | Permission                         |
| ----------------------------------- | ---------------------------------- |
| List/detail/settings read           | `withdrawals.view`                 |
| Decrypt destination                 | `withdrawals.account_details.view` |
| Approve                             | `withdrawals.approve`              |
| Reject and refund                   | `withdrawals.reject`               |
| Place on hold                       | `withdrawals.hold`                 |
| Confirm manual payout               | `withdrawals.payout.confirm`       |
| Manage global withdrawal settings   | `withdrawals.settings.manage`      |
| Manage method and field definitions | `withdrawal_methods.manage`        |

Routes are mounted below `/api/v1/admin/withdrawals` and `/api/v1/admin/withdrawal-methods`. The review endpoint derives the permission from the validated decision, so possessing one decision permission never authorizes another.

High-value requests use `withdrawals.high_value_dual_approval_minor`. At or above a non-zero threshold, two distinct Admin approval rows are required; the table's unique request/Admin key prevents one Admin from supplying both. The first approval leaves the request `under_review` and moves no money.

## Manual payout confirmation

Only an `approved` request with the exact current version can be confirmed. The operator supplies a bounded external reference and reason after verifying the external transfer. In one database transaction the service:

1. locks and revalidates the request;
2. creates unique provider evidence for the request;
3. debits the gross held amount;
4. credits net amount to `payout_clearing` and fee to `withdrawal_fees`;
5. updates the request to `paid` with the payout ledger reference;
6. appends status-history evidence.

The unique payout/request index, ledger idempotency key, optimistic version, and row lock prevent duplicate confirmation. A provider reference is operational evidence, not proof generated by this application; operators must reconcile it against the external account independently.

## Method activation checklist

Activation fails unless all checks pass:

- method is `draft` or `inactive` and version matches;
- currency is the active settlement currency;
- one through twenty valid destination fields exist;
- method processing mode matches global configuration;
- provider registry supports the exact provider/mode pair;
- effective minimum can produce a valid quote with fee lower than gross.

Phase 14 registers only `manual` + `manual`, so automatic methods cannot activate.

## Deployment and incident checklist

Before enabling withdrawals:

1. Back up and test-restore the intended database.
2. Put the application in maintenance mode and stop workers/schedulers.
3. Run `npm run verify:environment`, `npm run migrate:dry-run`, and review migration `019`.
4. Apply migrations in staging, verify triggers/constraints/indexes, then run `npm run check`.
5. Configure one manual method and fields while it is inactive; test quote, request, rejection refund, one/two-person approval, and payout reconciliation.
6. Grant sensitive permissions only to the minimum required roles.
7. Enable the `withdrawals` feature flag and then the versioned `withdrawals.enabled` setting.
8. Monitor held balances, open request age, duplicate/conflict errors, and payout reconciliation.

If a payout is uncertain, do not confirm it and do not issue a second external transfer. Place the request on hold, preserve the external reference/evidence, reconcile outside the app, and escalate through the operator's incident process. Never edit ledger entries or protected evidence rows. Corrections require a separately authorized append-only accounting operation.

No live database, payout provider, Telegram environment, or real funds were accessed while Phase 14 was built.
