Раздел 9 · Backend

SaaS и деньги: платежи, webhooks, подписки, metering

senior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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.