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
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:
parent
63dbc209b2
commit
5bd9c586b7
2 changed files with 114 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue