All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m11s
Три дыры в одном замке (строка без payment_url невидима для _find_live_payment, но видима предикату UNIQUE 279 → ложный 409 на 30 минут): - tbank_client ловил пару (TimeoutException, NetworkError): RemoteProtocolError, ProxyError и UnsupportedProtocol летели наружу голым httpx-типом мимо `except TBankApiError` в checkout. Ловим родителя — httpx.TransportError. - Ветка «Success:true без PaymentURL» отвечала 502, не трогая статус, — здесь банк заказ ПРИНЯЛ. Тот же терминальный статус, error_code=no_payment_url; UPDATE вынесен в общий _mark_init_failed. - _FakeDb в тестах применял статус по наличию ключа в params, а не по тексту SQL: мутант без `SET status = :status` оставался зелёным. Гейт по SQL — мутант краснит все три теста про замок. Комментарий про «возможный холд» на ветке отказа Init поправлен: Init холд не создаёт, авторизация идёт с оплаты формы, а форму покупателю не выдавали.
841 lines
44 KiB
Python
841 lines
44 KiB
Python
"""Платёжный роутер МЕРЫ (Т-Банк эквайринг) — PR-D3: checkout, нотификация, выдача.
|
||
|
||
Проводка уже готовых слоёв: `app/services/payments/*` (подпись, разбор, httpx-
|
||
клиент — PR-C, БД и конфига не знают), схема `data/sql/233_payments.sql` (PR-B),
|
||
периметр (`ratelimit`/`request_audit`/`sentry_scrub` — PR-D2). Здесь — только
|
||
то, чего не было: HTTP-ручки и статус-машина.
|
||
|
||
ВСЁ за kill-switch `settings.payments_enabled` (дефолт False): при выключенном
|
||
контуре каждая ручка отвечает 503 и не ходит ни в банк, ни в платёжные таблицы.
|
||
Merge безопасен на выключенном контуре — на проде это ровно ноль изменений
|
||
поведения, пока владелец не выставит PAYMENTS_ENABLED=true вместе с ключами
|
||
терминала (`app/main.py` роняет старт, если включить без ключей).
|
||
|
||
── Идемпотентность держится на БД, а не на «проверить-потом-вставить» ────────
|
||
Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт
|
||
AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки),
|
||
закрыты UNIQUE-ключами миграций 233 и 279 + `ON CONFLICT DO NOTHING`. Пара
|
||
«SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти
|
||
параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT
|
||
живого платежа в `checkout` остался, но только как быстрый путь для честного
|
||
повтора — гонку ловит не он, а `payments_live_estimate_product_uidx`.
|
||
|
||
`payment_notifications.processed_at` — единственный признак «выдача
|
||
состоялась». Наличие строки нотификации таким признаком НЕ является: процесс
|
||
мог упасть между INSERT нотификации и выдачей, и тогда ретрай банка обязан
|
||
довести выдачу до конца, а не ответить "OK" на полпути (см. блок про
|
||
processed_at в шапке 233_payments.sql).
|
||
|
||
── Никаких исходящих HTTP внутри notify ─────────────────────────────────────
|
||
Бюджет одного вызова `TBankClient` — до ~74 с (см. его докстринг), окно ответа
|
||
банку — порядка 10 с. Поэтому обработчик нотификации только валидирует,
|
||
сохраняет и отвечает `"OK"` текстом (банк ждёт именно эту строку); любая сверка
|
||
с банком (GetState/CheckOrder) — задача реконсиляции, вне HTTP-цикла.
|
||
|
||
── Доставка купленного: capability-ссылка ───────────────────────────────────
|
||
`/r/<token>` — непредсказуемый `secrets.token_urlsafe(32)`, лежит в
|
||
`payment_entitlements.subject` (колонка документирована как «username или
|
||
anon-token, кому выдано»). Право доступа — сам токен: у покупателя-физлица на
|
||
meraocenka.ru идентичности нет и не будет. `ref_id` при этом остаётся
|
||
`estimate_id` — как задокументировано в миграции: именно на `(payment_id, kind,
|
||
ref_id)` держится UNIQUE «выдали один раз», и подстановка туда случайного
|
||
токена молча отменила бы эту гарантию (каждый повтор дал бы новый ref_id →
|
||
новую строку).
|
||
|
||
Токен НЕ логируется: ни в `logger.*` здесь, ни в GlitchTip — путь `/r/<token>`
|
||
режет `redact_report_link_token` в `app/observability/sentry_scrub.py`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import secrets
|
||
from datetime import UTC, datetime
|
||
from typing import Annotated, Any
|
||
from uuid import UUID, uuid4
|
||
|
||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.api.v1.trade_in import (
|
||
ESTIMATE_READABLE_SQL,
|
||
_assert_estimate_access,
|
||
load_estimate,
|
||
)
|
||
from app.core.config import settings
|
||
from app.core.db import get_db
|
||
from app.schemas.trade_in import AggregatedEstimate
|
||
from app.services.payments.notification import NotificationParseError, parse_notification
|
||
from app.services.payments.receipt import ReceiptItem, build_receipt, receipt_total_kopecks
|
||
from app.services.payments.tbank_client import TBankApiError, TBankClient
|
||
from app.services.payments.token import verify_notification_token
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# ── Каталог ───────────────────────────────────────────────────────────────────
|
||
# Цена — НЕ из тела запроса (иначе клиент назначает её сам), а из этой таблицы
|
||
# по product_code. 15 000 копеек = 150 ₽ = `SERVICE_PRICE_RUB` в
|
||
# frontend/src/app/mera-public/content.ts, где цена названа в оферте (п. 4.1) и
|
||
# в политике возврата. Рассинхронизацию ловит тест
|
||
# tests/test_payments_router.py::test_price_matches_published_offer — цена,
|
||
# отличающаяся от опубликованной в оферте, это не баг рендера, а неисполнение
|
||
# договора.
|
||
PRODUCT_PAID_REPORT = "paid_report"
|
||
_PRODUCTS: dict[str, tuple[str, int]] = {
|
||
# product_code: (наименование позиции чека — <=128 символов, цена в копейках)
|
||
PRODUCT_PAID_REPORT: ("Оценка стоимости квартиры (онлайн-отчёт)", 15_000),
|
||
}
|
||
|
||
ENTITLEMENT_REPORT_LINK = "report_link"
|
||
|
||
# Полный список из CHECK payments_status_check (233_payments.sql). Любой статус
|
||
# вне списка пишется как 'UNKNOWN' — контракт миграции: тихо исказить статус
|
||
# хуже, чем громко упасть, но и падать на нотификации нельзя (банк ретраит
|
||
# сутки). Дрейф относительно миграции ловит
|
||
# tests/test_payments_router.py::test_status_whitelist_matches_migration.
|
||
_KNOWN_STATUSES = frozenset(
|
||
{
|
||
"NEW",
|
||
"FORM_SHOWED",
|
||
"DEADLINE_EXPIRED",
|
||
"CANCELED",
|
||
"PREAUTHORIZING",
|
||
"AUTHORIZING",
|
||
"AUTHORIZED",
|
||
"AUTH_FAIL",
|
||
"REJECTED",
|
||
"3DS_CHECKING",
|
||
"3DS_CHECKED",
|
||
"CHECKING",
|
||
"CHECKED",
|
||
"PROCESSING",
|
||
"CONFIRMING",
|
||
"CONFIRMED",
|
||
"COMPLETING",
|
||
"COMPLETED",
|
||
"REVERSING",
|
||
"PARTIAL_REVERSED",
|
||
"REVERSED",
|
||
"REFUNDING",
|
||
"PARTIAL_REFUNDED",
|
||
"REFUNDED",
|
||
"REFUND_FAILED",
|
||
"UNKNOWN",
|
||
}
|
||
)
|
||
|
||
# Статусы ДО подтверждения: нотификация с таким статусом не имеет права
|
||
# затереть уже проставленный CONFIRMED. Банк не гарантирует порядок доставки
|
||
# (AUTHORIZED и CONFIRMED уходят одновременно при одностадийной оплате), а
|
||
# ретрай «отставшей» нотификации может прийти через час — без этой проверки
|
||
# оплаченный платёж откатился бы в AUTHORIZED и отчёт перестал бы выдаваться.
|
||
_PRE_CONFIRM_STATUSES = frozenset(
|
||
{
|
||
"NEW",
|
||
"FORM_SHOWED",
|
||
"PREAUTHORIZING",
|
||
"AUTHORIZING",
|
||
"AUTHORIZED",
|
||
"3DS_CHECKING",
|
||
"3DS_CHECKED",
|
||
"CHECKING",
|
||
"CHECKED",
|
||
"PROCESSING",
|
||
"CONFIRMING",
|
||
}
|
||
)
|
||
|
||
# Платёж в одном из этих статусов ещё «живой»: повторный checkout по той же
|
||
# оценке обязан вернуть ту же ссылку, а не создавать второй холд на карте
|
||
# покупателя. Терминальные (CANCELED/REJECTED/REFUNDED/...) сюда не входят —
|
||
# после отказа человек вправе попробовать оплатить заново.
|
||
#
|
||
# Этот же список — предикат частичного UNIQUE(estimate_id, product_code)
|
||
# миграции 279, который и делает «один живой платёж» свойством БД, а не
|
||
# порядка выполнения. Расхождение кода и миграции ловит
|
||
# tests/test_payments_router.py::test_live_status_predicate_matches_code.
|
||
_REUSABLE_STATUSES = _PRE_CONFIRM_STATUSES
|
||
|
||
# Статусы, из которых платёж НИКОГДА не выйдет сам: банк шлёт нотификации по
|
||
# исходу платежа, а не по факту «форма открыта», поэтому брошенный checkout
|
||
# остаётся в NEW/FORM_SHOWED навсегда. Без границы по времени покупатель
|
||
# получал бы на каждый повторный checkout одну и ту же ссылку — а она у банка
|
||
# уже протухла, и начать оплату заново становилось бы невозможно.
|
||
#
|
||
# Границу двигаем ТОЛЬКО по этим двум статусам. Всё, что дальше по цепочке
|
||
# (AUTHORIZING/AUTHORIZED/3DS_*/CHECKING/PROCESSING/CONFIRMING), означает, что
|
||
# карточный поток уже начался и деньги, возможно, захолдированы: пометить такое
|
||
# «просроченным» и выдать вторую ссылку — это и есть второй холд. Разгребать
|
||
# зависшие карточные статусы — работа реконсиляции (PR-E, GetState), а не
|
||
# checkout'а.
|
||
_ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
|
||
|
||
# 30 минут. Обоснование срока: за это окно нельзя «случайно» не дойти до карты —
|
||
# ввод карты и 3DS укладываются в минуты, а как только поток начался, статус
|
||
# уходит из _ABANDONABLE_STATUSES и окно к платежу вообще не применяется.
|
||
# Значит, к моменту истечения окна карточный поток провабельно не начинался, и
|
||
# освободить пару (estimate_id, product_code) под новую попытку безопасно.
|
||
# Остаточный риск честно называем: если покупатель через час всё-таки дооплатит
|
||
# СТАРУЮ форму, банк подтвердит её (выдача состоится по ней — статус-машина
|
||
# ниже это переживает), а новая попытка так и останется неоплаченной; два
|
||
# списания требуют, чтобы человек намеренно оплатил обе формы.
|
||
_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-ссылки
|
||
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
||
_REPORT_TOKEN_BYTES = 32
|
||
|
||
|
||
def _mark_init_failed(db: Session, order_id: str, *, code: str, message: str) -> None:
|
||
"""Переводит строку провалившегося Init в терминальный статус + «почему».
|
||
|
||
Общая для ВСЕХ исходов, после которых ссылки у строки не будет: отказ банка
|
||
и Success:true без PaymentURL. Статус NEW тут оставлять нельзя — см.
|
||
комментарий к `_INIT_FAILED_STATUS`: строка без `payment_url` невидима для
|
||
`_find_live_payment`, но видима предикату UNIQUE миграции 279, и следующий
|
||
checkout получил бы ложный 409 на все `_ABANDONED_AFTER_MINUTES`.
|
||
"""
|
||
db.execute(
|
||
text(
|
||
"""
|
||
UPDATE payments
|
||
SET status = :status,
|
||
error_code = :code,
|
||
error_message = :message,
|
||
updated_at = NOW()
|
||
WHERE order_id = :order_id
|
||
"""
|
||
),
|
||
{
|
||
"status": _INIT_FAILED_STATUS,
|
||
"code": code[:64],
|
||
"message": message[:500],
|
||
"order_id": order_id,
|
||
},
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def _require_enabled() -> None:
|
||
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
|
||
if not settings.payments_enabled:
|
||
raise HTTPException(status_code=503, detail="payments are disabled")
|
||
|
||
|
||
def _client() -> TBankClient:
|
||
return TBankClient(
|
||
terminal_key=settings.tbank_terminal_key,
|
||
password=settings.tbank_password.get_secret_value(),
|
||
base_url=settings.tbank_api_base_url,
|
||
)
|
||
|
||
|
||
class CheckoutInput(BaseModel):
|
||
estimate_id: UUID
|
||
product_code: str = Field(default=PRODUCT_PAID_REPORT, max_length=64)
|
||
# Чек 54-ФЗ требует Email ИЛИ Phone. Оба опциональны здесь и проверяются
|
||
# только когда чек включён (TBANK_RECEIPT_ENABLED) — до подключения ОФД
|
||
# требовать контакт незачем.
|
||
customer_email: str | None = Field(default=None, max_length=254)
|
||
customer_phone: str | None = Field(default=None, max_length=32)
|
||
|
||
|
||
class CheckoutOut(BaseModel):
|
||
order_id: str
|
||
payment_url: str
|
||
amount_kopecks: int
|
||
status: str
|
||
|
||
|
||
class ReportLinkOut(BaseModel):
|
||
"""Статус оплаты для экрана «после оплаты».
|
||
|
||
`report_url` — None, пока выдача не состоялась. Именно None, а не
|
||
правдоподобная ссылка «которая скоро заработает»: пустой результат честнее
|
||
ссылки, ведущей в 404.
|
||
"""
|
||
|
||
order_id: str
|
||
status: str
|
||
report_url: str | None
|
||
|
||
|
||
@router.post("/payments/checkout", response_model=CheckoutOut)
|
||
def checkout(
|
||
payload: CheckoutInput,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||
) -> CheckoutOut:
|
||
"""Создаёт платёж и возвращает `PaymentURL` формы Т-Банка.
|
||
|
||
Идемпотентность — свойство БД, а не порядка выполнения: частичный UNIQUE
|
||
(estimate_id, product_code) по живым статусам (миграция 279) физически не
|
||
даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO
|
||
NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и,
|
||
значит, во второй холд на карте покупателя).
|
||
|
||
Три исхода: живой платёж с готовой ссылкой — 200 с ТОЙ ЖЕ ссылкой; параллельный
|
||
checkout ещё не дошёл до ответа банка — 409 (ретрай через секунду вернёт
|
||
ссылку); иначе создаём новый платёж.
|
||
"""
|
||
_require_enabled()
|
||
|
||
product = _PRODUCTS.get(payload.product_code)
|
||
if product is None:
|
||
raise HTTPException(status_code=400, detail="unknown product_code")
|
||
item_name, amount_kopecks = product
|
||
|
||
estimate = db.execute(
|
||
text(
|
||
f"""
|
||
SELECT id, created_by
|
||
FROM trade_in_estimates
|
||
WHERE id = CAST(:id AS uuid) AND {ESTIMATE_READABLE_SQL}
|
||
"""
|
||
),
|
||
{"id": str(payload.estimate_id)},
|
||
).fetchone()
|
||
if estimate is None:
|
||
raise HTTPException(status_code=404, detail="estimate not found or expired")
|
||
if estimate.created_by is not None:
|
||
# Тот же IDOR-гвард (#690), что у остальных ручек по оценке. Он здесь не
|
||
# про «показать чужой отчёт напрямую», а про две другие двери: checkout
|
||
# по чужому estimate_id возвращает order_id чужого живого платежа (ветка
|
||
# переиспользования ниже), а order_id — это право доступа для
|
||
# /payments/status/<order_id>, который отдаёт capability-ссылку на отчёт,
|
||
# как только владелец заплатит.
|
||
#
|
||
# Проверяем только оценки, у которых владелец ЕСТЬ. У анонимной покупки
|
||
# на meraocenka.ru идентичности нет (см. блок про capability-ссылку в
|
||
# шапке модуля), и правом там работает сам неугадываемый estimate_id —
|
||
# требовать заголовок означало бы сделать анонимный checkout
|
||
# невозможным, а не более безопасным.
|
||
_assert_estimate_access(estimate.created_by, x_authenticated_user)
|
||
|
||
# Освобождаем пару (estimate_id, product_code) от брошенных попыток ДО
|
||
# проверки живого платежа: иначе и переиспользование вернуло бы мёртвую
|
||
# ссылку, и UNIQUE миграции 279 не дал бы создать новую (см. комментарий у
|
||
# _ABANDONED_AFTER_MINUTES).
|
||
db.execute(
|
||
text(
|
||
"""
|
||
UPDATE payments
|
||
SET status = 'DEADLINE_EXPIRED', updated_at = NOW()
|
||
WHERE estimate_id = CAST(:estimate_id AS uuid)
|
||
AND product_code = :product_code
|
||
AND status = ANY(CAST(:abandonable AS text[]))
|
||
AND created_at < NOW() - make_interval(mins => CAST(:mins AS int))
|
||
"""
|
||
),
|
||
{
|
||
"estimate_id": str(payload.estimate_id),
|
||
"product_code": payload.product_code,
|
||
"abandonable": sorted(_ABANDONABLE_STATUSES),
|
||
"mins": _ABANDONED_AFTER_MINUTES,
|
||
},
|
||
)
|
||
|
||
existing = _find_live_payment(db, payload)
|
||
if existing is not None:
|
||
return CheckoutOut(
|
||
order_id=existing.order_id,
|
||
payment_url=existing.payment_url,
|
||
amount_kopecks=existing.amount_kopecks,
|
||
status=existing.status,
|
||
)
|
||
|
||
receipt = _build_receipt_or_none(item_name, amount_kopecks, payload)
|
||
|
||
# order_id генерируем СВОЙ и до похода в банк: он и есть ключ, по которому
|
||
# нотификация найдёт платёж, а UNIQUE(order_id) — страховка от двойной
|
||
# записи. 5 + 32 = 37 символов, влезает в CHECK(char_length <= 50).
|
||
order_id = f"{_ORDER_ID_PREFIX}{uuid4().hex}"
|
||
inserted = db.execute( # fetchone() ДО commit(): курсор после коммита пуст
|
||
text(
|
||
"""
|
||
INSERT INTO payments (
|
||
order_id, terminal_key, product_code, amount_kopecks, status,
|
||
created_by, estimate_id, customer_email, customer_phone
|
||
) VALUES (
|
||
:order_id, :terminal_key, :product_code, :amount, 'NEW',
|
||
:created_by, CAST(:estimate_id AS uuid), :email, :phone
|
||
)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING order_id
|
||
"""
|
||
),
|
||
{
|
||
"order_id": order_id,
|
||
"terminal_key": settings.tbank_terminal_key,
|
||
"product_code": payload.product_code,
|
||
"amount": amount_kopecks,
|
||
"created_by": x_authenticated_user,
|
||
"estimate_id": str(payload.estimate_id),
|
||
"email": payload.customer_email,
|
||
"phone": payload.customer_phone,
|
||
},
|
||
).fetchone()
|
||
db.commit()
|
||
|
||
if inserted is None:
|
||
# Гонку выиграл параллельный checkout (двойной клик). Своего Init не
|
||
# делаем ни при каких условиях — он и есть второй холд.
|
||
rival = _find_live_payment(db, payload)
|
||
if rival is not None:
|
||
return CheckoutOut(
|
||
order_id=rival.order_id,
|
||
payment_url=rival.payment_url,
|
||
amount_kopecks=rival.amount_kopecks,
|
||
status=rival.status,
|
||
)
|
||
# Соперник вставил строку, но ответа банка ещё не получил: ссылки пока
|
||
# нет ни у кого. Честный 409 — придумать ссылку нечем, а ждать чужого
|
||
# Init внутри HTTP-цикла нельзя (его бюджет — до ~74 с).
|
||
logger.info("checkout: параллельный checkout ещё в полёте, estimate_id известен клиенту")
|
||
raise HTTPException(status_code=409, detail="checkout already in progress, retry shortly")
|
||
|
||
try:
|
||
# asyncio.run в синхронном хендлере — тот же мост, что и
|
||
# trade_in._try_revive_dead_estimate: Starlette гоняет `def`-хендлер в
|
||
# threadpool, поэтому свой event loop здесь никому не мешает, а
|
||
# остальные db.execute() остаются синхронными.
|
||
init = asyncio.run(
|
||
_client().init_payment(
|
||
order_id=order_id,
|
||
amount_kopecks=amount_kopecks,
|
||
description=item_name,
|
||
notification_url=settings.tbank_notification_url or None,
|
||
success_url=settings.tbank_success_url or None,
|
||
fail_url=settings.tbank_fail_url or None,
|
||
receipt=receipt,
|
||
pay_type=settings.tbank_pay_type,
|
||
)
|
||
)
|
||
except TBankApiError as exc:
|
||
# Запись остаётся в БД с текстом ошибки — иначе факт попытки (и заказа,
|
||
# который банк мог принять до обрыва) не остался бы нигде. Холда здесь
|
||
# быть не может: Init только заводит заказ и отдаёт ссылку на форму, а
|
||
# авторизация суммы происходит, когда покупатель платит по форме — её
|
||
# ему не выдавали. Слепой повтор Init по тому же order_id всё равно
|
||
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
|
||
#
|
||
# Статус — терминальный (см. `_mark_init_failed`); если банк всё же
|
||
# пришлёт по этой строке нотификацию, статус-машина notify доведёт её до
|
||
# конца (терминальный статус не блокирует CONFIRMED).
|
||
_mark_init_failed(db, order_id, code=exc.error_code, message=str(exc))
|
||
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
|
||
raise HTTPException(status_code=502, detail="payment provider error") from exc
|
||
|
||
payment_url = init.get("PaymentURL")
|
||
tbank_payment_id = init.get("PaymentId")
|
||
if not isinstance(payment_url, str) or not payment_url:
|
||
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
|
||
# ссылку нечем. Замок тот же, что и у отказа выше, и даже вернее: банк
|
||
# заказ ПРИНЯЛ, а ссылки у строки уже не будет — оставить её в NEW
|
||
# значит отдать следующему checkout ложный 409 на 30 минут.
|
||
_mark_init_failed(
|
||
db,
|
||
order_id,
|
||
code="no_payment_url",
|
||
message=f"Init: Success без PaymentURL (Status={init.get('Status')!r})",
|
||
)
|
||
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
|
||
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
|
||
|
||
status = init.get("Status")
|
||
db.execute(
|
||
text(
|
||
"""
|
||
UPDATE payments
|
||
SET tbank_payment_id = :payment_id,
|
||
payment_url = :payment_url,
|
||
status = :status,
|
||
init_response = CAST(:init AS jsonb),
|
||
updated_at = NOW()
|
||
WHERE order_id = :order_id
|
||
"""
|
||
),
|
||
{
|
||
"payment_id": str(tbank_payment_id) if tbank_payment_id is not None else None,
|
||
"payment_url": payment_url,
|
||
"status": status if status in _KNOWN_STATUSES else "UNKNOWN",
|
||
"init": json.dumps(init, ensure_ascii=False),
|
||
"order_id": order_id,
|
||
},
|
||
)
|
||
db.commit()
|
||
|
||
return CheckoutOut(
|
||
order_id=order_id,
|
||
payment_url=payment_url,
|
||
amount_kopecks=amount_kopecks,
|
||
status=status if status in _KNOWN_STATUSES else "UNKNOWN",
|
||
)
|
||
|
||
|
||
def _find_live_payment(db: Session, payload: CheckoutInput) -> Any:
|
||
"""Живой платёж по этой оценке, у которого уже есть ссылка на форму.
|
||
|
||
`payment_url IS NOT NULL` обязателен: строка без ссылки — это чужой checkout
|
||
в полёте (Init ещё не ответил), и отдавать по ней `payment_url=None` значило
|
||
бы соврать в схеме ответа. Такой случай — 409 у вызывающей стороны.
|
||
"""
|
||
return db.execute(
|
||
text(
|
||
"""
|
||
SELECT order_id, payment_url, amount_kopecks, status
|
||
FROM payments
|
||
WHERE estimate_id = CAST(:estimate_id AS uuid)
|
||
AND product_code = :product_code
|
||
AND payment_url IS NOT NULL
|
||
AND status = ANY(CAST(:reusable AS text[]))
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{
|
||
"estimate_id": str(payload.estimate_id),
|
||
"product_code": payload.product_code,
|
||
"reusable": sorted(_REUSABLE_STATUSES),
|
||
},
|
||
).fetchone()
|
||
|
||
|
||
def _build_receipt_or_none(
|
||
item_name: str, amount_kopecks: int, payload: CheckoutInput
|
||
) -> dict[str, Any] | None:
|
||
"""Чек 54-ФЗ — только когда владелец включил его и задал систему налогообложения.
|
||
|
||
Сверка `receipt_total_kopecks == Init.Amount` здесь и есть тот инвариант,
|
||
который `build_receipt` намеренно не проверяет у себя (см. его докстринг):
|
||
чек на сумму, отличную от списанной, — это фискальное нарушение, а не
|
||
косметика.
|
||
"""
|
||
if not settings.tbank_receipt_enabled:
|
||
return None
|
||
if not settings.tbank_taxation:
|
||
logger.error("checkout: TBANK_RECEIPT_ENABLED=true, но TBANK_TAXATION не задан")
|
||
raise HTTPException(status_code=503, detail="receipt is not configured")
|
||
if not payload.customer_email and not payload.customer_phone:
|
||
raise HTTPException(status_code=400, detail="customer_email or customer_phone is required")
|
||
receipt = build_receipt(
|
||
items=[ReceiptItem(name=item_name, price_kopecks=amount_kopecks)],
|
||
taxation=settings.tbank_taxation, # type: ignore[arg-type]
|
||
email=payload.customer_email,
|
||
phone=payload.customer_phone,
|
||
)
|
||
if receipt_total_kopecks(receipt) != amount_kopecks:
|
||
logger.error("checkout: сумма чека разошлась с суммой заказа")
|
||
raise HTTPException(status_code=500, detail="receipt total mismatch")
|
||
return receipt
|
||
|
||
|
||
@router.post("/payments/notify")
|
||
async def notify(request: Request, db: Annotated[Session, Depends(get_db)]) -> Response:
|
||
"""Вебхук Т-Банка. Отвечает `"OK"` текстом — банк ждёт именно эту строку.
|
||
|
||
Любой другой ответ банк трактует как недоставленную нотификацию и ретраит.
|
||
Поэтому "OK" отдаётся и на том, что мы обработать не можем, но что нашей
|
||
проблемой не является (неизвестный OrderId); отказ (4xx) остаётся только
|
||
там, где принять событие означало бы солгать про деньги: неверная подпись
|
||
и расхождение суммы.
|
||
"""
|
||
_require_enabled()
|
||
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="malformed body") from None
|
||
if not isinstance(body, dict):
|
||
raise HTTPException(status_code=400, detail="malformed body")
|
||
|
||
token_valid = verify_notification_token(body, settings.tbank_password.get_secret_value())
|
||
|
||
# Пишем сырой лог ДО любых выводов о содержимом (включая невалидную
|
||
# подпись): append-only лог входящих — единственное место, где остаётся
|
||
# факт попытки. Ключи читаем «как есть», без разбора: разбор может упасть,
|
||
# запись факта — нет.
|
||
notif = _log_notification(db, body, token_valid=token_valid)
|
||
|
||
if not token_valid:
|
||
db.commit()
|
||
logger.warning("notify: подпись не сошлась — нотификация отвергнута")
|
||
raise HTTPException(status_code=403, detail="invalid token")
|
||
|
||
try:
|
||
parsed = parse_notification(body)
|
||
except NotificationParseError as exc:
|
||
db.commit()
|
||
logger.warning("notify: тело не разобралось: %s", exc)
|
||
raise HTTPException(status_code=400, detail="malformed notification") from None
|
||
|
||
if notif is not None and notif.processed_at is not None:
|
||
# Выдача по этой нотификации уже состоялась — ретрай банка.
|
||
db.commit()
|
||
return Response(content="OK", media_type="text/plain")
|
||
|
||
payment = db.execute(
|
||
text(
|
||
"""
|
||
SELECT id, order_id, status, amount_kopecks, estimate_id, created_by
|
||
FROM payments
|
||
WHERE order_id = :order_id
|
||
"""
|
||
),
|
||
{"order_id": parsed.order_id},
|
||
).fetchone()
|
||
if payment is None:
|
||
# Чужой/устаревший OrderId. Ретраи не помогут — отвечаем "OK", факт
|
||
# уже лежит в payment_notifications.
|
||
db.commit()
|
||
logger.warning("notify: нотификация по неизвестному order_id")
|
||
return Response(content="OK", media_type="text/plain")
|
||
|
||
if parsed.amount_kopecks != payment.amount_kopecks:
|
||
# Подпись Т-Банка конкатенирует значения БЕЗ разделителя, поэтому сама
|
||
# по себе не гарантирует сумму (см. докстринг notification.py). Сверка
|
||
# с суммой, записанной при Init, — единственная реальная защита.
|
||
db.commit()
|
||
logger.error(
|
||
"notify: сумма нотификации разошлась с суммой заказа (order_id=%s)", parsed.order_id
|
||
)
|
||
raise HTTPException(status_code=400, detail="amount mismatch")
|
||
|
||
status = parsed.status if parsed.status in _KNOWN_STATUSES else "UNKNOWN"
|
||
if not (payment.status == "CONFIRMED" and status in _PRE_CONFIRM_STATUSES):
|
||
_apply_status(db, payment.order_id, status, parsed.payment_id)
|
||
|
||
if status == "CONFIRMED" and parsed.success:
|
||
_fulfill(db, payment_id=payment.id, estimate_id=payment.estimate_id)
|
||
|
||
if notif is not None:
|
||
# processed_at ставится ПОСЛЕ выдачи — иначе ретрай банка увидел бы
|
||
# «обработано» по нотификации, выдача по которой не состоялась.
|
||
db.execute(
|
||
text(
|
||
"UPDATE payment_notifications SET processed_at = NOW() "
|
||
"WHERE id = :id AND processed_at IS NULL"
|
||
),
|
||
{"id": notif.id},
|
||
)
|
||
db.commit()
|
||
return Response(content="OK", media_type="text/plain")
|
||
|
||
|
||
def _log_notification(db: Session, body: dict[str, Any], *, token_valid: bool) -> Any:
|
||
"""INSERT ... ON CONFLICT DO NOTHING + добор уже существующей строки.
|
||
|
||
Дедуп делает БД (UNIQUE NULLS NOT DISTINCT на (tbank_payment_id, status,
|
||
amount_kopecks, token), миграция 233), а не «SELECT, потом INSERT»: между
|
||
проверкой и вставкой проходит параллельный ретрай банка, и выдача
|
||
происходит дважды.
|
||
|
||
Если строка уже была — достаём её тем же ключом через IS NOT DISTINCT FROM
|
||
(зеркало NULLS NOT DISTINCT), потому что решение «выдавать или нет»
|
||
принимается по её `processed_at`, а не по факту существования.
|
||
"""
|
||
key = {
|
||
"order_id": _raw_str(body.get("OrderId")),
|
||
"payment_id": _raw_str(body.get("PaymentId")),
|
||
"status": _raw_str(body.get("Status")),
|
||
"amount": body.get("Amount") if isinstance(body.get("Amount"), int) else None,
|
||
"token": _raw_str(body.get("Token")),
|
||
}
|
||
inserted = db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO payment_notifications (
|
||
order_id, tbank_payment_id, status, amount_kopecks, token, token_valid, body
|
||
) VALUES (
|
||
:order_id, :payment_id, :status, :amount, :token, :token_valid,
|
||
CAST(:body AS jsonb)
|
||
)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING id, processed_at
|
||
"""
|
||
),
|
||
{**key, "token_valid": token_valid, "body": json.dumps(body, ensure_ascii=False)},
|
||
).fetchone()
|
||
if inserted is not None:
|
||
return inserted
|
||
return db.execute(
|
||
text(
|
||
"""
|
||
SELECT id, processed_at
|
||
FROM payment_notifications
|
||
WHERE tbank_payment_id IS NOT DISTINCT FROM :payment_id
|
||
AND status IS NOT DISTINCT FROM :status
|
||
AND amount_kopecks IS NOT DISTINCT FROM :amount
|
||
AND token IS NOT DISTINCT FROM :token
|
||
"""
|
||
),
|
||
key,
|
||
).fetchone()
|
||
|
||
|
||
def _raw_str(value: Any) -> str | None:
|
||
"""Значение для сырого лога: строка как есть, всё остальное — NULL.
|
||
|
||
Приводить чужие типы к строке здесь нельзя: дедуп-ключ должен совпадать у
|
||
оригинала и ретрая побайтово, а `str(1)` и `"1"` — уже разные истории.
|
||
"""
|
||
return value if isinstance(value, str) else None
|
||
|
||
|
||
def _apply_status(db: Session, order_id: str, status: str, tbank_payment_id: str) -> None:
|
||
"""Двигает статус платежа и проставляет отметки времени переходов."""
|
||
db.execute(
|
||
text(
|
||
"""
|
||
UPDATE payments
|
||
SET status = :status,
|
||
tbank_payment_id = COALESCE(tbank_payment_id, :payment_id),
|
||
authorized_at = CASE WHEN :status = 'AUTHORIZED'
|
||
THEN COALESCE(authorized_at, NOW()) ELSE authorized_at END,
|
||
confirmed_at = CASE WHEN :status = 'CONFIRMED'
|
||
THEN COALESCE(confirmed_at, NOW()) ELSE confirmed_at END,
|
||
refunded_at = CASE WHEN :status IN ('REFUNDED', 'PARTIAL_REFUNDED',
|
||
'REVERSED', 'PARTIAL_REVERSED')
|
||
THEN COALESCE(refunded_at, NOW()) ELSE refunded_at END,
|
||
updated_at = NOW()
|
||
WHERE order_id = :order_id
|
||
"""
|
||
),
|
||
{"status": status, "payment_id": tbank_payment_id, "order_id": order_id},
|
||
)
|
||
|
||
|
||
def _fulfill(db: Session, *, payment_id: Any, estimate_id: Any) -> None:
|
||
"""Выдача: capability-ссылка на оплаченный отчёт + продление хранения оценки.
|
||
|
||
«Выдали один раз» гарантирует UNIQUE NULLS NOT DISTINCT (payment_id, kind,
|
||
ref_id) миграции 233: повторная нотификация не вернёт строку из RETURNING и
|
||
второй токен не родится. Проверять существование заранее нельзя — гонка.
|
||
"""
|
||
if estimate_id is None:
|
||
logger.error("fulfill: платёж без estimate_id — выдавать нечего")
|
||
return
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO payment_entitlements (payment_id, subject, kind, ref_id, expires_at)
|
||
VALUES (
|
||
CAST(:payment_id AS uuid), :subject, :kind, CAST(:ref_id AS uuid),
|
||
NOW() + make_interval(days => CAST(:days AS int))
|
||
)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING id
|
||
"""
|
||
),
|
||
{
|
||
"payment_id": str(payment_id),
|
||
"subject": secrets.token_urlsafe(_REPORT_TOKEN_BYTES),
|
||
"kind": ENTITLEMENT_REPORT_LINK,
|
||
"ref_id": str(estimate_id),
|
||
"days": settings.trade_in_paid_retention_days,
|
||
},
|
||
).fetchone()
|
||
if row is None:
|
||
logger.info("fulfill: выдача по этому платежу уже была — повтор не создаётся")
|
||
return
|
||
|
||
# Оплаченная оценка живёт год (retain_until, миграция 240) — иначе purge-
|
||
# джоба удалит строку через 24 часа и capability-ссылка укажет в пустоту.
|
||
# GREATEST — чтобы повторная покупка не УКОРАЧИВАЛА уже выданный срок.
|
||
db.execute(
|
||
text(
|
||
"""
|
||
UPDATE trade_in_estimates
|
||
SET retain_until = GREATEST(
|
||
COALESCE(retain_until, NOW()),
|
||
NOW() + make_interval(days => CAST(:days AS int))
|
||
)
|
||
WHERE id = CAST(:id AS uuid)
|
||
"""
|
||
),
|
||
{"days": settings.trade_in_paid_retention_days, "id": str(estimate_id)},
|
||
)
|
||
|
||
|
||
@router.get("/payments/status/{order_id}", response_model=ReportLinkOut)
|
||
def payment_status(
|
||
order_id: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> ReportLinkOut:
|
||
"""Экран «после оплаты»: статус заказа и ссылка на отчёт, когда выдача была.
|
||
|
||
Правом здесь работает сам `order_id` — 128 бит случайности, известные
|
||
только браузеру покупателя (он же уходит в банк как OrderId и возвращается
|
||
на SuccessURL). Пока выдачи нет — `report_url: null`.
|
||
"""
|
||
_require_enabled()
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT p.order_id, p.status, pe.subject AS report_token
|
||
FROM payments p
|
||
LEFT JOIN payment_entitlements pe
|
||
ON pe.payment_id = p.id AND pe.kind = :kind
|
||
WHERE p.order_id = :order_id
|
||
"""
|
||
),
|
||
{"order_id": order_id, "kind": ENTITLEMENT_REPORT_LINK},
|
||
).fetchone()
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="order not found")
|
||
return ReportLinkOut(
|
||
order_id=row.order_id,
|
||
status=row.status,
|
||
report_url=f"/api/v1/trade-in/r/{row.report_token}" if row.report_token else None,
|
||
)
|
||
|
||
|
||
@router.get("/r/{token}", response_model=AggregatedEstimate)
|
||
def report_by_link(
|
||
token: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> AggregatedEstimate:
|
||
"""Оплаченный отчёт по capability-ссылке — БЕЗ RBAC, право доступа = токен.
|
||
|
||
Одна и та же 404 на «токена нет», «токен протух» и «оценка удалена»:
|
||
различать их значило бы подтверждать существование чужих токенов
|
||
перебирающему.
|
||
"""
|
||
_require_enabled()
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT ref_id, expires_at
|
||
FROM payment_entitlements
|
||
WHERE kind = :kind AND subject = :token
|
||
"""
|
||
),
|
||
{"kind": ENTITLEMENT_REPORT_LINK, "token": token},
|
||
).fetchone()
|
||
if row is None or row.ref_id is None:
|
||
raise HTTPException(status_code=404, detail="report not found")
|
||
if row.expires_at is not None and row.expires_at.replace(tzinfo=UTC) <= datetime.now(tz=UTC):
|
||
raise HTTPException(status_code=404, detail="report not found")
|
||
|
||
# capability_granted=True: право уже доказано токеном выше. Флаг не является
|
||
# полем запроса — подобрать его снаружи нельзя (см. докстринг load_estimate).
|
||
return load_estimate(
|
||
db, UUID(str(row.ref_id)), x_authenticated_user=None, capability_granted=True
|
||
)
|