# Инвойсы

Инвойс — ожидание платежа на конкретную сумму в конкретном активе до дедлайна. Мерчант создаёт инвойс под свой заказ, получает адрес и показывает его пользователю вместе с суммой.

## Активы

Что сервис принимает в боевой сети TRON. Адреса контрактов прочитаны в сети, а не взяты из статей (ADR-0026):

| Актив | Контракт | Знаков | Минимум | Выплаты |
| --- | --- | --- | --- | --- |
| `USDT_TRON` | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | 6 | 1 USDT | да |
| `USDC_TRON` | `TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8` | 6 | 1 USDC | да |
| `TRX_TRON` | нативная монета | 6 | — | нет |

`TRX_TRON` **платежом пока не считается**: перевод TRX на адрес приёма записывается как `unexpected_asset` и инвойс не закрывает. Причина и условие для изменения — в том же ADR. Выплаты в TRX не появятся и после: суточные лимиты складывают суммы в одной учётной единице, что верно только для привязанных к доллару активов.

В тестовых сетях принимается только USDT: проверяемого контракта USDC в Nile и Shasta нет — см. тестовые сети.

Единственный источник знаков, минимумов и числа подтверждений — таблица `assets`; в коде эти значения не зашиты нигде, поэтому добавление актива не меняет ни одной строки логики.

## Создать инвойс

`POST /v1/invoices`

### Запрос

| Поле | Тип | Обязательно | Описание |
| --- | --- | --- | --- |
| `order_id` | string | да | Идентификатор заказа мерчанта, уникальный в пределах мерчанта: 1–200 видимых ASCII-символов без пробелов. Ключ идемпотентности |
| `asset` | string | да | Актив, например `USDT_TRON` |
| `amount` | string | да | Сумма в единицах актива: `"49.9"`. Не больше знаков после запятой, чем у актива, без знака, экспоненты и пробелов |
| `expires_in` | integer | нет | Сколько секунд ждать платежа: от 300 (5 минут) до 259200 (72 часа), по умолчанию 3600 (1 час) |
| `external_user_id` | string | в режиме `per_user` | Пользователь мерчанта. В режиме `per_user` инвойс получает его постоянный адрес. В режиме `per_invoice` поле только сохраняется |
| `return_url` | string | нет | Куда [страница оплаты](https://stons.io/docs/checkout.md) вернёт покупателя после оплаты. Переопределяет адрес проекта и допускается только с тем же origin, что у адреса возврата проекта или у сайта проекта из заявки, а на `t.me` и `telegram.me` — ещё и с тем же именем бота или канала — см. [адреса возврата](https://stons.io/docs/checkout.md#адреса-возврата); `https`, до 2000 символов, без `@`, пробелов и параметров `invoice_id`, `order_id`, `status`, `ts`, `signature` |
| `cancel_url` | string | нет | Куда ведёт кнопка «Вернуться в магазин» на странице оплаты, пока инвойс не оплачен, и куда страница вернёт покупателя, если срок вышел без полной суммы. Те же правила, что у `return_url`, относительно адреса отмены проекта и сайта проекта |

Сумма должна быть не меньше минимального депозита актива: для `USDT_TRON` это 1 USDT. Переводы меньше минимального депозита сервис считает пылью и не зачисляет.

### Ответ

`201` — инвойс создан, `200` — инвойс для этого `order_id` уже был создан раньше. Тело в обоих случаях одинаковое:

| Поле | Тип | Описание |
| --- | --- | --- |
| `invoice_id` | string (UUID) | Идентификатор инвойса |
| `order_id` | string | Идентификатор заказа мерчанта |
| `status` | string | Статус, см. [ниже](#статусы) |
| `asset` | string | Актив |
| `network` | string | Сеть актива: `tron` |
| `address` | string | Адрес для платежа, целиком |
| `amount_requested` | string | Цена мерчанта — `amount` запроса со всеми знаками актива: `"49.900000"` |
| `amount_expected` | string | Сумма, которую должен заплатить покупатель: цена и комиссия сервиса — процент и фиксированная сумма, — если её платит покупатель — см. [комиссии](#комиссии) |
| `amount_received` | string | Сумма подтверждённых платежей |
| `amount_credited` | string | Подтверждённые платежи за вычетом удержанных комиссий: сколько зачислено мерчанту |
| `fees` | object | Комиссия сервиса и строка комиссии сети — см. [комиссии](#комиссии) |
| `external_user_id` | string или null | Пользователь мерчанта |
| `return_url` | string или null | Переопределение адреса возврата, как его прислал мерчант; `null` — действует адрес мерчанта |
| `cancel_url` | string или null | Переопределение адреса отмены, как его прислал мерчант; `null` — действует адрес мерчанта |
| `checkout_url` | string или null | [Страница оплаты](https://stons.io/docs/checkout.md) этого инвойса; `null`, если оператор её не настроил |
| `expires_at` | string | Дедлайн, ISO 8601 в UTC |
| `created_at` | string | Время создания, ISO 8601 в UTC |
| `deposits` | array | Платежи по инвойсу, см. [получить инвойс](#получить-инвойс) |

### Идемпотентность

Повторный запрос с тем же `order_id`, активом, суммой, `external_user_id`, `return_url` и `cancel_url` не создаёт второй инвойс и не возвращает ошибку. Ответом будет уже существующий инвойс с кодом `200`. Поэтому запрос можно безопасно повторять после таймаута. Одновременные запросы с одним `order_id` тоже создают один инвойс.

Сумма повтора сравнивается с ценой `amount_requested`, а не с `amount_expected`: повтор присылает ту же цену, а комиссия, добавленная к ней при создании, — условие уже выставленного инвойса. Ответ на повтор показывает инвойс с условиями, записанными при создании, даже если ставка с тех пор изменилась.

Запрос с тем же `order_id`, но другой суммой, активом, пользователем или адресами возврата отклоняется с кодом `409 order_id_conflict`: это другой платёж, а не повтор. Повтор принятого запроса возвращает инвойс, даже если оператор с тех пор сменил адреса мерчанта: переопределения проверяются при создании.

### Ошибки

| HTTP | `error.code` | Когда |
| --- | --- | --- |
| 400 | `validation_failed` | Поле отсутствует, лишнее или неверного формата; у суммы слишком много знаков после запятой; в режиме `per_user` нет `external_user_id` |
| 401 | `unauthorized` | Нет ключа API или он неверный |
| 403 | `merchant_disabled` | Мерчант отключён |
| 403 | `merchant_not_approved` | Проект ещё не прошёл проверку или отклонён. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс |
| 409 | `order_id_conflict` | Инвойс с этим `order_id` уже есть и описывает другой платёж |
| 422 | `redirect_url_not_allowed` | Origin `return_url` или `cancel_url` (на `t.me` и `telegram.me` — и имя бота или канала) не совпадает ни с адресом проекта того же вида, ни с сайтом проекта, либо у проекта нет ни того, ни другого |
| 422 | `asset_not_supported` | Актив неизвестен или не принимается к оплате |
| 422 | `asset_payments_disabled` | Оператор временно выключил приём в этом активе. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс |
| 422 | `asset_disabled_by_merchant` | Приём в этом активе выключен в настройках проекта. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс |
| 422 | `amount_below_minimum` | Сумма меньше минимального депозита актива |
| 429 | `rate_limited` | Превышен лимит запросов |
| 503 | `addresses_exhausted` | Индексы деривации закончились. Практически недостижимо |
| 503 | `network_fee_quote_unavailable` | У проекта осталась прежняя настройка «комиссию сети платит покупатель», а отдельной комиссии сети у инвойса нет: инвойс не создаётся, пока владелец не сохранит настройки комиссий в кабинете. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс |

### Пример

```sh
curl -s -X POST https://api.stons.io/v1/invoices \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ord_1001", "asset": "USDT_TRON", "amount": "49.9", "expires_in": 3600}'
```

```json
{
  "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
  "order_id": "ord_1001",
  "status": "created",
  "asset": "USDT_TRON",
  "network": "tron",
  "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
  "amount_requested": "49.900000",
  "amount_expected": "49.900000",
  "amount_received": "0.000000",
  "amount_credited": "0.000000",
  "fees": {
    "service": { "payer": "merchant", "percent": "1.00", "fixed": "0.000000", "amount": "0.000000" },
    "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
  },
  "external_user_id": null,
  "return_url": null,
  "cancel_url": null,
  "checkout_url": null,
  "expires_at": "2026-09-11T21:03:05.531Z",
  "created_at": "2026-09-11T20:03:05.531Z",
  "deposits": []
}
```

## Получить инвойс

`GET /v1/invoices/{id}` возвращает текущее состояние инвойса в том же формате, что и ответ на создание. Именно по этому ответу, а не по телу вебхука, решается, отдавать ли товар — см. [вебхуки](https://stons.io/docs/webhooks.md).

Инвойс другого мерчанта для вызывающего не существует: ответ `404 invoice_not_found`. Идентификатор не в формате UUID — `400 validation_failed`.

Поля элемента `deposits`:

| Поле | Тип | Описание |
| --- | --- | --- |
| `tx_hash` | string | Хэш транзакции |
| `amount` | string | Сумма перевода в единицах актива |
| `confirmations` | integer | Сколько блоков набрано над блоком перевода |
| `status` | string | `seen` — перевод в блоке, подтверждений недостаточно; `confirmed` — подтверждений достаточно |
| `block_time` | string | Время блока с переводом, ISO 8601 в UTC |
| `late` | boolean | `true`, если блок с переводом новее дедлайна инвойса |

В `deposits` попадают только платежи. Пыль, переводы другого актива и переводы, исчезнувшие при реорганизации цепи, в список не входят.

```sh
curl -s https://api.stons.io/v1/invoices/6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44 -H "X-Api-Key: $API_KEY"
```

## Статусы

Схема статусов и её обоснование — в ADR-0006.

| Статус | Значение | Что делать мерчанту |
| --- | --- | --- |
| `created` | Инвойс создан, переводов нет | Ждать |
| `seen` | До дедлайна пришёл перевод, но подтверждённая сумма меньше ожидаемой | Ждать: перевод ещё можно откатить |
| `confirmed` | Подтверждённые переводы до дедлайна покрывают сумму. Переплата — тоже `confirmed`, см. `amount_received` | Отдать товар |
| `underpaid` | Дедлайн прошёл, подтверждено больше нуля, но меньше ожидаемого | Решить самому: доплата, частичная выдача, возврат через поддержку |
| `expired` | Дедлайн прошёл, подтверждённых переводов нет | Закрыть заказ |
| `paid_late` | После `expired` или `underpaid` пришёл и подтвердился новый перевод | Решить самому |
| `settled` | Средства инвойса перенесены в хранилище | Ничего: для мерчанта это то же, что `confirmed` |

```mermaid
stateDiagram-v2
  [*] --> created
  created --> seen: перевод в блоке до дедлайна
  seen --> created: перевод исчез при реорганизации
  seen --> confirmed: подтверждено не меньше ожидаемого
  created --> confirmed: подтверждено не меньше ожидаемого
  seen --> underpaid: дедлайн, подтверждено меньше ожидаемого
  created --> expired: дедлайн без переводов
  underpaid --> paid_late: подтверждён поздний перевод
  expired --> paid_late: подтверждён поздний перевод
  confirmed --> settled: средства в хранилище
```

Правила:

- Вовремя пришёл перевод или нет, решает время блока, в который он попал, а не момент подтверждения. Перевод, отправленный за минуту до дедлайна, считается своевременным, даже если подтверждения наберутся позже.
- Инвойс становится `underpaid` или `expired` только после того, как сервис просканировал блоки после дедлайна и все своевременные переводы подтвердились. Поэтому инвойс может оставаться в `created` или `seen` немного дольше дедлайна.
- До дедлайна переводы на адрес инвойса суммируются: первый перевод не считается окончательным.
- Переплата не отдельный статус: инвойс становится `confirmed`, а `amount_received` больше `amount_expected`.
- Перевод, исчезнувший из цепи при реорганизации, перестаёт учитываться: инвойс без других переводов возвращается в `created`, мерчант получает `deposit.orphaned`. Если перевод затем снова попадает в цепь, он учитывается заново. В этом редком случае инвойс может выйти из `expired` или `underpaid` обратно в `seen`.

Статусы меняет сканер блокчейна. Он читает блоки с отставанием в один блок от головы цепи, поэтому перевод виден через несколько секунд. Подтверждение USDT в TRON — 20 блоков, около минуты. О каждой смене статуса мерчант получает [вебхук](https://stons.io/docs/webhooks.md#события).

## Режим per_user

В режиме `per_user` у пользователя один постоянный адрес. Перевод закрывает самый ранний открытый инвойс этого пользователя в том же активе на точно такую же сумму. Перевод на другую сумму — это пополнение баланса, а не частичная оплата. Поэтому в этом режиме не бывает статусов `underpaid` и `paid_late`, а перевод на точную сумму после дедлайна тоже считается пополнением.

Сумма, с которой сравнивается перевод, — `amount_expected`: если комиссию сервиса платит покупатель, это цена вместе с комиссией.

## Комиссии

С каждого подтверждённого платежа STONS удерживает комиссию сервиса — процент и фиксированную сумму, которые назначает оператор (ADR-0038, поправка «процент и фиксированная сумма на каждой операции»). Процент — общий для платформы или свой у проекта, фиксированная сумма — своя у каждого актива, одна на платформу. Любое из чисел может быть нулём. Кто платит комиссию — мерчант или покупатель, — задаёт настройка проекта.

**Отдельной комиссии сети у инвойса нет.** Затраты сети на приём — перенос средств с адреса приёма в хранилище — входят в комиссию сервиса, как принято на рынке (ADR-0038). Строка `fees.network` это и говорит: `status` = `included`. Комиссия сети есть только у [выплат](https://stons.io/docs/payouts.md).

Ставка, фиксированная сумма и плательщики **записываются в инвойс при его создании** и дальше не меняются: смена тарифа или настроек проекта касается только новых инвойсов. Инвойсы, созданные до появления комиссии, записаны со ставкой 0, а созданные до появления фиксированной суммы — с суммой 0.

### Поля

| Поле | Описание |
| --- | --- |
| `fees.service.payer` | `merchant` — комиссия удерживается из полученного; `customer` — комиссия добавлена к цене и входит в `amount_expected` |
| `fees.service.percent` | Ставка, записанная в инвойс, в процентах с двумя знаками: `"1.00"` |
| `fees.service.fixed` | Фиксированная сумма, записанная в инвойс, со всеми знаками актива: `"1.700000"`. Одна на инвойс, сколько бы переводов его ни оплатили |
| `fees.service.amount` | Комиссия — процент и фиксированная сумма вместе, — удержанная с подтверждённых платежей инвойса на сейчас. До первого подтверждения — ноль |
| `fees.network.status` | Всегда `included`: затраты сети входят в комиссию сервиса, отдельно не начисляются и не удерживаются |
| `fees.network.payer` | Всегда `merchant`: покупатель сеть отдельно не оплачивает |
| `fees.network.quote`, `fees.network.quote_rate`, `fees.network.actual` | Всегда `null`. Оставлены, чтобы не ломать интеграции, которые уже читают эти поля |
| `amount_credited` | `amount_received` минус `fees.service.amount`: сколько из полученного зачислено мерчанту |

### Как считается комиссия сервиса

- **Покупатель платит комиссию.** К цене прибавляются ставка от цены, округлённая вверх до последнего знака актива, и фиксированная сумма: `amount_expected = amount_requested + ⌈amount_requested × ставка⌉ + фиксированная сумма`. Инвойс на 100 USDT при 2% и 1.70 USDT ждёт 103.70 USDT.
- **Удерживается при подтверждении каждого платежа**, в той же операции, что его зачисление, и считается от суммы всех подтверждённых платежей инвойса — назовём её «получено»:
  - платит мерчант — `min(получено, ⌊получено × ставка⌋ + фиксированная сумма)`;
  - платит покупатель — `min(получено, фиксированная сумма + ⌊max(0, получено − фиксированная сумма) × ставка / (1 + ставка)⌋)`: сначала фиксированная сумма, затем доля процента внутри оплаченного.
- **Не больше полученного.** Платёж меньше фиксированной суммы удерживается целиком, но не больше: зачисленное не уходит ниже нуля.
- **Округление вниз**, в пользу мерчанта. Инвойс, оплаченный несколькими переводами, удерживает столько же, сколько оплаченный одним, и фиксированную сумму — один раз: каждый следующий перевод удерживает разницу между комиссией с новой суммы и уже удержанной.
- **Точная оплата покупателем** удерживает ровно то, что прибавилось к цене, или на одну минимальную единицу актива меньше, если процент от цены не делится нацело: округление вниз оставляет эту единицу мерчанту.
- **Переплата** — комиссия с большей суммы. **Недоплата** — с полученного. **Платёж после срока** — как любой подтверждённый.
- **Пыль и переводы другого актива** комиссии не несут: это не платежи. Перевод, исчезнувший при реорганизации до подтверждения, не зачислен и комиссии не несёт.
- **Пополнение `per_user` без инвойса** — комиссия с суммы перевода по ставке мерчанта и фиксированной сумме актива на момент блока перевода, не больше суммы перевода, платит мерчант. Каждое пополнение — отдельная операция со своей фиксированной суммой. Комиссию видно в балансе проекта; события `deposit.*` полей комиссий не несут.

Пример: цена 100 USDT, ставка 2%, фиксированная сумма 1.70 USDT, комиссию платит покупатель. Он платит двумя переводами: 1 USDT, затем 102.70 USDT.

| Момент | `amount_requested` | `amount_expected` | `amount_received` | `fees.service.amount` | `amount_credited` |
| --- | --- | --- | --- | --- | --- |
| Создан | `100.000000` | `103.700000` | `0.000000` | `0.000000` | `0.000000` |
| Первый перевод | `100.000000` | `103.700000` | `1.000000` | `1.000000` | `0.000000` |
| Оплачен | `100.000000` | `103.700000` | `103.700000` | `3.700000` | `100.000000` |

**Интеграция сравнивает свою цену с `amount_requested`**, а покупателю показывает `amount_expected`: только эта сумма закрывает инвойс.

## Суммы

Суммы передаются строками в единицах актива, например `"49.900000"`. JSON-числа для сумм не используются: при разборе в число с плавающей точкой копейки теряются. Число знаков после запятой у каждого актива своё: у USDT в TRON — 6, у USDT в BNB Chain — 18.
