From 78e9ad4fa99c42509c5b05310ff6d1c0abf90f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sat, 18 Apr 2026 00:58:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= =?UTF-8?q?=20NotificationURL=20=D0=B8=20SuccessURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adapters/tbank/client.py | 8 ++ app/config.py | 2 + config.example.yaml | 2 + config.test.yaml | 2 + spec/index.md | 5 +- spec/tasks/029_add_tbank_payment_urls.md | 54 +++++++++++ .../delivery_providers/cdek/test_client.py | 2 + tests/adapters/tbank/test_client.py | 69 +++++++++++++- tests/config/fixtures/config.default.yaml | 2 + .../config.invalid_price_multiplier.yaml | 2 + .../fixtures/config.missing_adapter.yaml | 2 + .../config.missing_address_suggestions.yaml | 2 + .../config.missing_observability.yaml | 2 + ...config.missing_observability_endpoint.yaml | 2 + .../config.missing_price_multiplier.yaml | 2 + .../config/fixtures/config.test.override.yaml | 2 + tests/config/test_config_sections.py | 93 +++++++++++++++++++ tests/repositories/cache/test_redis_cache.py | 2 + 18 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 spec/tasks/029_add_tbank_payment_urls.md diff --git a/app/adapters/tbank/client.py b/app/adapters/tbank/client.py index e411760..1ac1cd1 100644 --- a/app/adapters/tbank/client.py +++ b/app/adapters/tbank/client.py @@ -20,6 +20,8 @@ class TBankAdapter: http_client: httpx.AsyncClient, *, init_url: str, + notification_url: str, + success_url: str, terminal_key: str, password: str, timeout_seconds: float = 10.0, @@ -29,6 +31,8 @@ class TBankAdapter: ) -> None: self._http_client = http_client self._init_url = init_url + self._notification_url = notification_url + self._success_url = success_url self._terminal_key = terminal_key self._password = password self._timeout_seconds = timeout_seconds @@ -46,6 +50,8 @@ class TBankAdapter: return cls( http_client=http_client, init_url=config.init_url, + notification_url=config.notification_url, + success_url=config.success_url, terminal_key=config.auth.terminal_key, password=config.auth.password, timeout_seconds=config.timeout_seconds, @@ -116,6 +122,8 @@ class TBankAdapter: "TerminalKey": self._terminal_key, "Amount": amount_kopecks, "OrderId": order_uuid, + "NotificationURL": self._notification_url, + "SuccessURL": self._success_url, } payload["Token"] = _build_tbank_token(payload, password=self._password) return payload diff --git a/app/config.py b/app/config.py index 1075662..b44801d 100644 --- a/app/config.py +++ b/app/config.py @@ -55,6 +55,8 @@ class TBankPaymentAuthConfig(BaseModel): class TBankPaymentConfig(BaseModel): init_url: str = Field(..., min_length=1) + notification_url: str = Field(..., min_length=1) + success_url: str = Field(..., min_length=1) auth: TBankPaymentAuthConfig timeout_seconds: float = Field(default=10.0, gt=0) retry_attempts: int = Field(default=2, ge=0) diff --git a/config.example.yaml b/config.example.yaml index 9b44a38..04e920a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,6 +25,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.example.com/tbank/notification" + success_url: "https://merchant.example.com/payment/success" auth: terminal_key: "change-me-terminal-key" password: "change-me-password" diff --git a/config.test.yaml b/config.test.yaml index 890ce96..9c864d3 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -25,6 +25,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/spec/index.md b/spec/index.md index c562142..b74ce90 100644 --- a/spec/index.md +++ b/spec/index.md @@ -35,9 +35,10 @@ | 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` | | 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` | | 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` | +| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` | ## Summary -- Total: **29** +- Total: **30** - TODO: **0** -- DONE: **29** +- DONE: **30** diff --git a/spec/tasks/029_add_tbank_payment_urls.md b/spec/tasks/029_add_tbank_payment_urls.md new file mode 100644 index 0000000..4483ee8 --- /dev/null +++ b/spec/tasks/029_add_tbank_payment_urls.md @@ -0,0 +1,54 @@ +--- +id: 029 +title: Add TBank payment notification and success URLs +status: DONE +created: 2026-04-18 +--- + +## Context +TBank Init API должен получать URLs для обработки webhook-уведомлений и возврата клиента после успешной оплаты. Сейчас `TBankAdapter` и секция `tbank_payment` не фиксируют передачу `NotificationURL` и `SuccessURL`. + +## Goal +Добавить в конфигурацию TBank payment обязательные параметры `notification_url` и `success_url` и передавать их в payload инициализации платежа как `NotificationURL` и `SuccessURL`. + +## Constraints +- Изменения ограничены TBank payment config, TBank adapter payload mapping, wiring конфигурации и тестами. +- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига. +- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling. +- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали. +- Не изменять публичный request/response contract `POST /api/v1/delivery/init-payment`. +- Не изменять price flow, address suggestion flow, CDEK adapter и order repository. +- Не добавлять новые endpoints. + +## Acceptance criteria +- `TBankPaymentConfig` содержит обязательные поля `notification_url: str` и `success_url: str`. +- Runtime YAML-конфиг и test YAML-конфиг содержат секцию `tbank_payment` с `notification_url` и `success_url`. +- `TBankAdapter` при вызове TBank Init API отправляет `NotificationURL` со значением `tbank_payment.notification_url`. +- `TBankAdapter` при вызове TBank Init API отправляет `SuccessURL` со значением `tbank_payment.success_url`. +- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload. +- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации. +- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов. +- Endpoint `POST /api/v1/delivery/init-payment` продолжает возвращать только `InitPaymentResponse(payment_url=...)`. + +## Definition of Done +- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config. +- [ ] YAML-конфиги обновлены новыми параметрами TBank payment. +- [ ] `TBankAdapter` передаёт `NotificationURL` и `SuccessURL` в payload TBank Init API. +- [ ] Token generation для TBank request остаётся корректной после добавления новых payload fields. +- [ ] Service и Controller не знают о `NotificationURL` и `SuccessURL`. +- [ ] Добавлены или обновлены тесты конфигурации. +- [ ] Добавлены или обновлены тесты TBank adapter payload. +- [ ] Все команды из раздела Commands проходят. + +## Tests +- Обновить `tests/config/test_config_sections.py`: проверить обязательность `tbank_payment.notification_url` и `tbank_payment.success_url`. +- Обновить `tests/adapters/tbank/test_client.py`: проверить, что successful Init request payload содержит `NotificationURL` и `SuccessURL` из конфигурации. +- Обновить `tests/adapters/tbank/test_client.py`: проверить, что token generation остаётся детерминированной после добавления новых payload fields. +- При необходимости обновить `tests/smoke/test_app_import.py`, если smoke config требует новые обязательные поля. + +## Commands +- `poetry run pytest tests/config/test_config_sections.py -q` +- `poetry run pytest tests/adapters/tbank/test_client.py -q` +- `poetry run pytest tests/smoke/test_app_import.py -q` +- `poetry run pytest -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 1b5b9a0..8bef333 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -298,6 +298,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/adapters/tbank/test_client.py b/tests/adapters/tbank/test_client.py index 68907c6..b46515d 100644 --- a/tests/adapters/tbank/test_client.py +++ b/tests/adapters/tbank/test_client.py @@ -10,6 +10,7 @@ from app.adapters.tbank.base import ( TBankPaymentAdapterError, TBankPaymentRequestError, ) +from app.config import TBankPaymentAuthConfig, TBankPaymentConfig class SequenceHTTPClient: @@ -48,6 +49,8 @@ class SequenceHTTPClient: def _make_adapter( http_client: SequenceHTTPClient, *, + notification_url: str = "https://example.test/tbank/notify", + success_url: str = "https://example.test/payment/success", retry_attempts: int = 0, retry_backoff_seconds: float = 0.2, sleep_calls: list[float] | None = None, @@ -59,6 +62,8 @@ def _make_adapter( return TBankAdapter( http_client=http_client, # type: ignore[arg-type] init_url="https://securepay.tinkoff.ru/v2/Init", + notification_url=notification_url, + success_url=success_url, terminal_key="TBankTest", password="test-password", timeout_seconds=7.5, @@ -85,7 +90,14 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None ) expected_token = hashlib.sha256( - "125000order-uuid-1test-passwordTBankTest".encode("utf-8") + ( + "125000" + "https://example.test/tbank/notify" + "order-uuid-1" + "test-password" + "https://example.test/payment/success" + "TBankTest" + ).encode("utf-8") ).hexdigest() assert result == "https://securepay.test/pay/1" assert http_client.calls == [ @@ -96,6 +108,8 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None "TerminalKey": "TBankTest", "Amount": 125000, "OrderId": "order-uuid-1", + "NotificationURL": "https://example.test/tbank/notify", + "SuccessURL": "https://example.test/payment/success", "Token": expected_token, }, "data": None, @@ -108,6 +122,59 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None ] +def test_from_config_posts_configured_urls_and_deterministic_token() -> None: + response = httpx.Response( + 200, + json={"Success": True, "PaymentURL": "https://securepay.test/pay/2"}, + request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"), + ) + http_client = SequenceHTTPClient([response]) + config = TBankPaymentConfig( + init_url="https://securepay.tinkoff.ru/v2/Init", + notification_url="https://merchant.test/tbank/notification", + success_url="https://merchant.test/payment/success", + auth=TBankPaymentAuthConfig( + terminal_key="ConfigTerminal", + password="config-password", + ), + timeout_seconds=6.25, + retry_attempts=0, + retry_backoff_seconds=0.1, + ) + adapter = TBankAdapter.from_config( + http_client=http_client, # type: ignore[arg-type] + config=config, + ) + + result = asyncio.run( + adapter.create_payment_link( + order_uuid="order-uuid-2", + amount_kopecks=9900, + ) + ) + + expected_token = hashlib.sha256( + ( + "9900" + "https://merchant.test/tbank/notification" + "order-uuid-2" + "config-password" + "https://merchant.test/payment/success" + "ConfigTerminal" + ).encode("utf-8") + ).hexdigest() + assert result == "https://securepay.test/pay/2" + assert http_client.calls[0]["json"] == { + "TerminalKey": "ConfigTerminal", + "Amount": 9900, + "OrderId": "order-uuid-2", + "NotificationURL": "https://merchant.test/tbank/notification", + "SuccessURL": "https://merchant.test/payment/success", + "Token": expected_token, + } + assert http_client.calls[0]["timeout"] == 6.25 + + def test_create_payment_link_maps_4xx_to_request_error() -> None: response = httpx.Response( 400, diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index 4f1d9b3..2c77dd2 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -17,6 +17,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://default.test/tbank/notification" + success_url: "https://default.test/payment/success" auth: terminal_key: "yaml-terminal-key" password: "yaml-password" diff --git a/tests/config/fixtures/config.invalid_price_multiplier.yaml b/tests/config/fixtures/config.invalid_price_multiplier.yaml index 3441b80..01fee1c 100644 --- a/tests/config/fixtures/config.invalid_price_multiplier.yaml +++ b/tests/config/fixtures/config.invalid_price_multiplier.yaml @@ -25,6 +25,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/config/fixtures/config.missing_adapter.yaml b/tests/config/fixtures/config.missing_adapter.yaml index ff5ea38..b0c2b0f 100644 --- a/tests/config/fixtures/config.missing_adapter.yaml +++ b/tests/config/fixtures/config.missing_adapter.yaml @@ -14,6 +14,8 @@ repository: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/config/fixtures/config.missing_address_suggestions.yaml b/tests/config/fixtures/config.missing_address_suggestions.yaml index 70632d0..abdb769 100644 --- a/tests/config/fixtures/config.missing_address_suggestions.yaml +++ b/tests/config/fixtures/config.missing_address_suggestions.yaml @@ -25,6 +25,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/config/fixtures/config.missing_observability.yaml b/tests/config/fixtures/config.missing_observability.yaml index 3f6a7ce..99ad8c8 100644 --- a/tests/config/fixtures/config.missing_observability.yaml +++ b/tests/config/fixtures/config.missing_observability.yaml @@ -25,6 +25,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/config/fixtures/config.missing_observability_endpoint.yaml b/tests/config/fixtures/config.missing_observability_endpoint.yaml index d26120e..458059d 100644 --- a/tests/config/fixtures/config.missing_observability_endpoint.yaml +++ b/tests/config/fixtures/config.missing_observability_endpoint.yaml @@ -25,6 +25,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/config/fixtures/config.missing_price_multiplier.yaml b/tests/config/fixtures/config.missing_price_multiplier.yaml index e140dc0..1d037eb 100644 --- a/tests/config/fixtures/config.missing_price_multiplier.yaml +++ b/tests/config/fixtures/config.missing_price_multiplier.yaml @@ -24,6 +24,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 974be73..9ce56e7 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -17,6 +17,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://override.test/tbank/notification" + success_url: "https://override.test/payment/success" auth: terminal_key: "override-terminal-key" password: "override-password" diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index b67a1ed..1313ac0 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -104,6 +104,56 @@ def _use_runtime_config_files( get_settings.cache_clear() +def _write_tbank_url_validation_config( + tmp_path: Path, + *, + include_notification_url: bool = True, + include_success_url: bool = True, +) -> Path: + notification_url = ( + ' notification_url: "https://merchant.test/tbank/notification"\n' + if include_notification_url + else "" + ) + success_url = ( + ' success_url: "https://merchant.test/payment/success"\n' + if include_success_url + else "" + ) + config_file = tmp_path / "config.yaml" + config_file.write_text( + f""" +controller: {{}} + +service: {{}} + +business_logic: + provider_price_multiplier: 1.0 + +repository: {{}} + +adapter: {{}} + +tbank_payment: + init_url: "https://securepay.tinkoff.ru/v2/Init" +{notification_url}{success_url} auth: + terminal_key: "test-terminal-key" + password: "test-password" + +postgres: + dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/config_test" + +address_suggestions: {{}} + +observability: + service_name: "config-test" + otlp_endpoint: "http://collector:4317" +""".strip(), + encoding="utf-8", + ) + return config_file + + def test_configuration_sections_are_loaded_from_yaml_file( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -127,6 +177,11 @@ def test_configuration_sections_are_loaded_from_yaml_file( assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_cache_ttl_seconds == 900 assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init" + assert ( + settings.tbank_payment.notification_url + == "https://merchant.test/tbank/notification" + ) + assert settings.tbank_payment.success_url == "https://merchant.test/payment/success" assert settings.tbank_payment.auth.terminal_key == "test-terminal-key" assert settings.tbank_payment.auth.password == "test-password" assert settings.tbank_payment.timeout_seconds == 10.0 @@ -203,6 +258,10 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) - assert first.service.provider_timeout_seconds == 10.0 assert first.business_logic.provider_price_multiplier == Decimal("1.0") assert first.tbank_payment.auth.terminal_key == "test-terminal-key" + assert ( + first.tbank_payment.notification_url + == "https://merchant.test/tbank/notification" + ) assert first.postgres.dsn.endswith("/g2s_aggregator_test") assert first.address_suggestions.country_to_provider["RU"] == "dadata" assert first.observability.service_name == "g2s-aggregator-test" @@ -226,6 +285,11 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.adapter.cdek_client_id == "test-id" assert settings.tbank_payment.auth.terminal_key == "override-terminal-key" assert settings.tbank_payment.auth.password == "override-password" + assert ( + settings.tbank_payment.notification_url + == "https://override.test/tbank/notification" + ) + assert settings.tbank_payment.success_url == "https://override.test/payment/success" assert settings.tbank_payment.timeout_seconds == 4.25 assert settings.tbank_payment.retry_attempts == 1 assert settings.tbank_payment.retry_backoff_seconds == 0.05 @@ -283,6 +347,35 @@ def test_get_settings_fails_when_provider_price_multiplier_is_missing( get_settings.cache_clear() +@pytest.mark.parametrize( + ("include_notification_url", "include_success_url", "expected_location"), + ( + (False, True, ("tbank_payment", "notification_url")), + (True, False, ("tbank_payment", "success_url")), + ), +) +def test_get_settings_fails_when_tbank_payment_url_is_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + include_notification_url: bool, + include_success_url: bool, + expected_location: tuple[str, str], +) -> None: + config_file = _write_tbank_url_validation_config( + tmp_path, + include_notification_url=include_notification_url, + include_success_url=include_success_url, + ) + _use_runtime_config_files(monkeypatch, test_config_file=config_file) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert expected_location in locations + get_settings.cache_clear() + + def test_get_settings_fails_when_observability_section_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py index 43fba2a..1eda307 100644 --- a/tests/repositories/cache/test_redis_cache.py +++ b/tests/repositories/cache/test_redis_cache.py @@ -181,6 +181,8 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://merchant.test/tbank/notification" + success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password"