# Страница оплаты

Кроме интеграции, в которой продукт сам показывает покупателю адрес и сумму, сервис умеет принимать платёж на своей странице. Продукт создаёт инвойс, отправляет покупателя по ссылке из ответа, страница показывает сумму, адрес и статус платежа, а когда у инвойса появляется исход — возвращает покупателя на сайт мерчанта подписанным редиректом. Вебхуки при этом приходят так же, как без страницы. Решение и его границы — ADR-0032.

## Главное правило

> **Не отдавайте товар по редиректу.** Получив покупателя на `return_url`, проверьте подпись, затем запросите инвойс `GET /v1/invoices/{id}` и отдавайте товар только по ответу API — так же, как после вебхука.

Подпись доказывает одно: адрес собрал сервис для этого инвойса в этом статусе. Она не мешает покупателю открыть ту же ссылку ещё раз, пока не истекло окно проверки, и не заменяет проверку статуса.

## Как это работает

1. Продукт создаёт инвойс: `POST /v1/invoices`. В ответе поле `checkout_url` — ссылка на страницу оплаты этого инвойса. Если оператор не настроил страницу, поле равно `null` — см. [инвойсы](https://stons.io/docs/invoices.md#создать-инвойс).
2. Продукт отправляет покупателя по `checkout_url`.
3. Страница показывает сумму, адрес целиком, срок и подтверждения, и обновляет их, пока покупатель платит.
4. Когда инвойс оплачен или срок вышел, страница отправляет покупателя на адрес возврата или отмены с параметрами и подписью.
5. Продукт проверяет подпись и статус через API.

## Страница для покупателя

Страница открывается по `checkout_url` без входа и без ключей. Вот что на ней видит покупатель:

- название мерчанта;
- сумму к оплате — `amount_expected` — с тикером и сетью, с кнопкой копирования;
- если комиссию сервиса платит покупатель — разбивку «Цена · Комиссия сервиса · Итого»: цена мерчанта, комиссия со ставкой и та же сумма к оплате;
- предупреждение отправлять ровно этот актив и в этой сети;
- QR-код;
- адрес целиком, с кнопкой копирования;
- время до срока оплаты;
- статус платежа.

QR-код содержит только адрес. В документации TRON нет формата платёжной ссылки, который передал бы кошельку ещё и сумму с токеном. Поэтому сумма показывается и копируется отдельно, а покупателя просят сверить адрес после вставки целиком. Адрес нигде не сокращается.

Страница сама обновляет статус, перезагружать её не нужно:

- первый запрос — через 3 секунды, второй — через 5, дальше каждые 10;
- после смены статуса отсчёт начинается заново;
- пока вкладка скрыта — раз в 30 секунд;
- при возвращении на вкладку страница спрашивает сразу.

Время до срока считается по часам сервера, а не телефона.

Что страница показывает по статусам:

| Статус | Что видит покупатель |
| --- | --- |
| `created` | Сумму, адрес, QR-код и оставшееся время |
| `seen` | «Платёж замечен, ждём подтверждений» и число подтверждений из нужных. Сумма и адрес остаются на экране |
| `confirmed`, `settled` | «Оплата получена» |
| `paid_late` | «Платёж пришёл после срока»: решение за магазином |
| `underpaid` | «Сумма пришла не полностью» и просьбу не досылать остаток на этот адрес |
| `expired` | «Срок оплаты истёк» и просьбу не отправлять деньги повторно, если перевод уже ушёл |

После статуса с исходом (`confirmed`, `settled`, `paid_late`, `underpaid`, `expired`) страница перестаёт обновляться. Если у мерчанта есть адрес нужного вида, через 5 секунд страница переводит покупателя на сайт мерчанта. Перейти можно и сразу, кнопкой. Подписанный адрес страница берёт из запроса, сделанного прямо перед переходом, а не из ответа, полученного раньше. Без адреса возврата страница показывает исход и оставляет покупателя на месте.

`expired` и `underpaid` ещё могут смениться на `paid_late`, если деньги придут позже. Покупатель увидит это, открыв страницу снова, мерчант узнает из вебхука `invoice.paid_late`.

Несуществующий инвойс и инвойс отключённого мерчанта дают одинаковый ответ `404` без подробностей. Запросы одного покупателя ограничены числом в минуту: страница отвечает «Слишком много запросов». Как это настраивается — в эксплуатации страницы оплаты.

## Адреса возврата

Какой адрес получает покупатель, зависит от статуса инвойса:

| Статус | Куда | Почему |
| --- | --- | --- |
| `created`, `seen` | никуда | Инвойс ещё можно оплатить |
| `confirmed`, `settled` | адрес возврата | Инвойс оплачен |
| `paid_late` | адрес возврата | Платёж пришёл после срока, но пришёл: решение за мерчантом, и принимать его должен сайт мерчанта |
| `expired`, `underpaid` | адрес отмены | Срок вышел без полной суммы |

Адреса мерчанта по умолчанию задаёт оператор — см. мерчанты. Без адреса нужного вида редиректа нет: страница показывает исход и оставляет покупателя на месте.

Мерчант может переопределить адрес для отдельного инвойса полями `return_url` и `cancel_url` в `POST /v1/invoices`. Переопределение допускается, только если у мерчанта задан адрес того же вида, и только с тем же origin — той же схемой, хостом и портом. Например, при адресе мерчанта `https://shop.example/thanks` допустимо `https://shop.example/orders/1001/paid`, а `https://pay.shop.example/…` и `https://shop.example:8443/…` — нет. Иначе ответ `422 redirect_url_not_allowed`.

Требования к любому адресу возврата:

- только `https`;
- без символа `@` и без пробелов — так в адрес не попадают логин и пароль;
- не длиннее 2000 символов;
- без параметров `invoice_id`, `order_id`, `status`, `ts` и `signature`: их добавляет сервис.

Остальные параметры и фрагмент адреса сохраняются как есть.

Итоговый адрес — переопределение инвойса, если оно есть, иначе адрес мерчанта **на момент редиректа**. Если оператор сменит адрес мерчанта, это сразу коснётся инвойсов без переопределения.

## Параметры редиректа

| Параметр | Значение |
| --- | --- |
| `invoice_id` | Идентификатор инвойса |
| `order_id` | Идентификатор заказа мерчанта |
| `status` | Статус инвойса в момент, когда страница собрала адрес |
| `ts` | Время сборки адреса, секунды Unix |
| `signature` | Подпись, 64 шестнадцатеричных символа в нижнем регистре |

Значения закодированы по правилам query-строки: читайте их через `URLSearchParams` или аналог, а не разбором строки вручную.

## Подпись

Подпись — HMAC-SHA256 в hex с секретом вебхуков мерчанта в качестве ключа. Подписывается строка из пяти строк, соединённых символом перевода строки `\n` (байт `0x0A`), без перевода строки в конце:

```text
redirect
<ts>
<invoice_id>
<status>
<order_id>
```

Разделитель — перевод строки, а не точка, потому что `order_id` может содержать точки, а перевод строки не может содержать ни одно поле. Первая строка `redirect` не даёт подписи редиректа сойти за подпись вебхука тем же секретом.

Получатель обязан:

- отклонять редирект, если `ts` отличается от текущего времени больше чем на 5 минут;
- сравнивать подписи за постоянное время;
- проверять, что `invoice_id` — это инвойс заказа `order_id`, и брать статус из API, а не из параметра.

Проверка на Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

// query — URLSearchParams запроса, пришедшего на return_url или cancel_url.
export function verifyRedirect({ query, secret, nowMs = Date.now() }) {
  const fields = ["ts", "invoice_id", "status", "order_id"].map((name) => query.get(name));
  const signature = query.get("signature");
  if (fields.some((value) => value === null || value.includes("\n")) || signature === null) return false;
  const [ts, invoiceId, status, orderId] = fields;
  if (!/^\d{1,12}$/.test(ts) || !/^[0-9a-f]{64}$/.test(signature)) return false;
  if (Math.abs(nowMs / 1000 - Number(ts)) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret)
    .update(["redirect", ts, invoiceId, status, orderId].join("\n"))
    .digest();
  return timingSafeEqual(expected, Buffer.from(signature, "hex"));
}
```

Секрет вебхуков выдаётся при создании мерчанта и хранится в менеджере секретов — см. [вебхуки](https://stons.io/docs/webhooks.md#подпись).

Пример для проверки своей реализации. При секрете `example-webhook-secret` и параметрах `ts=1789300000`, `invoice_id=6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44`, `status=confirmed`, `order_id=ord_1001` подпись равна `2af98109e8fad432cc8e511f5e73f542ed3be8913b8d2863fe360f5f31f8e651`. Этот пример проверяется тестом сервиса.

## Маршрут страницы оплаты

`GET /checkout/v1/invoices/{id}` — не маршрут для мерчантов. Его вызывает сервер страницы оплаты по приватной сети: API сервиса не публичное, и покупатель из интернета до него не достаёт. Ключ API не нужен: страницу открывает покупатель, у которого ключа нет, а инвойс открывается его идентификатором — случайным UUID, который мерчант передаёт покупателю вместе со ссылкой. В OpenAPI мерчантов маршрута нет.

Ответ не кэшируется (`Cache-Control: no-store`). Число запросов в минуту с одного IP-адреса ограничено тем же `API_RATE_LIMIT_PER_IP`, что и у `/v1`, но отдельным счётчиком. Все запросы страницы приходят с одного адреса её сервера, поэтому этот лимит защищает API, но не разделяет покупателей: ограничивать отдельного покупателя должна сама страница.

Инвойс отключённого мерчанта для этого маршрута не существует: страница не должна продолжать собирать деньги мерчанту, которого остановил оператор.

| Поле | Тип | Описание |
| --- | --- | --- |
| `invoice_id` | string (UUID) | Идентификатор инвойса |
| `merchant_name` | string | Название мерчанта, которое видит покупатель |
| `status` | string | Статус инвойса, см. [инвойсы](https://stons.io/docs/invoices.md#статусы) |
| `asset` | string | Актив, например `USDT_TRON` |
| `symbol` | string | Тикер актива для показа: `USDT` |
| `network` | string | Сеть актива: `tron` |
| `address` | string | Адрес для платежа, целиком |
| `amount_requested` | string | Цена мерчанта |
| `amount_expected` | string | Сумма к оплате со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель |
| `amount_received` | string | Сумма подтверждённых платежей |
| `fees` | object | Комиссии инвойса, как в [инвойсе](https://stons.io/docs/invoices.md#комиссии). Разбивку страница показывает при `fees.service.payer` = `customer`: комиссия сервиса — `amount_expected` минус `amount_requested`. Строки комиссии сети в разбивке нет: затраты сети входят в комиссию сервиса, и `fees.network` страница не читает |
| `expires_at` | string | Срок оплаты, ISO 8601 в UTC |
| `confirmations_required` | integer | Сколько подтверждений нужно платежу в этом активе — из таблицы `assets` |
| `deposits` | array | Платежи по инвойсу: `tx_hash`, `amount`, `confirmations`, `status` (`seen` или `confirmed`) |
| `redirect` | object или null | Для статусов с исходом: `kind` — `return` или `cancel`, `url` — подписанный адрес. `null`, пока инвойс можно оплатить или когда у мерчанта нет адреса нужного вида |

В ответ не попадают идентификатор мерчанта, его секреты, `external_user_id` и `order_id` отдельным полем: номер заказа доходит до покупателя только внутри подписанного адреса, где он нужен мерчанту. Нет и `amount_credited`: сколько зачислено мерчанту после комиссий, покупателя не касается.

Подписанный адрес собирается в момент запроса и действует 5 минут. Страница запрашивает инвойс заново непосредственно перед переходом, а не хранит адрес.

| HTTP | `error.code` | Когда |
| --- | --- | --- |
| 400 | `validation_failed` | Идентификатор не в формате UUID |
| 404 | `invoice_not_found` | Инвойса нет или мерчант отключён |
| 429 | `rate_limited` | Превышен лимит запросов с IP-адреса |

```json
{
  "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
  "merchant_name": "Shop",
  "status": "confirmed",
  "asset": "USDT_TRON",
  "symbol": "USDT",
  "network": "tron",
  "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
  "amount_requested": "49.900000",
  "amount_expected": "49.900000",
  "amount_received": "49.900000",
  "fees": {
    "service": { "payer": "merchant", "percent": "1.00", "amount": "0.499000" },
    "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
  },
  "expires_at": "2026-09-11T21:03:05.531Z",
  "confirmations_required": 20,
  "deposits": [
    {
      "tx_hash": "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c",
      "amount": "49.900000",
      "confirmations": 20,
      "status": "confirmed"
    }
  ],
  "redirect": {
    "kind": "return",
    "url": "https://shop.example/thanks?invoice_id=6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44&order_id=ord_1001&status=confirmed&ts=1789300000&signature=2af98109e8fad432cc8e511f5e73f542ed3be8913b8d2863fe360f5f31f8e651"
  }
}
```
