feat(mera/b2c): платёжный роутер, статус-машина и доставка купленного (за флагом) #3231

Merged
bot-backend merged 3 commits from feat/b2c-payments-router into main 2026-08-29 15:08:34 +00:00
8 changed files with 1739 additions and 5 deletions

View file

@ -0,0 +1,797 @@
"""Платёжный роутер МЕРЫ (Т-Банк эквайринг) — 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
_ORDER_ID_PREFIX = "mera-"
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
_REPORT_TOKEN_BYTES = 32
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:
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
# попытки (и возможного холда, если обрыв случился после приёма запроса
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
db.execute(
text(
"""
UPDATE payments
SET 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},
)
db.commit()
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 — контракт банка нарушен; выдумывать
# ссылку нечем.
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
)

View file

@ -627,6 +627,31 @@ def get_estimate(
Возвращает 404 если оценка не найдена или TTL истёк.
"""
return load_estimate(db, estimate_id, x_authenticated_user=x_authenticated_user)
def load_estimate(
db: Session,
estimate_id: UUID,
*,
x_authenticated_user: str | None,
capability_granted: bool = False,
) -> AggregatedEstimate:
"""Тело GET /estimate/{id} без FastAPI-обвязки — чтобы у ВТОРОГО права
доступа был тот же самый загрузчик, а не его копия.
`capability_granted=True` вызывающая сторона уже доказала право доступа
ДРУГИМ способом, чем `X-Authenticated-User` + roles.yaml: capability-ссылка
`/r/<token>` (app/api/v1/payments.py) отдаёт оплаченный отчёт анониму,
у которого идентичности нет и не будет там правом является сам
непредсказуемый токен, сверенный по `payment_entitlements`.
Флаг ИМЕННО параметр обычной функции, а не поле запроса: у route-хендлера
`get_estimate` выше его нет, поэтому подобрать его снаружи (query/заголовком)
невозможно включить его может только код в этом процессе. Обратное
(добавить параметр в сам хендлер с default=False) сделало бы обход IDOR-
гварда #690 доступным любому клиенту через `?capability_granted=true`.
"""
row = db.execute(
text(
f"""
@ -654,7 +679,8 @@ def get_estimate(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found or expired")
_assert_estimate_access(row.created_by, x_authenticated_user)
if not capability_granted:
_assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем

View file

@ -129,8 +129,27 @@ _PUBLIC_PATHS = frozenset(
# Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled.
"/api/public/mera/estimate",
"/api/public/mera/estimate/read",
# Вебхук Т-Банка (app/api/v1/payments.py::notify): сервер-к-серверу,
# X-Authenticated-User/сессии у банка нет и быть не может. Путь не
# секрет — аутентификацией здесь работает подпись `Token` тела,
# которую проверяет сам хендлер (services/payments/token.py), плюс
# отдельный узкий rate-limit (core/ratelimit.py). Всё остальное
# платёжное (checkout, статус заказа) остаётся ЗАКРЫТЫМ — открыт ровно
# тот путь, который иначе получил бы 401 у банка.
"/api/v1/trade-in/payments/notify",
}
)
# Capability-ссылка на оплаченный отчёт: /api/v1/trade-in/r/<token>. Точной
# строкой её в множество выше не положить — токен переменный, а множество
# проверяется как `path in`. Отдельный кортеж префиксов, а не превращение
# `_PUBLIC_PATHS` в префиксный матчер: тот механизм держит auth-гейт всего
# бэкенда, расширять его семантику ради одного роута нельзя.
#
# Открывается ИМЕННО подпуть /r/ и ничего выше: правом доступа служит сам
# непредсказуемый токен (32 байта энтропии), сверяемый по payment_entitlements
# в app/api/v1/payments.py. У покупателя-физлица на meraocenka.ru идентичности
# нет и не будет, поэтому иного способа отдать ему купленное не существует.
_PUBLIC_PATH_PREFIXES = ("/api/v1/trade-in/r/",)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
@ -217,7 +236,7 @@ async def rbac_guard(
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
return await call_next(request)
username: str | None = None

View file

@ -31,6 +31,7 @@ from app.api.v1 import (
glitchtip,
lead,
me,
payments,
privacy_admin,
search,
support,
@ -286,6 +287,11 @@ app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-ve
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
# Платёжный контур — весь за settings.payments_enabled (дефолт False): роутер
# подключён всегда, но каждая его ручка отвечает 503, пока контур выключен.
# Подключать по флагу было бы хуже: путь /payments/notify обязан существовать
# и отвечать предсказуемо, а не менять форму ответа вместе с конфигом.
app.include_router(payments.router, prefix="/api/v1/trade-in", tags=["trade-in-payments"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -131,6 +131,20 @@ _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
# Capability-токен оплаченного отчёта живёт В ПУТИ (`/api/v1/trade-in/r/<token>`,
# app/api/v1/payments.py), а не в query и не в теле — значит ни `_PII_KEYS`
# (ключ-based), ни `scrub_payment_request_body` (режет request.data по сегменту
# `/payments/`), ни sentry_sdk `sanitize_url` (режет только userinfo и query)
# его не касаются. А путь попадает в событие несколькими путями сразу:
# `event.request.url`, `transaction`, breadcrumb'ы, текст исключения. Токен —
# это ПРАВО ДОСТУПА целиком: утёкший в GlitchTip путь равен выданному отчёту.
# Поэтому — full-text regex по всему событию, как у TG-токена.
# `[\w-]` покрывает алфавит `secrets.token_urlsafe` (base64url), хвост
# `(?=[/?#]|$)` оставляет нетронутым остаток URL (query/фрагмент) — он полезен
# для диагностики и секретом не является.
_REPORT_LINK_TOKEN_RE = re.compile(r"(/api/v1/trade-in/r/)[\w-]+(?=[/?#]|$)")
_REPORT_LINK_TOKEN_REPLACEMENT = r"\g<1>" + _REDACTED
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -205,6 +219,7 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(event.get("contexts"))
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _REPORT_LINK_TOKEN_RE, _REPORT_LINK_TOKEN_REPLACEMENT)
return event

View file

@ -0,0 +1,65 @@
-- 279_payments_live_checkout_uidx.sql
-- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на
-- пару (estimate_id, product_code).
--
-- ── WHY ──────────────────────────────────────────────────────────────────────
-- До этой миграции переиспользование живого платежа в
-- app/api/v1/payments.py::checkout держалось на «SELECT, потом INSERT» — ровно
-- на том, что шапка того же модуля называет дефектом. Обычный двойной клик по
-- кнопке оплаты даёт два параллельных запроса: оба проходят SELECT до того, как
-- любой из них вставит строку, оба делают INSERT, оба зовут Init — и на карте
-- покупателя появляются ДВА ХОЛДА. Порядок выполнения тут ничего не решает,
-- решает уникальный ключ: второй INSERT обязан отбиться самой БД.
--
-- ── Почему частичный, а не полный UNIQUE(estimate_id, product_code) ──────────
-- Полный запретил бы повторную покупку навсегда: после CANCELED/REJECTED
-- человек вправе попробовать оплатить заново, а после CONFIRMED — купить
-- второй раз. Поэтому в предикате только «живые» статусы — те, при которых
-- платёжная сессия ещё не завершилась. Список = _REUSABLE_STATUSES
-- (= _PRE_CONFIRM_STATUSES) в app/api/v1/payments.py; расхождение ловит
-- tests/test_payments_router.py::test_live_status_predicate_matches_code —
-- индекс, который шире кода, запрещает легитимный повтор, а который уже —
-- пропускает второй холд.
--
-- Предикат ссылается только на неизменяемые выражения (сравнение колонок с
-- литералами), поэтому индекс легален как частичный, а строка входит в него и
-- выходит автоматически при UPDATE статуса.
--
-- estimate_id IS NOT NULL — обязательная часть предиката: колонка nullable
-- (FK ON DELETE SET NULL, 233_payments.sql), а обычный UNIQUE считает NULL
-- отличным от NULL, так что без этого условия строки с NULL просто копились бы
-- в индексе без пользы.
--
-- ── Безопасность применения ─────────────────────────────────────────────────
-- Контур выключен (PAYMENTS_ENABLED=false), таблица на проде пуста
-- (проверено 2026-08-29: SELECT count(*) FROM payments → 0), поэтому обычный
-- CREATE UNIQUE INDEX не может упасть на существующих дублях и не блокирует
-- ничью запись. IF NOT EXISTS — повторное применение безвредно.
BEGIN;
-- Конвенция проекта (#2752): CREATE UNIQUE INDEX на существующей таблице берёт
-- блокировку и без lock_timeout встанет в очередь за чужой сессией.
SET LOCAL lock_timeout = '5s';
CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx
ON payments (estimate_id, product_code)
WHERE estimate_id IS NOT NULL
AND status IN (
'NEW',
'FORM_SHOWED',
'PREAUTHORIZING',
'AUTHORIZING',
'AUTHORIZED',
'3DS_CHECKING',
'3DS_CHECKED',
'CHECKING',
'CHECKED',
'PROCESSING',
'CONFIRMING'
);
COMMENT ON INDEX payments_live_estimate_product_uidx IS
'Не больше одного живого платежа на (estimate_id, product_code): двойной '
'клик по кнопке оплаты не создаёт второй холд на карте покупателя.';
COMMIT;

View file

@ -635,12 +635,18 @@ def test_estimate_readable_sql_uses_disjunction() -> None:
def test_get_estimate_sql_built_from_shared_constant() -> None:
"""GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a
hand-copied literal regression guard against the two gates drifting apart
again (that's exactly what happened before this PR: 404 here, 410 in /pdf)."""
again (that's exactly what happened before this PR: 404 here, 410 in /pdf).
Inspects `load_estimate`, not the `get_estimate` route: the payments PR moved
the body there so the paid capability link (`/r/<token>`, app/api/v1/
payments.py) reuses the SAME loader instead of growing a third copy of the
readability gate which is precisely what this guard exists to prevent.
"""
import inspect
from app.api.v1.trade_in import get_estimate
from app.api.v1.trade_in import load_estimate
src = inspect.getsource(get_estimate)
src = inspect.getsource(load_estimate)
assert "ESTIMATE_READABLE_SQL" in src
assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant"
assert "retain_until" in src, "SELECT must also fetch retain_until"

View file

@ -0,0 +1,800 @@
"""Роутер платежей (app/api/v1/payments.py): идемпотентность выдачи, подпись,
kill-switch, capability-ссылка.
ПОЧЕМУ здесь свой мини-эмулятор БД, а не MagicMock. Проверяемое свойство
«повторная нотификация НЕ создаёт вторую выдачу» целиком держится на UNIQUE
из миграции 233 плюс `ON CONFLICT DO NOTHING`. MagicMock отдаёт то, что ему
скажут, поэтому такой тест был бы зелёным по построению: он не покраснел бы,
если убрать `ON CONFLICT` или заменить его на «SELECT, потом INSERT».
`_FakeDb` ниже объявляет UNIQUE-ключи ОТДЕЛЬНО от проверяемого SQL ровно
теми колонками, что записаны в миграции, и ведёт себя как Postgres: дубль
без `ON CONFLICT` падает ошибкой, дубль с `ON CONFLICT DO NOTHING` не
возвращает строку.
Фальсификация проверена руками (каждый раз краснеет ИМЕННО тот тест, который
про это свойство, и по значению, а не по ImportError):
- убрать `ON CONFLICT DO NOTHING` из INSERT в `payment_entitlements`
`test_retry_after_crash_between_issue_and_processed_does_not_double_issue`
падает 500 вместо "OK";
- убрать его же из INSERT в `payment_notifications` падают все три теста
про идемпотентность;
- отключить проверку подписи `test_notification_with_invalid_token_is_rejected`;
- отключить проверку срока `test_report_link_rejects_expired_token`;
- убрать `ON CONFLICT DO NOTHING` из INSERT в `payments`
`test_parallel_checkout_does_not_create_second_payment` краснеет
_UniqueViolationError вместо 409;
- растянуть `_ABANDONED_AFTER_MINUTES` до бесконечности (= убрать границу по
времени) `test_abandoned_checkout_does_not_lock_the_buyer_out` получает в
ответе мёртвую ссылку красное ПО ЗНАЧЕНИЮ;
- убрать `_assert_estimate_access` из checkout `test_checkout_rejects_foreign_estimate`
видит 200 и созданный платёж вместо 404.
SQLite вместо этого не годится: NULLS NOT DISTINCT, jsonb, make_interval и
CAST(:x AS uuid) там не существуют, а настоящий Postgres в юнит-тестах этого
репозитория не поднимается (см. tests/conftest.py DATABASE_URL заглушка).
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from pydantic import SecretStr # noqa: E402
_PASSWORD = "test-terminal-password"
_ORDER_ID = "mera-0123456789abcdef0123456789abcdef"
_PAYMENT_ID = "3000000001"
_PAYMENT_UUID = "22222222-2222-2222-2222-222222222222"
_ESTIMATE_UUID = "33333333-3333-3333-3333-333333333333"
_AMOUNT = 15_000
_REPO_ROOT = Path(__file__).resolve().parents[1].parent
_MIGRATION = _REPO_ROOT / "backend" / "data" / "sql" / "233_payments.sql"
_CONTENT_TS = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
class _UniqueViolationError(RuntimeError):
"""Стенд-in для psycopg UniqueViolation — INSERT без ON CONFLICT в дубль."""
class _FakeDb:
"""Мини-Postgres на словарях: только те statement'ы, что шлёт роутер.
UNIQUE-ключи заданы ЗДЕСЬ, по миграции 233, а не выведены из проверяемого
SQL иначе тест поедет вслед за дефектом вместо того, чтобы его поймать.
NULL считается равным NULL (NULLS NOT DISTINCT), как в миграции.
"""
_NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token")
_ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id")
# Частичный UNIQUE миграции 279: ключ (estimate_id, product_code), предикат —
# «живые» статусы. Переписан здесь ПО МИГРАЦИИ, а не импортирован из
# payments.py: иначе тест поехал бы вслед за дефектом. Совпадение списка с
# кодом отдельно гейтит test_live_status_predicate_matches_code.
_LIVE_PAYMENT_KEY = ("estimate_id", "product_code")
_LIVE_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
}
)
def __init__(self) -> None:
self.notifications: list[SimpleNamespace] = []
self.entitlements: list[SimpleNamespace] = []
self.payments: list[SimpleNamespace] = [
SimpleNamespace(
id=_PAYMENT_UUID,
order_id=_ORDER_ID,
status="NEW",
amount_kopecks=_AMOUNT,
estimate_id=_ESTIMATE_UUID,
created_by=None,
# product_code=None — эта строка обслуживает тесты нотификаций и
# не должна попадать в ключ живого платежа checkout-тестов.
product_code=None,
payment_url=None,
created_at=datetime.now(tz=UTC),
)
]
self.estimate_created_by: str | None = None
self.retain_until_updates: list[str] = []
self.commits = 0
# -- SQLAlchemy-совместимая поверхность -------------------------------
def execute(self, statement: Any, params: dict[str, Any] | None = None) -> Any:
sql = " ".join(str(statement).split())
params = params or {}
if "INSERT INTO payment_notifications" in sql:
return self._insert_notification(sql, params)
if "FROM payment_notifications" in sql and sql.startswith("SELECT"):
return _Result(self._find_notification(params))
if "UPDATE payment_notifications" in sql:
for row in self.notifications:
if row.id == params["id"] and row.processed_at is None:
row.processed_at = datetime.now(tz=UTC)
return _Result(None)
if "INSERT INTO payment_entitlements" in sql:
return self._insert_entitlement(sql, params)
if "FROM payment_entitlements" in sql and sql.startswith("SELECT"):
return _Result(
next(
(
row
for row in self.entitlements
if row.kind == params["kind"] and row.subject == params["token"]
),
None,
)
)
if "INSERT INTO payments" in sql:
return self._insert_payment(sql, params)
if "FROM payments" in sql and sql.startswith("SELECT"):
if "estimate_id" in params:
return _Result(self._find_live_payment(params))
return _Result(
next((p for p in self.payments if p.order_id == params.get("order_id")), None)
)
if "UPDATE payments" in sql and "DEADLINE_EXPIRED" in sql:
return _Result(self._expire_abandoned(params))
if "UPDATE payments" in sql:
for payment in self.payments:
if payment.order_id == params["order_id"] and "status" in params:
payment.status = params["status"]
if "payment_url" in params:
payment.payment_url = params["payment_url"]
return _Result(None)
if "FROM trade_in_estimates" in sql and sql.startswith("SELECT"):
if params.get("id") != _ESTIMATE_UUID:
return _Result(None)
return _Result(SimpleNamespace(id=_ESTIMATE_UUID, created_by=self.estimate_created_by))
if "UPDATE trade_in_estimates" in sql:
self.retain_until_updates.append(params["id"])
return _Result(None)
raise AssertionError(f"неожиданный SQL в тесте: {sql[:120]}")
def commit(self) -> None:
self.commits += 1
def close(self) -> None:
pass
# -- эмуляция UNIQUE ---------------------------------------------------
def _insert_notification(self, sql: str, params: dict[str, Any]) -> _Result:
key = tuple(
params[name]
for name in ("payment_id", "status", "amount", "token") # порядок = _NOTIFICATION_KEY
)
if any(self._key_of(row, self._NOTIFICATION_KEY) == key for row in self.notifications):
return self._conflict(sql)
row = SimpleNamespace(
id=len(self.notifications) + 1,
order_id=params["order_id"],
tbank_payment_id=params["payment_id"],
status=params["status"],
amount_kopecks=params["amount"],
token=params["token"],
token_valid=params["token_valid"],
processed_at=None,
)
self.notifications.append(row)
return _Result(row)
def _insert_payment(self, sql: str, params: dict[str, Any]) -> _Result:
"""Ведёт себя как Postgres с частичным UNIQUE миграции 279.
Конфликт наступает только когда УЖЕ есть строка с той же парой
(estimate_id, product_code) И статусом из предиката ровно как у
частичного индекса. Без `ON CONFLICT DO NOTHING` в SQL исключение.
"""
key = (params["estimate_id"], params["product_code"])
if key[0] is not None and any(
self._key_of(row, self._LIVE_PAYMENT_KEY) == key and row.status in self._LIVE_STATUSES
for row in self.payments
):
return self._conflict(sql)
row = SimpleNamespace(
id=f"pay-{len(self.payments) + 1}",
order_id=params["order_id"],
status="NEW",
amount_kopecks=params["amount"],
estimate_id=params["estimate_id"],
created_by=params["created_by"],
product_code=params["product_code"],
payment_url=None,
created_at=datetime.now(tz=UTC),
)
self.payments.append(row)
return _Result(row)
def _find_live_payment(self, params: dict[str, Any]) -> SimpleNamespace | None:
return next(
(
row
for row in self.payments
if self._key_of(row, self._LIVE_PAYMENT_KEY)
== (params["estimate_id"], params["product_code"])
and row.payment_url is not None
and row.status in set(params["reusable"])
),
None,
)
def _expire_abandoned(self, params: dict[str, Any]) -> None:
deadline = datetime.now(tz=UTC) - timedelta(minutes=int(params["mins"]))
for row in self.payments:
if (
row.estimate_id == params["estimate_id"]
and row.product_code == params["product_code"]
and row.status in set(params["abandonable"])
and row.created_at < deadline
):
row.status = "DEADLINE_EXPIRED"
return None
def _insert_entitlement(self, sql: str, params: dict[str, Any]) -> _Result:
key = (params["payment_id"], params["kind"], params["ref_id"])
if any(self._key_of(row, self._ENTITLEMENT_KEY) == key for row in self.entitlements):
return self._conflict(sql)
row = SimpleNamespace(
id=f"ent-{len(self.entitlements) + 1}",
payment_id=params["payment_id"],
subject=params["subject"],
kind=params["kind"],
ref_id=params["ref_id"],
expires_at=datetime.now(tz=UTC) + timedelta(days=int(params["days"])),
)
self.entitlements.append(row)
return _Result(row)
@staticmethod
def _key_of(row: SimpleNamespace, names: tuple[str, ...]) -> tuple[Any, ...]:
return tuple(getattr(row, name) for name in names)
@staticmethod
def _conflict(sql: str) -> _Result:
if "ON CONFLICT DO NOTHING" not in sql:
raise _UniqueViolationError("duplicate key value violates unique constraint")
return _Result(None)
def _find_notification(self, params: dict[str, Any]) -> SimpleNamespace | None:
key = (params["payment_id"], params["status"], params["amount"], params["token"])
return next(
(row for row in self.notifications if self._key_of(row, self._NOTIFICATION_KEY) == key),
None,
)
class _Result:
def __init__(self, row: Any) -> None:
self._row = row
def fetchone(self) -> Any:
return self._row
@pytest.fixture()
def db() -> _FakeDb:
return _FakeDb()
@pytest.fixture()
def client(db: _FakeDb, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from app.api.v1 import payments as payments_module
from app.core.config import settings
from app.core.db import get_db
monkeypatch.setattr(settings, "payments_enabled", True)
monkeypatch.setattr(settings, "tbank_password", SecretStr(_PASSWORD))
monkeypatch.setattr(settings, "tbank_terminal_key", "TERM-TEST")
app = FastAPI()
app.include_router(payments_module.router, prefix="/api/v1/trade-in")
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
def _signed_notification(**overrides: Any) -> dict[str, Any]:
from app.services.payments.token import sign
body: dict[str, Any] = {
"TerminalKey": "TERM-TEST",
"OrderId": _ORDER_ID,
"PaymentId": _PAYMENT_ID,
"Status": "CONFIRMED",
"Success": True,
"Amount": _AMOUNT,
}
body.update(overrides)
body["Token"] = sign(body, _PASSWORD)
return body
# ── идемпотентность выдачи ───────────────────────────────────────────────────
def test_repeat_notification_issues_only_one_entitlement(client: TestClient, db: _FakeDb) -> None:
"""Ретрай банка (тот же Token) не выдаёт второй отчёт и не рвёт ответ "OK".
Фальсификация (проверено руками): убрать `ON CONFLICT DO NOTHING` из
INSERT в payment_entitlements второй запрос падает _UniqueViolationError и
тест краснеет на status_code 500; заменить дедуп на «SELECT потом INSERT»
и снять UNIQUE len(entitlements) == 2.
"""
body = _signed_notification()
first = client.post("/api/v1/trade-in/payments/notify", json=body)
second = client.post("/api/v1/trade-in/payments/notify", json=body)
assert first.status_code == 200, first.text
assert first.text == "OK"
assert second.status_code == 200, second.text
assert second.text == "OK"
assert len(db.entitlements) == 1, "повторная нотификация выдала второй отчёт"
assert len(db.notifications) == 1, "дубль нотификации записался второй строкой"
assert db.notifications[0].processed_at is not None
assert db.retain_until_updates == [_ESTIMATE_UUID]
def test_unprocessed_duplicate_is_fulfilled_on_retry(client: TestClient, db: _FakeDb) -> None:
"""Строка нотификации есть, а processed_at пуст → выдача ОБЯЗАНА состояться.
Это контракт processed_at из миграции 233: падение процесса между записью
нотификации и выдачей не должно оставить клиента без товара при списанных
деньгах. Красный вариант трактовать существование строки как «уже
обработано» (тогда entitlements пуст).
"""
body = _signed_notification()
db.notifications.append(
SimpleNamespace(
id=1,
order_id=_ORDER_ID,
tbank_payment_id=_PAYMENT_ID,
status="CONFIRMED",
amount_kopecks=_AMOUNT,
token=body["Token"],
token_valid=True,
processed_at=None,
)
)
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 200, response.text
assert len(db.entitlements) == 1
assert db.notifications[0].processed_at is not None
def test_retry_after_crash_between_issue_and_processed_does_not_double_issue(
client: TestClient, db: _FakeDb
) -> None:
"""Худший реальный случай: выдача прошла, а processed_at проставить не успели.
Ретрай банка ОБЯЗАН дойти до конца (иначе processed_at не проставится
никогда и так будет каждый час сутки) и при этом не выдать второй отчёт.
Единственное, что здесь работает, UNIQUE (payment_id, kind, ref_id) +
ON CONFLICT DO NOTHING: `_FakeDb` ведёт себя как Postgres и на INSERT без
ON CONFLICT кидает _UniqueViolationError (проверено руками: убрать ON CONFLICT
из INSERT в payment_entitlements 500 вместо "OK", тест краснеет).
"""
body = _signed_notification()
db.notifications.append(
SimpleNamespace(
id=1,
order_id=_ORDER_ID,
tbank_payment_id=_PAYMENT_ID,
status="CONFIRMED",
amount_kopecks=_AMOUNT,
token=body["Token"],
token_valid=True,
processed_at=None,
)
)
_issue_entitlement(db, "already-issued-token", expires_at=None)
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 200, response.text
assert response.text == "OK"
assert len(db.entitlements) == 1, "выдан второй отчёт по тому же платежу"
assert db.entitlements[0].subject == "already-issued-token", "токен подменён на новый"
assert db.notifications[0].processed_at is not None
# ── подпись и сумма ──────────────────────────────────────────────────────────
def test_notification_with_invalid_token_is_rejected(client: TestClient, db: _FakeDb) -> None:
body = _signed_notification()
body["Token"] = "deadbeef" * 8 # подпись не от нашего пароля
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 403
assert db.entitlements == [], "выдача по неподписанной нотификации"
assert len(db.notifications) == 1, "факт попытки должен остаться в append-only логе"
assert db.notifications[0].token_valid is False
def test_amount_mismatch_is_rejected(client: TestClient, db: _FakeDb) -> None:
"""Подпись Т-Банка не гарантирует сумму (см. docstring notification.py) —
расхождение с суммой заказа обязано быть отказом, а не выдачей."""
response = client.post(
"/api/v1/trade-in/payments/notify", json=_signed_notification(Amount=_AMOUNT + 1)
)
assert response.status_code == 400
assert db.entitlements == []
def test_non_confirmed_status_does_not_fulfill(client: TestClient, db: _FakeDb) -> None:
response = client.post(
"/api/v1/trade-in/payments/notify", json=_signed_notification(Status="AUTHORIZED")
)
assert response.status_code == 200
assert db.entitlements == []
assert db.payments[0].status == "AUTHORIZED"
def test_pre_confirm_notification_does_not_downgrade_confirmed(
client: TestClient, db: _FakeDb
) -> None:
"""Отставший AUTHORIZED не имеет права откатить уже подтверждённый платёж."""
db.payments[0].status = "CONFIRMED"
client.post("/api/v1/trade-in/payments/notify", json=_signed_notification(Status="AUTHORIZED"))
assert db.payments[0].status == "CONFIRMED"
# ── capability-ссылка ────────────────────────────────────────────────────────
def _issue_entitlement(db: _FakeDb, token: str, *, expires_at: datetime | None) -> None:
db.entitlements.append(
SimpleNamespace(
id="ent-1",
payment_id=_PAYMENT_UUID,
subject=token,
kind="report_link",
ref_id=_ESTIMATE_UUID,
expires_at=expires_at,
)
)
def test_report_link_serves_estimate_for_valid_token(
client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch
) -> None:
from app.api.v1 import payments as payments_module
from app.schemas.trade_in import AggregatedEstimate
seen: dict[str, Any] = {}
def _fake_loader(_db: Any, estimate_id: Any, **kwargs: Any) -> AggregatedEstimate:
seen["estimate_id"] = str(estimate_id)
seen.update(kwargs)
return AggregatedEstimate(
estimate_id=_ESTIMATE_UUID,
median_price_rub=5_000_000,
range_low_rub=4_500_000,
range_high_rub=5_500_000,
median_price_per_m2=100_000,
confidence="medium",
n_analogs=7,
period_months=6,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
)
monkeypatch.setattr(payments_module, "load_estimate", _fake_loader)
_issue_entitlement(db, "good-token", expires_at=datetime.now(tz=UTC) + timedelta(days=1))
response = client.get("/api/v1/trade-in/r/good-token")
assert response.status_code == 200, response.text
assert seen["estimate_id"] == _ESTIMATE_UUID
assert seen["capability_granted"] is True
assert seen["x_authenticated_user"] is None
def test_report_link_rejects_foreign_token(client: TestClient, db: _FakeDb) -> None:
_issue_entitlement(db, "good-token", expires_at=datetime.now(tz=UTC) + timedelta(days=1))
response = client.get("/api/v1/trade-in/r/someone-elses-token")
assert response.status_code == 404
def test_report_link_rejects_expired_token(client: TestClient, db: _FakeDb) -> None:
_issue_entitlement(db, "stale-token", expires_at=datetime.now(tz=UTC) - timedelta(seconds=1))
response = client.get("/api/v1/trade-in/r/stale-token")
assert response.status_code == 404
def test_issued_token_is_unpredictable(client: TestClient, db: _FakeDb) -> None:
"""Токен — это всё право доступа: он обязан быть случайным, а не производной
от order_id/payment_id (иначе выводится по данным, которые видит покупатель)."""
client.post("/api/v1/trade-in/payments/notify", json=_signed_notification())
token = db.entitlements[0].subject
assert len(token) >= 40
assert _ORDER_ID not in token
assert _PAYMENT_ID not in token
# ── kill-switch ──────────────────────────────────────────────────────────────
def test_endpoints_are_closed_when_payments_disabled(
db: _FakeDb, monkeypatch: pytest.MonkeyPatch
) -> None:
"""PAYMENTS_ENABLED=false — 503 на всех ручках, без падений и без записи в БД."""
from app.api.v1 import payments as payments_module
from app.core.config import settings
from app.core.db import get_db
monkeypatch.setattr(settings, "payments_enabled", False)
app = FastAPI()
app.include_router(payments_module.router, prefix="/api/v1/trade-in")
app.dependency_overrides[get_db] = lambda: db
disabled = TestClient(app)
assert disabled.get("/api/v1/trade-in/r/any-token").status_code == 503
assert disabled.get(f"/api/v1/trade-in/payments/status/{_ORDER_ID}").status_code == 503
assert disabled.post("/api/v1/trade-in/payments/notify", json={}).status_code == 503
assert (
disabled.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
).status_code
== 503
)
assert db.notifications == []
assert db.entitlements == []
# ── периметр и синхронизация констант ────────────────────────────────────────
def test_notify_is_public_and_checkout_is_not() -> None:
from app.core.rbac import _PUBLIC_PATH_PREFIXES, _PUBLIC_PATHS
assert "/api/v1/trade-in/payments/notify" in _PUBLIC_PATHS
assert "/api/v1/trade-in/payments/checkout" not in _PUBLIC_PATHS
assert "/api/v1/trade-in/r/some-token".startswith(_PUBLIC_PATH_PREFIXES)
assert not "/api/v1/trade-in/history".startswith(_PUBLIC_PATH_PREFIXES)
def test_report_link_token_is_redacted_from_sentry_events() -> None:
from app.observability.sentry_scrub import scrub_pii_event
event = {
"request": {"url": "https://meraocenka.ru/api/v1/trade-in/r/s3cr3t-token-value"},
"transaction": "GET /api/v1/trade-in/r/s3cr3t-token-value",
}
scrubbed = scrub_pii_event(event, {}) # type: ignore[arg-type]
assert "s3cr3t-token-value" not in str(scrubbed)
assert "/api/v1/trade-in/r/[REDACTED]" in scrubbed["transaction"] # type: ignore[index]
def test_status_whitelist_matches_migration() -> None:
"""Свой список статусов не должен разъезжаться с CHECK миграции 233:
статус, которого нет в CHECK, уронит INSERT уже на проде."""
from app.api.v1.payments import _KNOWN_STATUSES
source = _MIGRATION.read_text(encoding="utf-8")
check = re.search(
r"ADD CONSTRAINT\s+payments_status_check\s+CHECK \(status IN \((.*?)\)\)", source, re.S
)
assert check is not None, "CHECK payments_status_check не найден в миграции 233"
migration_statuses = set(re.findall(r"'([^']+)'", check.group(1)))
assert _KNOWN_STATUSES == migration_statuses
def test_price_matches_published_offer() -> None:
"""Цена в коде обязана совпадать с ценой, названной в оферте (п. 4.1) —
единственный её источник для юр-текстов, mera-public/content.ts."""
from app.api.v1.payments import _PRODUCTS
match = re.search(r"SERVICE_PRICE_RUB\s*=\s*(\d+)", _CONTENT_TS.read_text(encoding="utf-8"))
assert match is not None, "SERVICE_PRICE_RUB не найден в mera-public/content.ts"
_name, price_kopecks = _PRODUCTS["paid_report"]
assert price_kopecks == int(match.group(1)) * 100
def test_live_status_predicate_matches_code() -> None:
"""Предикат частичного UNIQUE (279) и `_REUSABLE_STATUSES` — один список.
Индекс шире кода запрещает легитимную повторную попытку оплаты; индекс уже
кода пропускает второй холд на карте. И то и другое про деньги, поэтому
дрейф ловит тест, а не внимательность читателя.
"""
from app.api.v1.payments import _REUSABLE_STATUSES
path = _REPO_ROOT / "backend" / "data" / "sql" / "279_payments_live_checkout_uidx.sql"
source = path.read_text(encoding="utf-8")
predicate = re.search(r"AND status IN \((.*?)\)", source, re.S)
assert predicate is not None, "предикат по статусам не найден в миграции 279"
assert set(re.findall(r"'([^']+)'", predicate.group(1))) == set(_REUSABLE_STATUSES)
# ── checkout: один живой платёж на оценку ────────────────────────────────────
@pytest.fixture()
def bank(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Подменяет Т-Банк: собирает вызовы Init и отдаёт предсказуемую ссылку.
Список вызовов не украшение: «второй Init» и есть «второй холд на карте»,
поэтому проверяется именно его длина, а не только тело ответа.
"""
from app.api.v1 import payments as payments_module
calls: list[dict[str, Any]] = []
class _FakeClient:
async def init_payment(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {
"Success": True,
"Status": "NEW",
"PaymentId": f"300000000{len(calls)}",
"PaymentURL": f"https://securepayments.tinkoff.ru/{len(calls)}",
}
monkeypatch.setattr(payments_module, "_client", _FakeClient)
return calls
def _seed_live_payment(db: _FakeDb, *, payment_url: str | None, age: timedelta) -> SimpleNamespace:
row = SimpleNamespace(
id="pay-seed",
order_id="mera-seed",
status="NEW",
amount_kopecks=_AMOUNT,
estimate_id=_ESTIMATE_UUID,
created_by=None,
product_code="paid_report",
payment_url=payment_url,
created_at=datetime.now(tz=UTC) - age,
)
db.payments.append(row)
return row
def _paid_report_rows(db: _FakeDb) -> list[SimpleNamespace]:
return [p for p in db.payments if p.product_code == "paid_report"]
def test_parallel_checkout_does_not_create_second_payment(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Двойной клик: соперник уже вставил строку, но ссылки от банка ещё нет.
Второй запрос обязан отбиться о частичный UNIQUE (миграция 279) и не пойти
в банк второй Init это второй холд на карте покупателя. Ответ 409, а не
выдуманная ссылка.
Фальсификация (проверено): убрать `ON CONFLICT DO NOTHING` из INSERT в
payments _FakeDb кидает _UniqueViolationError, как настоящий Postgres, и
тест краснеет вместо ответа 409; вернуть «SELECT, потом INSERT» без
обработки конфликта вторая строка payments и второй вызов Init.
"""
_seed_live_payment(db, payment_url=None, age=timedelta(seconds=1))
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 409, response.text
assert len(_paid_report_rows(db)) == 1, "параллельный checkout создал второй платёж"
assert bank == [], "второй Init = второй холд на карте покупателя"
def test_repeat_checkout_reuses_live_link(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Честный повтор по живому платежу возвращает ТУ ЖЕ ссылку и не зовёт Init."""
seeded = _seed_live_payment(
db, payment_url="https://securepayments/live", age=timedelta(minutes=5)
)
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 200, response.text
assert response.json()["payment_url"] == "https://securepayments/live"
assert response.json()["order_id"] == seeded.order_id
assert len(_paid_report_rows(db)) == 1
assert bank == []
def test_abandoned_checkout_does_not_lock_the_buyer_out(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Брошенный NEW старше окна не переиспользуется: ссылка у банка протухла.
Без границы по времени покупатель навсегда получал бы одну и ту же мёртвую
ссылку и не мог начать оплату заново нотификации по зависшему NEW может
не прийти вовсе, сам из этого статуса платёж не выйдет.
Фальсификация (проверено руками): убрать UPDATE ... DEADLINE_EXPIRED (или
условие по created_at в нём) в ответе старая мёртвая ссылка
https://securepayments/dead, Init не вызывается: тест краснеет по значению,
а не по исключению.
"""
stale = _seed_live_payment(
db, payment_url="https://securepayments/dead", age=timedelta(hours=2)
)
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 200, response.text
assert response.json()["payment_url"] == "https://securepayments.tinkoff.ru/1"
assert stale.status == "DEADLINE_EXPIRED"
assert len(bank) == 1, "новая попытка оплаты обязана получить свежую ссылку банка"
assert len(_paid_report_rows(db)) == 2
def test_checkout_rejects_foreign_estimate(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
"""IDOR: checkout по чужой оценке — 404, платёж не создаётся.
Дверь здесь не «показать чужой отчёт», а order_id: он же право доступа для
/payments/status/<order_id>, который отдаёт capability-ссылку на отчёт,
как только владелец заплатит.
Фальсификация (проверено руками): убрать вызов `_assert_estimate_access` в
checkout 200 и строка в payments, тест краснеет по значению.
"""
from app.core import auth
monkeypatch.setattr(auth, "get_role", lambda username: "pilot")
db.estimate_created_by = "victim"
response = client.post(
"/api/v1/trade-in/payments/checkout",
json={"estimate_id": _ESTIMATE_UUID},
headers={"X-Authenticated-User": "attacker"},
)
assert response.status_code == 404, response.text
assert _paid_report_rows(db) == []
assert bank == []