> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Crypto refunds — operator runbook

> How to process a NOWPayments crypto refund today via the admin API (the inline dashboard button is not yet shipped).

# Crypto refunds — operator runbook

This runbook is for **platform operators** (super-admins) processing
NOWPayments crypto refunds. The tenant-facing view of the crypto top-up
flow lives in [Cryptocurrency payments](./crypto-payments).

<Warning>
  **No dashboard button yet — this is API-only today.** The inline "Refund
  crypto" button described in earlier drafts of this runbook has **not
  shipped** in the admin dashboard. Do not go looking for it in the
  transactions table — it is not there. Today, crypto refunds are initiated
  by calling the admin API directly (`POST /api/v1/admin/billing/crypto-refund`)
  with a super-admin session. The rest of this runbook documents that API
  call. A dashboard affordance is planned; until it lands, the API is the
  supported path and this page is the operator source of truth.
</Warning>

The backend endpoint exists and is the path this runbook uses: it
validates the inputs, calls NOWPayments `POST /v1/payout`, records a
pending refund row, and audit-logs the action. Only the UI trigger is
missing. There is no tenant-side crypto refund path — crypto refunds are
operator-only 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. This 90-day cap is an **operator
  policy you apply by hand**, not a server-side limit: the crypto
  refund endpoint does not check the transaction age and will not block a
  refund on an older top-up, so it is on you to check the date before
  submitting. (This differs from the tenant-facing fiat credit refund,
  which rejects lots older than 30 days in the API. Because crypto
  refunds are operator-only, the age judgement — including any approved
  escalation past 90 days — stays with you.)

## When to refuse

Refuse (or escalate) when ANY of these hold:

* The original transaction is older than 90 days (an operator policy —
  the endpoint will not enforce this for you; verify the date yourself and
  escalate rather than submit if it is past 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 `credit_transactions.id` of the
   finished top-up), the requested refund amount, and the destination
   wallet address.

2. **Confirm the 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. **Find the original top-up's transaction id.** There is no admin
   transactions table with an inline refund button yet, so pull the id
   from the ledger. The original top-up is the `credit_transactions` row
   whose `payment_method` is `crypto_nowpayments`:

   ```sql theme={null}
   SELECT id, organization_id, amount_cents, created_at
     FROM public.credit_transactions
    WHERE organization_id = '<org_id>'
      AND payment_method = 'crypto_nowpayments'
      AND type = 'credit'
    ORDER BY created_at DESC;
   ```

   The `id` you pass as `transaction_id` MUST be the ORIGINAL finished
   top-up — not a prior refund row. Refunding against a top-up whose
   `payment_method` is anything other than `crypto_nowpayments` is
   rejected with a `422 invalid_payment_method` before NOWPayments is
   ever called.

4. **Submit the refund via the admin API.** Call the endpoint with a
   super-admin session (the admin OTP cookie or a super-admin API key —
   the same auth the admin dashboard uses). The request body is:

   ```jsonc theme={null}
   // POST /api/v1/admin/billing/crypto-refund
   {
     "transaction_id": "<original credit_transactions.id>",
     "destination_address": "<wallet address the tenant confirmed>",
     "amount_cents": 5000,        // USD-equivalent; must be ≤ the original
     "reason": "ZD-12345 — full refund for wrong-amount transfer"
   }
   ```

   Field rules the endpoint enforces:

   * `destination_address` — chain-agnostic shape check (26–95 chars,
     letters and digits only, trimmed). It is **not** validated against
     the specific chain; NOWPayments' payout endpoint rejects a
     mismatched address with a 4xx that the API surfaces verbatim. Paste
     exactly what the tenant confirmed.
   * `amount_cents` — **default to the full original top-up.** The
     endpoint accepts a partial amount (1 cent up to the original) and
     pays out the crypto proportionally, and it lets you issue several
     partials against one top-up as long as their running total never
     exceeds the original — it tracks the cumulative refunded-so-far
     amount and rejects an over-refund (or a refund on an
     already-fully-refunded top-up) with a `409`. **But do NOT submit a
     one-off partial.** A lone partial over-reclaims the tenant's wallet,
     because the wallet-settlement leg always reclaims the FULL original
     credit regardless of the amount submitted here — only a set of
     partials that sums back to the full original nets out correctly. If
     the tenant needs just part of a top-up returned, use the
     full-refund-then-fresh-top-up path or a manual USD credit instead.
     Read "Partial refunds" under Edge cases for the proportional formula,
     the cumulative state machine, and the wallet-settlement caveat
     **before** submitting anything other than the full amount.
   * `reason` — optional but always set it to the ticket reference; it is
     recorded on the audit-log entry.

5. **Double-check before you send.** There is no confirmation dialog to
   catch a typo, so re-read the request body before submitting:
   `destination_address` matches — character for character — what the
   tenant confirmed, and `amount_cents` is the full original top-up in
   cents. This is an irreversible money movement once the network
   confirms; the request itself is your only checkpoint.

6. **Submit.** On success the endpoint returns `200` with:

   ```jsonc theme={null}
   {
     "data": {
       "refund_ledger_id": "cryrefund_…",
       "payout_id": "…",              // NOWPayments payout id
       "payout_status": "…",
       "amount_cents": 5000,
       "pay_currency": "usdttrc20",
       "destination_address": "…"
     }
   }
   ```

   Behind that response the system:

   * Records a pending refund row in `credit_transactions` (type
     `crypto_refund_pending`, negative `amount_cents`, `reference =
     crypto_refund:<transaction_id>`, `payment_method =
     crypto_nowpayments`) **before** calling the provider.
   * Calls NOWPayments `POST /v1/payout` synchronously and stamps the
     `payout_id` back onto the pending row.
   * 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:
   * Reclaims the tenant's wallet credit for the original payment,
     idempotently keyed on the original payment so a duplicate IPN never
     double-deducts. The amount reclaimed is the full USD value credited
     at top-up time (FX-locked) — it exactly undoes the original top-up.
     This wallet-settlement leg is distinct from the on-chain payout leg:
     the payout the operator initiates is proportional to the refund
     amount (see "Partial refunds"), whereas the `refunded` IPN carries
     no per-refund amount and always reclaims the full original credit.
     Read the "Partial refunds" caveat before issuing a single partial
     refund.
   * Updates the `crypto_topup_intents` row to `refunded`.
   * Emits a second audit-log entry (`billing.crypto_topup_ipn_refunded`,
     resource `crypto_topup_intents`).

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:

```sql theme={null}
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:

```sql theme={null}
SELECT ct.id AS pending_id,
       ct.amount_cents,
       ct.metadata->>'payout_id' AS payout_id,
       cti.nowpayments_payment_id AS nowpayments_payment_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
  -- The original top-up ledger row this refund is issued against.
  -- The admin controller stamps its id as `metadata.parent_transaction_id`
  -- on the pending row.
  JOIN public.credit_transactions topup
    ON topup.id = (ct.metadata->>'parent_transaction_id')
   AND topup.organization_id = ct.organization_id
  -- The NOWPayments intent that minted the original payment id. The
  -- top-up's `finished` IPN writes `metadata.nowpayments_payment_id`,
  -- and the intent table is the canonical home for that id.
  JOIN public.crypto_topup_intents cti
    ON cti.nowpayments_payment_id = (topup.metadata->>'nowpayments_payment_id')
   AND cti.organization_id = ct.organization_id
  -- The settled deduct written by the `refunded` IPN handler via
  -- `deductBalance(…, "nowpayments:refund:<payment_id>", …)`. The
  -- idempotency key is the ORIGINAL NOWPayments payment_id, NOT the
  -- payout_id. Read it from the first-class `idempotency_key` column
  -- (it is also shadowed into `metadata.idempotency_key`).
  LEFT JOIN public.credit_transactions refund
    ON refund.idempotency_key = ('nowpayments:refund:' || cti.nowpayments_payment_id)
   AND refund.type = 'debit'
 WHERE ct.type = 'crypto_refund_pending'
 ORDER BY ct.created_at DESC;
```

The correlation key is the original top-up's NOWPayments `payment_id`,
**not** the `payout_id` minted by `createPayout`. The IPN handler
settles the deduct with the idempotency key
`nowpayments:refund:<payment_id>`, so joining on `payout_id` (as an
earlier version of this runbook did) returns NULL for every real
refund — making every settled refund look stuck. The deduct row is
written by `deductBalance`, which records `type = 'debit'` (it does not
re-label crypto refunds), so the settled row is matched on
`type = 'debit'` plus the deterministic idempotency key.

The pending row is created by the API call; 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.** Supported on the on-chain payout leg. The admin
endpoint accepts any refund amount from 1 cent up to the original
top-up and computes the crypto payout **proportionally**:

```
payout_crypto = (refund_cents / original_amount_cents) × actually_paid_crypto
```

where `actually_paid_crypto` is the crypto amount the customer originally
sent (recorded on the original NOWPayments intent by its `finished` IPN).
So a $25 refund against a $100 top-up that was paid with 0.004 BTC pays
out 0.001 BTC. The payout is always denominated in the original pay
currency, never in USD dollars.

The endpoint tracks the cumulative amount already refunded against each
top-up and rejects, with a `409`, any request that would push the running
total past the original, or any refund against a top-up that has already
been fully refunded. You can therefore issue several partial refunds
against one top-up, as long as they sum to no more than the original.

**Wallet-settlement caveat — read before issuing a single partial.** The
on-chain payout is proportional, but the wallet-balance leg is not. The
`refunded` IPN carries no per-refund amount, so the IPN handler reclaims
the **full** original credited wallet amount the first time it settles
(idempotent on the original payment, so a duplicate IPN never
double-deducts). A set of partials that sums to
the original nets out correctly — one full wallet reclaim against the
full set of crypto payouts. But a single partial leaves the wallet
reclaim larger than the crypto actually returned. When a tenant wants
only part of a top-up back and you do not intend to refund the rest,
prefer one of the following instead of a lone on-chain partial:

* Refund the original in full, then ask the tenant to send a fresh
  top-up for the amount they want to keep on the wallet, or
* Credit the agreed amount back manually in USD via the bonus credits
  tool and refuse the on-chain refund.

**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.

**The original row is not tagged `crypto_nowpayments`.** The endpoint
only accepts a `transaction_id` whose `payment_method` is
`crypto_nowpayments`; anything else returns `422 invalid_payment_method`.
Migration 228 added the `payment_method` column and backfilled `'stripe'`
*only* for rows whose metadata marked them as Stripe (a
`stripe_session_id` or `stripe_payment_intent_id`, or a `source` of
`stripe`/`auto_topup`); it left every other legacy row NULL rather than
tagging it `'stripe'`. So a genuine crypto top-up created before the
column existed reads NULL — not `crypto_nowpayments` — unless it was
explicitly tagged at write time, and the endpoint will reject it. If a
crypto row genuinely needs a refund and its `payment_method` is NULL,
escalate to platform engineering — there is a manual SQL path documented
in the internal runbook.

**The refund is rejected with a 422 *before* NOWPayments is called.**
The admin endpoint runs two `VALIDATION_ERROR` guards against the
original payment intent before it ever hits the provider. These are
**not** NOWPayments errors — do NOT escalate them as a provider failure:

* 422 `pay_currency_missing` — the original NOWPayments intent has no
  `pay_currency` on file, so the payout coin cannot be derived.
* 422 `actually_paid_crypto_missing` — the original payment's crypto
  amount (`actually_paid`) is not on file, so the proportional payout
  cannot be computed.

Both fire when the original payment's `finished` IPN has not been
processed yet (or the intent JOIN missed — e.g. a pre-229 row with no
`nowpayments_payment_id`). This is the common case when an operator
tries to refund too soon. The remedy is to **wait for the reconciler to
sync** the intent's `actually_paid` / `pay_currency`, then retry the
refund. Do not retry in a tight loop and do not raise a provider ticket.

**NOWPayments returns a 4xx on the payout call.** The endpoint surfaces
the provider's error message verbatim (as a `422 VALIDATION_ERROR`). 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 endpoint returns a
`502` with a generic "Try again in a few minutes" message. The pending
ledger row is already persisted; re-submitting the same
`(transaction_id, destination_address, amount_cents)` collapses 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 refund API call is accepted. 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_topup_ipn_refunded` — written by the IPN handler
   when the `refunded` IPN lands (resource `crypto_topup_intents`). The
   action name is templated as `billing.crypto_topup_ipn_${status}`, so
   the `refunded` IPN yields `billing.crypto_topup_ipn_refunded`. It does
   NOT carry the on-chain transaction hash or the new wallet balance; it
   records the NOWPayments `payment_id`, the `payment_status`, the
   `actually_paid` amount, and the `outcome_amount` / `outcome_currency`.

Only the initiation entry is keyed on a resource id: query the
audit-logs view with `resource_id = '<refund_ledger_id>'` to find the
`admin.crypto_refund_initiated` row (resource `credit_transaction`,
`resourceId = refundLedgerId`). The IPN settlement entry
(`billing.crypto_topup_ipn_refunded`) has resource `crypto_topup_intents`
and carries **no** `resource_id`, so it cannot be found that way. Correlate
the two entries by `organization_id` plus the NOWPayments `payment_id`
(the settlement row records `payment_id` in its `details`), not by a shared
`resource_id`.
