Архитектура платёжных ссылок и счетов-поручений

CARA Pay

Архитектура платёжных ссылок и счетов-поручений

Версия: 1.0
Статус: базовая архитектурная спецификация
Компонент: Merchant Payment Links
Основная валюта оплаты: RUB
Расчётная валюта: USDT
Срок действия платёжной ссылки: 15 минут


1. Назначение системы

Система CARA Pay предоставляет мерчантам возможность создавать одноразовые платёжные ссылки для приёма оплаты в рублях на банковский счёт ИП.

Каждая платёжная ссылка:

  1. создаётся конкретным мерчантом;

  2. содержит уникальный счёт-поручение;

  3. получает фиксированный курс RUB/USDT;

  4. получает фиксированную сумму в RUB;

  5. содержит уникальное назначение платежа;

  6. содержит индивидуальный QR-код;

  7. действует 15 минут;

  8. не может быть изменена или продлена после создания;

  9. автоматически сверяется с входящими банковскими операциями;

  10. сохраняется в системе вместе со всеми исходными данными и историей статусов.

Основной принцип:

Одна платёжная ссылка
=
один Payment Order
=
один счёт-поручение
=
одна котировка
=
одна сумма
=
один QR-код

После истечения ссылки она не обновляется.

Для оплаты по новому курсу мерчант создаёт полностью новую платёжную ссылку.


2. Основные бизнес-правила

2.1. Неизменяемость платёжной ссылки

После успешного создания платёжной ссылки запрещается изменять:

Разрешается изменять только операционные статусы и добавлять сведения, появившиеся после создания:

2.2. Срок действия

Каждая ссылка действует ровно 15 минут:

expires_at = created_at + 15 минут

Время рассчитывается только на сервере.

Время устройства клиента не используется для определения срока действия.

2.3. Фиксация курса

Рыночный курс обновляется через API каждые 5 минут.

При создании платёжной ссылки система сохраняет снимок курса. Этот курс не изменяется в течение жизни ссылки, даже если текущий рыночный курс уже обновился.

2.4. Создание новой ссылки

После истечения срока действия:

2.5. Оплата просроченной ссылки

Если клиент оплатил после истечения 15 минут, операция не исполняется автоматически.

Такая оплата получает статус:

payment_status = paid_late
review_status = required

После этого сотрудник CARA Pay принимает решение вручную.


3. Термины

Merchant

Организация или лицо, интегрированное с CARA Pay и создающее платёжные ссылки.

Payment Order

Основная сущность платёжной операции.

Один Payment Order соответствует одной платёжной ссылке и одному счёту-поручению.

Публичный 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 применяется для:

UUID не показывается клиенту в публичном интерфейсе.

4.2. Публичный токен

Публичный токен используется в URL.

Формат:

XXXX-XXXX-XXXX

Пример:

7K3M-9Q2D-X8FA

В базе токен хранится без дефисов:

7K3M9Q2DX8FA

В интерфейсе отображается с дефисами.

Рекомендуемый алгоритм:

  1. получить криптографически стойкие случайные байты;

  2. закодировать в Crockford Base32;

  3. взять 12 символов;

  4. проверить уникальность;

  5. при конфликте повторить генерацию.

Токен должен:

Ограничение:

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

Правило:

Ограничение:

UNIQUE(merchant_id, idempotency_key)

Idempotency-Key защищает от:

Одинаковый external_order_id может использоваться в нескольких платёжных попытках.

Например:

ORDER-8711
├── попытка 1 — expired
├── попытка 2 — expired
└── попытка 3 — paid

Каждая попытка является отдельным Payment Order.


7. Работа с курсом

7.1. Сервис рыночных курсов

Отдельный Rate Service получает рыночные данные через API.

Период обновления:

каждые 5 минут

Сервис сохраняет:

Пример:

{
  "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-код должен содержать:

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 является главным источником данных.

Он позволяет:

9.4. QR Image Hash

После генерации изображения рассчитывается:

SHA-256(qr_image)

Хеш используется для проверки неизменности сохранённого изображения.

9.5. Неизменяемость 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. Атомарность

Операция должна быть транзакционной.

Платёжная ссылка публикуется только после того, как успешно созданы:

Если 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-код.
Для оплаты запросите новую ссылку у продавца.

На просроченной странице запрещено размещать:

Можно разместить:

Вернуться к мерчанту

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. Нормализация назначения платежа

Перед поиском назначения система:

  1. приводит текст к верхнему регистру;

  2. заменяет Ё на Е;

  3. приводит длинные тире к обычному дефису;

  4. удаляет лишнюю пунктуацию;

  5. объединяет повторяющиеся пробелы;

  6. ищет номер поручения по шаблону.

Регулярное выражение:

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. Основные критерии

Автоматическая сверка использует:

  1. банковский счёт получателя;

  2. входящее направление операции;

  3. валюту RUB;

  4. номер счёта-поручения;

  5. точную сумму;

  6. время операции;

  7. отсутствие предыдущей привязки банковской операции.

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

Банковская транзакция найдена, но обработка ещё не завершена.

Точный платёж поступил своевременно.

Платёж совершён после истечения ссылки.

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. Допустимые переходы

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-ключ:

25.2. Доступ к данным

Мерчант видит только собственные Payment Orders.

Проверка должна выполняться на backend:

payment_order.merchant_id = authenticated_merchant.id

Нельзя полагаться только на фильтрацию frontend.

25.3. Public Token

Public Token:

25.4. Rate Limiting

Рекомендуемые ограничения:

API создания ссылок — по merchant_id
Публичная страница — по IP и public_token
Поиск ссылок — только после авторизации
Банковские endpoints — только внутренний контур

25.5. QR Storage

QR-коды хранятся в закрытом object storage.

Публичный доступ осуществляется через:

25.6. Журнал аудита

Запрещено физически удалять:

Допускается архивирование согласно утверждённой политике хранения данных.


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. Критерии готовности

Система считается реализованной корректно, если:

  1. мерчант может создать платёжную ссылку через API;

  2. мерчант определяется по API-ключу;

  3. повторный запрос с тем же Idempotency-Key не создаёт дубликат;

  4. курс берётся из актуального Rate Snapshot;

  5. сумма в RUB фиксируется при создании;

  6. ссылка действует 15 минут;

  7. ссылка не может быть обновлена или продлена;

  8. QR payload и изображение сохраняются;

  9. сохраняется SHA-256 QR-изображения;

  10. назначение содержит уникальный instruction number;

  11. банковские операции загружаются через API;

  12. точная оплата сопоставляется автоматически;

  13. поздняя оплата направляется на ручную проверку;

  14. недоплата и переплата определяются;

  15. одна банковская транзакция не засчитывается дважды;

  16. в админке виден мерчант, создавший ссылку;

  17. в админке видны все статусы;

  18. сохраняется полная история действий;

  19. после истечения клиент не может обновить ссылку;

  20. для новой оплаты создаётся новый 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 + сумма + время + банковский счёт

Основной принцип:
одна ссылка — одна неизменяемая платёжная операция