<!-- Source: https://docs.infraio.xyz/ru/concepts/refunds -->
<!-- Last updated: 2026-10-04 -->

# Возвраты

**Refund** — сущность первого класса, а не флаг на Order. Вы можете
делать частичные возвраты, несколько возвратов против одного и того же
Order или возврат + повторное списание в одном потоке.

> **Note:**
>
> Возвраты также можно оформлять из [приложения для мерчантов](https://docs.infraio.xyz/ru/get-started/merchant-app).

Запись возврата может появиться двумя способами:

| Поток | Кто заполняет форму | Аутентификация | Попадает в |
| --- | --- | --- | --- |
| Инициированный мерчантом | Ваша панель / ваш backend | HMAC (sk_…) | `APPROVED` немедленно |
| Инициированный клиентом | Покупатель, на нашей размещённой странице | Одноразовый токен (без credential'ов) | `PENDING` — вы одобряете, или замыкается короче, если ваша конфигурация авто-одобряет |

Поток, инициированный клиентом, использует короткоживущий
**refund-request token**. Вы выпускаете токен (B2B или из панели),
передаёте URL покупателю любым удобным способом, и покупатель заполняет
детали возврата на `checkout.infraio.xyz/refund-request/:token`.
Покупатель никогда не касается вашего API и никогда не видит ваш
мерчантский ключ.

## Жизненный цикл возврата

```mermaid
stateDiagram-v2
    [*] --> PENDING:  refund created (customer submit or B2B customer-flow)
    PENDING --> APPROVED: passes review (auto for merchant-initiated)
    PENDING --> REJECTED: review denies
    APPROVED --> EXECUTED: on-chain tx confirmed
    APPROVED --> REJECTED: canceled before execution
    REJECTED --> [*]
    EXECUTED --> [*]
```

| Состояние | Значит |
| --- | --- |
| `PENDING` | Возврат записан, ожидает одобрения. Возвраты, инициированные клиентом, всегда начинаются здесь. |
| `APPROVED` | Допущен к исполнению. Возвраты, инициированные мерчантом, прыгают прямо сюда. |
| `REJECTED` | Возврат отклонён. Статус заказа не меняется. |
| `EXECUTED` | On-chain перевод подтверждён. Order переходит в `PARTIALLY_REFUNDED` / `REFUNDED`. |

---

## Инициированный мерчантом

Вы решаете сделать возврат (например, покупатель жаловался в чате).
Вызовите endpoint, инициированный мерчантом — он пропускает review и
сразу попадает в `APPROVED`.

```http
POST /b2b/v1/merchants/{merchant_id}/refunds
{
  "order_id": "ord_01J5K…",
  "amount": "49.00",                       // частичный или полный, в валюте отображения заказа
  "reason": "customer complaint #4521",
  "refund_to_address":   "0xBUYER…",       // обязательно для крипто-рейлов
  "refund_network":      "polygon",        // slug сети; см. Концепции → Сети
  "refund_token_address":"0xUSDC_CONTRACT" // ERC-20 контракт, в котором возвращаем; обычно оригинальный токен
}
```

Поля `currency` в запросе возврата нет — возвраты всегда наследуют
валюту отображения заказа (USD сегодня). Тройка
`(refund_to_address, refund_network, refund_token_address)` — это
on-chain назначение. На фиатных рейлах она игнорируется (авто-маршрутизация провайдером).

Заказ сохраняет свой текущий статус, пока вы не исполните on-chain
перевод (см. [Исполнение крипто-возврата](#executing-a-crypto-refund)).

---

## Инициированный клиентом — refund-request токены

Покупатель заполняет форму возврата **на нашей размещённой странице**,
не на вашей. Ваша единственная задача — выпустить токен и доставить
URL.

### Жизненный цикл токена

```mermaid
stateDiagram-v2
    [*] --> ACTIVE:           mint (B2B or dashboard)
    ACTIVE --> SUBMITTED:     buyer submits the form
    ACTIVE --> EXPIRED_UNUSED: now > expires_at
    ACTIVE --> CANCELED:      merchant cancels (dashboard)
    EXPIRED_UNUSED --> RENEWAL_REQUESTED: buyer clicks "Request new link"
    RENEWAL_REQUESTED --> RENEWED: merchant approves, new ACTIVE token issued
    SUBMITTED --> [*]
    CANCELED --> [*]
    RENEWED --> [*]
```

| Состояние | Значит | URL клиента отрисовывает |
| --- | --- | --- |
| `ACTIVE` | Токен живой, `now < expires_at` | Форму возврата (`refund_to_address`, `reason`, `amount`, необязательная заметка → `metadata.note`) |
| `SUBMITTED` | Покупатель заполнил форму; запись о возврате существует | Карточку статуса, отзеркаливающую `/r/:linkToken` |
| `EXPIRED_UNUSED` | TTL истёк до отправки покупателем | Подсказка: «Срок действия ссылки истёк. Запросите новую» |
| `RENEWAL_REQUESTED` | Покупатель запросил свежую ссылку | Уведомление об ожидании: «Ваш мерчант уведомлён» |
| `RENEWED` | Мерчант одобрил обновление и выпустил замену | «Эта ссылка заменена — проверьте email на наличие новой ссылки» (новый токен **не** раскрывается здесь, чтобы пресечь атаки с пересылкой ссылки) |
| `CANCELED` | Мерчант отозвал токен из панели | Простое «Этот запрос на возврат отменён» |

> **Note:**
>
> Токены одноразовые. После `SUBMITTED` URL остаётся валидным, чтобы
> покупатель мог проверить статус, но повторно отправить нельзя. Чтобы
> выпустить второй возврат против того же заказа, сгенерируйте новый
> токен.

### TTL по умолчанию

| Источник выпуска | TTL по умолчанию | Почему |
| --- | --- | --- |
| `POST /b2b/v1/merchants/{merchant_id}/refund-requests` (HMAC) | **30 минут** | Программно — предполагается, что URL сразу передаётся покупателю. |
| Панель мерчанта | **24 часа** | Вручную — мерчант вставляет URL в email / SMS. |

Значение по умолчанию можно переопределить полем тела `ttl_seconds`.
Минимум и максимум не ограничены; обычные значения — от 1 минуты до
7 дней.

### Выпуск через B2B API

Для backend'ов, которые хотят программно генерировать ссылку возврата
сразу после разговора в поддержке, потока отмены заказа и т. д.

```http
POST /b2b/v1/merchants/{merchant_id}/refund-requests
Content-Type: application/json
X-Client-ID: pk_live_…
X-Timestamp: 1729536000
X-Signature: 4f2a1b9c8d3e2f1a0b9c8d7e6f5a4b3c…

{
  "ref_type":    "order_id",                          // обязательно: order_id | order_number | session_id | session_key
  "ref_value":   "ord_01J5K…",                        // обязательно: соответствует ref_type
  "amount":      "49.00",                             // обязательно — фиксирует максимум, который покупатель может отправить
  "ttl_seconds": 1800,                                // опционально — по умолчанию 1800 (30 мин)
  "metadata":    { "support_ticket": "4521" },        // опционально — key/value в стиле Stripe
  "hide_summary": false,                              // опциональные UI-флаги для размещённой формы
  "hide_header":  false
}
```

> **Warning:**
>
> Подпись запроса B2B — это **сырой hex в нижнем регистре** **без
> префикса `sha256=`** — этот префикс появляется только на *входящих*
> подписях webhook (Infraio → ваш сервер). Исходящая строка подписи
> B2B — `METHOD\nPATH\nTIMESTAMP\nBODY`; см.
> [Аутентификация](https://docs.infraio.xyz/ru/api-reference/authentication) для канонического
> алгоритма.

Сумма **входит** в тело выпуска и **обязательна**. Она фиксирует
потолок, который покупатель может отправить через форму — он может
отправить меньше, но никогда больше. (Для частичных возвратов
выпускайте токен с частичной суммой; для полных возвратов выпускайте с
общей суммой заказа.)

Более старая форма `{ "order_id": "..." }` всё ещё принимается и
трактуется как `ref_type=order_id`, но новые интеграции должны
использовать явную пару `ref_type` + `ref_value`.

Ответ:

```json
{
  "token":      "rfqt_01J7P3Q9R…",
  "refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…",
  "expires_at": "2026-05-28T10:32:00Z"
}
```

Отправляет `refund_request.created` на ваши webhook-endpoint'ы (чтобы
вы могли логировать / аудировать, какой токен сейчас активен для
заказа).

### Выпуск через панель

Модал Issue Refund в [панели мерчанта](https://app.infraio.xyz)
показывает переключатель: **Execute now** vs **Send link to customer**.
Выбор второго варианта создаёт refund-request токен (так же, как
B2B-вызов выше) и показывает URL с кнопкой копирования и QR-кодом. Вставляйте его в любой
подходящий канал — email, чат поддержки, SMS.

### Через JavaScript SDK — `openRefundRequest`

Если у вас уже есть `@lartech/infraio-checkout-js` в стеке и вы хотите,
чтобы покупатель завершил возврат внутри вашего собственного потока
страницы (а не через внешний URL), сочетайте B2B-выпуск с
`sdk.openRefundRequest()`:

```ts
// На сервере: выпустить токен
const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json());

// На клиенте: открыть размещённую форму
const sdk = await loadInfraIo("pk_live_yourkeyhere");
const close = sdk.openRefundRequest({
  token,
  mode: "popup",                       // или "redirect" | "embed"
  onSuccess: ({ linkToken, refundId }) => {
    // linkToken → /r/:linkToken страница статуса покупателя.
    // refundId  → ссылка B2B API для approve / reject.
    window.location.href = `/r/${linkToken}`;
  },
  onCancel: () => { /* покупатель закрыл popup */ },
  onError:  (err) => { /* см. справочник SDK */ },
});
```

См. [Справочник SDK → `sdk.openRefundRequest()`](https://docs.infraio.xyz/ru/sdks/javascript#sdkopenrefundrequest-)
для полной таблицы опций.

### Обновление по запросу клиента — переоформление покупателем

Если покупатель открывает URL после истечения токена, страница вместо
формы показывает кнопку **Запросить новую ссылку**. Клик по ней:

1. Отправляет запрос на обновление (credential'ы не нужны — авторизует
   сама ссылка)
2. Опционально захватывает свободный текст (`customer_note`),
   который покупатель может оставить вам
3. Переводит токен в `RENEWAL_REQUESTED` и отправляет
   `refund_request.renewal_requested` на ваш webhook

Ваша панель показывает бейдж на виджете запросов обновлений. Одобрите
его (один клик), и будет выпущен новый `ACTIVE` токен, отправлено
`refund_request.renewed`, и вы сможете скопировать новый URL, чтобы
отправить снова. Старый URL остаётся доступным, но показывает «Заменён
— проверьте email», чтобы пересланная копия старого URL не могла
извлечь новый.

---

## Исполнение крипто-возврата

API записывает намерение — но не двигает средства. **Вы** подписываете
и транслируете on-chain перевод с вашего мерчантского кошелька, затем
проставляете tx hash на записи возврата:

```http
POST /b2b/v1/refunds/:refund_id/submit-tx
Content-Type: application/json

{
  "tx_hash": "0xabcd…",
  "network": "ethereum",
  "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
}
```

Все три поля тела обязательны: один и тот же tx hash может существовать
на разных сетях, и вы можете вернуть в другом стейблкоине, чем тот, в
котором был принят оригинальный платёж.

Когда InfraIO Pay увидит, что эта транзакция набрала необходимое число
подтверждений (см. [Сети и активы](https://docs.infraio.xyz/ru/concepts/chains)), возврат
перейдёт в `EXECUTED`, а общая возвращённая сумма Order будет
обновлена.

> **Warning:**
>
> Мы сознательно не храним средства мерчанта, что означает, что мы не
> можем исполнять возвраты от вашего имени. Встройте on-chain отправку
> в вашу админ-инструментацию — `eth_sendRawTransaction` из multisig
> или горячего кошелька, с workflow, который заканчивается отправкой
> tx hash в API возврата.

### Возвраты в TRON, Solana и TON

Процесс тот же: вы отправляете возврат со своего кошелька, затем передаёте хеш транзакции. Детали зависят от сети:

- Экран возврата в дашборде показывает адрес назначения, сумму, сеть и токен, а также QR-код там, где сеть его поддерживает: QR Solana Pay в Solana и ссылку на перевод TON в TON. В TRON показывается адрес назначения для копирования (ни одна ссылка кошелька не несёт сумму), поэтому сумму вводите сами.
- `token_address` — адрес токена в этой сети: контракт TRC-20, mint SPL или адрес Jetton master.
- Форматы хеша транзакции различаются: «голый» hex в TRON, подпись base58 в Solana, хеш hex или base64 в TON.
- Платформа проверяет именно эту транзакцию on-chain, а затем переводит возврат в `EXECUTED`, ориентируясь на число подтверждений из [Сети и активы](https://docs.infraio.xyz/ru/concepts/chains).

---

## События webhook

Подсистема возвратов отправляет два семейства событий:

### Жизненный цикл токена (`refund_request.*`)

| Событие | Срабатывает когда |
| --- | --- |
| `refund_request.created` | Токен был выпущен — `data.source` равно `b2b` / `dashboard` / `renewal` |
| `refund_request.renewal_requested` | Покупатель нажал «Запросить новую ссылку» после истечения токена. **Подпишитесь на это — это сигнал мерчанту действовать.** |
| `refund_request.renewed` | Вы одобрили обновление, и новый токен заменил старый. `data.old_token` / `data.new_token` формируют аудиторскую цепочку. |
| `refund_request.canceled` | Вы перевели токен в `CANCELED` из панели. Идемпотентно — отправляется только при первом переходе. `data.reason` — опциональная заметка мерчанта. |

### Жизненный цикл возврата (`payment.refund.*`)

| Событие | Срабатывает когда |
| --- | --- |
| `payment.refund.requested` | Новая строка Refund существует — из любого источника (отправка формы, API, инициированный мерчантом, панель). |
| `payment.refund.approved` | Возврат одобрен — либо авто-одобрен (инициированный мерчантом), либо после того, как вы вызвали `/approve` на ожидающем. |
| `payment.refund.rejected` | Вы вызвали `/reject` на ожидающем возврате. |
| `payment.refund.executed` | Средства переведены (ваш крипто tx hash набрал требуемые подтверждения). |

`payment.failed` **не** срабатывает для возврата — у возвратов своя
серия событий под префиксом `payment.refund.*`.

## Что дальше

- [Справочник SDK → `sdk.openRefundRequest()`](https://docs.infraio.xyz/ru/sdks/javascript#sdkopenrefundrequest-) — открыть размещённую форму возврата как popup / redirect / embed.
- [Справочник API → Возвраты](https://docs.infraio.xyz/ru/api-reference#refunds) — каталог endpoint'ов (выпуск, отправка, обновление, статус).
- [Концепции → Заказы](https://docs.infraio.xyz/ru/concepts/orders) — как состояние Refund возвращается обратно в жизненный цикл Order.
- [Webhooks → Обзор](https://docs.infraio.xyz/ru/webhooks/overview) — полный каталог событий.
