SaaS и деньги: платежи, webhooks, подписки, metering
В прошлом уроке афиша научилась заводить тенантов и держать их данные порознь. Теперь тенанты должны платить, и это меняет цену ошибки. В обычной фиче баг означает неудобство, в биллинге баг означает деньги: списали дважды, не списали вовсе, отключили платящего клиента, оставили доступ неплатящему. Хорошая новость: ничего принципиально нового здесь нет. Идемпотентность, inbox, ретраи и статус-машины из уроков про надёжность и есть биллинг, просто теперь на другом конце провода живёт платёжный провайдер, а каждое сообщение стоит конкретных рублей.
Платёж: почему провайдер, а не сам
Первое решение принимается за тебя: номера карт ты не хранишь и не обрабатываешь. Любая система, через которую проходит PAN, попадает в область действия PCI DSS, а полный аудит соответствия стоит как небольшая команда разработки. Поэтому индустрия сошлась на токенизации: форма с номером карты живёт в iframe или на странице провайдера, номер уезжает с браузера пользователя сразу к провайдеру, твой сервер видит только токен. Такая схема сводит твою часть PCI-обязательств до SAQ-A, анкеты на пару десятков пунктов вместо аудита.
Внутри токенизации два режима интеграции. Hosted checkout: редирект на страницу провайдера, которая принимает оплату и возвращает пользователя назад. Интегрируется за день, выглядит как провайдер, а не как твой продукт. Embedded-поля (Stripe Elements и аналоги): провайдерские iframe-поля встраиваются в твою форму, UX полностью твой, интеграция и поддержка дороже. Типичная траектория SaaS: стартовать с hosted, переехать на embedded, когда конверсия формы оплаты станет важнее скорости разработки.
Дальше словарь самого платежа, на примере Stripe (у Adyen и ЮKassa понятия те же, имена другие). Платёж это не один вызов, а объект с жизненным циклом: PaymentIntent объединяет авторизацию, проверку SCA и само списание. Платёж может потребовать действий пользователя (3DS-редирект), может зависнуть в processing, может отвалиться и быть повторённым с другой картой, и всё это один объект со статус-машиной, а не четыре разрозненных вызова. Его близнец SetupIntent делает всё то же, кроме списания: так подписочный SaaS привязывает карту на триале, чтобы списать через четырнадцать дней.
Каждый вызов платёжного API едет с idempotency key из урока про resilience, потому что таймаут на вызове create payment это самый дорогой таймаут в твоей системе: без ключа ретрай создаёт второе списание.
const intent = await stripe.paymentIntents.create(
{
amount: 4900,
currency: 'eur',
customer: tenant.billingCustomerId,
metadata: { tenantId: tenant.id, invoiceId: invoice.id },
},
// повтор после таймаута вернёт тот же intent, а не создаст второй
{ idempotencyKey: `invoice-${invoice.id}` },
);
Заметь metadata: провайдер охотно хранит твои идентификаторы, и
позже, когда прилетит webhook про этот платёж, ты по tenantId
поймёшь, чей он, без поиска по своим таблицам.
И последняя пара слов, которую путают: refund и dispute. Refund инициируешь ты: клиент попросил, ты вернул, вопрос закрыт. Dispute инициирует банк клиента, и это уже разбирательство: у тебя окно в одну-три недели на доказательства (логи доступа, переписку, факт оказания услуги), проигрыш стоит суммы платежа плюс фиксированный сбор, а высокая доля disputes ведёт к штрафным программам карточных сетей и расторжению договора. Поэтому зрелый SaaS возвращает деньги по первой просьбе: добровольный refund почти всегда дешевле выигранного dispute, не говоря о проигранном.
Webhooks: чужие события в твоём inbox
Платёж асинхронен по своей природе: 3DS-челлендж занимает минуты, dispute приходит через месяц, очередное списание подписки происходит вообще без твоего участия. Опрашивать провайдера в цикле нельзя, поэтому он сам приносит события: webhook это POST от провайдера на твой эндпоинт с фактом: платёж прошёл, списание отклонено, открыт dispute. И вместе с удобством webhook приносит весь набор проблем at-least-once доставки из урока про надёжную доставку: дубли, потери, нарушенный порядок. Только теперь producer не твой сервис, а чужой, и договариваться с ним нельзя, можно только защищаться. Схема защиты стандартна и состоит из трёх ходов.
Ход первый: проверить подпись. Эндпоинт webhook публичен, и POST на него может прислать кто угодно. Провайдер подписывает каждый запрос HMAC-ом с общим секретом, подпись и timestamp едут в заголовке. Проверка отклоняет и поддельные запросы, и replay: запрос с timestamp старше пяти минут отвергается, даже если подпись верна. Практическая ловушка, на которой спотыкаются все: подпись считается по сырым байтам тела. Если глобальный JSON-middleware уже распарсил тело, ты восстановишь из объекта чуть другие байты, и проверка не сойдётся. Для webhook-роута тело нужно брать сырым.
Ход второй: принять быстро, обработать потом. Провайдер ждёт
2xx несколько секунд; не дождался, значит посчитал недоставленным и
будет ретраить с нарастающими интервалами. Если хендлер внутри
запроса начисляет доступ, шлёт письма и пишет в три таблицы, он
рано или поздно не уложится, и провайдер начнёт присылать дубли
поверх медленной обработки. Поэтому хендлер делает минимум: проверил
подпись, записал событие в inbox, поставил задачу воркеру, ответил
200. Это ровно inbox-паттерн из урока про доставку, дедупликация
через UNIQUE(event_id):
app.post('/webhooks/stripe', raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event;
try {
// проверка по сырому телу: и подпись, и допуск по timestamp
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return res.status(400).send('bad signature');
}
const inserted = await db
.insert(webhookInbox)
.values({ eventId: event.id, type: event.type, payload: event })
.onConflictDoNothing()
.returning({ id: webhookInbox.id });
// дубль не вставился, значит уже принят: молча подтверждаем
if (inserted.length > 0) {
await queue.add('billing-event', { eventId: event.id });
}
return res.status(200).send('ok');
});
Ход третий: не верить порядку. Провайдер не гарантирует
очерёдность: invoice.paid может приехать раньше, чем
invoice.created, ретрай старого события может прийти после нового.
Поэтому воркер обращается с событием как с сигналом, а не как с
истиной: получив любое событие про подписку, он запрашивает у
провайдера её актуальное состояние и синхронизирует свою таблицу с
ним. Такую схему называют thin events: payload события годится для
маршрутизации, источником правды служит GET по объекту. Старое
событие, пришедшее последним, при таком подходе безвредно: GET всё
равно вернёт свежее состояние.
Остаётся последняя дыра: webhook может вообще не дойти. Твой сервис
лежал дольше, чем провайдер ретраил, событие потерялось при
инциденте у самого провайдера, эндпоинт неделю отвечал 500 из-за
бага. На этот случай существует
реконсиляция:
периодическая джоба тянет у провайдера список событий (GET /events
с курсором), сравнивает с inbox и доигрывает пропущенное. Push для
скорости, pull для полноты: webhook доставляет событие за секунды,
реконсиляция гарантирует, что за сутки не потеряется ничего. В
деньгах нужны обе половины.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.