Skip to content

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.

  1. Balance is derived, never stored. You are not “editing a number”: you’re writing a ledger entry.
  2. Every adjustment is double-entry. A grant of +100 coins writes two rows: +100 to the user’s wallet_bonus (or wallet_paid), -100 to a source account (source_promo, source_support_grant, etc.). Net movement across the two rows = 0.
  3. Support cap: 500 coins per user per day. Server-side, cannot be bypassed by the UI.
  4. Any grant > 2000 coins goes to the approvals queue. No matter your role. A second admin (finance or superadmin) with wallet:adjust and different admin_id must approve.
  5. Every adjustment writes an AuditLog entry. Reason field is required.

Open from User 360 → Actions → Adjust coins (or the button on the Wallet & ledger tab).

Fields:

FieldTypeRules
Directiongrant / deduct
Amountint, 1 – 1,000,000Support cannot exceed 500/day cumulative across all grants to this user.
Reasonstring, 3–200 charsMachine-readable-ish tag: apology_bad_video, promo_launch, fraud_recoup, etc.
Notestring, ≤500 charsFree-form explanation. Shown in the audit log.
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}
end

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
}

The reason field is free text, but conventions matter for economy analytics. Use one of:

Reason tagLedger accounts movedUse case
apology_*source_support_grantwallet_bonusComping for a bad experience.
promo_*source_promowallet_bonusMarketing campaign.
refund_*source_refundwallet_bonus (or wallet_paid if IAP refund)Compensating a bad purchase.
fraud_recoupwallet_paid/bonussink_fraudDeducting from a suspected fraudster.
test_*source_testwallet_bonusDev/testing only.

The economy monitor (Analytics → Economy) slices by these tags: inconsistent tagging = messy numbers.

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.

Same modal, direction = deduct. Rules:

  • Cannot take balance below zero. The API refuses with insufficient_balance if you’d overdraw. Use the “shortfall clamping” pattern only via refunds: see Process a refund.
  • Deducts prefer wallet_bonus first (mirror of the spend logic). If bonus is empty, the API deducts from wallet_paid and audit-logs deduct_from_paid=true.
  • 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.
  • banned users can’t be adjusted at all. Reactivate first if you need to comp; usually you should have compped before banning.
Terminal window
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.