# Этап 4: чеки Checkbox по ТТН НП с послеплатой (ЕТТН) ## Контекст Заказы с наложенным платежом НП не пробиваем сами. Кассир создаёт в Checkbox **ЕТТН-чек** (шаблон чека, привязанный к ТТН). Когда клиент оплачивает посылку в отделении, НП шлёт webhook в Checkbox, и Checkbox **сам фискализирует** чек. Наша система только: создаёт ЕТТН-чек → отслеживает его статус → даёт отменить/пересоздать. ### Checkbox API (из `api.checkbox.ua/api/openapi.json`) - Эндпоинты: `POST/GET /api/v1/ettn`, `GET/PUT/DELETE /api/v1/ettn/{order_id}`, `PUT /api/v1/ettn/fiscalize-manually/{order_id}`. (`/api/v1/np/ettn` устарел, не используем.) - Авторизация: `Authorization: Bearer ` через `POST /api/v1/cashier/signinPinCode` (`pin_code` + заголовок `X-License-Key`). Всегда слать `X-Client-Name`, `X-Client-Version`, `X-License-Key`. - Тело `ETTNCreateReceiptSchema`: `provider="novapost"`, `receipt_body`: - `goods[]`: `good{code, name, price (коп. за 1000), tax[]}`, `quantity` (тысячные), `is_return=false`, `discounts[]` - `discounts[]`: `{type: "PRE_PAYMENT", mode: "VALUE", value}` — предоплата - `payments[]`: `{value: <сумма наложки, коп.>, ettn: <номер ТТН>}` - `delivery`: `phone` / `emails` — куда отправить чек после фискализации - Ответ `BaseEttnResponse`: `id`, `status`, `receiptId` (после фискализации), `rawError`, `totalSum`. - Статусы: `CREATED`, `CANCELLED`, `RECEIPT_ERROR`, `DONE`, `DONE_WITHOUT_SMS`, `RETURNED`. - Инвариант: сумма ЕТТН = сумма товаров − предоплата = наложка НП. ### Решения (согласовано) - Запуск только вручную: кнопка «Чек» в строке/модалке + массово «Создать чеки по выбранным». - Частичная предоплата бывает: кассир вводит её в поле «Предоплата» → `PRE_PAYMENT` скидка. - Коды налогов настраиваются на уровне кассы (по умолчанию пусто = не передаём `tax`). - Касса и секреты — в БД, `license_key` и PIN шифруются Fernet (`app/core/crypto.py`). ### Предусловия (вне кода, проверить до старта) - В портале Checkbox касса подключена к НП (токен НП + телефон ФОП). - ТТН создаются с **«Контролем оплаты»** (AfterpaymentOnGoodsCost), а не с обычным денежным переводом. - ЕТТН-чек нужно создать **до** получения посылки клиентом. --- ## Этап 4.1 — Модели и миграция - [x] `app/db/models/cash_register.py` — `CashRegister(UUIDPrimaryKeyMixin, TimestampMixin)`: `name`, `fiscal_number`, `license_key_enc`, `cashier_pin_enc`, `tax_codes` (JSONB, default `[]`), `is_active`, `is_default` (partial unique index — одна касса по умолчанию). - [x] `app/db/models/receipt.py` — `Receipt(UUIDPrimaryKeyMixin, TimestampMixin)`: `order_id` FK→orders, `cash_register_id` FK, `created_by_id` FK→users, `waybill_number`, `total_kopecks`, `prepayment_kopecks`, `cod_kopecks`, `status` (StrEnum: `pending`, `created`, `done`, `returned`, `cancelled`, `failed`, `receipt_error`), `checkbox_ettn_id`, `checkbox_receipt_id`, `error`, `request_body` (JSONB — снимок отправленного), `last_checked_at`. Partial unique index `(order_id) WHERE status NOT IN ('cancelled','failed')` — не больше одного живого чека на заказ. - [x] Импорт моделей в `app/db/models/__init__.py`. - [x] Миграция `0005_cash_registers_and_receipts.py` вручную по образцу `0001`/`0002` (`op.f(...)`, формат `op.create_table(\n "",` — его парсит `test_migration_matches_models.py`). - [x] `AuditAction`: `CASH_REGISTER_CREATED/UPDATED`, `RECEIPT_CREATE_REQUESTED`, `RECEIPT_CANCELLED`. - [x] Добавить `pin`/`cashier_pin` в `_REDACTED_KEYS` (`app/services/audit.py`). ## Этап 4.2 — Checkbox-клиент - [x] `app/services/checkbox/client.py` — `Protocol CheckboxClient` + `CheckboxError` (по образцу `services/nova_poshta/client.py`): `sign_in(license_key, pin) -> token`, `create_ettn(...)`, `get_ettn(id)`, `delete_ettn(id)`. - [x] `app/services/checkbox/http_client.py` — httpx, заголовки `X-Client-*`, `X-License-Key`, Bearer; при 401 — повторный `signinPinCode` и один retry. Токен кэшируется в памяти процесса по `cash_register_id` (клиент — синглтон на процесс; Redis не понадобился). - [x] `app/services/checkbox/stub_client.py` — стаб для тестов и локальной разработки (ЕТТН на тестовой кассе Checkbox не работает — стаб единственный способ прогнать цикл без боевой кассы). In-memory состояние: `create` → `CREATED`, последующие `get` умеют отдавать `DONE`/`RETURNED`/`RECEIPT_ERROR`. - [x] Флаг `CHECKBOX_USE_STUB` (default `false`); выбор реализации в одном месте (`deps.get_checkbox_client` + worker startup). В `is_production` при `true` — ошибка старта. - [x] `app/schemas/checkbox.py` — pydantic-модели запроса/ответа ЕТТН. - [x] Settings: `checkbox_base_url` (default `https://api.checkbox.ua`), `checkbox_client_name`, `checkbox_client_version`; обновить `.env.example`. - [x] Тесты `tests/test_checkbox_client.py` через `respx` (успех, 401→re-signin, 422, таймаут). ## Этап 4.3 — Сервис чеков (бизнес-логика) - [x] `app/services/receipts.py`: - `build_ettn_body(order, prepayment_kopecks, register)` — маппинг CRM goods → Checkbox: `price "600.00"`→ коп., `quantity "2.000"`→ 2000, `code = sku or id`, построчная скидка CRM → `DISCOUNT/VALUE`, `tax = register.tax_codes`, `payments=[{value: cod, ettn: waybill}]`, `delivery` из телефона/email получателя. Переиспользовать `_to_kopecks` из `services/orders.py`. - Валидация перед созданием: есть ТТН; `np_cod_amount_kopecks > 0`; посылка ещё не получена (NP status code ∉ получено/возврат); нет живого чека; `сумма товаров − предоплата == np_cod_amount_kopecks` — иначе понятная ошибка для UI. - `request_receipts(order_ids, prepayments, user)` — создаёт `Receipt(status=pending)` + audit в одной транзакции, commit, затем enqueue ARQ-задачи по каждому чеку. - `create_ettn(receipt_id)` (выполняет worker): вызов Checkbox → `created` + `checkbox_ettn_id`, `order.receipt_created_at = now`; при ошибке → `failed` + `error`. Идемпотентность: при таймауте не повторять вслепую — сверить через `GET /api/v1/ettn` (поиск по `ettnNumber`) перед повторной отправкой. - `cancel_receipt(receipt_id, user)` — `DELETE /api/v1/ettn/{id}` только из `created`, → `cancelled`, `order.receipt_created_at = NULL` (заказ возвращается в очередь). - `sync_ettn_statuses()` — опрос `GET /api/v1/ettn/{id}` для чеков в `created`: `DONE/DONE_WITHOUT_SMS`→`done` (+`receiptId`), `RETURNED`→`returned`, `RECEIPT_ERROR`→`receipt_error` (+`rawError`), `CANCELLED`→`cancelled`. - [x] Тесты `tests/test_receipts_service.py`: маппинг сумм/количеств, предоплата, все ветки валидации, переходы статусов. ## Этап 4.4 — Worker - [x] `app/worker.py`: `functions=[create_ettn_receipt]`, cron `poll_ettn_statuses` (раз в 2–5 мин), `ctx["checkbox_client"]`, `ctx["redis"]` для кэша токенов. - [x] API ставит задачи через `app/services/task_queue.py` (ленивый ARQ-пул; сбой enqueue не критичен — cron `poll_receipts` подхватит зависший `pending`). ## Этап 4.5 — API - [x] `app/api/v1/cash_registers.py` (AdminUser): list / create / update; секреты только на запись, в ответе — `mask(...)` из `core/crypto.py`. Проверка «подключиться» = пробный `signinPinCode`. - [x] `app/api/v1/receipts.py` (CashierUser): - `POST /receipts` — `{items: [{order_id, prepayment_kopecks}]}` → 202 + список чеков/ошибок валидации по заказам. - `GET /receipts?order_id=` / `GET /receipts/{id}`. - `POST /receipts/{id}/cancel`. - [x] `schemas/orders.py`: в `OrderRowOut` добавить `receipt_status`, `receipt_error`, `prepayment`. - [x] Подключить роутеры в `app/api/v1/router.py`; тесты по образцу `tests/test_orders_router.py` (`dependency_overrides`, стаб-клиенты, monkeypatch сервисов). ## Этап 4.6 — Frontend - [x] `features/receipts/` — `api.ts`, `types.ts` (union-типы статусов, без `enum`), хуки react-query. - [x] `DashboardPage.tsx`: - поле «Предоплата» — контролируемое, в ₴, по умолчанию `сумма − наложка`; подсветка, если `сумма − предоплата ≠ наложка`; - кнопка «Чек» и «Создать чеки по выбранным» — включить, вызвать `POST /receipts`, показать ошибки по заказам; - вкладка «Выписаны чеки» — статус ЕТТН-чека (создан / фискализирован / возврат / ошибка), кнопка «Отменить» для `created`. - [x] `useOrders`: `refetchInterval` пока есть чеки в `pending`. - [x] `OrderDetailModal.tsx` — блок «Чек»: статус, Checkbox ID, ошибка, отмена. - [x] Админ-страница «Кассы» (name, license key, PIN, коды налогов, по умолчанию) — маршрут в `app/routes.tsx`, только admin. ## Этап 4.7 — Доработки и эксплуатация - [ ] На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя `RECEIPT_ERROR`. - [ ] Сверить реальное значение `np_payment_status` (`Payed` vs `Paid` — фронт и стаб расходятся). - [x] Обновить `CLAUDE.md`/`README.md`/`.env.example` (статус, новые env-переменные). - [x] Первый боевой чек (ТТН 20451543715206, 2026-09-24): `value` скидок — в копейках (подтверждено). `PRE_PAYMENT` → 400 `third_party.generic`; предоплата теперь идёт обычной скидкой `DISCOUNT` «Знижка», как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются. Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка. - [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker). - [x] После создания чека — статус заказа в CRM `PACKED` (`SetStatus`, миграция 0006 `receipts.crm_status_set_at`, повтор cron'ом при сбое CRM). Проверено на боевой CRM: заказы 123901, 123793. - [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса. --- ## Проверка 1. `cd backend && .venv/Scripts/python -m pytest -q` и `ruff check app tests` — чисто, включая `test_migration_matches_models.py`. 2. `.venv/Scripts/alembic upgrade head` на локальном Postgres. 3. `cd frontend && npm run build && npm run lint`. 4. Локальный E2E без Checkbox: `CHECKBOX_USE_STUB=true` → полный цикл в UI (создание → `created` → стаб переводит в `done`/`returned`/`receipt_error`, отмена, пересоздание). 5. Боевая проверка — только на **реальной** кассе (ЕТТН на тестовой не работает), аккуратно, на одном реальном заказе с контролем оплаты: создать чек → убедиться, что он виден в портале Checkbox → дождаться получения посылки и статуса `done` + `receiptId`. Отмену проверять на этом же чеке до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.