Adjust coins correctly
Coin adjustments are the single most common support action, and the single most common way support agents accidentally hurt the ledger. This guide is written to keep that from happening.
The rules (memorize these)
Section titled “The rules (memorize these)”- Balance is derived, never stored. You are not “editing a number”: you’re writing a ledger entry.
- Every adjustment is double-entry. A grant of +100 coins writes two rows:
+100to the user’swallet_bonus(orwallet_paid),-100to a source account (source_promo,source_support_grant, etc.). Net movement across the two rows = 0. - Support cap: 500 coins per user per day. Server-side, cannot be bypassed by the UI.
- Any grant > 2000 coins goes to the approvals queue. No matter your role. A second admin (finance or superadmin) with
wallet:adjustand differentadmin_idmust approve. - Every adjustment writes an
AuditLogentry. Reason field is required.
The adjust modal
Section titled “The adjust modal”Open from User 360 → Actions → Adjust coins (or the button on the Wallet & ledger tab).
Fields:
| Field | Type | Rules |
|---|---|---|
| Direction | grant / deduct | |
| Amount | int, 1 – 1,000,000 | Support cannot exceed 500/day cumulative across all grants to this user. |
| Reason | string, 3–200 chars | Machine-readable-ish tag: apology_bad_video, promo_launch, fraud_recoup, etc. |
| Note | string, ≤500 chars | Free-form explanation. Shown in the audit log. |
What happens on submit
Section titled “What happens on submit”sequenceDiagram autonumber participant U as UI participant API as API participant SVC as WalletService participant DB as Postgres
U->>API: POST /admin/users/:id/wallet/adjust API->>SVC: adjust(...) SVC->>SVC: check role & 500/day cap alt amount > 2000 SVC->>DB: create admin_approval (pending) SVC-->>U: {kind: 'approval_pending', approvalId} else amount ≤ 2000 (or approver executing) SVC->>DB: insert 2 coin_ledger rows (same event_id) SVC->>DB: audit_log entry SVC-->>U: {kind: 'applied', eventId, balanceTotal} endThe three response modes
Section titled “The three response modes”1. Applied: the common path. Returns:
{ "kind": "applied", "eventId": "3f...", "balanceTotal": 480}2. Approval pending: for amount > 2000 (or if you’re a support agent hitting the daily cap). Returns:
{ "kind": "approval_pending", "approvalId": "9c...", "reason": "amount_exceeds_threshold"}The action shows in System → Approvals for a second admin to approve.
3. Rejected: the 500/day support cap is hit. HTTP 400 with:
{ "error": "support_daily_cap_exceeded", "capRemaining": 200}Reason tags & accounts
Section titled “Reason tags & accounts”The reason field is free text, but conventions matter for economy analytics. Use one of:
| Reason tag | Ledger accounts moved | Use case |
|---|---|---|
apology_* | source_support_grant → wallet_bonus | Comping for a bad experience. |
promo_* | source_promo → wallet_bonus | Marketing campaign. |
refund_* | source_refund → wallet_bonus (or wallet_paid if IAP refund) | Compensating a bad purchase. |
fraud_recoup | wallet_paid/bonus → sink_fraud | Deducting from a suspected fraudster. |
test_* | source_test → wallet_bonus | Dev/testing only. |
The economy monitor (Analytics → Economy) slices by these tags: inconsistent tagging = messy numbers.
Paid vs bonus coins
Section titled “Paid vs bonus coins”Coins live in two wallet accounts:
wallet_paid: coins the user actually paid money for (IAP top-ups, card top-ups, VAS entitlements).wallet_bonus: coins granted for free (promos, subscription bonuses, support comps).
On unlock, the mobile client spends bonus first, then paid. This preserves the user’s paid balance for as long as possible.
On refund, the ledger reverses back into the same accounts the funds originally came from. Support grants deduct back into wallet_bonus.
When adjusting, support grants always go to wallet_bonus. The UI does not let you write to wallet_paid: that’s reserved for actual purchases.
Deducting coins
Section titled “Deducting coins”Same modal, direction = deduct. Rules:
- Cannot take balance below zero. The API refuses with
insufficient_balanceif you’d overdraw. Use the “shortfall clamping” pattern only via refunds: see Process a refund. - Deducts prefer
wallet_bonusfirst (mirror of the spend logic). If bonus is empty, the API deducts fromwallet_paidand audit-logsdeduct_from_paid=true.
Gotchas
Section titled “Gotchas”- Don’t grant then deduct to “test”. The ledger keeps both events forever. The audit log will show your test dance. Use the dev DB.
- The 500/day cap is cumulative across grants. You cannot grant 400, then 200 later that day. UTC date, resets at 00:00 UTC.
- Deducts don’t count against the 500/day cap. Only grants.
bannedusers can’t be adjusted at all. Reactivate first if you need to comp; usually you should have compped before banning.
API call
Section titled “API call”curl -X POST https://api.twistcue.local/v1/admin/users/{userId}/wallet/adjust \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amount": 300, "direction": "grant", "reason": "apology_bad_video", "note": "Ticket #4432: episode 7 audio bug" }'Full details in API: Users.