From 5bd9c586b7dc3c081f7b35ac5071d36a0fdf1db4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 23:54:22 +0500 Subject: [PATCH] =?UTF-8?q?fix(payments):=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=BD=D0=BA=D0=B0=20=D0=BD=D0=B0=20Init=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BD=D0=B0=2030?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строка после провального Init оставалась в NEW и без payment_url: для _find_live_payment (фильтр payment_url IS NOT NULL) её нет, а для предиката UNIQUE миграции 279 — есть. Следующий checkout ловил конфликт и получал 409 'retry shortly' до истечения _ABANDONED_AFTER_MINUTES — из-за сбоя банка, а не своего действия. Переводим такую строку в терминальный DEADLINE_EXPIRED (тот же, которым checkout уже помечает брошенные попытки) в том же UPDATE, что пишет error_code/error_message: статус вне предиката 279, пара (estimate_id, product_code) освобождается сразу. Новый статус в CHECK 233 не заводим — миграция ради ярлыка не нужна, причина и так в error_*. Refs #3323 --- tradein-mvp/backend/app/api/v1/payments.py | 43 +++++++++-- .../backend/tests/test_payments_router.py | 77 +++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/payments.py b/tradein-mvp/backend/app/api/v1/payments.py index dc48b658..bcd8c2be 100644 --- a/tradein-mvp/backend/app/api/v1/payments.py +++ b/tradein-mvp/backend/app/api/v1/payments.py @@ -187,6 +187,16 @@ _ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"}) # списания требуют, чтобы человек намеренно оплатил обе формы. _ABANDONED_AFTER_MINUTES = 30 +# Статус для строки, чей Init отвалился: своего кода вроде INIT_FAILED в +# CHECK миграции 233 нет, а заводить его ради этого случая значило бы менять +# схему ради ярлыка — «почему» и так лежит в error_code/error_message. Берём +# терминальный DEADLINE_EXPIRED, которым checkout уже помечает попытки, из +# которых платёж не выйдет сам. Обязательное свойство ровно одно: статус ВНЕ +# предиката 279 (= вне _REUSABLE_STATUSES), иначе мёртвая строка продолжит +# держать пару (estimate_id, product_code). Это стережёт +# tests/test_payments_router.py::test_init_failed_status_is_terminal. +_INIT_FAILED_STATUS = "DEADLINE_EXPIRED" + _ORDER_ID_PREFIX = "mera-" # 32 байта энтропии (43 символа base64url) — перебор capability-ссылки # неосуществим, а сама ссылка остаётся кликабельной в мессенджере. @@ -389,19 +399,40 @@ def checkout( ) ) except TBankApiError as exc: - # Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт - # попытки (и возможного холда, если обрыв случился после приёма запроса - # банком) не остался бы нигде. Слепой повтор Init по тому же order_id - # запрещён (см. докстринг init_payment) — это работа реконсиляции. + # Запись остаётся в БД с текстом ошибки — иначе факт попытки (и + # возможного холда, если обрыв случился после приёма запроса банком) не + # остался бы нигде. Слепой повтор Init по тому же order_id запрещён + # (см. докстринг init_payment) — это работа реконсиляции. + # + # А вот статус NEW оставлять нельзя: ссылки у этой строки нет и уже не + # будет, поэтому `_find_live_payment` её не увидит (там + # `payment_url IS NOT NULL`), но предикат UNIQUE миграции 279 — увидит, + # и следующий checkout получит конфликт вместо новой попытки. Покупатель + # оказался бы заперт на 30 минут (_ABANDONED_AFTER_MINUTES) из-за сбоя + # на стороне банка. Переводим строку в тот же терминальный + # DEADLINE_EXPIRED, которым выше помечаются брошенные попытки: он вне + # предиката 279, поэтому пара (estimate_id, product_code) освобождается + # той же транзакцией, а «почему» лежит в error_code/error_message. + # Форма покупателю не выдавалась, так что списать по этой строке нечего; + # если банк всё же пришлёт по ней нотификацию — статус-машина notify + # доведёт её до конца (терминальный статус не блокирует CONFIRMED). db.execute( text( """ UPDATE payments - SET error_code = :code, error_message = :message, updated_at = NOW() + SET status = :status, + error_code = :code, + error_message = :message, + updated_at = NOW() WHERE order_id = :order_id """ ), - {"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id}, + { + "status": _INIT_FAILED_STATUS, + "code": exc.error_code[:64], + "message": str(exc)[:500], + "order_id": order_id, + }, ) db.commit() logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc) diff --git a/tradein-mvp/backend/tests/test_payments_router.py b/tradein-mvp/backend/tests/test_payments_router.py index 25031832..aa6f0b58 100644 --- a/tradein-mvp/backend/tests/test_payments_router.py +++ b/tradein-mvp/backend/tests/test_payments_router.py @@ -772,6 +772,83 @@ def test_abandoned_checkout_does_not_lock_the_buyer_out( assert len(_paid_report_rows(db)) == 2 +def test_bank_refusal_on_init_does_not_lock_the_buyer_out( + client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch +) -> None: + """Отказ банка на Init не запирает покупателя до истечения окна. + + Строка после провального Init остаётся навсегда без `payment_url`: + `_find_live_payment` её не видит (там `payment_url IS NOT NULL`), а предикат + UNIQUE миграции 279 — видит. Если оставить ей статус NEW, следующий checkout + получит конфликт и ложный 409 «retry shortly» на все 30 минут + `_ABANDONED_AFTER_MINUTES` — из-за чужого сбоя, а не своего действия. + + Фальсификация (проверено `git apply -R`): убрать `status = :status` из + UPDATE в ветке TBankApiError → второй checkout отвечает 409 вместо 200, + новой строки и второго Init нет. Тест краснеет по значению. + """ + from app.api.v1 import payments as payments_module + from app.api.v1.payments import _REUSABLE_STATUSES + from app.services.payments.tbank_client import TBankApiError + + calls: list[dict[str, Any]] = [] + + class _FlakyClient: + async def init_payment(self, **kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + if len(calls) == 1: + raise TBankApiError("Init", "9999", "терминал временно недоступен") + return { + "Success": True, + "Status": "NEW", + "PaymentId": "3000000042", + "PaymentURL": "https://securepayments.tinkoff.ru/after-retry", + } + + monkeypatch.setattr(payments_module, "_client", _FlakyClient) + + failed = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + assert failed.status_code == 502, failed.text + + dead = _paid_report_rows(db)[0] + assert dead.status not in _REUSABLE_STATUSES, "мёртвая строка осталась в предикате 279" + assert dead.payment_url is None + + retry = client.post("/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}) + + assert retry.status_code == 200, retry.text + assert retry.json()["payment_url"] == "https://securepayments.tinkoff.ru/after-retry" + assert len(_paid_report_rows(db)) == 2, "повтор после отказа банка не создал новую попытку" + assert len(calls) == 2 + + +def test_init_failed_status_is_outside_the_live_predicate() -> None: + """`_INIT_FAILED_STATUS` обязан быть вне предиката 279 и внутри CHECK 233. + + Сверка идёт с ТЕКСТОМ миграций, а не с константами того же модуля: статус, + попавший в предикат, вернул бы ложный 409, а статус вне CHECK уронил бы + UPDATE прямо в ветке обработки отказа банка. + """ + from app.api.v1.payments import _INIT_FAILED_STATUS + + sql_dir = _REPO_ROOT / "backend" / "data" / "sql" + predicate = re.search( + r"AND status IN \((.*?)\)", + (sql_dir / "279_payments_live_checkout_uidx.sql").read_text(encoding="utf-8"), + re.S, + ) + assert predicate is not None + assert _INIT_FAILED_STATUS not in set(re.findall(r"'([^']+)'", predicate.group(1))) + + check = re.search( + r"payments_status_check\s+CHECK \(status IN \((.*?)\)\)", + (sql_dir / "233_payments.sql").read_text(encoding="utf-8"), + re.S, + ) + assert check is not None, "CHECK статусов не найден в миграции 233" + assert _INIT_FAILED_STATUS in set(re.findall(r"'([^']+)'", check.group(1))) + + def test_checkout_rejects_foreign_estimate( client: TestClient, db: _FakeDb, bank: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch ) -> None: