The Jerusalem PostTrump press secretary Zacharia failed US bar exam despite claiming to practice law - reportCNN TürkAntalya 14 milyon yabancı turisti ağırladı: İşte öne çıkan pazarlarPunchZelensky to resume strikes on Russian refineries as energy truce collapsesDaily MaverickINPICTURES: Wildlife photographer of the year 2026 winnersRTP DesportoSporting de Braga visita Copenhaga para a Liga ConferênciaInquirerFormer Senate leader Koko Pimentel calls for respect for PWDsColliderRussell Crowe’s Best-Reviewed Movie in 27 Years Officially Removed From 1,500 TheatersGhaflaWinnie Odinga Faces Travel Delay After Kenya Airways Cancels Flight On Eve Of Raila MemorialSky TG24Angelina Mango, il nuovo singolo Vita da adulti uscirà il 16 ottobreGIGAZINE2026年10月15日のヘッドラインニュースSoompiB1A4’s Jin Young Dishes On His Upcoming Drama “Summer Vacation,” Chemistry With Cho Yi Hyun, And MoreZDF heuteEntdecken Sie das ZDF-Nachrichtenstudio
The Daily Newsstand · Free, Always
Thursday, October 15, 2026

Пять платёжных провайдеров, два выжили

Translate

С каждого доллара, который пользователь платит звёздами Telegram, до нас доходит 65 центов. Тридцать пять процентов забирают по дороге, и это ещё хорошая ставка — просто потому, что другие способы принять деньги у нас работают не везде и не всегда.

За время жизни проекта мы подключили пять платёжных провайдеров. Сегодня деньги реально принимают два: два других выпилены полностью, ещё один остался в репозитории клиентским классом, который никто не вызывает. Причины ухода у всех нетехнические, а вот уборка после них — вполне техническое упражнение, ради которого стоит написать статью.

Проект под NDA: названия провайдеров, где это существенно, обобщены, таблицы и поля переименованы, суммы и доли — публичные, их легко проверить в документации. Всё остальное настоящее.

Почему их не один

Очевидный вопрос: зачем городить абстракцию, если можно взять один эквайринг и жить спокойно.

Аудитория у бота глобальная. У каждого метода оплаты своя география: где-то не принимают карты, где-то не работает конкретный провайдер, где-то пользователь физически не может завершить платёж, хотя очень хочет. Плюс есть причины, которых NDA не даёт коснуться, — часть классических вариантов нам просто недоступна.

Отсюда набор, который сложился сам: нативные платежи Telegram, внешний сервис подписок с вебхуками и криптовалюта. Разные деньги, разные жизненные циклы, разные способы узнать, что платёж прошёл. Крипта сейчас спит — клиент к её API лежит в репозитории, но ни одного вызова к нему нет, — и про этот вид долга будет отдельно в конце.

И одно общее требование: код, который начисляет купленное, должен быть ровно один. Иначе каждый новый провайдер — это ещё одна копия логики «положить пользователю на баланс», и в какой-то момент они разъедутся.

Разные провайдеры, один контракт, одна таблица платежей

Разные провайдеры, один контракт, одна таблица платежей

Контракт на шесть методов

Единственная общая вещь между провайдерами — ответы на вопросы «кто заплатил», «за что» и «сколько из этого наше»:

interface InvoiceContract
{
    public function getTelegramChat(): TelegramChat;

    public function getProduct(): Product|Subscription;

    public function getPayment(): ?Payment;

    public function getPaidAmount(): float;

    public function getProviderFee(): float;

    public function getNetAmount(): float;
}

Три последних метода появились не сразу, и это была ошибка. Сначала мы хранили только сумму, которую заплатил пользователь. Потом понадобилось понять, сколько мы на самом деле получили, и выяснилось, что комиссия у всех считается по-разному: у одного процент от суммы, у другого разница между двумя полями в вебхуке, у третьего она зашита в курс конвертации. Считать это на лету в отчётах — значит считать по-разному в трёх местах.

Реализация контракта у каждого провайдера своя и занимает несколько строк. Для звёзд, например, вся арифметика выглядит так:

public function getPaidAmount(): float
{
    return (float) $this->price * 0.02;   // звезда — два цента
}

public function getProviderFee(): float
{
    return (float) $this->price * 0.02 * 0.35;
}

public function getNetAmount(): float
{
    return (float) $this->price * 0.02 * 0.65;
}

Для провайдера, который присылает и сумму платежа, и сумму к выплате, — просто вычитание одного из другого.

Одна таблица платежей, много таблиц инвойсов

Соблазн сделать единую таблицу invoices со всеми полями всех провайдеров велик, и он ведёт к сорока нулевым колонкам.

У каждого провайдера свои данные и свой жизненный цикл. Для звёзд инвойс создаётся до оплаты: мы просим у Telegram ссылку, кладём строку в базу и ждём. Для внешнего сервиса подписок инвойса до оплаты не существует вообще — он создаётся из вебхука постфактум, потому что пользователь уходит платить на сторону и мы узнаём об этом только по факту. Крипта — третий вариант: инвойс есть, но сумма в нём плавающая до момента подтверждения.

Поэтому таблицы разные, а платёж — один:

class Payment extends Model
{
    public function paymentable(): MorphTo
    {
        return $this->morphTo();
    }
}

payments — это то, на что смотрит отчётность: кто, когда, сколько, какая комиссия. paymentable_type и paymentable_id ведут к конкретному инвойсу, если нужно разобраться в деталях.

Начисление тоже одно, на весь проект:

public static function handleInvoice(InvoiceContract $invoice): Payment
{
    $billing = $invoice->getTelegramChat()->billing;
    $product = $invoice->getProduct();

    if ($product instanceof Product) {
        $billing->balance += $product->amount;
    }

    if ($product instanceof Subscription) {
        $billing->balance += $product->amount;
        $billing->premium_until = $billing->premium_until?->isFuture()
            ? $billing->premium_until->addDays($product->days)   // продление
            : now()->addDays($product->days);                    // новая подписка
    }

    $billing->save();

    return new Payment([...]);
}

Эти десять строк — вся разница между «пользователь заплатил» и «пользователь получил». Когда добавляется провайдер номер шесть, их трогать не нужно.

Обратите внимание на тернарник с подпиской. Продление активной подписки должно прибавляться к текущей дате окончания, а не затирать её. Выглядит банально, но именно на этом месте пользователь, купивший месяц за неделю до конца предыдущего, теряет неделю и идёт писать в поддержку. Продлевать от now() можно только если подписка уже истекла.

Деньги — целые числа

Сумма в базе лежит в минимальных единицах, а наружу отдаётся человеческим числом:

class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, $value, array $attributes)
    {
        return $value === null ? null : (float) ($value / 100);
    }

    public function set(Model $model, string $key, $value, array $attributes)
    {
        return $value === null ? null : (int) ($value * 100);
    }
}

Десять строк, которые снимают целый класс проблем: в базе нет дробей, складывать и суммировать безопасно, SUM() в отчёте даёт ровно то, что ожидается.

Одна засада тут всё же есть, и она в (int). Приведение типа отбрасывает дробную часть, а 19.99 * 100 в двоичной арифметике — это иногда 1998.9999999999998. Правильно (int) round($value * 100). У нас цены целые, так что не выстрелило, но это ровно тот случай, когда «не выстрелило» и «нет бага» — разные вещи.

Звёзды: что важно знать до интеграции

Нативные платежи Telegram устроены проще, чем любой эквайринг, потому что всю платёжную часть провайдер берёт на себя. Вы создаёте ссылку на инвойс, пользователь платит, вам приходит апдейт.

$link = $bot->getApi()->createInvoiceLink(
    title: $this->title,
    description: $this->description,
    payload: (string) $this->id,      // id строки в нашей базе
    currency: 'XTR',
    prices: [new LabeledPrice($this->title, $this->price)],
);

Главное решение здесь — что класть в payload. Это единственное, что вернётся вам обратно вместе с платежом. Не user_id, не id продукта, а идентификатор строки, в которой уже записано всё: кто, что покупает и почём. Если положить туда product_id, придётся догадываться, какой именно пользователь и по какой цене платил, — а цена могла измениться, пока ссылка лежала в чате.

Про курс. Внутри всё считается в звёздах, но звёзды — не деньги, их надо переводить в валюту отчётности, и коэффициент вшит в код константой. По-хорошему он должен лежать в конфиге: Telegram его менял и может поменять снова, а константа в модели найдётся не сразу.

Десять секунд на pre_checkout_query

Перед списанием Telegram шлёт боту запрос pre_checkout_query: подтверди, что готов принять этот платёж. Ответить надо в течение десяти секунд, иначе платёж не состоится.

$invoice = TelegramInvoice::whereFirst('id', $query->invoicePayload);

if ($invoice === null) {
    $bot->getApi()->answerPreCheckoutQuery(
        preCheckoutQueryId: $query->id,
        ok: false,
        errorMessage: 'Something goes wrong. Please try again.',
    );

    throw new \Exception('Оплата несуществующего инвойса');
}

$bot->getApi()->answerPreCheckoutQuery(
    preCheckoutQueryId: $query->id,
    ok: true,
);

Два правила, которые стоят этих десяти секунд.

В хендлере не должно быть ничего тяжёлого. Ни похода во внешний сервис, ни генерации чего-либо, ни длинной транзакции. Только проверка, что инвойс существует и валиден, и ответ. Всё остальное делается после того, как платёж прошёл.

Текст в errorMessage увидит пользователь. У нас там до сих пор висит дежурная английская фраза, хотя интерфейс бота многоязычный, — маленький долг, который заметен именно в момент, когда человек пытается заплатить.

Идентификатор, без которого нельзя вернуть деньги

После успешной оплаты приходит апдейт, и в нём есть поле, которое обязательно нужно сохранить:

$invoice->is_paid = true;
$invoice->telegram_payment_charge_id = $update->message->successfulPayment->telegramPaymentChargeId;
$invoice->save();

Возврат звёзд делается только по этому идентификатору. Не по id инвойса, не по пользователю. Если вы его не сохранили — вернуть деньги через API нельзя, остаётся ручная переписка с поддержкой Telegram.

Сам возврат выглядит безобидно:

$bot->getApi()->refundStarPayment($chat->chat_id, $this->telegram_payment_charge_id);

С ним связана моя любимая строчка во всём платёжном коде. Возврат берёт бота не из инвойса и не из чата, а так:

$bot = TelegramBot::where('id', 2)->first();

Магическая двойка. Работает, потому что платёжный бот у нас действительно второй в таблице, и сломается в тот день, когда это перестанет быть правдой. В конфиге для этого есть отдельный ключ, которым пользуется соседний метод, — просто сюда руки не дошли.

Как тестировать платежи, не разоряясь

Платёжный поток нельзя проверить моками до конца: половина проблем живёт в реальном ответе провайдера. Поэтому в локальном окружении после успешной оплаты мы сразу делаем возврат:

if (config('app.env') === 'local') {
    $api->refundStarPayment($chat->chat_id, $invoice->telegram_payment_charge_id);
}

Платёж настоящий, проходит весь путь целиком, начисление происходит, а деньги возвращаются на счёт разработчика. Проверка обходится в ноль, повторять можно сколько угодно.

Два предостережения, если будете повторять. Условие должно быть завязано на окружение и ни на что больше: любая проверка вида «если пользователь из списка тестовых» однажды попадёт в прод вместе со списком. И помните, что возврат — это тоже событие в системе; у нас он проходит мимо обработчика возвратов, так что в локальной базе остаётся начисление без платежа. Для разработки нормально, но знать об этом надо.

Вебхуки: подпись, сырое тело и опасный дефолт

Провайдер, который живёт за пределами Telegram, сообщает об оплате вебхуком. Проверка подлинности — HMAC от тела запроса:

$computed = hash_hmac('sha256', $request->getContent(), $secret);

return hash_equals($computed, $signatureHeader);

Здесь важны две детали.

$request->getContent() — это сырое тело запроса, ровно те байты, которые прислал провайдер. Если распарсить JSON и закодировать обратно, подпись не сойдётся: порядок ключей, экранирование юникода, пробелы — всё поменяется. Считать HMAC можно только от оригинала.

hash_equals, а не ===. Обычное сравнение строк выходит из цикла на первом несовпавшем байте, и по времени ответа можно подбирать подпись. Атака экзотическая, защита бесплатная.

А теперь то, что мне в этом коде не нравится:

if (empty($secret)) {
    return true;
}

Если секрет не задан в окружении, проверка подписи молча выключается и вебхук принимает что угодно. Удобно на старте интеграции, опасно навсегда. Правильное поведение — падать на старте приложения, если провайдер включён, а секрета нет. У нас это до сих пор так, и я пишу об этом в статье в том числе чтобы наконец починить.

Ещё одна мелочь, которая экономит часы: логировать сырое тело и заголовки нужно до валидации, а не после. Когда подпись не сходится, единственный способ понять почему — посмотреть, что именно пришло.

Коды ответа — это управление ретраями

Вебхук — не просто обработчик, а диалог. Тем, что вы вернёте, вы говорите провайдеру, повторять ему попытку или нет.

У нас получилось так:

Что случилось

Код

Что сделает провайдер

Подпись не сошлась

401

перестанет слать (и правильно)

Пустое тело

400

перестанет слать

Нет обязательного поля

422

перестанет слать

Пользователь не найден

404

перестанет слать

Всё хорошо

200

закроет доставку

Самая спорная строчка — «пользователь не найден». Сейчас мы отвечаем 404, то есть просим больше не приходить. А это ровно та ситуация, когда повторить стоило бы: пользователь мог удалиться, база могла отстать, вебхук мог обогнать наш собственный процесс регистрации. Гонка редкая, но её цена — потерянный платёж и ручной разбор.

Общее правило: 4xx — это «я не буду это обрабатывать никогда», 5xx — «попробуй позже». Отвечать 200 на необработанное — верный способ потерять деньги тихо.

Чего у нас нет, а должно быть

Самый большой пробел в этой схеме — идемпотентность вебхуков.

Провайдеры ретраят. Ответ не дошёл, таймаут, сетевая ошибка — и то же самое событие приезжает второй раз. Сейчас обработчик на каждый вебхук создаёт новый инвойс и начисляет купленное заново. Спасает только то, что провайдер ретраит редко.

Лечится скучно и надёжно: уникальный индекс на идентификатор транзакции провайдера, вставка с перехватом нарушения уникальности, и при повторе — ответ 200 без начисления. Ровно тот же приём, которым мы решаем конкурентность в других местах системы: уникальность проверяет база, а не приложение.

Второе, чего нет: платежи не в транзакции. Начисление, сохранение платежа и обновление инвойса — три отдельные операции. Если упасть посередине, получится начисление без записи о платеже или наоборот. За всё время не стрельнуло ни разу, что доказывает только одно — нам везло.

Что стоило выпиливание двух провайдеров

Когда два провайдера перестали быть нужны, удалять пришлось:

  • контроллеры вебхуков и отдельные джобы обработки;

  • два мидлвара на каждого: проверка подписи и белый список подсетей;

  • модели инвойсов и отдельные таблицы сырых вебхуков;

  • DTO для событий провайдера;

  • экраны и действия в боте, которые вели к оплате;

  • клиентские сервисы к их API.

Больше двадцати файлов на двоих. При этом PaymentService, InvoiceContract, таблица payments и вся отчётность не изменились ни на строчку — провайдеры отвалились по границе абстракции, как и было задумано.

Вывод, за который я готов спорить: стоимость платёжной интеграции — это не код, который вы пишете, а код, который вы потом носите. Каждый провайдер — это свои мидлвары, свои таблицы логов, свой формат ошибок и свой способ ретраить. Абстракция нужна не чтобы красиво добавлять, а чтобы уметь убирать.

И есть третий сценарий, хуже обоих: интеграция, которую не выпилили и не используют. Клиент к криптоплатежам живёт у нас в репозитории целым классом с документацией в комментариях — фиксированные курсы, оценка суммы, колбэки, — и ни одного вызова к нему нет. Он проходит проверки, светится в отчётах о покрытии, его видит каждый, кто ищет по слову «payment», и каждый раз кто-то тратит минуту, чтобы понять, живой он или нет. Выпилить его — пятиминутная задача, которую никто не делает, потому что «вдруг вернёмся». Удалять надо сразу: код лежит в гите, а не в голове, и вернуть его оттуда проще, чем объяснять новому человеку, почему половина платёжного модуля не подключена.

Чеклист

Если делаете приём платежей с нуля:

  1. Интерфейс инвойса до первого провайдера, а не после третьего. В нём сразу должны быть комиссия и чистая сумма, а не только уплаченное.

  2. Одна таблица платежей для отчётности, отдельные таблицы инвойсов под каждого провайдера.

  3. Начисление купленного существует в одном экземпляре на весь проект.

  4. Деньги в базе — целые. Умножение на сто через round, а не через приведение типа.

  5. Продление подписки считается от даты окончания, если она ещё не прошла.

  6. В payload платежа кладите идентификатор своей строки, а не продукта и не пользователя.

  7. Идентификатор транзакции провайдера сохраняется всегда. Без него нет возврата.

  8. Хендлер подтверждения платежа — только проверка и ответ, никакой работы.

  9. HMAC считается от сырого тела и сравнивается через hash_equals.

  10. Нет секрета — приложение не стартует. Не «пропускаем проверку».

  11. Сырой вебхук логируется до валидации.

  12. Уникальный индекс на идентификатор транзакции провайдера. Ретрай — это не гипотеза.

Про пункт 12 отдельно интересно: как вы отвечаете на повторный вебхук — 200 без обработки или 409? Мы склоняемся к 200, чтобы провайдер спокойно закрыл доставку, но аргументы против тоже слышал.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.