Skip to main content

Documentation Index

Fetch the complete documentation index at: https://orbit-docs.devotel.io/llms.txt

Use this file to discover all available pages before exploring further.

Crypto refunds — operator runbook

This runbook is for platform operators (super-admins) processing NOWPayments crypto refunds from the admin dashboard. The tenant-facing view of the same flow lives in Cryptocurrency payments. The refund button is exposed inline in the admin transactions table on any row whose payment_method is crypto_nowpayments. There is no tenant-side refund button — crypto refunds are operator-only at launch to prevent self-service mistakes that would be irrecoverable.

When to refund

Process a refund when ALL of the following hold:
  • The tenant has opened a support ticket with a clear refund reason (wrong-amount transfer, dispute closed in tenant’s favour, accidental top-up, etc.).
  • The tenant has provided a destination wallet address. Confirm with the tenant via the same channel as the ticket — never accept an address from a different channel without re-confirmation.
  • The destination address is on the same chain as the original payment. Cross-chain refunds will lose the funds; refuse them.
  • The tenant is in the same jurisdiction window the operator is comfortable refunding (consult legal / finance for edge cases).
  • The original top-up is recent enough that the refund makes sense. Operationally we cap at 90 days from the original transaction — older requests need escalation.

When to refuse

Refuse (or escalate) when ANY of these hold:
  • The original transaction is older than 90 days.
  • There is an active fraud review or chargeback dispute on the org.
  • The destination wallet address is suspected of fraud or sanctions exposure (consult the AML SOP).
  • The amount requested exceeds the original top-up.
  • The tenant insists on a different chain than the original payment.
  • The tenant’s spending pattern shows the refunded amount has already been spent on services rendered.
When refusing, reply to the ticket with the reason and a path forward (e.g. “send a fresh top-up first, then we can refund the original”).

Step-by-step

  1. Verify the ticket. Open the tenant’s support ticket. Confirm the original transaction id (txn_*), the requested refund amount, and the destination wallet address.
  2. Confirm wallet address out-of-band. Reply on the ticket asking the tenant to confirm the destination address. Wait for the tenant to reply with the same address before proceeding — this is the primary defence against ticket compromise.
  3. Open admin transactions view. Dashboard → Billing → find the row for the original transaction. The row’s payment method column shows crypto_nowpayments and the inline action shows a red “Refund crypto” button.
  4. Click “Refund crypto”. The destructive AlertDialog opens with:
    • Destination wallet address — paste the value the tenant confirmed.
    • Amount — defaults to the full original amount, max-clamped. Set to the partial amount the tenant requested if applicable.
    • Reason — paste the ticket reference (e.g. ZD-12345 — partial refund for wrong-amount transfer).
  5. Review the warning banner. Before the “Submit refund” button activates, the dialog shows a warning summary: Refunding $X.XX to wallet abc…def on USDTTRC20. This is irreversible once the network confirms. Verify the truncated address matches the FIRST six and LAST six characters of what the tenant gave you.
  6. Submit. Click “Submit refund”. The system:
    • Records a pending refund row in credit_transactions (negative amount, status crypto_refund_pending).
    • Calls NOWPayments POST /v1/payout synchronously.
    • Writes an audit-log entry (admin.crypto_refund_initiated) with the operator user id, ticket reference, address, and amount.
  7. Wait for the IPN. NOWPayments fires a refunded IPN when the on-chain payout confirms. The IPN handler:
    • Deducts the refund amount from the tenant’s wallet balance.
    • Updates the crypto_topup_intents row to refunded.
    • Emits a second audit-log entry (billing.crypto_refunded).
  8. Reply to the ticket. Once the IPN lands and the wallet has been deducted, reply to the ticket with the payout id + on-chain transaction hash (when NOWPayments includes it on the IPN payload).

Reconciliation

To find all crypto refunds processed by the operator team:
SELECT id, organization_id, amount_cents, metadata, created_at
  FROM public.credit_transactions
 WHERE type = 'crypto_refund_pending'
    OR metadata->>'source' = 'crypto_nowpayments_refund_pending'
 ORDER BY created_at DESC;
To match a pending refund to its eventual refunded IPN:
SELECT ct.id AS pending_id,
       ct.amount_cents,
       ct.metadata->>'payout_id' AS payout_id,
       ct.created_at AS initiated_at,
       refund.id AS settled_id,
       refund.amount_cents AS settled_amount_cents,
       refund.created_at AS settled_at
  FROM public.credit_transactions ct
  LEFT JOIN public.credit_transactions refund
    ON refund.metadata->>'idempotency_key' = ('nowpayments:refund:' || (ct.metadata->>'payout_id'))
 WHERE ct.type = 'crypto_refund_pending'
 ORDER BY ct.created_at DESC;
The pending row is created by the admin button click; the settled row is created by the IPN handler. If the settled row is NULL for a row older than 24 hours, the on-chain payout has not yet been confirmed (or has failed) — escalate to platform engineering.

Edge cases

Refund larger than the org’s current wallet balance. The IPN handler’s deductBalance call is allowed to take the wallet below zero in this case — the org goes into negative balance pending operator reconciliation. Surface this as a follow-up task in the same ticket (“your balance is now -$X — please top up or contact support before sending again”). Partial refunds. Fully supported. The amount input is clamped to the original top-up. Multiple partial refunds against the same original are allowed; each one is keyed on (transaction_id, destination_address, amount_cents) so re-submitting the same partial refund collapses on the deterministic ledger id. Multi-currency refunds. Out of scope at launch. NOWPayments payouts are always in the SAME crypto currency as the original top-up. If the tenant asks for a refund in a different coin, refuse and offer either (a) refund in the original coin or (b) cancel the refund and credit the wallet manually in USD. Refund button is missing on a row. The button only renders when transaction.payment_method === "crypto_nowpayments". Rows from before the payment_method backfill (migration 228) may have this column as NULL. If a crypto row genuinely needs a refund and the button is missing, escalate to platform engineering — there is a manual SQL path documented in the internal runbook. NOWPayments returns a 4xx on the payout call. The button surfaces the provider’s error message verbatim. The two common ones:
  • 422 “Invalid address” — the destination address is not valid for the pay currency. Re-ask the tenant; this typically means a wrong-chain paste.
  • 422 “Amount below minimum” — NOWPayments has per-currency minimum payouts. For tiny refunds the only path is to credit the wallet in USD via the bonus credits tool and refuse the on-chain refund.
NOWPayments returns 5xx on the payout call. The button surfaces a generic “Try again in a few minutes” message. The pending ledger row is already persisted; re-clicking the button will collapse onto the same id via the deterministic key, so it is safe to retry.

Audit trail

Every refund initiates two audit-log entries (chain-linked via the tamper-evident audit chain):
  1. admin.crypto_refund_initiated — written by the admin controller the moment the operator clicks “Submit refund”. Contains the operator user id, source IP, ticket reason, destination address, amount, original transaction id, payout id from NOWPayments, and initial payout status.
  2. billing.crypto_refunded — written by the IPN handler when the refunded IPN lands. Contains the on-chain transaction hash (when available), the actual wallet deduction amount, and the new wallet balance.
To pull the full trail for a single refund, query the audit-logs view with resource_id = '<pending_ledger_id>'. Both entries link to the same credit_transactions.id.