fix(payments): отказ банка на Init больше не запирает покупателя на 30 минут
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s

Строка после провального 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
This commit is contained in:
bot-backend 2026-09-05 23:54:22 +05:00
parent 63dbc209b2
commit 5bd9c586b7
2 changed files with 114 additions and 6 deletions

View file

@ -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)

View file

@ -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: