Fix ETTN creation on live Checkbox

- prepayment as DISCOUNT: live API rejects PRE_PAYMENT with 400 third_party.generic
- normalize lowercase ETTN statuses returned by the live API
- show Checkbox error code instead of bare 'Internal Server Error'
- page size 50 for ETTN list, 429 rate limit is retryable, pause polling on it
- phone in 380XXXXXXXXX format like portal receipts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:49:50 +03:00
co-authored by Claude Opus 5.5
parent 912a37c00c
commit 3a6ad85d11
7 changed files with 73 additions and 26 deletions
+4 -1
View File
@@ -133,7 +133,10 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек.
- [ ] На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя `RECEIPT_ERROR`.
- [ ] Сверить реальное значение `np_payment_status` (`Payed` vs `Paid` — фронт и стаб расходятся).
- [x] Обновить `CLAUDE.md`/`README.md`/`.env.example` (статус, новые env-переменные).
- [ ] Проверить на первом боевом чеке, что `value` скидок (`DISCOUNT`/`PRE_PAYMENT`, mode `VALUE`) — в копейках.
- [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).
- [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса.
+1 -1
View File
@@ -122,7 +122,7 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
### Checkbox ETTN receipts
- We never fiscalize ourselves: the cashier creates an **ETTN receipt** in Checkbox bound to a Nova Poshta TTN with payment control; Checkbox fiscalizes it when the customer pays at the NP branch. Invariant: `order total − prepayment == np_cod_amount_kopecks`.
- We never fiscalize ourselves: the cashier creates an **ETTN receipt** in Checkbox bound to a Nova Poshta TTN with payment control; Checkbox fiscalizes it when the customer pays at the NP branch. Invariant: `order total − prepayment == np_cod_amount_kopecks`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them).
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
+8 -1
View File
@@ -7,7 +7,7 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class EttnStatus:
@@ -30,3 +30,10 @@ class EttnOut(BaseModel):
total_sum: int | None = Field(default=None, alias="totalSum")
receipt_id: str | None = Field(default=None, alias="receiptId")
raw_error: str | None = Field(default=None, alias="rawError")
# В OpenAPI статусы заглавные, а боевой API отдаёт строчные (`"done"`,
# `"created"`) — приводим к одному регистру, иначе статусы не сопоставятся.
@field_validator("status", mode="before")
@classmethod
def _upper_status(cls, value: object) -> object:
return value.upper() if isinstance(value, str) else value
+10 -3
View File
@@ -23,8 +23,9 @@ from app.services.checkbox.client import (
_PROVIDER = "novapost"
_TIMEOUT = httpx.Timeout(30, connect=10)
# Сколько страниц списка ЕТТН просматривать при сверке после таймаута.
_FIND_PAGES = 5
_FIND_PAGE_SIZE = 100
# Список отдаётся от новых к старым; больше 50 за страницу Checkbox не принимает.
_FIND_PAGES = 3
_FIND_PAGE_SIZE = 50
def _error_message(response: httpx.Response) -> str:
@@ -35,6 +36,11 @@ def _error_message(response: httpx.Response) -> str:
if isinstance(data, dict):
message = data.get("message")
detail = data.get("detail")
# Ошибки со стороны Новой Почты Checkbox отдаёт как
# {"code": "third_party.*", "message": "Internal Server Error"} —
# без кода кассир видит только бесполезное «Internal Server Error».
if code := data.get("code"):
message = f"{message or 'Ошибка'} ({code})"
if isinstance(detail, list) and detail:
parts = [
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
@@ -74,7 +80,8 @@ class HttpCheckboxClient:
)
except httpx.HTTPError as exc:
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
if response.status_code >= 500:
# 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо.
if response.status_code >= 500 or response.status_code == 429:
raise CheckboxUnavailableError(_error_message(response))
return response
+14 -14
View File
@@ -88,13 +88,13 @@ def _line_sum(price_kopecks: int, quantity: int) -> int:
def _normalize_phone(phone: str | None) -> str | None:
"""Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`."""
"""Телефон для отправки чека в формате `380XXXXXXXXX`, как в чеках из портала Checkbox."""
if not phone:
return None
digits = re.sub(r"\D", "", phone)
if len(digits) == 10 and digits.startswith("0"):
digits = "38" + digits
return f"+{digits}" if re.fullmatch(r"380\d{9}", digits) else None
return digits if re.fullmatch(r"380\d{9}", digits) else None
# --- Тело запроса ------------------------------------------------------------
@@ -145,7 +145,6 @@ def build_ettn_body(
order: Order, register: CashRegister, amounts: ReceiptAmounts
) -> dict[str, Any]:
goods, goods_total = build_goods(order, register.tax_codes)
discounts: list[dict[str, Any]] = []
# Скидка на весь заказ в CRM (не распределённая по строкам).
order_discount = goods_total - amounts.total_kopecks
@@ -153,18 +152,14 @@ def build_ettn_body(
raise ReceiptValidationError(
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
)
if order_discount:
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
# 400 `third_party.generic` (проверено на боевой кассе).
discount_total = order_discount + amounts.prepayment_kopecks
discounts: list[dict[str, Any]] = []
if discount_total:
discounts.append(
{"type": "DISCOUNT", "mode": "VALUE", "value": order_discount, "name": "Знижка"}
)
if amounts.prepayment_kopecks:
discounts.append(
{
"type": "PRE_PAYMENT",
"mode": "VALUE",
"value": amounts.prepayment_kopecks,
"name": "Передоплата",
}
{"type": "DISCOUNT", "mode": "VALUE", "value": discount_total, "name": "Знижка"}
)
# Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата)
@@ -490,6 +485,11 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
creds = registers[receipt.cash_register_id] = credentials(register)
try:
ettn = await client.get_ettn(creds, receipt.checkbox_ettn_id)
except CheckboxUnavailableError as exc:
# Недоступен или лимит частоты запросов — остальные чеки опросим
# в следующий раз, а не будем добивать Checkbox прямо сейчас.
log.warning("ettn_poll_paused", receipt_id=str(receipt.id), error=str(exc))
break
except CheckboxError as exc:
log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc))
continue
+32
View File
@@ -148,3 +148,35 @@ async def test_bad_pin_raises() -> None:
)
with pytest.raises(CheckboxError, match="Невірний пін-код"):
await _client().sign_in(CREDS)
@respx.mock
async def test_lowercase_status_from_live_api_is_normalized() -> None:
_signin()
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(
return_value=Response(200, json={**ETTN, "status": "done"})
)
ettn = await _client().get_ettn(CREDS, "e-1")
assert ettn.status == "DONE"
@respx.mock
async def test_third_party_error_code_is_shown() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=Response(
400, json={"code": "third_party.generic", "message": "Internal Server Error"}
)
)
with pytest.raises(CheckboxError, match=r"third_party\.generic"):
await _client().create_ettn(CREDS, {})
@respx.mock
async def test_rate_limit_is_retryable() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=Response(429, json={"message": "Занадто часто виконуються запити"})
)
with pytest.raises(CheckboxUnavailableError):
await _client().create_ettn(CREDS, {})
+4 -6
View File
@@ -117,7 +117,7 @@ class TestBuildBody:
}
]
assert "discounts" not in rb
assert rb["delivery"] == {"phone": "+380501112233", "emails": ["a@b.ua"]}
assert rb["delivery"] == {"phone": "380501112233", "emails": ["a@b.ua"]}
def test_prepayment_line_and_order_discounts_and_tax(self) -> None:
order = _order(
@@ -132,10 +132,8 @@ class TestBuildBody:
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),
]
# Скидка заказа 50 ₴ + предоплата 200 ₴ — одной «Знижкой», без PRE_PAYMENT.
assert [(d["type"], d["value"]) for d in rb["discounts"]] == [("DISCOUNT", 25000)]
assert rb["payments"][0]["value"] == 85000
def test_all_goods_with_prices_quantities_and_sums(self) -> None:
@@ -166,7 +164,7 @@ class TestBuildBody:
goods_total = sum(svc._line_sum(g["good"]["price"], g["quantity"]) for g in rb["goods"])
assert goods_total - 20000 == rb["payments"][0]["value"] == 130049
assert rb["discounts"] == [
{"type": "PRE_PAYMENT", "mode": "VALUE", "value": 20000, "name": "Передоплата"}
{"type": "DISCOUNT", "mode": "VALUE", "value": 20000, "name": "Знижка"}
]
def test_fractional_quantity(self) -> None: