Add Checkbox ETTN receipts for Nova Poshta COD waybills

Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment
control; Checkbox fiscalizes it itself when the parcel is paid for.

- cash_registers (Fernet-encrypted license key / PIN) and receipts tables
- Checkbox HTTP client + stub (ETTN does not work on test registers)
- two-phase create via ARQ job, timeout reconciliation, cron status polling
- /receipts and /cash-registers API, audit records
- dashboard: per-order and bulk create, prepayment, cancel; cash registers page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 23:39:00 +03:00
co-authored by Claude Opus 5.5
parent d13e7ce2b3
commit 518a99197f
44 changed files with 2931 additions and 20 deletions
+150
View File
@@ -0,0 +1,150 @@
"""Тесты HttpCheckboxClient. Сеть замокана через respx — реальных запросов не делает."""
from __future__ import annotations
import json
import uuid
import httpx
import pytest
import respx
from httpx import Response
from app.core.config import Settings
from app.services.checkbox.client import (
CheckboxCredentials,
CheckboxError,
CheckboxUnavailableError,
)
from app.services.checkbox.http_client import HttpCheckboxClient
BASE = "https://api.checkbox.test"
CREDS = CheckboxCredentials(cash_register_id=uuid.uuid4(), license_key="lic-123", pin_code="1111")
ETTN = {
"id": "e-1",
"status": "CREATED",
"ettnNumber": "20450123456789",
"totalSum": 120000,
"description": "",
"recipientPhone": "",
"employee": {"id": "x", "dateCreated": "2026-09-23"},
"cashRegister": {"id": "y", "dateCreated": "2026-09-23"},
"lastCheckDate": "2026-09-23",
"dateCreated": "2026-09-23",
}
def _client() -> HttpCheckboxClient:
settings = Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
checkbox_base_url=BASE,
) # type: ignore[arg-type]
return HttpCheckboxClient(settings)
def _signin(token: str = "tok-1") -> respx.Route:
return respx.post(f"{BASE}/api/v1/cashier/signinPinCode").mock(
return_value=Response(200, json={"access_token": token})
)
@respx.mock
async def test_create_signs_in_and_sends_headers() -> None:
signin = _signin()
create = respx.post(f"{BASE}/api/v1/ettn").mock(return_value=Response(200, json=ETTN))
ettn = await _client().create_ettn(CREDS, {"provider": "novapost"})
assert ettn.id == "e-1" and ettn.ettn_number == "20450123456789"
assert json.loads(signin.calls.last.request.content) == {"pin_code": "1111"}
assert signin.calls.last.request.headers["X-License-Key"] == "lic-123"
sent = create.calls.last.request
assert sent.headers["Authorization"] == "Bearer tok-1"
assert sent.headers["X-License-Key"] == "lic-123"
assert sent.headers["X-Client-Name"] == "lux_fiscal"
@respx.mock
async def test_token_is_cached_between_calls() -> None:
signin = _signin()
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN))
client = _client()
await client.get_ettn(CREDS, "e-1")
await client.get_ettn(CREDS, "e-1")
assert signin.call_count == 1
@respx.mock
async def test_401_triggers_single_resign_in() -> None:
signin = _signin("tok-2")
route = respx.get(f"{BASE}/api/v1/ettn/e-1").mock(
side_effect=[Response(401, json={"message": "expired"}), Response(200, json=ETTN)]
)
ettn = await _client().get_ettn(CREDS, "e-1")
assert ettn.status == "CREATED"
assert signin.call_count == 2
assert route.calls.last.request.headers["Authorization"] == "Bearer tok-2"
@respx.mock
async def test_422_raises_checkbox_error_with_message() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=Response(
422,
json={
"message": "Validation error",
"detail": [{"loc": ["body", "payments"], "msg": "bad", "type": "x"}],
},
)
)
with pytest.raises(CheckboxError, match="body.payments: bad") as exc_info:
await _client().create_ettn(CREDS, {})
assert not isinstance(exc_info.value, CheckboxUnavailableError)
@respx.mock
async def test_timeout_and_5xx_are_unavailable() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(side_effect=httpx.ReadTimeout("slow"))
with pytest.raises(CheckboxUnavailableError):
await _client().create_ettn(CREDS, {})
respx.post(f"{BASE}/api/v1/ettn").mock(return_value=Response(503, text="down"))
with pytest.raises(CheckboxUnavailableError):
await _client().create_ettn(CREDS, {})
@respx.mock
async def test_find_skips_cancelled_and_other_waybills() -> None:
_signin()
respx.get(f"{BASE}/api/v1/ettn").mock(
return_value=Response(
200,
json=[
{**ETTN, "id": "old", "status": "CANCELLED"},
{**ETTN, "id": "other", "ettnNumber": "111"},
{**ETTN, "id": "match"},
],
)
)
found = await _client().find_ettn(CREDS, "20450123456789")
assert found is not None and found.id == "match"
@respx.mock
async def test_bad_pin_raises() -> None:
respx.post(f"{BASE}/api/v1/cashier/signinPinCode").mock(
return_value=Response(400, json={"message": "Невірний пін-код"})
)
with pytest.raises(CheckboxError, match="Невірний пін-код"):
await _client().sign_in(CREDS)
+7
View File
@@ -79,6 +79,13 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
async def fake_latest(session: object, order_ids: list[str]) -> dict:
return {}
monkeypatch.setattr(
orders_router.receipts_service, "latest_receipts_by_order", fake_latest
)
async def fake_record(session: object, **kwargs: object) -> None:
return None
+78
View File
@@ -0,0 +1,78 @@
"""Тесты роутера чеков: роли, постановка задач после commit, ответ с ошибками по заказам."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.models.receipt import Receipt, ReceiptStatus
from app.db.models.user import User, UserRole
from app.main import app
from app.services import receipts as receipts_service
from app.services.task_queue import get_task_queue
class FakeQueue:
def __init__(self) -> None:
self.jobs: list[tuple[str, tuple[Any, ...]]] = []
async def enqueue(self, function: str, *args: Any) -> None:
self.jobs.append((function, args))
def _user(role: UserRole) -> User:
return User(id=uuid.uuid4(), email="t@e.ua", password_hash="x", full_name="Т", role=role)
@pytest.fixture
def queue(monkeypatch: pytest.MonkeyPatch) -> FakeQueue:
fake = FakeQueue()
receipt = Receipt(
id=uuid.uuid4(),
order_id="1",
waybill_number="20450123456789",
total_kopecks=120000,
prepayment_kopecks=0,
cod_kopecks=120000,
status=ReceiptStatus.PENDING,
created_at=datetime.now(UTC),
)
async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any:
return receipts_service.RequestResult(created=[receipt], errors={"2": "У заказа нет ТТН"})
async def fake_commit(self: AsyncSession) -> None:
return None
monkeypatch.setattr(receipts_service, "request_receipts", fake_request)
monkeypatch.setattr(AsyncSession, "commit", fake_commit)
app.dependency_overrides[get_task_queue] = lambda: fake
yield fake
app.dependency_overrides.clear()
def test_viewer_cannot_create(queue: FakeQueue) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
response = TestClient(app).post("/api/v1/receipts", json={"items": [{"order_id": "1"}]})
assert response.status_code == 403
assert queue.jobs == []
def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = TestClient(app).post(
"/api/v1/receipts", json={"items": [{"order_id": "1"}, {"order_id": "2"}]}
)
assert response.status_code == 202
body = response.json()
assert [r["order_id"] for r in body["created"]] == ["1"]
assert body["errors"] == {"2": "У заказа нет ТТН"}
assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))]
+289
View File
@@ -0,0 +1,289 @@
"""Тесты сервиса ЕТТН-чеков: суммы, валидация, машина состояний.
БД заменена минимальной фейковой сессией (`get` по словарю + счётчик commit),
Checkbox — `StubCheckboxClient`.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from app.core import crypto
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.db.models.receipt import Receipt, ReceiptStatus
from app.schemas.checkbox import EttnStatus
from app.services import receipts as svc
from app.services.checkbox.client import CheckboxUnavailableError
from app.services.checkbox.stub_client import StubCheckboxClient
def _good(**overrides: Any) -> dict[str, Any]:
good = {
"id": "1",
"sku": "SKU-1",
"name": "Товар 1",
"price": "600.00",
"quantity": "2.000",
"discount_amount": "0.00",
"discount_percent": "0.00",
"amount": "1200.00",
}
return {**good, **overrides}
def _order(**overrides: Any) -> Order:
fields: dict[str, Any] = {
"id": "100",
"create_date_time": datetime(2026, 9, 20, tzinfo=UTC),
"recipient_name": "Тест",
"recipient_phone": "0501112233",
"recipient_email": "a@b.ua",
"waybill_number": "20450123456789",
"notes": None,
"total_amount_kopecks": 120000,
"goods": [_good()],
"is_deleted": False,
"receipt_created_at": None,
"np_status": "В дорозі",
"np_status_code": "5",
"np_cod_amount_kopecks": 120000,
}
fields.update(overrides)
return Order(**fields)
def _register(tax_codes: list[Any] | None = None) -> CashRegister:
return CashRegister(
id=uuid.uuid4(),
name="Каса",
license_key_enc=crypto.encrypt("lic"),
cashier_pin_enc=crypto.encrypt("1111"),
tax_codes=tax_codes or [],
is_active=True,
is_default=True,
)
class TestResolveAmounts:
def test_default_prepayment_is_total_minus_cod(self) -> None:
amounts = svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), None)
assert amounts.prepayment_kopecks == 20000
assert amounts.cod_kopecks == 100000
def test_explicit_prepayment_must_match_cod(self) -> None:
with pytest.raises(svc.ReceiptValidationError, match="≠ наложка"):
svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000)
@pytest.mark.parametrize(
("overrides", "message"),
[
({"waybill_number": None}, "нет ТТН"),
({"np_cod_amount_kopecks": None}, "контроля оплаты"),
({"np_status_code": "9"}, "не в пути"),
({"np_cod_amount_kopecks": 130000}, "больше суммы заказа"),
({"is_deleted": True}, "удалён"),
],
)
def test_rejects(self, overrides: dict[str, Any], message: str) -> None:
with pytest.raises(svc.ReceiptValidationError, match=message):
svc.resolve_amounts(_order(**overrides), None)
class TestBuildBody:
def test_goods_payments_delivery(self) -> None:
order = _order()
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None))
assert body["provider"] == "novapost"
rb = body["receipt_body"]
assert rb["goods"] == [
{
"good": {"code": "SKU-1", "name": "Товар 1", "price": 60000},
"quantity": 2000,
"is_return": False,
}
]
assert rb["payments"] == [{"value": 120000, "ettn": "20450123456789"}]
assert "discounts" not in rb
assert rb["delivery"] == {"phone": "+380501112233", "emails": ["a@b.ua"]}
def test_prepayment_line_and_order_discounts_and_tax(self) -> None:
order = _order(
goods=[_good(amount="1100.00")], # скидка по строке 100 ₴
total_amount_kopecks=105000, # ещё 50 ₴ скидки на заказ
np_cod_amount_kopecks=85000, # 200 ₴ предоплаты
)
body = svc.build_ettn_body(order, _register([8]), svc.resolve_amounts(order, 20000))
rb = body["receipt_body"]
assert rb["goods"][0]["good"]["tax"] == [8]
assert rb["goods"][0]["discounts"] == [
{"type": "DISCOUNT", "mode": "VALUE", "value": 10000}
]
assert [(d["type"], d["value"]) for d in rb["discounts"]] == [
("DISCOUNT", 5000),
("PRE_PAYMENT", 20000),
]
assert rb["payments"][0]["value"] == 85000
def test_fractional_quantity(self) -> None:
order = _order(
goods=[_good(price="100.00", quantity="2.250", amount="225.00")],
total_amount_kopecks=22500,
np_cod_amount_kopecks=22500,
)
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None))
assert body["receipt_body"]["goods"][0]["quantity"] == 2250
def test_bad_phone_is_skipped(self) -> None:
order = _order(recipient_phone="12345", recipient_email=None)
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None))
assert "delivery" not in body["receipt_body"]
class FakeSession:
def __init__(self, *objects: Any) -> None:
self.objects = {(type(o), o.id): o for o in objects}
self.commits = 0
async def get(self, model: type, key: Any, **_: Any) -> Any:
return self.objects.get((model, key))
async def commit(self) -> None:
self.commits += 1
def _pending(order: Order, register: CashRegister) -> Receipt:
amounts = svc.resolve_amounts(order, None)
return Receipt(
id=uuid.uuid4(),
order_id=order.id,
cash_register_id=register.id,
waybill_number=order.waybill_number,
total_kopecks=amounts.total_kopecks,
prepayment_kopecks=amounts.prepayment_kopecks,
cod_kopecks=amounts.cod_kopecks,
status=ReceiptStatus.PENDING,
request_body=svc.build_ettn_body(order, register, amounts),
)
class TestCreateEttn:
async def test_pending_becomes_created(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.CREATED
assert receipt.checkbox_ettn_id in client.orders
assert order.receipt_created_at is not None
async def test_second_call_is_noop(self) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
await svc.create_ettn_for_receipt(session, client, receipt.id)
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert len(client.orders) == 1
async def test_rejection_fails_and_releases_order(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
# Та же ТТН уже привязана — стаб отвечает ошибкой, как Checkbox.
await client.create_ettn(svc.credentials(register), receipt.request_body)
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.FAILED
assert "уже привязана" in (receipt.error or "")
assert order.receipt_created_at is None
async def test_unavailable_keeps_pending_then_reconciles(self) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
real_create = client.create_ettn
async def create_then_timeout(creds: Any, body: dict[str, Any]) -> Any:
await real_create(creds, body) # запрос дошёл до Checkbox…
raise CheckboxUnavailableError("timeout") # …но ответа мы не получили
client.create_ettn = create_then_timeout # type: ignore[method-assign]
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.PENDING and receipt.error
client.create_ettn = real_create # type: ignore[method-assign]
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.CREATED
assert len(client.orders) == 1 # второго чека на ту же ТТН нет
class TestApplyEttn:
@pytest.mark.parametrize(
("checkbox_status", "expected"),
[
(EttnStatus.CREATED, ReceiptStatus.CREATED),
(EttnStatus.DONE, ReceiptStatus.DONE),
(EttnStatus.DONE_WITHOUT_SMS, ReceiptStatus.DONE),
(EttnStatus.RETURNED, ReceiptStatus.RETURNED),
(EttnStatus.RECEIPT_ERROR, ReceiptStatus.RECEIPT_ERROR),
(EttnStatus.CANCELLED, ReceiptStatus.CANCELLED),
],
)
async def test_status_mapping(self, checkbox_status: str, expected: ReceiptStatus) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
client.set_status(receipt.checkbox_ettn_id or "", checkbox_status, raw_error="boom")
ettn = await client.get_ettn(svc.credentials(register), receipt.checkbox_ettn_id or "")
svc._apply_ettn(receipt, ettn)
assert receipt.status == expected
if expected == ReceiptStatus.DONE:
assert receipt.checkbox_receipt_id
if expected == ReceiptStatus.RECEIPT_ERROR:
assert receipt.error == "boom"
class TestCancel:
async def test_cancel_created(self, monkeypatch: pytest.MonkeyPatch) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
session.add = lambda obj: None # type: ignore[attr-defined]
await svc.create_ettn_for_receipt(session, client, receipt.id)
order.receipt_created_at = datetime.now(UTC)
await svc.cancel_receipt(session, client, receipt.id, user=None) # type: ignore[arg-type]
assert receipt.status == ReceiptStatus.CANCELLED
assert client.orders[receipt.checkbox_ettn_id or ""].status == EttnStatus.CANCELLED
assert order.receipt_created_at is None
async def test_cannot_cancel_done(self) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
receipt.status = ReceiptStatus.DONE
with pytest.raises(svc.ReceiptStateError):
await svc.cancel_receipt(
FakeSession(order, register, receipt), # type: ignore[arg-type]
StubCheckboxClient(),
receipt.id,
user=None, # type: ignore[arg-type]
)