# Earnings Adjustments & Withdrawal Reject — Expected Behavior This document defines the intended behavior for: - Admin-created **earnings adjustments** (positive or negative earning lines) - Admin **withdrawal reject** (cancel a withdrawal request and release reserved earning lines) - **Authorization** rules (permission + group leader scope + cross-tenant isolation) ## Concepts ### Earnings Line An `earnings line` is an immutable money movement entry for a clerk. Amount can be positive or negative. ### Adjustment An `adjustment` is an admin-originated earnings line, designed to support future extensibility (many “reasons”, auditability, idempotency, async apply). Key semantics: - It **creates exactly one** earnings line when applied. - The created earnings line uses: - `earningType = ADJUSTMENT` - `sourceType = ADJUSTMENT` - `sourceId = adjustmentId` - `orderId = null` - `amount` can be positive or negative - `unlockTime = effectiveTime` (adjustments are effective at their “unlock” time) ### Withdrawal Reject Admin reject is a cancel operation that: - marks the withdrawal request as canceled/rejected - releases reserved `withdrawing` earnings lines back to `available` / `frozen` ## Authorization Model (New Endpoints) Authorization is **two-layer**: 1) **Action-level permission**: does the user have permission to call the endpoint? 2) **Object-level scope**: can the user act on the target clerk / request? ### Permission Strings - Create adjustment: `withdraw:adjustment:create` - Read/poll adjustment status: `withdraw:adjustment:read` - Reject withdrawal request: `withdraw:request:reject` ### Group Leader Scope If the current user is **not** `superAdmin`, they can only act on clerks that belong to a group where: - `clerk.groupId = group.id` - `group.sysUserId = currentUserId` If this scope check fails, the endpoint returns **HTTP 403**. ### Super Admin Bypass If `superAdmin == true`, the user bypasses permission checks and scope checks for these new endpoints. ### Cross-Tenant Isolation All operations are tenant-scoped. - If `X-Tenant` does not match the target entity’s `tenantId`, the API returns **HTTP 404** (do not leak existence across tenants). ## Admin Earnings Adjustments API ### Create Adjustment `POST /admin/earnings/adjustments` Headers: - `Idempotency-Key: ` (required) - `X-Tenant: ` (required) Body: ```json { "clerkId": "clerk-id", "amount": "20.00", "reasonType": "MANUAL", "reasonDescription": "text", "effectiveTime": "2026-01-01T12:00:00" // optional } ``` Validation rules: - `Idempotency-Key` required - `tenantId` required - `clerkId` required - `amount` must be non-zero (positive = reward-like, negative = punishment-like) - `reasonType` required (currently hard-coded enum values, extend later) - `reasonDescription` required, non-blank Idempotency behavior: - Same `tenantId + Idempotency-Key` with the **same request body** returns the **same** `adjustmentId`. - Same `tenantId + Idempotency-Key` with a **different request body** returns **HTTP 409**. Response behavior: - Always returns **HTTP 202 Accepted** on success (request is “in-progress”). - Includes `Location: /admin/earnings/adjustments/idempotency/{Idempotency-Key}` for polling. Response example: ```json { "code": 202, "message": "请求处理中", "data": { "adjustmentId": "adj-uuid", "idempotencyKey": "same-key", "status": "PROCESSING" } } ``` ### Poll Adjustment Status `GET /admin/earnings/adjustments/idempotency/{key}` Behavior: - If not found in this tenant: **HTTP 404** - If found: - returns **HTTP 200** - `status` is one of: - `PROCESSING`: accepted but not yet applied - `APPLIED`: earnings line has been created - `FAILED`: apply failed (and should be visible for operator debugging) Stress / eventual consistency note: - Under load (DB latency / executor backlog), polling may stay in `PROCESSING` longer, but must not create duplicate earnings lines. ## Withdrawal Reject API ### Reject Withdrawal Request `POST /admin/withdraw/requests/{id}/reject` Body: ```json { "reason": "text (optional)" } ``` Behavior: - If request does not exist in this tenant: **HTTP 404** - If request is already canceled/rejected: return **HTTP 200** (idempotent) - If request is `success`: return **HTTP 400** (cannot reject a successful payout) - Otherwise: - request status transitions to `canceled` (or `rejected` depending on legacy naming) - all earnings lines with: - `withdrawalId = requestId` - `status = withdrawing` are released: - `withdrawalId` set to `null` - if `unlockTime > now` -> `status = frozen` - else -> `status = available` ## Stats: includeAdjustments toggle The statistics endpoint supports a toggle `includeAdjustments`: - when `includeAdjustments = false` (default): only order-derived earnings contribute - when `includeAdjustments = true`: adjustment earnings lines (`sourceType=ADJUSTMENT`) are included in the revenue sum Time-window behavior: - adjustment inclusion is based on `unlockTime` window (equivalent to `effectiveTime`)