# Эквайринг Сбербанка

В проекте есть **две независимые интеграции** со Сбером:

1. **Классический интернет-эквайринг** (REST API `ecommerce.sberbank.ru`) — оплата банковской картой через платёжную форму Сбера.
2. **PlatiQR / СБП** (REST API `mc.api.sberbank.ru` + OAuth + mTLS) — оплата по QR через СБП.

Они никак не связаны: разные клиенты, разные креды, разные точки входа. Мерчант в `saleDepartment` влияет только на эквайринг — для СБП-QR креды захардкожены в коде, мерчант общий для всех площадок.

## 1. Классический эквайринг (карты)

### Клиент: `models/SberPaymentApi.php`

Тонкая обёртка над Guzzle, никаких внешних SDK не используется (хотя в `composer.json` подключён `voronkovich/sberbank-acquiring-client` — он мёртвый, есть только `use`-импорт в `Purchase.php:11`).

- `createPayment()` → `POST https://ecommerce.sberbank.ru/ecomm/gw/partner/api/v1/register.do`, авторизация `userName/password` (логин/пароль ТСП), возвращает `{orderId, formUrl}`.
- `getStatus()` → `getOrderStatusExtended.do` по `orderId`.
- Никакого dev/test-режима — всегда стучится в прод-хост.

Особенности:
- `orderNumber` = `"{publicId}-{time()}"` (`SberPaymentApi.php:51`). При повторной попытке оплаты создаётся новый orderNumber → в Сбере появляется новый заказ, в `bankTransaction` остаётся «висящая» строка со старым `bankId`.
- Если email пустой — подставляется `test@test.com` и уходит в Сбер (`SberPaymentApi.php:46`).
- `'verify' => false` — отключена проверка TLS-сертификата сервера. Поскольку mTLS здесь не используется (только логин/пароль), это безопасно отключать, но как практика — плохо.

### Где вызывается

| Файл | Точка | Что делает |
|---|---|---|
| `modules/admin/models/Purchase.php:501` | `createSberPayment()` | Создаёт API-клиента с `bankLogin/bankPassword` из `saleDepartment` покупки, дёргает `createPayment()` |
| `controllers/PaymentController.php:74` | `actionPaySber($secret, $model)` | Зовёт `createSberPayment`, пишет в `bankTransaction` (vendor=`sber`, status=`0`), редиректит пользователя на `formUrl` |
| `controllers/PaymentController.php:152` | `actionPay($secret)` | Роутер: если `saleDepartment.bank === 'sber'` → `actionPaySber` |
| `controllers/PaymentController.php:97` | `actionSberSuccess($orderId)` | Возврат с успешной оплаты: ставит `purchase.paymentConfirm = 1`, редиректит на `/payment/prepay` |
| `controllers/PaymentController.php:124` | `actionSberFailure()` | Просто рендерит «Ошибка платежа» |

### Поток

```
Клиент → GET /payment/pay?secret=...
       → actionPay → actionPaySber
       → Purchase::createSberPayment
       → POST register.do → {orderId, formUrl}
       → BankTransaction (vendor=sber, status=0)
       → 302 на formUrl Сбера

Сбер  → GET /payment/sber-success?orderId=...
       → BankTransaction по bankId → Purchase
       → purchase.paymentConfirm = 1
       → 302 на /payment/prepay
```

### Cron — добивка статусов

`commands/PaymentController.php:315` `actionCheckSberStatus` — каждые N минут крутит:

```sql
SELECT * FROM bankTransaction
WHERE dateCreated > NOW() - INTERVAL 1 DAY
  AND vendor = 'sber'
  AND status = '0'
```

Для каждой строки дёргает `getStatus`, при `orderStatus == 2` ставит `purchase.paymentConfirm = 1`.

```bash
php yii payment/check-sber-status
```

### Связанный, но отдельный поток — `PaymentForm`

`commands/PaymentController.php:272` `actionCheckFormPaymentStatusSber` обрабатывает не `Purchase`, а сущность `PaymentForm` (отдельные платёжные формы, не привязанные к заказам).

⚠️ **Содержит баг**: `$result = $sberApi->status;` возвращает `stdClass` (через magic-getter `getStatus()`), а проверяется `isset($result["orderStatus"])` — обращение к `stdClass` как к массиву всегда `false`. Условие не срабатывает, статусы не обновляются. Сравни с рабочим `actionCheckSberStatus:338` где правильно `$result->orderStatus`.

### Прочее

- `Purchase::getSberUrl($bankId)` (`:450`) — строит ссылку на сберовскую форму вручную (для отображения «перейти к оплате» в админке). Только здесь различается dev (`3dsec.sberbank.ru`) и prod (`securepayments.sberbank.ru`) через `$_ENV['mode']`.
- `Setting.sberPaymentLink` — статичная ссылка в настройках сайта, к API отношения не имеет (для ручных переводов).

## 2. СБП QR (PlatiQR)

### Клиент: `models/SberQR.php`

OAuth2 `client_credentials` + mTLS-сертификат `@app/certificates/sber.pem` (пароль `@Larek910` в коде).

Перед каждым вызовом дёргается `getAccessToken($scope_key)` — отдельный токен на каждый scope:

| Scope | URL |
|---|---|
| `create` | `https://api.sberbank.ru/qr/order.create` |
| `status` | `https://api.sberbank.ru/qr/order.status` |
| `cancel` | `https://api.sberbank.ru/qr/order.cancel` |
| `registry` | `auth://qr/order.registry` |

API-операции:

| Метод | Endpoint | Назначение |
|---|---|---|
| `createQr($order_number, $order_sum, $description)` | `POST mc.api.sberbank.ru/prod/qr/order/v3/creation` | Создаёт QR. Возвращает `order_id`, `order_form_url` (это `https://qr.nspk.ru/...`), `order_state` |
| `getStatus($order_number, $order_id)` | `POST mc.api.sberbank.ru/prod/qr/order/v3/status` | Статус (`CREATED`, `PAID`, и т.д.) |
| `cancel(...)` | `POST mc.api.sberbank.ru:443/prod/qr/order/v3/cancel` | Refund |
| `getList()` | `POST mc.api.sberbank.ru/prod/qr/order/v3/registry` | Реестр операций |

В коде захардкожены идентификаторы ТСП Сбера: `id_qr=28711832`, `member_id=00003446`, `sbp_member_id=100000000111` (`SberQR.php:163-164`).

### Креды

`client_id` / `client_secret` дублируются **в четырёх местах**:

- `modules/admin/models/Purchase.php:131,191`
- `commands/PaymentController.php:32`
- `commands/TaskController.php:117,158`

При смене мерчанта править все четыре. По CN сертификата (`93ee0ae1-...`) видно, что `client_id` совпадает с CN — Сбер именно так и связывает.

### Где вызывается

| Файл | Точка | Что делает |
|---|---|---|
| `modules/admin/models/Purchase.php:128` | `getSberQr()` | Создаёт `BankTransaction` (vendor=`sber-qr-api`), дёргает `createQr`, апдейтит транзакцию с `bankId`/`url`/`status` |
| `modules/admin/models/Purchase.php:188` | `checkSberQrStatus($order_id)` | По `bankId` находит транзакцию, дёргает `getStatus`, при `order_state === 'PAID'` ставит `purchase.paymentConfirm = 1` |
| `controllers/ApiController.php:150` | `actionGetSbpUrl` | POST `/api/get-sbp-url` с `{publicId, amount}` от фронта → `Purchase::getSberQr` → JSON `{ok, url, bankId}` |
| `controllers/ApiController.php:178` | `actionCheckPaymentStatus($bankId)` | GET `/api/check-payment-status?bankId=...` → `checkSberQrStatus` → bool |
| `controllers/PaymentController.php:129` | `actionPayQr($secret)` | Альтернативный флоу — редирект на `order_form_url` (для мобильной кнопки). ⚠️ Бажный: ставит `response->format = FORMAT_JSON` перед `redirect()` |

### Поток (десктоп, через модалку)

```
Клиент → клик по .btn-sbp (views/payment/prepay.php:61)
       → src/sbp/index.ts:47 → POST /api/get-sbp-url {publicId, amount}

API   → ApiController::actionGetSbpUrl
       → Purchase::getSberQr
       → SberQR::getAccessToken('create')
         → POST mc.api.sberbank.ru/prod/tokens/v3/oauth (mTLS) → access_token
       → SberQR::createQr
         → POST .../prod/qr/order/v3/creation (mTLS + Bearer)
         → {order_id, order_form_url (qr.nspk.ru), order_state}
       → BankTransaction обновляется
       → JSON {ok:true, url, bankId} → фронту

Фронт → ModalFule открывает iframe на order_form_url (там показывается QR)
       → каждые 3 сек: GET /api/check-payment-status?bankId=...
                       → checkSberQrStatus → getStatus
                       → если PAID → bool true → reload страницы
```

### Поток (мобильный, через редирект)

`GET /payment/pay-qr?secret=...` (кнопка `d-block d-md-none` в `views/payment/prepay.php:57`) — `actionPayQr` создаёт QR и редиректит на `order_form_url`. ⚠️ Содержит `Yii::$app->response->format = Response::FORMAT_JSON;` перед `redirect()`, что мешает редиректу. Перед использованием убрать эту строку.

### Cron — добивка статусов

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

```bash
php yii payment/check-sber-qr-status
```

`commands/PaymentController.php:20` — берёт все `bankTransaction.vendor='sber-qr-api'` где привязанный `purchase.paymentConfirm = 0`, для каждого шлёт `getStatus`, при `PAID` ставит `paymentConfirm = 1`. Поставить в crontab каждые 5–15 минут.

### Возвраты — интерактивный CLI

```bash
php yii task/payment
```

`commands/TaskController.php:87` — интерактивно (через `League\CLImate`):

1. Спрашивает `purchaseId` (publicId).
2. Показывает все `bankTransaction` по этой покупке таблицей.
3. Спрашивает `bankId`.
4. Дёргает `getStatus`, показывает сумму операции и описание.
5. Спрашивает сумму возврата.
6. Шлёт `cancel` (`operation_type='REFUND'`, валюта `643`).
7. Перепроверяет статус.

### Реестр операций

```bash
php yii task/qr
```

`commands/TaskController.php:155` — дёргает `getList()`. ⚠️ Период захардкожен в `SberQR::getList:185` (`2023-10-16`). Перед использованием параметризовать или поправить даты.

## Сертификаты

| Файл | Назначение |
|---|---|
| `certificates/sber.pem` | Клиентский сертификат + приватный ключ (не зашифрован, `BEGIN PRIVATE KEY`) + цепочка из 3 сертификатов. Используется в mTLS для `mc.api.sberbank.ru`. CN = `client_id` PlatiQR |
| `certificates/sber.p12` | Тот же сертификат в формате PKCS#12, пароль `@Larek910`. В коде не используется, оставлен как исходник |

Пароль `@Larek910` зашит в коде в трёх местах: `SberPaymentApi.php:94` (закомментировано), `SberQR.php:91,132`, `commands/SberController.php:46`. Сейчас приватный ключ в PEM расшифрован, так что параметр пароля Guzzle фактически не использует — но при ротации сертификата PEM с зашифрованным ключом сломает интеграцию.

### Перевыпуск

Сертификаты PlatiQR имеют срок действия ~1 год. Когда они истекают, Сбер на TLS-handshake возвращает `400 Bad Request: The SSL certificate error` (это nginx Сбера маскирует TLS-ошибку под HTTP). В этом случае:

1. В личном кабинете SberAPI (`api.developer.sber.ru`) или в Сбер.Бизнес выпустить новый сертификат для того же мерчанта (`id_qr=28711832`).
2. Положить новый PEM в `certificates/sber.pem` — приватный ключ + клиентский + цепочка. Если придёт `.p12`, конвертация:
   ```bash
   openssl pkcs12 -in sber.p12 -out sber.pem -nodes
   ```
3. Если поменяются `client_id` / `client_secret` — обновить во всех четырёх местах (см. выше). Обычно остаются прежними.
4. Проверить: `openssl x509 -in certificates/sber.pem -noout -dates` — `notAfter` должен быть в будущем.

## Логирование

В `config/web.php:101-115` настроены два таргета:

| Таргет | Уровни | Категории | Куда |
|---|---|---|---|
| `notamedia\sentry\SentryTarget` | error, warning | все | Sentry (DSN из `$_ENV['sentry_key']`) |
| `yii\log\FileTarget` | error, warning, info | `sber-qr` | `runtime/logs/sber-qr.log` |

Файловый таргет добавлен специально под Sber-QR. Все стадии вызова пишутся туда:

| Stage | Где | Когда |
|---|---|---|
| `actionGetSbpUrl` | `ApiController::actionGetSbpUrl` | На входе и при ошибках |
| `oauth-request` / `oauth-response` | `SberQR::getAccessToken` | До и после OAuth-вызова, ошибки с телом ответа Сбера |
| `request` / `response` | `SberQR::request` | До и после `creation`/`status`/`cancel`/`registry`, ошибки с телом |
| `createQr-response` | `Purchase::getSberQr` | Если Сбер не вернул `order_id`/`order_form_url` |
| `getSberQr` | `Purchase::getSberQr` | На любом исключении внутри метода |

Эквайринг (карты) логирует только через Sentry — отдельной категории под него пока нет.

При неуспехе `actionGetSbpUrl` возвращает HTTP 500 и JSON `{ok:false, url:'', error: '<сообщение>'}`. Раньше ошибка глушилась `print_r($e)` и наружу не выходила.

## Подводные камни и известные баги

1. **`actionCheckFormPaymentStatusSber`** (`commands/PaymentController.php:295`) — обращение к `stdClass` как к массиву, статусы `PaymentForm` никогда не обновляются. Исправить: `$result->orderStatus`.

2. **`actionPayQr`** (`controllers/PaymentController.php:131`) — `Yii::$app->response->format = Response::FORMAT_JSON;` перед `redirect()`. Убрать строку.

3. **`SberQR::getList`** (`models/SberQR.php:185`) — `startPeriod`/`endPeriod` захардкожены датой `2023-10-16`. Сейчас возвращает пустой реестр. Параметризовать перед использованием.

4. **Креды в коде, а не в env.** Четыре копии одинаковых `client_id`/`client_secret` для SberQR + логин/пароль ТСП в `commands/SberController.php:22`. При утечке репо — компрометация всех точек. Вынести в `.env` (`Yii::$app->params['sber.qr.client_id']`).

5. **`src/sbp/index.ts:47-68`** — фронт открывает модалку и стартует поллинг даже если `/api/get-sbp-url` вернул `ok:false`. После провала первого вызова идёт стена 400-х из `check-payment-status` с `bankId=undefined`. Поправить: проверять `result.ok` перед `ModalFule`.

6. **Кнопки СБП** в `views/payment/prepay.php:57,61` могут быть закомментированы (история проекта). Если их нет — клиент не увидит способа оплаты СБП.

7. **`Setting.sberPaymentLink`** — не имеет отношения к API, это просто текстовое поле в настройках сайта. Не путать с эквайрингом.

## Сводная таблица

| Что | Способ оплаты | Где креды | Сертификат | Endpoint |
|---|---|---|---|---|
| Карты | Платёжная форма Сбера | `saleDepartment.bankLogin/bankPassword` | нет | `ecommerce.sberbank.ru` |
| СБП | QR-код (NSPK) | `client_id/secret` в коде | `certificates/sber.pem` | `mc.api.sberbank.ru` |
