Process a refund
Refunds in TwistCue always reverse the ledger. There’s no “flip a status” shortcut: every refund is a real coin-ledger event with the money moving back to a sink_refund account. This keeps the economy monitor honest.
Two amounts, two paths
Section titled “Two amounts, two paths”| Amount | Path |
|---|---|
| ≤ $50 (5000 cents) | Support opens request → finance/superadmin executes directly. No approvals queue. |
| > $50 | Any refund creates an admin_approval row. A second payments:refund holder (not the requester) must approve before execution. |
The full state machine
Section titled “The full state machine”stateDiagram-v2 [*] --> requested: support/finance creates request requested --> approved: (≤$50) executor with payments:refund requested --> pending_approval: (>$50) auto-routed to approvals queue pending_approval --> approved: second admin approves pending_approval --> rejected: second admin rejects approved --> executed: provider API called → ledger reversed executed --> [*] rejected --> [*] requested --> rejected: executor rejects directlyCreating a refund request
Section titled “Creating a refund request”Payments → Transactions → [transaction] → Refund (or the User 360 → Purchases tab → Refund).
Fields:
| Field | Type | Notes |
|---|---|---|
| Purchase | pre-filled | The transaction being refunded. |
| Amount (cents) | int | For partials; defaults to full amount. |
| Reason | string, required | Short reason: duplicate_purchase, fraud_confirmed, chargeback, customer_apology, service_outage. |
| Reverse unlocks | bool | If true, rolls back the episode unlocks this purchase paid for. |
Ledger reversal (what actually happens)
Section titled “Ledger reversal (what actually happens)”On execute, the service inserts a matched double-entry pair:
event_id = <new UUID>- wallet_paid (or wallet_bonus) : -N cents-worth-of-coins (reason='refund')- sink_refund : +N cents-worth-of-coins (reason='refund', ref_type='purchase', ref_id=purchaseId)Shortfall clamping: if the user has already spent the coins from this purchase, we cannot take the balance below zero. The service clamps the deduction at min(purchase_coins, current_balance) and logs underpaid_cents on the refund record. Finance sees this in reconciliation and eats the delta on the P&L.
Unlock rollback (optional)
Section titled “Unlock rollback (optional)”If Reverse unlocks is checked:
- Every
EpisodeUnlockfunded by this purchase is soft-deleted. - The user loses access to those episodes on next refresh.
- Audit log records
unlocks_rolled_back: [episodeId, ...].
Rule of thumb:
- Duplicate purchase: don’t rollback unlocks (user only used one purchase’s worth anyway).
- Fraud / chargeback: rollback unlocks (kill access).
- Customer apology: never rollback (goodwill refund).
Provider execution
Section titled “Provider execution”Depending on the rail, the “execute” step:
| Rail | Provider action | Automation status |
|---|---|---|
| Apple / Google IAP | We don’t refund store IAP: Apple/Google do that. Our record-only mode logs the refund from a store webhook. | Record-only (webhook-triggered) |
| Cards (web) | Refund API call to the card MID. | Stubbed in v2: see Integrations status |
| dLocal | dLocal refund API. | Stubbed in v2 |
| pawaPay | pawaPay refund API. | Stubbed in v2 |
| VAS | adbilling.me cancel-subscription; refunds not typically supported at carrier level. | Record-only |
In v2, refund execution is record-only mode for card / dLocal / pawaPay: the ledger reverses correctly, but the actual provider-side money movement is a manual step until the APIs are wired. See Integrations status for the roadmap.
Approval flow for >$50
Section titled “Approval flow for >$50”sequenceDiagram autonumber participant S as Support/Finance participant API as API participant AQ as Approvals queue participant F as Finance (2nd) participant P as Provider
S->>API: POST /admin/refunds {amount: 8000, reason} API->>AQ: create admin_approval (pending) API-->>S: {kind: 'pending_approval', approvalId} F->>AQ: GET /admin/approvals (pending) F->>API: POST /admin/approvals/:id/approve API->>P: refund provider call (or record-only) API->>API: ledger reversal (double-entry) API->>API: (optional) unlock rollback API-->>F: {status: 'executed'}Rejecting a refund
Section titled “Rejecting a refund”Executors with payments:refund can Reject a pending refund from the queue or detail page. Reject writes:
refund_requests.status = 'rejected'refund_requests.approved_by = <rejector id>- Audit log entry
action='refund.reject'
No ledger movement.
Gotchas
Section titled “Gotchas”- You cannot refund a refund. The API refuses if the target
purchase.statusis alreadyrefunded. - Partial refunds: supported at the API level (pass a smaller
amount_cents) but require a rail that supports partials. Store IAP does not. - Chargebacks: a
disputedstatus arrives via webhook. Treat these as inevitable refunds; process the refund proactively rather than fighting the chargeback (helps our MID reputation). - The approver must not be the requester. Enforced server-side.
API calls
Section titled “API calls”| Action | Endpoint |
|---|---|
| Create refund request | POST /v1/admin/refunds |
| List refunds | GET /v1/admin/refunds |
| Get refund detail | GET /v1/admin/refunds/:id |
| Reject refund | POST /v1/admin/refunds/:id/reject |
| Approve (from queue) | POST /v1/admin/approvals/:id/approve |
Full details in API: Payments.