Практика: платёжные провайдеры (Payment providers)
Платёжный провайдер — это семейство: приём платежа, возвраты и разбор вебхуков. Возврат, ушедший не тому провайдеру, который принял платёж, — реальный инцидент, и абстрактная фабрика делает его невозможным по построению.
Базовый пример: Абстрактная фабрика. Там семейство выбирает тест. В жизни семейство выбирается данными (валюта, страна, магазин), меняется со временем, а продукты одного провайдера должны находить друг друга спустя месяцы: возврат по старому платежу обязан уйти туда, где платёж был сделан.
Задача
Магазин принимает платежи через двух провайдеров. У каждого свой клиент для списания, свой API возвратов и своя схема подписи вебхуков: один подписывает тело HMAC-SHA256 с секретом, другой присылает статичный токен. Маршрутизация по валюте задаётся в конфиге и меняется: рубли сначала шли одному провайдеру, потом другому.
В первой версии три сервиса (PaymentService, RefundService, WebhookController) каждый по-своему решали, какой провайдер нужен. Возвраты смотрели на текущую маршрутизацию по валюте, и после переключения рублей на нового провайдера возвраты по старым платежам полетели не туда.
Что урезано
- Нет HTTP: шлюзы выдают идентификаторы из счётчика, вебхуки — строка
тип:идентификаторс подписью. - Провайдеры вымышленные, с разными схемами идентификаторов и подписи, чтобы различие семейств было видно.
- Конфиг маршрутизации — словарь «валюта → код провайдера» и запасной код.
- Нет хранения платежей: «старый платёж» в тесте создаётся руками.
Решение
Продукты — три интерфейса: Gateway принимает платёж и возвращает Charge с кодом провайдера, Refunder делает возврат по Charge, WebhookParser проверяет подпись и разбирает событие. Charge помнит, кто его создал, и умеет проверить принадлежность.
Абстрактная фабрика PaymentProvider выдаёт все три продукта одного провайдера и свой код. Семейства AcmePay и FastPay отличаются префиксами идентификаторов и схемой подписи; возврат чужого платежа каждое семейство отвергает.
Providers — реестр фабрик по коду плюс маршрутизация по валюте из конфига. Checkout выбирает провайдера дважды по-разному: для платежа — по валюте, для возврата — по коду из самого платежа. Вебхук приходит с кодом провайдера в адресе, и разбирает его парсер того же семейства.
Структура
Продукты несут код провайдера в данных (Charge.provider): так семейство можно восстановить спустя время по сохранённому платежу. Это отличие от учебного примера, где семейство существует только пока жива фабрика.
Реестр и маршрутизация
Конфиг задаёт маршрутизацию и запасной код:
fallback: acmepay
by_currency:
RUB: fastpayProviders отвечает на два вопроса: «какой провайдер для этой валюты» и «какой провайдер с этим кодом». Первый нужен только при платеже. Всё остальное — возвраты, вебхуки, выписки — идёт по коду, который записан в данных. Код из конфига, для которого нет зарегистрированной фабрики, падает при первом обращении с понятной ошибкой.
Как это собирается
Семейства регистрируются один раз при старте приложения: секреты AcmePay и FastPay приходят из окружения, маршрутизация по валюте и запасной код - из конфига. Готовый Providers отдаётся в Checkout, и дальше ни один вызывающий код не выбирает провайдера сам. Платёж в рублях уходит FastPay, в долларах - запасному AcmePay, и каждый Charge уносит код своего семейства.
Контроллер вебхуков берёт код провайдера из адреса и отдаёт тело в handleWebhook: разбирает его парсер того же семейства, а неверная подпись превращается в ответ 401, касса о схемах подписи не знает. Возврат идёт по Charge.provider, поэтому попадает к FastPay, который принял платёж.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Семейства собраны из общих заготовок: шлюз со счётчиком и префиксом, возврат с проверкой владельца. Разное у них — префикс идентификаторов и парсер вебхуков. Так видно, что семейство — это не обязательно шесть классов: это набор продуктов, согласованных по провайдеру.
| Язык | Особенность |
|---|---|
| PHP | Шлюз создаётся один раз и кэшируется в фабрике (??=), остальные продукты лёгкие и создаются на каждый вызов; hash_equals для подписи |
| Go | Общие заготовки продуктов: seqGateway, ownRefunder как строковый тип, parserFunc; подпись через crypto/hmac, токен через subtle.ConstantTimeCompare |
| Python | Фабрика и продукты — Protocol; заготовки продуктов — приватные dataclass; hmac.compare_digest для обеих схем |
| TypeScript | Продукты — объектные литералы из фабричных функций; node:crypto для HMAC и timingSafeEqual |
Запуск: make test T=patterns/creational/abstract-factory/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Платёж в рублях уходит FastPay, в долларах — запасному AcmePay. Главный тест воспроизводит инцидент: маршрутизация рублей переключена на AcmePay, а возврат по старому платежу FastPay всё равно уходит FastPay. Третий тест показывает защиту семейства: возвратчик AcmePay отвергает чужой платёж с понятной ошибкой. Четвёртый — каждое семейство проверяет вебхуки по-своему, подделанная подпись отклоняется. Пятый — код провайдера в конфиге без зарегистрированной фабрики падает сразу.
Где граница
- Провайдеров пять и у каждого десяток продуктов — интерфейс фабрики растёт вместе с ними, каждый новый продукт трогает все семейства. Тогда семейство становится модулем или контейнером, который отдаёт продукты по типу, а не по методу на каждый.
- Семейство выбирается на каждый вызов по данным — это всё ещё абстрактная фабрика, но реестр фабрик по коду обязателен: без него «какой провайдер» размажется по сервисам, как в первой версии.
- Провайдер один — фабрика не нужна, нужны просто три сервиса. Приём окупается с появлением второго провайдера или тестового семейства «песочница».
- Нужны не согласованные продукты, а один взаимозаменяемый алгоритм — это Стратегия.
Отличия между языками
- PHP — фабрика может кэшировать тяжёлые продукты (
??=): абстрактная фабрика часто ещё и владеет временем жизни клиентов. Исключения разных классов для «чужой платёж» и «плохая подпись». - Go — семейство собирается из заготовок:
ownRefunder— это просто строка с методом,parserFunc— функция с методом. Три сентинел-ошибки, тесты сверяют их черезerrors.Is. - Python —
Protocolдля фабрики с@property code; класс провайдера хранит код атрибутом класса. Заготовки продуктов приватные, снаружи видны только семейства. - TypeScript — продукты как объектные литералы, фабрика как класс с полями-продуктами.
timingSafeEqualтребует буферы одной длины, поэтому длина сравнивается заранее.
Связанные темы
- Абстрактная фабрика (базовый пример)
- Практика Стратегии: расчёт доставки — тот же реестр по коду и правила из конфига для одного продукта