# ADR-006: Интеграция платформы, постамата и приложения пользователя

**Дата:** 2026-03-29
**Статус:** Proposed
**Связанные ADR:** ADR-001 (архитектура), ADR-003 (аппаратная платформа), ADR-005 (клиентское приложение)

---

## Контекст

Система состоит из трёх компонентов, которые должны работать как единое целое:

1. **Платформа** — PocketBase (Go BaaS) + Go-хуки, VPS Aeza
2. **Постамат** — ESP32 (MVP) / RPi CM4 + nRF52840 (продакшен), открытый стеллаж с ремнями
3. **Приложение пользователя** — Flutter 3 (Android APK + Max Mini App через Flutter Web)

Каждая пара компонентов использует свой протокол связи:

```
┌─────────────────┐       REST/SSE (HTTPS)       ┌──────────────────┐
│  Приложение     │◄───────────────────────────►│  Платформа        │
│  (Flutter)      │                              │  (PocketBase +    │
│  Android / Max  │       BLE (proximity)        │   Go-хуки)       │
│                 │◄─────────────┐               │                  │
└─────────────────┘              │               └────────┬─────────┘
                                 │                        │
                                 │                        │ MQTT (mTLS)
                                 │                        │
                                 ▼                        ▼
                          ┌──────────────┐
                          │  Постамат    │
                          │  (ESP32/RPi) │
                          └──────────────┘
```

Данный ADR фиксирует решения по интеграции всех трёх компонентов: протоколы, потоки данных, жизненный цикл аренды, обработку ошибок и offline-сценарии.

---

## Решение

### 1. Протоколы связи

| Канал | Протокол | Транспорт | Аутентификация | Назначение |
|-------|----------|-----------|----------------|------------|
| Приложение ↔ Платформа | REST API + SSE | HTTPS (TLS 1.3) | JWT (access 15 мин + refresh 30 дней) | CRUD, платежи, фото, реалтайм |
| Платформа ↔ Постамат | MQTT 5.0 | TLS 1.3, порт 8883 | mTLS (per-device X.509, CN=`LKR-{serial}`) | Команды, телеметрия, статусы |
| Приложение ↔ Постамат | BLE 5.0 GATT | — | Challenge-Response Ed25519 | Proximity verification, идентификация |

**Принцип:** Платформа — единственный источник правды. Постамат и приложение не общаются напрямую для бизнес-логики, BLE используется только для подтверждения физического присутствия.

---

### 2. Жизненный цикл аренды (FSM)

```
                    ┌──────────┐
                    │  booked  │
                    └────┬─────┘
            ┌────────────┼────────────┐
            ▼            │            ▼
     ┌────────────┐      │     ┌────────────┐
     │ cancelled  │      │     │  expired   │
     │ (user/auto)│      │     │ (30 мин)   │
     └────────────┘      │     └────────────┘
                         ▼
                    ┌──────────┐
                    │  active  │
                    └────┬─────┘
                         │
              ┌──────────┼──────────┐
              ▼          │          ▼
       ┌────────────┐   │   ┌────────────┐
       │  overdue   │   │   │ returning  │
       │ (cron)     │   │   │            │
       └──────┬─────┘   │   └──────┬─────┘
              │          │          │
              └──────────┼──────────┘
                         ▼
                    ┌──────────┐
                    │completed │
                    └──────────┘
```

#### Переходы состояний — кто и что делает

| # | Переход | Триггер | Инициатор | App | Платформа | Постамат |
|---|---------|---------|-----------|-----|-----------|----------|
| 1 | → `booked` | Пользователь бронирует | App | `POST /rentals` | Создаёт rental, холд залога (CloudPayments auth) | — |
| 2 | `booked` → `cancelled` | Пользователь отменяет | App | `POST /rentals/:id/cancel` | Void залога, освобождает слот | — |
| 3 | `booked` → `expired` | Таймаут 30 мин | Платформа | Push «Бронь истекла» | Cron: void залога, статус expired | — |
| 4 | `booked` → `active` | Пользователь забирает | App | Скан QR → BLE proximity → `POST /rentals/:id/pickup` | Подтверждает, СБП оплата, чек Kit Online | MQTT: `cmd/slot` → update_status |
| 5 | `active` → `returning` | Пользователь возвращает | App | `POST /rentals/:id/return` | Назначает слот для возврата | — |
| 6 | `active` → `overdue` | Время аренды истекло | Платформа | Push «Аренда просрочена» | Cron: начисляет пени, уведомляет | — |
| 7 | `returning` → `completed` | Фото-верификация | App | Фото → `POST /rentals/:id/photo` | Верифицирует (оператор/AI), void залога, чек | MQTT: `cmd/slot` → update_status |
| 8 | `overdue` → `completed` | Возврат с пенями | App | Фото → `POST /rentals/:id/photo` | Списание пени, void остатка залога, чек | MQTT: `cmd/slot` → update_status |

---

### 3. Поток «Бронирование и оплата»

```
  Приложение                     Платформа                     Внешние сервисы
     │                              │                              │
     │  POST /rentals               │                              │
     │  {equipmentId, lockerId,     │                              │
     │   duration, slotId}          │                              │
     │─────────────────────────────►│                              │
     │                              │  CloudPayments auth(deposit) │
     │                              │─────────────────────────────►│
     │                              │◄─────────────────────────────│
     │                              │  Создаёт rental (booked)     │
     │  201: {rentalId, status,     │  Резервирует слот            │
     │        slotId, expiresAt}    │                              │
     │◄─────────────────────────────│                              │
     │                              │                              │
     │  POST /payments/sbp/qr      │                              │
     │  {rentalId, amount}          │                              │
     │─────────────────────────────►│                              │
     │                              │  Т-Банк Init + GetQR        │
     │                              │─────────────────────────────►│
     │                              │◄─────────────────────────────│
     │  200: {qrUrl, qrSvg}        │                              │
     │◄─────────────────────────────│                              │
     │                              │                              │
     │  [Пользователь сканирует    │                              │
     │   QR в банковском приложении]│                              │
     │                              │                              │
     │                              │  POST /webhooks/tbank        │
     │                              │◄─────────────────────────────│
     │                              │  Платёж подтверждён          │
     │  SSE: payment_confirmed      │                              │
     │◄─────────────────────────────│  Kit Online: чек "Аванс"    │
     │                              │─────────────────────────────►│
```

---

### 4. Поток «Выдача» (pickup)

```
  Приложение              Платформа              Постамат
     │                       │                       │
     │  [Скан QR на стеллаже │                       │
     │   → получает lockerId]│                       │
     │                       │                       │
     │  BLE connect          │                       │
     │──────────────────────────────────────────────►│
     │  BLE: read postomatId │                       │
     │◄──────────────────────────────────────────────│
     │                       │                       │
     │  POST /rentals/:id/   │                       │
     │  pickup               │                       │
     │  {lockerId, slotId,   │                       │
     │   bleProximity: true, │                       │
     │   gps: {...}}         │                       │
     │──────────────────────►│                       │
     │                       │  Проверяет:           │
     │                       │  - rental.status ==   │
     │                       │    booked             │
     │                       │  - payment confirmed  │
     │                       │  - lockerId matches   │
     │                       │  - GPS ≤ 50м          │
     │                       │                       │
     │                       │  MQTT: cmd/slot       │
     │                       │  {cmd: update_status, │
     │                       │   slotId, status:     │
     │                       │   empty}              │
     │                       │──────────────────────►│
     │                       │                       │
     │                       │  MQTT: event          │
     │                       │  {event: cmd_ack}     │
     │                       │◄──────────────────────│
     │                       │                       │
     │                       │  rental.status =      │
     │                       │  active               │
     │  200: {status: active,│                       │
     │   slotInfo, timer}    │                       │
     │◄──────────────────────│                       │
     │                       │                       │
     │  [Показывает: "Полка 3│                       │
     │   Снимите предмет,    │                       │
     │   отстегните ремень"] │                       │
```

#### Выдача без BLE (Max Mini App)

Max Mini App работает в WebView и не имеет доступа к BLE. Сценарий упрощённый:

```
  Max Mini App             Платформа              Постамат
     │                       │                       │
     │  [Скан QR → lockerId] │                       │
     │                       │                       │
     │  POST /rentals/:id/   │                       │
     │  pickup               │                       │
     │  {lockerId, slotId,   │                       │
     │   bleProximity: false, │                      │
     │   gps: {...}}         │                       │
     │──────────────────────►│                       │
     │                       │  Проверяет:           │
     │                       │  - GPS ≤ 50м (обяз.)  │
     │                       │  - Остальные проверки │
     │                       │  ...                  │
```

> **Компромисс:** Без BLE proximity нет криптографического подтверждения присутствия. GPS можно подделать. Для MVP — приемлемо. Для ценного оборудования (>10K руб.) — требовать Android-приложение с BLE.

---

### 5. Поток «Возврат» (return)

```
  Приложение              Платформа              Постамат
     │                       │                       │
     │  POST /rentals/:id/   │                       │
     │  return               │                       │
     │  {lockerId}           │                       │
     │──────────────────────►│                       │
     │                       │  Назначает свободный  │
     │                       │  слот для возврата    │
     │  200: {slotId: 7,     │                       │
     │   instructions: "..."}│                       │
     │◄──────────────────────│                       │
     │                       │                       │
     │  [Пользователь ставит │                       │
     │   предмет на полку 7, │                       │
     │   фиксирует ремнём]  │                       │
     │                       │                       │
     │  [Фотографирует через │                       │
     │   камеру приложения]  │                       │
     │                       │                       │
     │  POST /rentals/:id/   │                       │
     │  photo                │                       │
     │  (multipart:          │                       │
     │   image, lockerId,    │                       │
     │   slotId, gps,        │                       │
     │   bleProximity,       │                       │
     │   timestamp)          │                       │
     │──────────────────────►│                       │
     │                       │  Сохраняет фото в S3  │
     │                       │  Привязывает к rental │
     │                       │                       │
     │                       │  Верификация:         │
     │                       │  P1: оператор в ТГ    │
     │                       │  P2: AI (CLIP/ResNet) │
     │                       │                       │
     │                       │  rental.status =      │
     │                       │  completed            │
     │                       │                       │
     │                       │  CloudPayments void   │
     │                       │  (возврат залога)     │
     │                       │                       │
     │                       │  Kit Online: чек      │
     │                       │  "Расчёт"             │
     │                       │                       │
     │                       │  MQTT: cmd/slot       │
     │                       │  {cmd: update_status, │
     │                       │   slotId: 7,          │
     │                       │   status: occupied,   │
     │                       │   equipmentId}        │
     │                       │──────────────────────►│
     │                       │                       │
     │  SSE: rental_completed│                       │
     │◄──────────────────────│                       │
     │                       │                       │
     │  Push / Max Bot:      │                       │
     │  "Аренда завершена,   │                       │
     │   залог возвращён"    │                       │
     │◄──────────────────────│                       │
```

---

### 6. REST API — полная карта эндпоинтов интеграции

#### Авторизация

| Метод | Эндпоинт | Назначение | Платформа |
|-------|----------|------------|-----------|
| POST | `/api/v1/auth/send-otp` | Отправка FlashCall OTP | Android |
| POST | `/api/v1/auth/verify-otp` | Верификация OTP → JWT | Android |
| POST | `/api/v1/auth/max-login` | Валидация HMAC initData → JWT | Max Mini App |
| POST | `/api/v1/auth/refresh` | Обновление JWT по refresh token | Все |

#### Каталог и постаматы

| Метод | Эндпоинт | Назначение |
|-------|----------|------------|
| GET | `/api/v1/equipment` | Каталог оборудования (фильтры, поиск) |
| GET | `/api/v1/equipment/:id` | Карточка оборудования |
| GET | `/api/v1/lockers` | Список постаматов (координаты, доступность) |
| GET | `/api/v1/lockers/:id` | Постамат: слоты, оборудование, статусы |
| GET | `/api/v1/lockers/:id/slots` | Доступные слоты постамата |

#### Аренда

| Метод | Эндпоинт | Назначение |
|-------|----------|------------|
| POST | `/api/v1/rentals` | Создать бронирование |
| POST | `/api/v1/rentals/:id/cancel` | Отменить бронирование |
| POST | `/api/v1/rentals/:id/pickup` | Подтвердить выдачу |
| POST | `/api/v1/rentals/:id/return` | Инициировать возврат → получить slotId |
| POST | `/api/v1/rentals/:id/photo` | Загрузить фото верификации (multipart) |
| POST | `/api/v1/rentals/:id/extend` | Продлить аренду |
| GET | `/api/v1/rentals/active` | Активные аренды пользователя |
| GET | `/api/v1/rentals/history` | История аренд |

#### Платежи

| Метод | Эндпоинт | Назначение |
|-------|----------|------------|
| POST | `/api/v1/payments/sbp/qr` | Генерация QR для СБП (Т-Банк) |
| POST | `/api/v1/payments/hold` | Холд залога (CloudPayments) |
| POST | `/api/v1/payments/capture` | Списание (подтверждение холда) |
| POST | `/api/v1/payments/refund` | Возврат средств |
| POST | `/api/v1/webhooks/tbank` | Callback от Т-Банк |
| POST | `/api/v1/webhooks/cloudpayments` | Callback от CloudPayments |

#### Реалтайм

| Канал | Назначение |
|-------|------------|
| SSE `/api/v1/realtime` | PocketBase SSE: подписка на изменения rental, payment |
| Polling (fallback) | `GET /rentals/:id/status` для Max Mini App при проблемах с SSE |

---

### 7. MQTT — карта топиков интеграции

#### Device → Server

| Топик | QoS | Retained | Частота | Данные |
|-------|:---:|:--------:|---------|--------|
| `locker/{id}/register` | 1 | Нет | Однократно | serial, fw_version, hw_revision, capabilities |
| `locker/{id}/heartbeat` | 0 | Нет | 30 сек | uptime, free_ram, cpu_temp, fw_version |
| `locker/{id}/telemetry` | 0 | Нет | 60 сек | temperature, humidity, voltage, slots_summary |
| `locker/{id}/status` | 1 | Да | При изменении | online, fw_version, ip, slots_total |
| `locker/{id}/status/slot/{n}` | 1 | Да | При изменении | occupied, equipment_id, enabled |
| `locker/{id}/event` | 1 | Нет | По событию | cmd_ack, equipment_picked_up, equipment_returned, tamper |

#### Server → Device

| Топик | QoS | Retained | Данные |
|-------|:---:|:--------:|--------|
| `locker/{id}/config` | 1 | Да | heartbeat_interval, telemetry_interval, slots config |
| `locker/{id}/cmd/slot` | 1 | Нет | assign_equipment, unassign_equipment, disable, inventory, update_status |
| `locker/{id}/cmd/system` | 1 | Нет | reboot, diagnostics, factory_reset |
| `locker/{id}/ota` | 1 | Нет | update (url, sha256, version, component) |
| `locker/{id}/photo` | 1 | Нет | Уведомление о загруженном фото (photoId, rentalId, status) |

#### Безопасность MQTT

- **mTLS:** Каждый постамат имеет уникальный клиентский сертификат (CN=`LKR-{serial}`)
- **ACL:** Постамат `LKR-ABC123` может писать только в `locker/ABC123/*`, читать только из `locker/ABC123/*`
- **LWT (Last Will):** При потере связи NanoMQ публикует `{online: false}` в `locker/{id}/status`

---

### 8. BLE — протокол proximity verification

#### BLE GATT-сервис постамата

| Характеристика | UUID | Операция | Назначение |
|----------------|------|----------|------------|
| Postomat ID | `0001` | Read | Идентификатор постамата (для сопоставления с QR) |
| Challenge | `0002` | Read | Сервер-сгенерированный nonce (обновляется каждые 30 сек) |
| Response | `0003` | Write | Подписанный ответ от приложения |
| Status | `0004` | Notify | Результат верификации (ok / fail) |

#### Протокол Challenge-Response (Ed25519)

```
  Приложение                    Постамат (ESP32/nRF52840)
     │                              │
     │  1. BLE Scan + Connect       │
     │─────────────────────────────►│
     │                              │
     │  2. Read Challenge           │
     │  (nonce, timestamp)          │
     │◄─────────────────────────────│
     │                              │
     │  3. Отправляет nonce на      │
     │     платформу для подписи    │
     │──► POST /rentals/:id/        │
     │    ble-challenge             │
     │    {nonce, lockerId}         │
     │                              │
     │  4. Платформа подписывает:   │
     │  sig = Ed25519.sign(         │
     │    server_key,               │
     │    nonce + timestamp +       │
     │    lockerId + rentalId)      │
     │                              │
     │  5. Write Response           │
     │  {signature, rentalId,       │
     │   timestamp}                 │
     │─────────────────────────────►│
     │                              │
     │  6. Постамат верифицирует:   │
     │  - Ed25519.verify(           │
     │      server_pub_key, sig)    │
     │  - nonce одноразовый         │
     │  - TTL ≤ 30 сек             │
     │                              │
     │  7. Notify: {status: ok}     │
     │◄─────────────────────────────│
     │                              │
     │  8. Постамат отправляет      │
     │     MQTT event:              │
     │     proximity_confirmed      │
     │                     ────────►│ (Платформа)
```

> **Зачем подписывает платформа, а не приложение:** Приватный ключ никогда не покидает сервер. Приложение — ненадёжная среда (можно декомпилировать). Постамат доверяет только серверной подписи.

#### Fallback без BLE

Если BLE недоступен (Max Mini App, старый телефон):
1. Пользователь сканирует QR-код на стеллаже → `lockerId`
2. Приложение отправляет `lockerId` + GPS на платформу
3. Платформа проверяет GPS (≤ 50 м от координат постамата)
4. Без криптографического proximity — допустимо для оборудования ≤ 10K руб.

---

### 9. Платёжная интеграция — два провайдера

#### Распределение ролей

```
┌─────────────────────────────────────────────────────────┐
│                   Жизненный цикл аренды                  │
│                                                          │
│  Бронирование          Выдача            Возврат         │
│  ┌──────────┐    ┌──────────────┐   ┌──────────────┐    │
│  │CloudPay  │    │  Т-Банк СБП  │   │ CloudPay     │    │
│  │auth      │    │  QR-оплата   │   │ void/confirm │    │
│  │(залог)   │    │  (аренда)    │   │ (залог)      │    │
│  └──────────┘    └──────────────┘   └──────────────┘    │
│       │                │                    │            │
│       ▼                ▼                    ▼            │
│  ┌─────────────────────────────────────────────────┐    │
│  │              Kit Online (54-ФЗ)                  │    │
│  │  Чек "Аванс"  │  —  │  Чек "Расчёт" / возврат  │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘
```

#### Модели залога

| Модель | Срок аренды | Схема | Фаза |
|--------|-------------|-------|------|
| **A (холд)** | ≤ 7 дней | `auth(залог)` → аренда → `confirm(факт)` + `void(остаток)` | MVP |
| **B (полная оплата)** | > 7 дней | `charge(полная сумма)` → аренда → `refund(разница)` | MVP |
| **C (рекуррент)** | Открытый | Рекуррентные `void` + `auth` каждые 6 дней | v2 |

#### Правило фискализации (54-ФЗ)

| Событие | Чек Kit Online | Тип |
|---------|---------------|-----|
| auth (холд залога) | **Нет** — не расчёт | — |
| void (отмена холда) | **Нет** | — |
| Оплата аренды СБП | **Да** | Аванс |
| confirm (списание залога/пеней) | **Да** | Расчёт |
| refund (возврат переплаты) | **Да** | Возврат |

---

### 10. Уведомления — маршрутизация по каналам

| Событие | Android | Max Mini App | SMS (fallback) |
|---------|---------|-------------|----------------|
| Бронирование создано | FCM push | Max Bot sendMessage | — |
| Оплата подтверждена | FCM push | Max Bot | — |
| Напоминание о выдаче (25 мин) | FCM push | Max Bot | — |
| Бронь истекла | FCM push | Max Bot | — |
| Напоминание о возврате (за 1 ч) | FCM push | Max Bot | — |
| Аренда просрочена | FCM push | Max Bot | **SMS** |
| Пени начислены | FCM push | Max Bot | **SMS** |
| Фото верифицировано | FCM push | Max Bot | — |
| Аренда завершена, залог возвращён | FCM push | Max Bot | — |
| Тампер (вандализм) | — | — | SMS оператору |

**Маршрутизация:**
```
Go-хук (триггер события)
  │
  ├─► user.platform == "android" ? FCM push
  ├─► user.platform == "max_web" ? Max Bot API
  └─► event.severity == "critical" ? SMS (Zvonok.com)
```

---

### 11. Обработка ошибок и edge cases

#### Потеря связи постамата

| Сценарий | Обнаружение | Реакция платформы | Реакция приложения |
|----------|-------------|-------------------|--------------------|
| Постамат offline (heartbeat timeout 90 сек) | LWT в `locker/{id}/status` | Алерт оператору, блокирует новые бронирования | Показывает «Стеллаж временно недоступен» |
| Постамат восстановил связь | `locker/{id}/status` → online | Снимает блокировку, запрашивает inventory | — |
| Команда не доставлена (MQTT QoS 1 retry исчерпан) | event timeout 30 сек | Retry 3 раза, затем алерт оператору | Показывает «Повторите попытку» |

#### Ошибки BLE

| Сценарий | Реакция |
|----------|---------|
| BLE не поддерживается (Max Mini App) | Fallback на QR + GPS (без proximity) |
| BLE connect timeout (10 сек) | Показать «Подойдите ближе» → retry → fallback на QR + GPS |
| Challenge-Response failed | «Ошибка идентификации. Попробуйте сканировать QR-код» |
| Nonce expired (TTL > 30 сек) | Автоматический retry с новым nonce |

#### Ошибки платежей

| Сценарий | Реакция |
|----------|---------|
| СБП QR не оплачен за 10 мин | Отмена QR, предложение сгенерировать новый |
| CloudPayments auth declined | «Недостаточно средств для залога», предложить другую карту |
| Webhook не пришёл за 60 сек | Polling статуса: `GET /payments/:id/status` каждые 5 сек |
| Kit Online недоступен | Retry с exponential backoff (до 24 ч), чек формируется позже |

#### Ошибки фото-верификации

| Сценарий | Реакция |
|----------|---------|
| Фото слишком маленькое (< 500 KB) | «Сделайте фото ближе, с лучшим освещением» |
| GPS далеко от постамата (> 100 м) | «Подойдите к стеллажу для фото» |
| Оператор отклонил фото | Push «Пожалуйста, сделайте новое фото возврата» |
| Upload failed (сеть) | Сохранить локально, retry при восстановлении сети |

---

### 12. Offline-сценарии

#### Приложение offline

| Функция | Offline | Описание |
|---------|:-------:|----------|
| Просмотр каталога | Да | Кэш в Hive/Isar (Android), Service Worker (Max) |
| Просмотр активных аренд | Да | Локальный кэш |
| Бронирование | Нет | Требует серверной валидации и холда |
| Выдача / возврат | Нет | Требует серверного подтверждения |
| Загрузка фото | Отложенная | Фото сохраняется локально, upload при восстановлении сети |

#### Постамат offline

| Функция | Offline | Описание |
|---------|:-------:|----------|
| Heartbeat / телеметрия | Буфер | Накапливает, отправляет при восстановлении |
| Приём команд | Нет | MQTT QoS 1 — доставит при восстановлении |
| BLE proximity | Да | Верификация Ed25519 работает локально (публичный ключ на устройстве) |
| Обновление статуса слотов | Буфер | Отправляет при восстановлении |

> **Принцип:** Постамат не принимает бизнес-решений. Без связи с сервером бронирования и возвраты невозможны. BLE proximity работает автономно, но результат отправляется при восстановлении связи.

---

### 13. Адаптация под платформу (PlatformAdapter)

```dart
abstract class PlatformAdapter {
  // Аутентификация
  Future<AuthResult> authenticate();

  // Возможности устройства
  bool get supportsBLE;       // proximity verification
  bool get supportsNFC;       // fallback proximity
  bool get supportsCamera;    // фото-верификация
  bool get supportsOffline;   // кэширование

  // UI
  void showMainButton(String text, VoidCallback onTap);
  void hapticFeedback(HapticType type);

  // Уведомления
  Future<void> requestNotificationPermission();

  String get platform; // 'android' | 'max_web' | 'ios'
}
```

| Возможность | Android | Max Mini App | iOS (P3) |
|-------------|:-------:|:------------:|:--------:|
| BLE proximity | + | — | + |
| NFC | + | — | + |
| Камера (фото) | + | + | + |
| Offline кэш | + | — | + |
| Push (FCM) | + | — | + |
| Max Bot уведомления | — | + | — |
| Auth: SMS OTP | + | — | + |
| Auth: Max Login | — | + | — |

#### Условная логика в потоках

```dart
// Выдача
Future<void> pickup(Rental rental) async {
  if (platformAdapter.supportsBLE) {
    // Android/iOS: BLE proximity → Challenge-Response → pickup
    final proximity = await bleService.verifyProximity(rental.lockerId);
    await api.confirmPickup(rental.id, bleProximity: true, gps: gps);
  } else {
    // Max Mini App: только QR + GPS
    await api.confirmPickup(rental.id, bleProximity: false, gps: gps);
  }
}

// Возврат
Future<void> returnItem(Rental rental) async {
  // Камера доступна на всех платформах
  final photo = await camera.takePhoto();
  await api.uploadReturnPhoto(rental.id, photo, gps: gps);
}
```

---

## Последствия

### Положительные

- **Единый источник правды** — платформа управляет всей бизнес-логикой, постамат и приложение не принимают решений
- **Безопасность** — Ed25519 Challenge-Response невозможно подделать, mTLS изолирует постаматы
- **Масштабируемость** — добавление постамата = подключение нового MQTT-клиента, приложение не меняется
- **Кросс-платформенность** — PlatformAdapter абстрагирует различия Android / Max Mini App / iOS
- **Graceful degradation** — BLE → QR+GPS → ручное подтверждение оператором

### Отрицательные / Риски

| Риск | Вероятность | Митигация |
|------|------------|-----------|
| GPS spoofing (Max Mini App без BLE) | Средняя | Ограничить ценность оборудования для Max Mini App; для дорогого — требовать Android с BLE |
| Постамат offline при выдаче/возврате | Низкая | BLE proximity работает offline; команда доставится при восстановлении; алерт оператору |
| Задержка webhook от Т-Банк/CloudPayments | Низкая | Polling fallback каждые 5 сек; SSE для мгновенного уведомления |
| Фото-верификация ненадёжна (плохое фото) | Средняя | Валидация на клиенте (размер, освещение); retry; AI-верификация в P2 |
| BLE interference в ТЦ | Средняя | Retry + NFC fallback + QR-code fallback |
| Разрыв SSE-соединения | Средняя | Автоматический reconnect; polling как fallback; exponential backoff |

---

## Альтернативы (рассмотрены и отвергнуты)

### Постамат принимает бизнес-решения (smart postmat)

- **Против:** Усложняет прошивку, дублирует бизнес-логику, сложнее обновлять. Компрометация одного постамата → риск для всей системы
- **За:** Работает полностью offline — но для открытых полок без замков offline-выдача неактуальна

### WebSocket вместо SSE

- **Против:** PocketBase нативно поддерживает SSE, WebSocket потребует дополнительной инфраструктуры
- **За:** Двунаправленная связь — но приложение отправляет данные через REST, SSE достаточно для подписки

### gRPC вместо REST

- **Против:** Не поддерживается PocketBase из коробки, Flutter Web (Max Mini App) ограниченная поддержка
- **За:** Строгая типизация, streaming — но для MVP REST + SSE проще и покрывает все потребности

### MQTT напрямую из приложения

- **Против:** Открывает MQTT-брокер для пользователей, усложняет ACL, расход батареи на мобильных устройствах
- **За:** Реалтайм без SSE — но SSE достаточно, а безопасность MQTT важнее

---

## Ссылки

- [ADR-001: Архитектура системы](ADR-001-system-architecture.md) — §20-24 (платежи, BLE), §26-27 (MQTT, безопасность)
- [ADR-003: Аппаратная платформа](ADR-003-postomat-hardware.md) — ESP32, BLE, 4G
- [ADR-005: Клиентское приложение](ADR-005-client-app-max-android.md) — Flutter, PlatformAdapter, Max Mini App
- [PocketBase Realtime API](https://pocketbase.io/docs/api-realtime/)
- [NanoMQ MQTT Broker](https://nanomq.io/)
- [flutter_blue_plus](https://pub.dev/packages/flutter_blue_plus)
- [Ed25519 (RFC 8032)](https://datatracker.ietf.org/doc/html/rfc8032)
- [Т-Банк СБП API](https://www.tbank.ru/kassa/dev/payments/)
- [Max Bot API](https://dev.max.ru/docs-api)
