Архитектура платёжных ссылок и счетов-поручений
CARA Pay
Архитектура платёжных ссылок и счетов-поручений
Версия: 1.0
Статус: базовая архитектурная спецификация
Компонент: Merchant Payment Links
Основная валюта оплаты: RUB
Расчётная валюта: USDT
Срок действия платёжной ссылки: 15 минут
1. Назначение системы
Система CARA Pay предоставляет мерчантам возможность создавать одноразовые платёжные ссылки для приёма оплаты в рублях на банковский счёт ИП.
Каждая платёжная ссылка:
-
создаётся конкретным мерчантом;
-
содержит уникальный счёт-поручение;
-
получает фиксированный курс RUB/USDT;
-
получает фиксированную сумму в RUB;
-
содержит уникальное назначение платежа;
-
содержит индивидуальный QR-код;
-
действует 15 минут;
-
не может быть изменена или продлена после создания;
-
автоматически сверяется с входящими банковскими операциями;
-
сохраняется в системе вместе со всеми исходными данными и историей статусов.
Основной принцип:
Одна платёжная ссылка
=
один Payment Order
=
один счёт-поручение
=
одна котировка
=
одна сумма
=
один QR-код
После истечения ссылки она не обновляется.
Для оплаты по новому курсу мерчант создаёт полностью новую платёжную ссылку.
2. Основные бизнес-правила
2.1. Неизменяемость платёжной ссылки
После успешного создания платёжной ссылки запрещается изменять:
-
мерчанта;
-
сумму в USDT;
-
сумму в RUB;
-
курс RUB/USDT;
-
источник курса;
-
наценку или спред;
-
банковские реквизиты;
-
назначение платежа;
-
номер счёта-поручения;
-
публичный токен;
-
QR payload;
-
изображение QR-кода;
-
срок действия;
-
дату создания.
Разрешается изменять только операционные статусы и добавлять сведения, появившиеся после создания:
-
статус ссылки;
-
статус оплаты;
-
статус исполнения;
-
банковскую транзакцию;
-
результат автоматической сверки;
-
данные ручной проверки;
-
сведения о возврате;
-
историю событий.
2.2. Срок действия
Каждая ссылка действует ровно 15 минут:
expires_at = created_at + 15 минут
Время рассчитывается только на сервере.
Время устройства клиента не используется для определения срока действия.
2.3. Фиксация курса
Рыночный курс обновляется через API каждые 5 минут.
При создании платёжной ссылки система сохраняет снимок курса. Этот курс не изменяется в течение жизни ссылки, даже если текущий рыночный курс уже обновился.
2.4. Создание новой ссылки
После истечения срока действия:
-
старая ссылка получает статус
expired; -
старый QR-код становится недействующим;
-
старая сумма и курс сохраняются в истории;
-
обновить или продлить ссылку невозможно;
-
мерчант должен выполнить новый запрос на создание ссылки;
-
новая ссылка получает новый UUID, токен, номер поручения, курс, сумму и QR-код.
2.5. Оплата просроченной ссылки
Если клиент оплатил после истечения 15 минут, операция не исполняется автоматически.
Такая оплата получает статус:
payment_status = paid_late
review_status = required
После этого сотрудник CARA Pay принимает решение вручную.
3. Термины
Merchant
Организация или лицо, интегрированное с CARA Pay и создающее платёжные ссылки.
Payment Order
Основная сущность платёжной операции.
Один Payment Order соответствует одной платёжной ссылке и одному счёту-поручению.
Payment Link
Публичный URL, по которому клиент открывает страницу оплаты.
Пример:
https://pay.carapay.me/p/7K3M-9Q2D-X8FA
Public Token
Короткий случайный идентификатор, используемый в публичной ссылке.
Пример:
7K3M-9Q2D-X8FA
Instruction Number
Уникальный номер счёта-поручения.
Пример:
CP-M042-20260711-000123
Rate Snapshot
Неизменяемый снимок курса, использованный при формировании конкретной ссылки.
QR Payload
Точное текстовое содержимое, зашитое в QR-код.
Bank Transaction
Входящая банковская операция, полученная через API банка.
External Order ID
Номер заказа в системе самого мерчанта.
Пример:
ORDER-8711
4. Идентификаторы
Для каждой платёжной операции используются разные идентификаторы.
4.1. Внутренний UUID
Для внутренних связей используется UUIDv7.
Пример:
0198a6f2-7b91-7a21-9c15-1cb4b42f33e8
UUIDv7 применяется для:
-
первичного ключа;
-
связей между таблицами;
-
внутренних API;
-
журналов;
-
событий;
-
поиска;
-
технического аудита.
UUID не показывается клиенту в публичном интерфейсе.
4.2. Публичный токен
Публичный токен используется в URL.
Формат:
XXXX-XXXX-XXXX
Пример:
7K3M-9Q2D-X8FA
В базе токен хранится без дефисов:
7K3M9Q2DX8FA
В интерфейсе отображается с дефисами.
Рекомендуемый алгоритм:
-
получить криптографически стойкие случайные байты;
-
закодировать в Crockford Base32;
-
взять 12 символов;
-
проверить уникальность;
-
при конфликте повторить генерацию.
Токен должен:
-
быть случайным;
-
не содержать последовательный номер;
-
не содержать Merchant ID;
-
не раскрывать дату;
-
не позволять перебирать соседние ссылки;
-
иметь уникальный индекс в базе.
Ограничение:
UNIQUE(public_token)
Публичный токен не используется для определения мерчанта без обращения к базе.
Связь выглядит так:
public_token
↓
payment_order
↓
merchant_id
4.3. Merchant Code
Каждый мерчант получает короткий постоянный код.
Примеры:
M001
M042
M105
Merchant Code используется:
-
в номере счёта-поручения;
-
в админке;
-
в финансовых отчётах;
-
при поиске;
-
при ручной сверке.
Merchant Code не является секретом.
4.4. Номер счёта-поручения
Формат:
CP-{MERCHANT_CODE}-{YYYYMMDD}-{SEQUENCE}
Пример:
CP-M042-20260711-000123
Состав:
CP — CARA Pay / счёт-поручение
M042 — код мерчанта
20260711 — дата создания
000123 — порядковый номер операции
Порядковый номер формируется атомарно.
Рекомендуется использовать отдельную последовательность для каждого мерчанта и каждого календарного дня.
Уникальность:
UNIQUE(instruction_number)
Дополнительно:
UNIQUE(merchant_id, instruction_date, sequence_number)
5. Связь операции с мерчантом
Мерчант не передаёт собственный merchant_id при создании ссылки.
Мерчант определяется сервером по API-ключу.
API key
↓
merchant_api_keys
↓
merchant_id
↓
payment_orders.merchant_id
Это предотвращает создание ссылки от имени другого мерчанта.
Пример запроса:
POST /v1/payment-orders
Authorization: Bearer cp_live_xxxxxxxxx
Idempotency-Key: 6e08f18f-8baf-48be-b199-0ab7644d4048
Content-Type: application/json
Тело:
{
"external_order_id": "ORDER-8711",
"settlement_amount": "500.0000",
"settlement_currency": "USDT",
"customer_reference": "CLIENT-177"
}
Сервер самостоятельно определяет:
merchant_id
merchant_code
pricing_profile
bank_account
rate_source
payment purpose
instruction number
6. Idempotency
Для защиты от создания дубликатов каждый запрос должен содержать:
Idempotency-Key: UUID
Правило:
-
повторный запрос с тем же ключом возвращает ранее созданную ссылку;
-
повторный запрос не создаёт второй Payment Order;
-
для создания новой ссылки мерчант должен передать новый Idempotency-Key.
Ограничение:
UNIQUE(merchant_id, idempotency_key)
Idempotency-Key защищает от:
-
повторной отправки формы;
-
сетевого тайм-аута;
-
повторной попытки клиента API;
-
двойного клика;
-
повторной доставки запроса прокси-сервером.
Одинаковый external_order_id может использоваться в нескольких платёжных попытках.
Например:
ORDER-8711
├── попытка 1 — expired
├── попытка 2 — expired
└── попытка 3 — paid
Каждая попытка является отдельным Payment Order.
7. Работа с курсом
7.1. Сервис рыночных курсов
Отдельный Rate Service получает рыночные данные через API.
Период обновления:
каждые 5 минут
Сервис сохраняет:
-
источник;
-
валютную пару;
-
bid;
-
ask;
-
средний или выбранный курс;
-
время получения;
-
исходный ответ API;
-
статус источника;
-
время последнего успешного обновления.
Пример:
{
"pair": "USDT/RUB",
"source": "rate_provider_1",
"bid": "83.12000000",
"ask": "83.25000000",
"selected_market_rate": "83.25000000",
"fetched_at": "2026-07-11T15:10:00Z"
}
7.2. Проверка свежести курса
При создании ссылки нельзя использовать устаревший курс.
Пример правила:
rate_age <= 6 минут
Если курс старше допустимого значения или источник недоступен:
Payment Order не создаётся
API возвращает:
{
"error": "RATE_UNAVAILABLE",
"message": "Актуальный курс временно недоступен"
}
7.3. Расчёт клиентского курса
Пример:
market_rate = 83.2500
merchant_markup = 0.6000
client_rate = 83.8500
Формула:
client_rate = market_rate + merchant_markup
Либо:
client_rate = market_rate × markup_multiplier
Конкретная модель наценки хранится в тарифном профиле мерчанта.
7.4. Расчёт суммы
Пример:
settlement_amount = 500.0000 USDT
client_rate = 83.8500 RUB/USDT
payment_amount = 41 925.00 RUB
Формула:
payment_amount_rub =
round(settlement_amount_usdt × client_rate, 2)
Все вычисления выполняются через Decimal.
Запрещается использовать:
float
double
Рекомендуемые типы:
USDT amount: DECIMAL(24,8)
rate: DECIMAL(24,8)
RUB amount: DECIMAL(24,2)
Режим округления должен быть единым для всей системы:
ROUND_HALF_UP
7.5. Rate Snapshot
В момент создания ссылки сохраняются:
{
"rate_source": "rate_provider_1",
"market_rate": "83.25000000",
"merchant_markup": "0.60000000",
"client_rate": "83.85000000",
"source_fetched_at": "2026-07-11T15:10:00Z",
"snapshot_created_at": "2026-07-11T15:12:18Z"
}
Rate Snapshot после создания неизменяем.
8. Назначение платежа
Базовый формат:
Перечисление денежных средств по счёту-поручению № CP-M042-20260711-000123. Без НДС.
Короткий формат:
Счёт-поручение CP-M042-20260711-000123. Без НДС.
Рекомендуемый системный шаблон:
Счёт-поручение {INSTRUCTION_NUMBER}. {VAT_TEXT}
Где:
INSTRUCTION_NUMBER = CP-M042-20260711-000123
VAT_TEXT = Без НДС.
VAT_TEXT является настраиваемым полем и не должен быть навсегда захардкожен.
Для автоматической сверки главным элементом является:
CP-M042-20260711-000123
Пунктуация, символ №, регистр и пробелы не должны влиять на поиск номера поручения.
9. Генерация QR-кода
9.1. Содержимое QR
QR-код должен содержать:
-
банковские реквизиты получателя;
-
сумму в RUB;
-
назначение платежа;
-
идентификатор QR у банка или провайдера;
-
дополнительные обязательные банковские поля.
9.2. Что необходимо сохранять
Сохраняется не только PNG-изображение.
Обязательные поля:
qr_payload
qr_image
qr_image_sha256
qr_provider
qr_provider_reference
qr_format
created_at
expires_at
Пример:
{
"qr_payload": "ST00012|Name=...|PersonalAcc=...|Sum=4192500|Purpose=...",
"qr_provider": "bank_api",
"qr_provider_reference": "QR-82918271",
"qr_format": "BANK_DETAILS",
"qr_image_storage_key": "payment-qr/2026/07/7K3M9Q2DX8FA.png",
"qr_image_sha256": "f91b1d11d64a...",
"created_at": "2026-07-11T15:12:20Z",
"expires_at": "2026-07-11T15:27:18Z"
}
9.3. QR Payload
qr_payload является главным источником данных.
Он позволяет:
-
повторно воспроизвести QR;
-
проверить сумму;
-
проверить реквизиты;
-
проверить назначение;
-
доказать, какие данные были переданы клиенту;
-
сравнить QR с банковской операцией.
9.4. QR Image Hash
После генерации изображения рассчитывается:
SHA-256(qr_image)
Хеш используется для проверки неизменности сохранённого изображения.
9.5. Неизменяемость QR
После публикации ссылки запрещается:
-
генерировать новый QR для того же Payment Order;
-
менять сумму внутри QR;
-
менять назначение;
-
менять банковские реквизиты;
-
продлевать срок действия QR.
Для нового QR создаётся новый Payment Order.
10. Алгоритм создания платёжной ссылки
10.1. Входные данные
Мерчант передаёт:
{
"external_order_id": "ORDER-8711",
"settlement_amount": "500.0000",
"settlement_currency": "USDT",
"customer_reference": "CLIENT-177"
}
10.2. Алгоритм
1. Проверить API-ключ.
2. Определить merchant_id.
3. Проверить статус мерчанта:
- active;
- API-доступ разрешён;
- тарифный профиль существует;
- банковский маршрут доступен.
4. Проверить Idempotency-Key.
5. Если такой ключ уже использован:
вернуть ранее созданный Payment Order.
6. Провалидировать сумму и валюту.
7. Получить последний Rate Snapshot.
8. Проверить свежесть курса.
9. Рассчитать client_rate.
10. Рассчитать фиксированную сумму в RUB.
11. Получить следующий порядковый номер мерчанта.
12. Сформировать instruction_number.
13. Сгенерировать UUIDv7.
14. Сгенерировать public_token.
15. Сформировать назначение платежа.
16. Установить:
created_at = server_now
expires_at = created_at + 15 минут
17. Сформировать QR payload.
18. Получить или создать QR через банковский API.
19. Сохранить QR payload.
20. Сохранить изображение QR.
21. Рассчитать SHA-256 изображения.
22. Создать Payment Order.
23. Записать событие payment_order.created.
24. Вернуть мерчанту готовую платёжную ссылку.
10.3. Атомарность
Операция должна быть транзакционной.
Платёжная ссылка публикуется только после того, как успешно созданы:
-
Payment Order;
-
Rate Snapshot;
-
номер поручения;
-
назначение платежа;
-
QR payload;
-
QR image.
Если QR не удалось получить, ссылка не должна становиться публично доступной.
Допускается технический статус:
creation_status = creating
creation_status = ready
creation_status = failed
Клиенту доступны только записи:
creation_status = ready
Пропуски в порядковых номерах допустимы.
11. Ответ API при создании ссылки
{
"id": "0198a6f2-7b91-7a21-9c15-1cb4b42f33e8",
"public_token": "7K3M9Q2DX8FA",
"public_code": "7K3M-9Q2D-X8FA",
"instruction_number": "CP-M042-20260711-000123",
"external_order_id": "ORDER-8711",
"settlement_amount": "500.0000",
"settlement_currency": "USDT",
"payment_amount": "41925.00",
"payment_currency": "RUB",
"client_rate": "83.85000000",
"payment_url": "https://pay.carapay.me/p/7K3M-9Q2D-X8FA",
"created_at": "2026-07-11T15:12:18Z",
"expires_at": "2026-07-11T15:27:18Z",
"link_status": "active",
"payment_status": "pending"
}
12. Публичная страница оплаты
Страница должна отображать:
Мерчант
Номер счёта-поручения
Сумма в RUB
Количество USDT
Зафиксированный курс
Назначение платежа
QR-код
Банковские реквизиты
Время окончания действия
Текущий статус
Пример:
К оплате:
41 925.00 RUB
Сумма поручения:
500.0000 USDT
Зафиксированный курс:
83.8500 RUB за 1 USDT
Счёт-поручение:
CP-M042-20260711-000123
Ссылка действует до:
23:27:18
На странице отображается обратный отсчёт.
Обратный отсчёт строится от серверного expires_at.
После истечения:
Срок действия платёжной ссылки истёк.
Не оплачивайте ранее сформированный QR-код.
Для оплаты запросите новую ссылку у продавца.
На просроченной странице запрещено размещать:
-
«Обновить курс»;
-
«Продлить»;
-
«Создать новый QR»;
-
«Пересчитать сумму».
Можно разместить:
Вернуться к мерчанту
13. Получение операций из банка
Отдельный Bank Integration Service обращается к API банка и получает входящие операции.
Рекомендуемая частота:
каждые 30–60 секунд
Если банк поддерживает webhook, webhook используется совместно с периодической сверкой.
По каждой операции сохраняются:
bank_transaction_id
bank_account_id
direction
amount
currency
raw_purpose
normalized_purpose
payer_name
payer_account
bank_operation_at
bank_booked_at
detected_at
raw_bank_response
Пример:
{
"bank_transaction_id": "BANK-839182721",
"bank_account_id": "rub-account-1",
"direction": "credit",
"amount": "41925.00",
"currency": "RUB",
"raw_purpose": "Счет поручение CP-M042-20260711-000123 без НДС",
"normalized_purpose": "СЧЕТ ПОРУЧЕНИЕ CP-M042-20260711-000123 БЕЗ НДС",
"payer_name": "Иванов Иван Иванович",
"payer_account": "40817...",
"bank_operation_at": "2026-07-11T15:24:42Z",
"detected_at": "2026-07-11T15:26:10Z"
}
Ограничение:
UNIQUE(bank_account_id, bank_transaction_id)
Одна банковская транзакция не может быть засчитана дважды.
14. Нормализация назначения платежа
Перед поиском назначения система:
-
приводит текст к верхнему регистру;
-
заменяет
ЁнаЕ; -
приводит длинные тире к обычному дефису;
-
удаляет лишнюю пунктуацию;
-
объединяет повторяющиеся пробелы;
-
ищет номер поручения по шаблону.
Регулярное выражение:
CP-M[0-9]{3,6}-[0-9]{8}-[0-9]{6}
Поиск instruction number имеет приоритет над полным сравнением назначения.
Примеры должны считаться эквивалентными:
Счёт-поручение № CP-M042-20260711-000123. Без НДС.
СЧЕТ ПОРУЧЕНИЕ CP-M042-20260711-000123 БЕЗ НДС
Оплата по счету CP-M042-20260711-000123
15. Алгоритм автоматической сверки
15.1. Основные критерии
Автоматическая сверка использует:
-
банковский счёт получателя;
-
входящее направление операции;
-
валюту RUB;
-
номер счёта-поручения;
-
точную сумму;
-
время операции;
-
отсутствие предыдущей привязки банковской операции.
15.2. Основной алгоритм
1. Получить новую банковскую транзакцию.
2. Проверить уникальность bank_transaction_id.
3. Проверить:
direction = credit
currency = RUB
4. Извлечь instruction_number из назначения.
5. Найти Payment Order по instruction_number.
6. Если Payment Order найден:
сравнить сумму;
сравнить время;
проверить, не оплачен ли Order ранее.
7. Если номер поручения не найден:
найти кандидатов по:
- сумме;
- банковскому счёту;
- временному диапазону;
- статусу pending.
8. Определить результат матчинга.
9. Сохранить Payment Match.
10. Обновить payment_status.
11. Добавить событие в payment_events.
12. Отправить webhook мерчанту.
16. Правила определения результата платежа
16.1. Точная своевременная оплата
Условия:
instruction_number совпадает
amount совпадает
bank_operation_at <= expires_at
bank_operation_at >= created_at
Результат:
payment_status = paid
review_status = not_required
execution_status = ready
Важно:
Платёж считается своевременным по времени операции банка, а не по времени, когда CARA Pay увидел транзакцию.
Пример:
Ссылка истекла: 23:25:00
Клиент оплатил: 23:24:42
Банк отдал операцию: 23:26:10
Результат:
paid
16.2. Оплата после истечения
Условия:
instruction_number совпадает
amount совпадает
bank_operation_at > expires_at
Результат:
payment_status = paid_late
review_status = required
execution_status = not_started
16.3. Недоплата
Условия:
instruction_number совпадает
amount < expected_amount
Результат:
payment_status = underpaid
review_status = required
16.4. Переплата
Условия:
instruction_number совпадает
amount > expected_amount
Результат:
payment_status = overpaid
review_status = required
16.5. Неверное назначение
Если сумма совпадает, но номер поручения отсутствует или отличается:
payment_status = purpose_mismatch
review_status = required
Автоматическая привязка допускается только как кандидат, но не как окончательно подтверждённый платёж.
16.6. Неоднозначный платёж
Если одна банковская транзакция подходит сразу к нескольким операциям:
payment_status = ambiguous_match
review_status = required
16.7. Повторная оплата
Если Payment Order уже оплачен, а по тому же номеру приходит ещё одна операция:
payment_status текущего Order не изменяется
новая транзакция → manual review
16.8. Частичные платежи
Автоматическое исполнение нескольких частичных платежей не допускается.
Даже если сумма нескольких операций равна сумме поручения:
review_status = required
17. Статусы
Для предотвращения противоречий используются отдельные группы статусов.
17.1. Creation Status
creating
ready
failed
creating
Ссылка находится в процессе формирования.
ready
Все обязательные данные и QR успешно созданы.
failed
Ссылка не была сформирована.
17.2. Link Status
active
expired
cancelled
active
Ссылка доступна для оплаты.
Условия:
now < expires_at
payment_status = pending
expired
15 минут истекли, своевременная оплата не найдена.
cancelled
Ссылка отменена мерчантом или сотрудником до оплаты.
Переход обратно в active запрещён.
17.3. Payment Status
pending
detected
paid
paid_late
underpaid
overpaid
purpose_mismatch
ambiguous_match
refunded
pending
Платёж не найден.
detected
Банковская транзакция найдена, но обработка ещё не завершена.
paid
Точный платёж поступил своевременно.
paid_late
Платёж совершён после истечения ссылки.
underpaid
Получена меньшая сумма.
overpaid
Получена большая сумма.
purpose_mismatch
Назначение не соответствует Payment Order.
ambiguous_match
Однозначно определить Payment Order невозможно.
refunded
Деньги возвращены плательщику.
17.4. Review Status
not_required
required
approved
rejected
not_required
Ручная проверка не нужна.
required
Операция требует решения сотрудника.
approved
Сотрудник разрешил принять нестандартный платёж.
rejected
Платёж не принят к исполнению.
17.5. Execution Status
not_started
ready
processing
completed
failed
cancelled
not_started
Исполнение поручения не начиналось.
ready
Оплата подтверждена, поручение можно исполнять.
processing
Поручение исполняется.
completed
Поручение полностью исполнено.
failed
При исполнении возникла ошибка.
cancelled
Исполнение отменено.
18. Допустимые переходы
Link Status
active → expired
active → cancelled
Запрещено:
expired → active
cancelled → active
expired → продление
Payment Status
pending → detected
detected → paid
detected → paid_late
detected → underpaid
detected → overpaid
detected → purpose_mismatch
detected → ambiguous_match
paid → refunded
paid_late → refunded
overpaid → refunded
underpaid → refunded
Review Status
required → approved
required → rejected
Execution Status
not_started → ready
ready → processing
processing → completed
processing → failed
ready → cancelled
Переход в ready разрешается, если:
payment_status = paid
или:
review_status = approved
19. Производный статус для админки
Общий статус можно рассчитывать, но не обязательно хранить отдельно.
Приоритет:
1. manual_review
2. completed
3. processing
4. paid
5. awaiting_payment
6. expired
7. cancelled
8. failed
Примеры:
link_status = active
payment_status = pending
→ awaiting_payment
payment_status = paid
execution_status = ready
→ paid
payment_status = paid_late
review_status = required
→ manual_review
execution_status = completed
→ completed
20. Автоматическое истечение ссылок
Проверка должна выполняться двумя способами.
20.1. Планировщик
Фоновая задача запускается не реже одного раза в минуту:
найти active Payment Orders
где expires_at <= now
и payment_status = pending
Затем:
link_status = expired
20.2. Проверка при чтении
При каждом открытии ссылки сервер дополнительно проверяет:
if now >= expires_at and link_status = active:
expire order
Это защищает от задержек планировщика.
Операция истечения должна быть идемпотентной.
21. Структура базы данных
21.1. merchants
id UUIDv7 PK
merchant_code VARCHAR UNIQUE
name VARCHAR
legal_name VARCHAR
status ENUM
pricing_profile_id UUID
default_bank_account_id UUID
created_at TIMESTAMP
updated_at TIMESTAMP
21.2. merchant_api_keys
id UUIDv7 PK
merchant_id UUID FK
key_prefix VARCHAR
key_hash VARCHAR
status ENUM
created_at TIMESTAMP
expires_at TIMESTAMP NULL
last_used_at TIMESTAMP NULL
revoked_at TIMESTAMP NULL
API-ключ хранится только в виде хеша.
21.3. merchant_sequences
merchant_id UUID
sequence_date DATE
last_value BIGINT
updated_at TIMESTAMP
Ограничение:
PRIMARY KEY(merchant_id, sequence_date)
21.4. payment_orders
id UUIDv7 PK
merchant_id UUID FK
created_by_type ENUM
created_by_id UUID NULL
merchant_api_key_id UUID NULL
public_token VARCHAR UNIQUE
instruction_number VARCHAR UNIQUE
instruction_date DATE
sequence_number BIGINT
external_order_id VARCHAR NULL
customer_reference VARCHAR NULL
idempotency_key VARCHAR
settlement_amount DECIMAL(24,8)
settlement_currency VARCHAR
payment_amount DECIMAL(24,2)
payment_currency VARCHAR
payment_purpose TEXT
vat_text VARCHAR
bank_account_id UUID FK
rate_snapshot_id UUID FK
qr_code_id UUID FK
creation_status ENUM
link_status ENUM
payment_status ENUM
review_status ENUM
execution_status ENUM
created_at TIMESTAMP
expires_at TIMESTAMP
cancelled_at TIMESTAMP NULL
paid_at TIMESTAMP NULL
completed_at TIMESTAMP NULL
Ограничения:
UNIQUE(merchant_id, idempotency_key)
UNIQUE(instruction_number)
UNIQUE(public_token)
21.5. rate_snapshots
id UUIDv7 PK
pair VARCHAR
source VARCHAR
bid DECIMAL(24,8)
ask DECIMAL(24,8)
market_rate DECIMAL(24,8)
merchant_markup DECIMAL(24,8)
client_rate DECIMAL(24,8)
source_fetched_at TIMESTAMP
created_at TIMESTAMP
raw_response JSONB
21.6. payment_qr_codes
id UUIDv7 PK
payment_order_id UUID UNIQUE FK
provider VARCHAR
provider_reference VARCHAR NULL
format VARCHAR
payload TEXT
image_storage_key VARCHAR
image_sha256 VARCHAR
amount DECIMAL(24,2)
currency VARCHAR
payment_purpose TEXT
created_at TIMESTAMP
expires_at TIMESTAMP
raw_provider_response JSONB
21.7. bank_accounts
id UUIDv7 PK
owner_legal_entity_id UUID
bank_name VARCHAR
account_number_masked VARCHAR
currency VARCHAR
status ENUM
created_at TIMESTAMP
21.8. bank_transactions
id UUIDv7 PK
bank_account_id UUID FK
external_transaction_id VARCHAR
direction ENUM
amount DECIMAL(24,2)
currency VARCHAR
raw_purpose TEXT
normalized_purpose TEXT
extracted_instruction_number VARCHAR NULL
payer_name VARCHAR NULL
payer_account_masked VARCHAR NULL
bank_operation_at TIMESTAMP
bank_booked_at TIMESTAMP NULL
detected_at TIMESTAMP
raw_response JSONB
created_at TIMESTAMP
Ограничение:
UNIQUE(bank_account_id, external_transaction_id)
21.9. payment_matches
id UUIDv7 PK
payment_order_id UUID FK
bank_transaction_id UUID FK
match_type ENUM
match_score DECIMAL NULL
amount_match BOOLEAN
purpose_match BOOLEAN
time_match BOOLEAN
matched_automatically BOOLEAN
matched_by_user_id UUID NULL
status ENUM
created_at TIMESTAMP
reviewed_at TIMESTAMP NULL
21.10. payment_events
id UUIDv7 PK
payment_order_id UUID FK
event_type VARCHAR
actor_type ENUM
actor_id UUID NULL
old_value JSONB NULL
new_value JSONB NULL
metadata JSONB NULL
created_at TIMESTAMP
История событий не изменяется и не удаляется обычными пользователями.
22. API мерчанта
22.1. Создать ссылку
POST /v1/payment-orders
22.2. Получить операцию
GET /v1/payment-orders/{id}
22.3. Получить операцию по внешнему заказу
GET /v1/payment-orders?external_order_id=ORDER-8711
Может вернуть несколько попыток.
22.4. Отменить активную ссылку
POST /v1/payment-orders/{id}/cancel
Разрешено только если:
link_status = active
payment_status = pending
22.5. Запрещённые методы
Не должны существовать:
PATCH /v1/payment-orders/{id}/amount
PATCH /v1/payment-orders/{id}/rate
PATCH /v1/payment-orders/{id}/expires-at
POST /v1/payment-orders/{id}/refresh
POST /v1/payment-orders/{id}/regenerate-qr
POST /v1/payment-orders/{id}/extend
23. Webhooks мерчанту
Рекомендуемые события:
payment_order.created
payment_order.expired
payment_order.cancelled
payment.detected
payment.paid
payment.paid_late
payment.underpaid
payment.overpaid
payment.manual_review
execution.processing
execution.completed
execution.failed
payment.refunded
Пример:
{
"event_id": "0198...",
"event_type": "payment.paid",
"created_at": "2026-07-11T15:26:10Z",
"data": {
"payment_order_id": "0198...",
"external_order_id": "ORDER-8711",
"instruction_number": "CP-M042-20260711-000123",
"payment_amount": "41925.00",
"payment_currency": "RUB",
"payment_status": "paid"
}
}
Webhook подписывается HMAC.
Заголовки:
CARA-Event-ID: 0198...
CARA-Timestamp: 1783783570
CARA-Signature: sha256=...
Повторная доставка одного события должна иметь тот же event_id.
Мерчант должен обрабатывать webhook идемпотентно.
24. Административная панель
24.1. Список операций
Необходимые столбцы:
Дата создания
Номер счёта-поручения
Публичный код
Мерчант
External Order ID
Сумма USDT
Курс
Сумма RUB
Срок действия
Статус ссылки
Статус оплаты
Статус проверки
Статус исполнения
Банковская операция
24.2. Фильтры
Мерчант
Merchant Code
Instruction Number
Public Token
External Order ID
Дата создания
Дата оплаты
Link Status
Payment Status
Review Status
Execution Status
Сумма RUB
Сумма USDT
Банковский Transaction ID
24.3. Карточка операции
Карточка должна содержать:
Основные данные
Payment Order UUID
Public Token
Instruction Number
Merchant
Merchant Code
External Order ID
Idempotency-Key
Дата создания
Дата окончания
Финансовые данные
USDT amount
Market rate
Markup
Client rate
RUB amount
Rate source
Rate fetched at
Платёжные данные
Банковский счёт
Назначение платежа
QR payload
QR image
QR SHA-256
QR provider reference
Банковские данные
Bank Transaction ID
Сумма
Назначение
Плательщик
Время операции
Время обнаружения
Результат сверки
История
Все изменения статусов
Все автоматические события
Все ручные действия
Все webhooks
24.4. Ручные действия
Допустимые действия:
Отменить активную неоплаченную ссылку
Подтвердить поздний платёж
Отклонить поздний платёж
Подтвердить недоплату
Подтвердить переплату
Связать банковскую транзакцию вручную
Отклонить ошибочный match
Зафиксировать возврат
Запустить исполнение
Завершить исполнение
Отметить ошибку исполнения
Каждое действие должно сохранять:
user_id
роль пользователя
дата и время
старое состояние
новое состояние
комментарий
IP-адрес
25. Безопасность
25.1. API-ключи
API-ключ:
-
показывается мерчанту только один раз;
-
хранится в базе в виде хеша;
-
имеет публичный prefix;
-
может быть отозван;
-
может быть заменён;
-
имеет дату последнего использования.
25.2. Доступ к данным
Мерчант видит только собственные Payment Orders.
Проверка должна выполняться на backend:
payment_order.merchant_id = authenticated_merchant.id
Нельзя полагаться только на фильтрацию frontend.
25.3. Public Token
Public Token:
-
случайный;
-
непоследовательный;
-
достаточно длинный;
-
защищён rate limiting;
-
не раскрывает внутренний UUID;
-
не раскрывает Merchant ID напрямую.
25.4. Rate Limiting
Рекомендуемые ограничения:
API создания ссылок — по merchant_id
Публичная страница — по IP и public_token
Поиск ссылок — только после авторизации
Банковские endpoints — только внутренний контур
25.5. QR Storage
QR-коды хранятся в закрытом object storage.
Публичный доступ осуществляется через:
-
backend proxy;
-
временные signed URL;
-
авторизованный запрос.
25.6. Журнал аудита
Запрещено физически удалять:
-
Payment Order;
-
Rate Snapshot;
-
QR payload;
-
Bank Transaction;
-
Payment Match;
-
Payment Event.
Допускается архивирование согласно утверждённой политике хранения данных.
26. Конкурентность и защита от гонок
26.1. Генерация номера
Следующий sequence_number получается внутри транзакции с блокировкой строки последовательности.
26.2. Двойной банковский match
При привязке банковской операции используется транзакция базы и блокировка:
SELECT ... FOR UPDATE
Перед подтверждением проверяется:
bank_transaction не связан
payment_order не имеет принятого платежа
26.3. Одновременное истечение и поступление оплаты
При обработке банковской транзакции система использует bank_operation_at.
Даже если планировщик уже поставил ссылке expired, своевременный банковский платёж может перевести:
payment_status → paid
При этом link_status может оставаться expired, поскольку сама ссылка уже закончила действие.
Общий статус в интерфейсе будет paid.
27. Ошибки API
Рекомендуемые коды:
INVALID_API_KEY
MERCHANT_INACTIVE
MERCHANT_NOT_CONFIGURED
INVALID_AMOUNT
INVALID_CURRENCY
RATE_UNAVAILABLE
RATE_STALE
BANK_ROUTE_UNAVAILABLE
QR_GENERATION_FAILED
IDEMPOTENCY_CONFLICT
PAYMENT_ORDER_NOT_FOUND
PAYMENT_ORDER_EXPIRED
PAYMENT_ORDER_ALREADY_PAID
PAYMENT_ORDER_CANNOT_BE_CANCELLED
INTERNAL_ERROR
Пример:
{
"error": "RATE_STALE",
"message": "Последний курс устарел. Создание платёжной ссылки временно недоступно.",
"request_id": "req_0198..."
}
28. Логирование и мониторинг
Необходимо отслеживать:
Количество созданных ссылок
Количество ошибок создания
Доступность Rate API
Возраст последнего курса
Доступность Bank API
Время задержки обнаружения платежа
Количество paid
Количество paid_late
Количество underpaid
Количество overpaid
Количество purpose_mismatch
Количество ambiguous_match
Количество manual review
Количество истёкших ссылок
Ошибки webhook
Время генерации QR
Критические алерты:
Нет актуального курса
Bank API недоступен
QR provider недоступен
Резкий рост manual review
Резкий рост purpose mismatch
Платежи не обнаруживаются
Webhook queue растёт
Каждый запрос получает:
request_id
trace_id
29. Сценарии
Сценарий 1. Успешная оплата
23:10 — ссылка создана
23:10 — курс зафиксирован
23:10 — QR создан
23:24 — клиент оплатил
23:26 — банк передал операцию
Результат:
link_status = expired или active в зависимости от момента обработки
payment_status = paid
review_status = not_required
execution_status = ready
Сценарий 2. Ссылка истекла без оплаты
23:10 — ссылка создана
23:25 — срок истёк
Результат:
link_status = expired
payment_status = pending
execution_status = not_started
Для оплаты мерчант создаёт новую ссылку.
Сценарий 3. Поздняя оплата
23:25 — ссылка истекла
23:28 — клиент оплатил
Результат:
payment_status = paid_late
review_status = required
Сценарий 4. Новая попытка оплаты
ORDER-8711 / попытка 1
CP-M042-20260711-000123
status = expired
ORDER-8711 / попытка 2
CP-M042-20260711-000124
status = paid
Это две независимые операции.
Сценарий 5. Недоплата
Ожидалось:
41 925.00 RUB
Получено:
41 900.00 RUB
Результат:
payment_status = underpaid
review_status = required
Сценарий 6. Правильная сумма без назначения
Сумма совпадает, но instruction number отсутствует.
Результат:
payment_status = purpose_mismatch
review_status = required
30. Инварианты системы
Следующие условия должны выполняться всегда:
Один Payment Order принадлежит только одному мерчанту.
Один Payment Order имеет только один public_token.
Один Payment Order имеет только один instruction_number.
Один Payment Order имеет только один Rate Snapshot.
Один Payment Order имеет только один QR-код.
Сумма и курс после создания не изменяются.
expires_at всегда равен created_at + 15 минут.
Просроченную ссылку нельзя продлить.
Истёкшую ссылку нельзя активировать повторно.
Новому курсу всегда соответствует новый Payment Order.
Одна банковская транзакция не может быть принята для двух Payment Orders.
Исполнение не начинается без подтверждённой оплаты или решения ручной проверки.
Все ручные действия записываются в аудит.
31. Критерии готовности
Система считается реализованной корректно, если:
-
мерчант может создать платёжную ссылку через API;
-
мерчант определяется по API-ключу;
-
повторный запрос с тем же Idempotency-Key не создаёт дубликат;
-
курс берётся из актуального Rate Snapshot;
-
сумма в RUB фиксируется при создании;
-
ссылка действует 15 минут;
-
ссылка не может быть обновлена или продлена;
-
QR payload и изображение сохраняются;
-
сохраняется SHA-256 QR-изображения;
-
назначение содержит уникальный instruction number;
-
банковские операции загружаются через API;
-
точная оплата сопоставляется автоматически;
-
поздняя оплата направляется на ручную проверку;
-
недоплата и переплата определяются;
-
одна банковская транзакция не засчитывается дважды;
-
в админке виден мерчант, создавший ссылку;
-
в админке видны все статусы;
-
сохраняется полная история действий;
-
после истечения клиент не может обновить ссылку;
-
для новой оплаты создаётся новый Payment Order.
32. Итоговая схема
Merchant
│
├── Merchant Code
├── API Key
└── Pricing Profile
│
▼
POST /payment-orders
│
├── Проверка Idempotency-Key
├── Получение актуального курса
├── Фиксация Rate Snapshot
├── Расчёт RUB
├── Генерация UUIDv7
├── Генерация Public Token
├── Генерация Instruction Number
├── Формирование назначения
├── Генерация QR Payload
├── Сохранение QR Image + SHA-256
└── expires_at = created_at + 15 минут
│
▼
Payment Link
│
▼
Клиент оплачивает
│
▼
Bank API
│
├── Получение транзакции
├── Извлечение Instruction Number
├── Проверка суммы
├── Проверка времени
└── Проверка уникальности
│
┌────────────────┼─────────────────┐
▼ ▼ ▼
paid paid_late under/overpaid
│ │ │
▼ ▼ ▼
execution ready manual review manual review
33. Финальный стандарт CARA Pay
Публичная ссылка:
https://pay.carapay.me/p/7K3M-9Q2D-X8FA
Public Token:
7K3M9Q2DX8FA
Счёт-поручение:
CP-M042-20260711-000123
Назначение:
Счёт-поручение CP-M042-20260711-000123. Без НДС.
Срок действия:
15 минут
Курс:
фиксируется при создании
Сумма:
фиксируется при создании
QR:
один, неизменяемый
Истекла ссылка:
создаётся новый Payment Order
Поздняя оплата:
ручная проверка
Связь с мерчантом:
payment_orders.merchant_id
Поиск оплаты:
instruction number + сумма + время + банковский счёт
Основной принцип:
одна ссылка — одна неизменяемая платёжная операция