Skip to content

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.

AmountPath
≤ $50 (5000 cents)Support opens request → finance/superadmin executes directly. No approvals queue.
> $50Any refund creates an admin_approval row. A second payments:refund holder (not the requester) must approve before execution.
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 directly

Payments → Transactions → [transaction] → Refund (or the User 360 → Purchases tab → Refund).

Fields:

FieldTypeNotes
Purchasepre-filledThe transaction being refunded.
Amount (cents)intFor partials; defaults to full amount.
Reasonstring, requiredShort reason: duplicate_purchase, fraud_confirmed, chargeback, customer_apology, service_outage.
Reverse unlocksboolIf true, rolls back the episode unlocks this purchase paid for.

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.

If Reverse unlocks is checked:

  1. Every EpisodeUnlock funded by this purchase is soft-deleted.
  2. The user loses access to those episodes on next refresh.
  3. 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).

Depending on the rail, the “execute” step:

RailProvider actionAutomation status
Apple / Google IAPWe 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
dLocaldLocal refund API.Stubbed in v2
pawaPaypawaPay refund API.Stubbed in v2
VASadbilling.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.

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'}

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.

  • You cannot refund a refund. The API refuses if the target purchase.status is already refunded.
  • 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 disputed status 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.
ActionEndpoint
Create refund requestPOST /v1/admin/refunds
List refundsGET /v1/admin/refunds
Get refund detailGET /v1/admin/refunds/:id
Reject refundPOST /v1/admin/refunds/:id/reject
Approve (from queue)POST /v1/admin/approvals/:id/approve

Full details in API: Payments.