178 lines
5.0 KiB
Markdown
178 lines
5.0 KiB
Markdown
# 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: <uuid>` (required)
|
||
- `X-Tenant: <tenantId>` (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`)
|
||
|