METABYTE
К списку статей

Stripe Connect для двухстороннего маркетплейса: архитектура, код, подводные камни

Пошаговое внедрение Stripe Connect в маркетплейс: выбор модели аккаунтов, архитектура, код на Node.js/TypeScript, комиссии, возвраты и что ломается в продакшене.

11 мая 202612 мин чтенияAI-research draft
Stripe Connect для двухстороннего маркетплейса: архитектура, код, подводные камни

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

Если кратко: начните с Express-аккаунтов и Destination Charges, храните stripe_account_id у продавца, принимайте платежи через Payment Intents с transfer_data, комиссию берите application_fee_amount, а все события ведите через подписанные вебхуки с идемпотентностью. Дальше — нюансы: онбординг, возвраты, споры, расписание выплат, локальные регуляции и устойчивость интеграции.

Когда Stripe Connect уместен

  • Двухсторонний маркетплейс: покупатели платят, продавцы (мерчанты) получают вознаграждение, платформа берет комиссию.
  • Нужна автоматическая KYC/онбординг мерчантов и соответствие регуляциям без построения собственного лицензируемого PSP.
  • Есть требования к разделению потоков денег: возвраты, чаевые, промокоды, разбиение платежа между несколькими получателями.

Если вы просто продаете сами — базовый Stripe (без Connect) проще. Если вы хотите кастомный онбординг под полный контроль — возможен Custom, но это больше ответственности и development-часов.

Выбор модели аккаунтов Stripe: Standard vs Express vs Custom

Главный выбор влияет на UX, комплаенс и бюджет сопровождения.

МодельКонтроль онбордингаPCI/комплаенс на платформеДоступность фичСаппорт/спорыКогда брать
StandardНизкий (Stripe UI)МинимальныйБазовые (продавец видит всё в своем Stripe)Львиная доля у StripeКогда платформа — каталог/агрегатор
ExpressСредний (встроенные Account Links)Низкий/среднийПочти всё, белый лейбл частичноСовместноХороший баланс для 80% маркетплейсов
CustomВысокий (полный контроль UI/данных)Выше, особенно по поддержке, диспутамМаксимум гибкостиПочти всё на васТолько если обязателен кастомный KYC/UX

Практически: начинайте с Express. Перейти на Custom позже можно, но дороже. Express ускоряет валидацию KYC, уменьшает юридический след, при этом дает контроль над комиссиями и выплатами.

Базовая архитектура платежного контура

Компоненты, которые обычно используем в проде:

  • Frontend: Next.js/React + Stripe Elements/Payment Element.
  • Backend API: Node.js/TypeScript (NestJS/Express), SDK stripe.
  • БД: Postgres для сущностей платформы (users, sellers, orders, payout_rules), Redis для блокировок/кэша идемпотентности.
  • Вебхуки: прием событий в отдельном изолированном сервисе (worker), очередь (SQS/RabbitMQ) желательна.
  • Безопасность: подписанные вебхуки, секреты в Vault/SSM, ограниченный доступ ролей.
  • Отчеты и сверки: периодические джобы, выгрузки Balance Transactions из Stripe и сравнение с вашей книгой учета.

Поток данных (упрощенно):

  1. Продавец проходит онбординг (Express Account Link) → вы сохраняете stripe_account_id.
  2. Покупатель оформляет заказ → создается PaymentIntent на платформенном аккаунте с transfer_data.destination = <seller_account> и application_fee_amount.
  3. Stripe удерживает средства → событие payment_intent.succeeded → вы фиксируете заказ в БД и, при необходимости, создаете Transfer (если используете отдельную схему переводов).
  4. Выплаты продавцу идут автоматически (балансы на его аккаунте) по его расписанию; вы контролируете hold-периоды политиками Connect и своими правилами.

Интеграция шаг за шагом (Node.js/TypeScript)

1) Создание аккаунта продавца и онбординг

Храним связку с продавцом в своей БД (seller.stripe_account_id).

// npm i stripe
import Stripe from 'stripe'
import { v4 as uuid } from 'uuid'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' })

export async function createOrGetConnectedAccount(sellerId: string) {
  // 1. Ищем уже существующий
  const seller = await db.seller.findUnique({ where: { id: sellerId } })
  if (seller?.stripeAccountId) return seller.stripeAccountId

  // 2. Создаем Express-аккаунт
  const account = await stripe.accounts.create({
    type: 'express',
    capabilities: { card_payments: { requested: true }, transfers: { requested: true } }
  })

  await db.seller.update({ where: { id: sellerId }, data: { stripeAccountId: account.id } })
  return account.id
}

export async function createAccountLink(accountId: string, returnUrl: string, refreshUrl: string) {
  const link = await stripe.accountLinks.create({
    account: accountId,
    refresh_url: refreshUrl,
    return_url: returnUrl,
    type: 'account_onboarding'
  })
  return link.url
}

Важно: проверяйте account.capabilities.*.status. Пока active нет — не разрешайте продавцу принимать платежи.

2) Создание платежа с комиссией платформы (Destination Charges)

export async function createPaymentIntent(opts: {
  amount: number // в минорных единицах валюты, например, центы
  currency: string
  sellerStripeAccountId: string
  customerId?: string
  metadata?: Record<string, string>
}) {
  const idempotencyKey = `pi_${uuid()}`

  const pi = await stripe.paymentIntents.create({
    amount: opts.amount,
    currency: opts.currency,
    automatic_payment_methods: { enabled: true },
    transfer_data: { destination: opts.sellerStripeAccountId },
    application_fee_amount: calcPlatformFee(opts.amount),
    metadata: opts.metadata
  }, { idempotencyKey })

  return { clientSecret: pi.client_secret }
}

На фронте используйте Payment Element и stripe.confirmPayment({ clientSecret }). Так вы проходите SCA/3DS без ручной гимнастики.

3) Обработка вебхуков и идемпотентность

Никаких «оверрайдов статуса» без подтверждения из вебхука. Все финальные статусы — только из событий Stripe. Защитите обработчик и не обрабатывайте один и тот же event дважды.

import crypto from 'crypto'
import express from 'express'

const app = express()
app.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'] as string
  let event

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
  }

  const alreadyHandled = await redis.setNX(`wh:${event.id}`, '1')
  if (!alreadyHandled) return res.status(200).end() // идемпотентность
  await redis.expire(`wh:${event.id}`, 60 * 60)

  switch (event.type) {
    case 'payment_intent.succeeded': {
      const pi = event.data.object as Stripe.PaymentIntent
      await orders.markPaidByPaymentIntent(pi.id)
      break
    }
    case 'charge.refunded': {
      const ch = event.data.object as Stripe.Charge
      await orders.markRefundedByCharge(ch.id)
      break
    }
    case 'account.updated': {
      const acc = event.data.object as Stripe.Account
      await sellers.updateCapabilities(acc.id, acc.capabilities)
      break
    }
  }

  res.json({ received: true })
})

Для локальной отладки удобно stripe listen --forward-to localhost:3000/stripe/webhook.

4) Возвраты и частичные возвраты

Возвраты инициируйте с учетом модели списания.

export async function refund(orderId: string, amountMinor?: number) {
  const order = await db.order.findUnique({ where: { id: orderId } })
  if (!order) throw new Error('order not found')

  // Найдите charge через paymentIntent
  const pi = await stripe.paymentIntents.retrieve(order.paymentIntentId)
  const chargeId = typeof pi.charges?.data?.[0]?.id === 'string' ? pi.charges.data[0].id : undefined
  if (!chargeId) throw new Error('no charge')

  const refund = await stripe.refunds.create({
    charge: chargeId,
    amount: amountMinor, // undefined = полный возврат
    reverse_transfer: true, // вернет средства с connected account
    refund_application_fee: true // вернет комиссию платформы
  })
  return refund
}

Замечание: reverse_transfer и refund_application_fee зависят от выбранной схемы (см. ниже). Не все сочетания валидны для Direct/Destination моделей.

Способы списания и переводов: что выбрать

Stripe предлагает несколько схем. Чаще всего для маркетплейса — Destination Charges или Separate Charges and Transfers. Разница — где «живет» платеж и как идут возвраты/споры.

СхемаГде создается ChargeКак взять комиссиюВозвратыСложностьКогда уместно
Destination ChargesНа аккаунте платформыapplication_fee_amount + transfer_data.destinationМожно делать reverse_transfer, refund_application_feeНизкая/средняяБольшинство маркетплейсов, один продавец на заказ
Separate Charges & TransfersCharge на платформе, потом transfers на селлеровКомиссия — разница между входом и трансферамиГибкие, но требуют согласования суммСредняя/высокаяСплит-платежи на нескольких селлеров
Direct ChargesНа аккаунте селлераКомиссия через платформенную подписку/приложениеСложнее управлять возвратами централизованноСредняяКогда продавцы юридически «полные мерчанты»

Если у вас 90% заказов — один продавец, берите Destination Charges: минимум кода и понятные возвраты. Для корзин с несколькими продавцами используйте Separate Charges & Transfers и аккуратные транзакции в своей БД.

Удержания, холды, споры и отчеты

Удержание средств и расписание выплат

  • Express-аккаунты сами управляют расписанием выплат. Платформа может ставить задержки через политики (например, не создавать трансфер сразу).
  • Если вам нужен «эскроу-подобный» сценарий — используйте transfer_schedule, ручные Transfers после наступления бизнес-события (доставки/подтверждения).

Споры (disputes)

  • События: charge.dispute.created, charge.dispute.closed.
  • Храните доказательства (скриншоты, доставку, переписку) заранее, чтобы быстро отвечать через API/дешборд.
  • Разделите SLA: кто готовит ответ — вы или продавец. В Express типично участвует платформа.

Отчеты и сверка

  • Снимайте balance_transactions и сводите с внутренней книгой. Нельзя полагаться на «только наши статусы в БД» — сеть карт и возвраты асинхронны.
  • Заводите ежедневную джобу сверки и алерты по рассинхронизации сумм > 0.

Небольшая инженерная ирония: одна хорошая сверка спасает от десяти «касаем» в чате поддержки.

Что ломается в продакшене

  • Идемпотентность: повторные вебхуки, повторные клики на оплату, ретраи сети. Используйте idempotency_key и setNX в Redis с TTL. Мы писали о высоконагруженных счетчиках и атомиках — контекст близкий: быстрые счетчики в Erlang.
  • Порядок событий: payment_intent.succeeded может прийти до вашего ответа фронту. Не стройте логику «только после редиректа». Состояние — из вебхуков.
  • Capabilities: кардинг-провалы «вчера платили — сегодня нет» часто связаны с KYC или включением новых стран/валют. Проверяйте requirements.disabled_reason.
  • Валюта и минорные единицы: перепутали RUB/USD — получили комичную сумму. Все суммы всегда в минорных единицах (amount в центах/копейках).
  • Refund + transfers: для некоторых конфигураций reverse_transfer невозможен. Проектируйте схему списания заранее, иначе получите миграцию платежной модели «на живую». Миграции в финансах похожи на смену типа колонки в проде: больно и дорого — см. наш разбор почему Cassandra не даст поменять тип колонки.
  • Тест/прод ключи и вебхуки: не смешивайте. Разделяйте окружения, используйте отдельные endpoint secrets.
  • 3DS/SCA: фронт должен уметь переинициировать подтверждение. Не гасите попап «просто перезагрузкой». Проверяйте requires_action статусы.

Безопасность и комплаенс

  • PCI DSS: с Payment Element и серверной интеграцией вы остаетесь в упрощенном SAQ-A/SAQ-A-EP, но не храните PAN/CSC. Не логируйте client_secret.
  • Вебхуки: верификация подписи обязательна. Отдавайте 2xx быстро, тяжелую работу — в очередь/воркер.
  • GDPR/PII: данные KYC — в Stripe. У себя храните минимум (только account_id и необходимые зеркала статусов).
  • Ограничения бизнеса: Stripe ведет список запрещенных категорий; валидируйте мерчантов на входе, чтобы не тратить ресурсы впустую.
  • Доступы: разделяйте ключи по ролям (reading, writing), ротуйте секреты, проверяйте транспорт (TLS 1.2+).

Бизнес-контекст: стоимость, сроки, ROI

На что тратятся деньги и время при запуске платежей через Connect:

  • Интеграция и онбординг: разработка флоу продавца, Account Links, хранение статусов, UI ошибок KYC.
  • Платежная логика: создание PaymentIntent, удержание комиссии, возвраты, частичные возвраты, отмены, тестовые сценарии.
  • Вебхуки/воркеры: идемпотентность, очередь, ретраи, мониторинг.
  • Отчеты и сверки: джобы, дашборды, экспорт в бухгалтерию.
  • Комиссии Stripe: обработка платежей, Connect-компоненты (за аккаунт/за выплату), комиссии за споры и конвертацию валют — актуальные тарифы смотрите в документации Stripe для вашей страны.
  • Юридические/политики: публичные условия для продавцов, процедура возвратов, политика споров, SLA ответов.

Практический расклад по этапам (ориентиры без «магических чисел»):

  • MVP-интеграция для одного региона и одной валюты, Express + Destination Charges: недели разработки, если команда знакома с Stripe.
  • Мультивалютность и корзина из нескольких продавцов: ощутимо сложнее, закладывайте дополнительные спринты.
  • Автоматизация отчетности и сверки: обязательный этап для выхода за рамки пилота.

ROI складывается из конверсии оплаты, скорости вывода продавцов в оборот (онбординг) и снижения нагрузки на саппорт по спорам/возвратам. Connect позволяет не строить собственный платежный процессинг и комплаенс-команду — экономия времени вывода на рынок.

Чеклист перед релизом

  • Продавец без capabilities.card_payments=active не может принимать платежи — UI должен это объяснять.
  • Все суммы — в минорных единицах; валюта закреплена в заказе.
  • Вебхуки: идемпотентность + retry-safe обработчики.
  • Тесты сценариев: полный и частичный возврат, отмена до захвата, спор, чарджбек, провал 3DS.
  • Логи и трассировка: корелляция order_idpayment_intent.idcharge.id.
  • Документация для саппорта: «как найти платеж», «как инициировать возврат», «как проверить статус онбординга селлера».

Пример конфигурации для мультиселлер-корзины (Separate Charges & Transfers)

Для корзины, где один заказ разбивается на нескольких продавцов, шаблон таков:

  1. Создаете один PaymentIntent на полную сумму.
  2. После succeeded — распределяете Transfers по продавцам, сохраняя комиссию у себя.
export async function distributeTransfers(orderId: string) {
  const order = await db.order.findUnique({ where: { id: orderId }, include: { items: true } })
  if (!order) throw new Error('no order')

  // Найдем charge
  const pi = await stripe.paymentIntents.retrieve(order.paymentIntentId)
  const chargeId = pi.charges.data[0].id

  // Идемпотентность на уровне ордера
  const lock = await redis.setNX(`dist:${orderId}`, '1')
  if (!lock) return
  await redis.expire(`dist:${orderId}`, 60)

  for (const item of order.items) {
    const seller = await db.seller.findUnique({ where: { id: item.sellerId } })
    const netAmount = item.totalMinor - calcPlatformFee(item.totalMinor)
    await stripe.transfers.create({
      amount: netAmount,
      currency: order.currency,
      destination: seller!.stripeAccountId!,
      source_transaction: chargeId,
      metadata: { orderId, itemId: item.id }
    })
  }
}

Плюсы: гибкость и сплит. Минусы: сложнее возвраты — надо зеркалить логику на уровне каждой позиции.

Мониторинг и алерты

  • Ошибки вебхуков > порог — алерт в Slack/PagerDuty.
  • Несовпадение сумм (ваша книга vs Stripe Balance Transactions) — алерт.
  • Процент requires_action по странам/банкам — сигнал к тюнингу UI.
  • Пул неуспешных онбордингов (застрявшие requirements) — отдельный отчет.

FAQ

Что выбрать для начала: Express или Custom?

Express. Он покрывает подавляющее большинство кейсов маркетплейса, снимает с вас львиную долю KYC/UI-обязанностей и ускоряет вывод на рынок. Custom — только если нужен полный контроль онбординга и нестандартные регуляторные требования.

Какая схема списания проще для возвратов?

Destination Charges. Она позволяет делать reverse_transfer и refund_application_fee в лоб, что упрощает возвраты и бухгалтерию. Separate Charges & Transfers дает гибкость при сплите на нескольких продавцов, но сложнее в обращении с возвратами.

Как правильно брать комиссию платформы?

В Destination Charges используйте application_fee_amount. В Separate Charges & Transfers — просто переводите продавцу нетто, удерживая свою комиссию до перевода. Не «перекидывайте» комиссию отдельным инвойсом — усложнит сверки и налоги.

Нужен ли собственный PCI DSS?

При использовании Stripe Elements/Payment Element и серверной интеграции вы попадаете в упрощенную область (SAQ-A/SAQ-A-EP). Не храните данные карт, не логируйте чувствительные токены и следуйте гайдлайнам Stripe.

Что с мультивалютностью?

Заранее закрепляйте валюту в заказе, храните суммы в минорных единицах и проверяйте, может ли селлер принимать эту валюту (capabilities + счета). Конвертацию и связанные комиссии берите из официальной документации Stripe, тестируйте отдельными кейсами.

Как тестировать вебхуки локально?

Используйте Stripe CLI: stripe listen --forward-to localhost:3000/stripe/webhook. Держите разные секрты для теста и продакшена, не смешивайте окружения, реализуйте идемпотентность в обработчике.

Ключевые выводы

  • Для 80% маркетплейсов — Express-аккаунты и Destination Charges: быстрее в прод и проще возвраты.
  • Все критичные статусы берите из вебхуков, а не из ответов клиентского SDK; без идемпотентности жить нельзя.
  • Продумайте модель денежных потоков заранее — менять ее в бою тяжелее, чем схему БД.
  • Сверка Stripe Balance Transactions с вашей книгой — обязательная ежедневная рутина.
  • Учтите комплаенс: capabilities, ограничения по видам бизнеса, SCA/3DS — иначе получите «вчера работало, сегодня нет».

Если вы строите маркетплейс и нужно быстро и безопасно принять первые оплаты — напишите нам. В MTBYTE мы проектируем и внедряем платежные контуры под нагрузку и бизнес-ограничения. Связаться: /contact.

СЛЕДУЮЩИЙ ШАГ

Понравилось как мыслим?

Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.