Перейти к содержимому

CrewsForge — Шаг 5: Денежный контур (Stripe Connect)

Статус: черновик на подтверждение · Шаг 5 из 9 Основание: ФТ раздел 7; машины Hold/Settlement (шаг 3); решения 0.3 (separate charges & transfers), 0.4 (минорные единицы), шаг 4 раздел 6


РешениеЗначениеОбоснование
МодельSeparate charges and transfersЗафиксировано в 0.3: цикл холда нереализуем на destination charges
Тип connected-аккаунтов командExpressStripe-hosted онбординг и KYC юрлица командой Stripe (Y-5: собственной формы KYC нет), дашборд выплат из коробки, минимум нашего кода. Custom дал бы белый лейбл ценой полной поддержки KYC-UI — не для беты
Merchant of recordПлатформа (Ryomen Corporation)Следствие separate charges; уже в юр-пакете
Валюта бетыUSD onlyМультивалютность (GBP/EUR/SGD/AED) — после беты; Project.currency уже готов, включение — конфиг + тариф методов оплаты на валюту
Stripe API versionПиннится в конфиге, обновление — осознанный PRВебхук-контракты зависят от версии

Онбординг команды: team-поверхность запрашивает подключение выплат → billing-api создаёт Express-аккаунт (accounts.create, country из Party/профиля) → Account Link (onboarding URL) → команда проходит форму Stripe → события account.updated ведут машину PayoutAccount:

Условие из account.updatedPayoutAccountState
аккаунт создан, онбординг не завершёнPENDING
charges_enabled && payouts_enabled && requirements.currently_due = []ACTIVE
payouts_enabled = false ИЛИ requirements.currently_due ≠ [] (после ACTIVE)RESTRICTED

requirements.currently_due сохраняется в PayoutAccount.requirements — это содержимое уведомления Y-4 («узнать за недели, не в момент PAID»): команде показывается, что именно просит Stripe.

⚠️ Открытая проверка (блокер supply-географии): поддержка Stripe-выплат для юрисдикций команд (Украина) — проверить актуальный список стран Express до онбординга первой команды. Если страна не поддержана — варианты: юрлицо команды в поддержанной юрисдикции (Wise/Payoneer-контур — отдельная юридическая проработка). В архитектуре это изолировано: PayoutAccount.provider — поле, порт выплат абстрагируем при появлении второго провайдера, не раньше.

UX-механика: Stripe Checkout Session (hosted). Не Elements: фронтенд критического flow и так отстаёт, hosted-страница снимает PCI-скоуп и вёрстку платёжной формы. Checkout mode: payment, payment_method_types по конфигу рынка.

Методы оплаты — ACH по умолчанию, карты допускаются. На чеках $2–10k за этап экономика методов радикально разная: ACH debit ≈ 0.8% с капом ~$5, карта ≈ 2.9% + $0.30 (на этапе $5 000 — $5 против ~$145). Процессинговые издержки несёт платформа из комиссии (surcharge клиенту не делаем — трение на самом чувствительном шаге воронки); карта остаётся для скорости (ACH идёт 3–5 дней — ровно «платёж в пути» из 7.3, состояние PENDING_IN это уже выражает честно).

Поток:

Фаундер: «Фондировать этап N»
→ billing-api: guard'ы H01 (этап PLAN_APPROVED, нет спора/расторжения, payout-аккаунт команды ≠ NOT_STARTED)
→ Hold создан (PENDING_IN), Checkout Session:
amount = milestone.amountMinor, currency = project.currency
metadata: { holdId, milestoneId, projectId }
payment_intent_data.transfer_group = `project:{projectId}`
idempotency key: `hold:{holdId}:checkout`
→ редирект фаундера на Stripe
Вебхуки:
checkout.session.completed → фиксация paymentIntentId на холде
payment_intent.succeeded → H02: Hold → HELD → каскад M03 (этап FUNDED)
payment_intent.payment_failed / canceled / session expired → H03: Hold → VOID

Отображение (7.3): фаундер видит «платёж в обработке (до 3–5 дней для банковского перевода)», команда видит «этап ожидает поступления средств» — оба состояния выводятся из hold.PENDING_IN, этап остаётся PLAN_APPROVED.

3. Выплата (Settlement → Transfer; Hold: HELD → RELEASING → RELEASED)

Заголовок раздела «3. Выплата (Settlement → Transfer; Hold: HELD → RELEASING → RELEASED)»

Ключевая механика Stripe — source_transaction. При separate charges средства charge’а становятся available на балансе платформы через ~2 дня; transfer без привязки требует available-баланса. Transfer с source_transaction = chargeId холда: (а) не зависит от текущего available-баланса — деньги двинутся, когда charge рассчитается; (б) жёстко связывает выплату с конкретным поступлением — сверка становится тривиальной; (в) ограничен суммой charge’а — для нас это дополнительный инвариант (выплатить больше холда невозможно даже багом).

Задача SETTLEMENT_EXECUTION (основание готово — шаг 4.6)
→ администратор видит системный расчёт сплита + основание + последствия (D-6)
→ подтверждение: Settlement PENDING → EXECUTED
→ transfer: { amount: teamAmountMinor, destination: acct_команды,
source_transaction: hold.chargeExternalId,
transfer_group, metadata: { settlementId },
idempotency key: `settlement:{settlementId}:transfer` }
→ Hold → RELEASING (H04; guard Y-1: PayoutAccount ACTIVE)

Когда RELEASED? Строгое чтение 7.3 («показывать „выплачено”, пока деньги не зачислены, запрещено»): RELEASED = деньги на банковском счёте команды, т.е. payout.paid на connected-аккаунте, а не создание transfer’а. Механика маппинга: payout агрегирует несколько transfer’ов; по payout.paid (Connect-вебхук) billing запрашивает balance transactions этого payout’а, сопоставляет source с нашими transfer id → все затронутые холды RELEASING → RELEASED (каскад M13/M15). Команде показываются три честных состояния: «выплата инициирована» (transfer создан) → «в пути на банковский счёт» (payout in_transit) → «зачислено» (PAID). Payout schedule на Express-аккаунтах — автоматический daily, ручное управление не берём.

payout.failed → холд остаётся RELEASING, задача администратору + уведомление команде (обычно устаревшие банковские реквизиты — Stripe сам переводит аккаунт в RESTRICTED).

СценарийМеханика
Полный возврат фаундеру (спор FOUNDER_FAVOR, расторжение TEAM_FAULT)refunds.create({ payment_intent, idempotency key: settlement:{id}:refund }) → вебхук charge.refunded → H06 (REFUNDED) → каскад M16
Сплит (PARTIAL, расторжение с оценкой)Один Settlement, две операции под одним ключом-префиксом: transfer(teamAmount, source_transaction) + refund(founderAmount). SPLIT терминален после подтверждения обеих операций (частичное исполнение = Settlement FAILED + задача администратору, повторный запуск идемпотентен)
КомиссияНе двигается никуда: остаток charge’а после transfer/refund и есть выручка платформы на её балансе. Отдельной проводки «fee платформе» не существует — меньше операций, меньше сверки

Округление (либа money, детерминировано, фиксируется в договоре): fee = ceil(amount × commissionRateBps / 10000) — остаток минорной единицы всегда платформе; team = amount − fee. При сплитах по долям (teamShareBps, completedCriteriaBps): доля команды округляется вниз, разница — фаундеру; комиссия по X-3 считается от доли команды. Сумма компонент строго равна холду — CHECK из шага 2.

  1. Вход (вебхуки): WebhookEvent @@unique([provider, externalEventId]) — повтор доставки становится SKIPPED_DUPLICATE до бизнес-логики.
  2. Переходы: CAS + idempotentTarget (шаг 3) — даже прорвавшийся дубль не проведёт второй переход.
  3. Выход (вызовы Stripe): детерминированные idempotency keys из id наших сущностей (hold:{id}:checkout, settlement:{id}:transfer, settlement:{id}:refund) — ретрай после сетевой ошибки не создаст второй интент/трансфер/возврат. Ключ живёт в коде, не генерируется случайно.

Порядок вебхуков не гарантирован — обработчики не предполагают последовательность: каждый сам проверяет предусловия через состояние холда (CAS отбросит невозможное).

Карточный chargeback (charge.dispute.created от Stripe) — событие вне нашей арбитражной модели: фаундер оспорил списание у своего банка, минуя платформу. Коллизия имён опасна — в коде и UI это ExternalPaymentDispute, никогда не Dispute.

Реакция: задача администратору (высокий приоритет) + сбор evidence-пакета (договор B, критерии, артефакты, журнал приёмки — всё уже хранится по L-*) для ответа Stripe. Риск-окно: chargeback возможен после RELEASED, деньги уже у команды — экспозиция платформы. Митигации: ACH-дефолт (у ACH нет chargeback-механики карт; возвраты ACH существуют, но окно короче), договорная обязанность фаундера решать претензии через платформенный арбитраж (пункт в договор B), лимит карточных платежей на этап (конфиг). ⚠️ Формулировки в договор — в пакет юристу (туда же, где Stripe-модель и анонимизация).

Ночной cron в platform-worker: (1) все HELD-холды имеют succeeded-интент на ту же сумму; (2) все EXECUTED-settlements имеют transfer/refund в терминальном статусе Stripe; (3) баланс платформы ≈ Σ(HELD) + нераспределённая комиссия − операционные списания; (4) «зависшие» состояния старше порога (PENDING_IN > 7 дней, RELEASING > 10 дней) → задача администратору. Расхождение любой проверки → задача + алерт. Сверка — единственный потребитель Stripe API «на чтение всего», у остальных модулей точечные вызовы.

ПараметрЗначение
Endpoint платформенных событийbilling-api /webhooks/stripe (checkout, payment_intent, charge, refund)
Endpoint Connect-событийbilling-api /webhooks/stripe-connect (account.updated, payout.*) — отдельный signing secret
Проверка подписиStripe-Signature, raw body (в NestJS — rawBody для этого маршрута)
СекретыSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_CONNECT_WEBHOOK_SECRET — конфиг-модуль configs/billing/stripe
Тест-режимStripe test mode + test clocks для сценариев «платёж в пути»; фикстуры вебхуков в e2e
  1. Express-аккаунты для команд (не Custom).
  2. Hosted Checkout для фондирования в бете (не Elements).
  3. ACH-дефолт, карты допускаются, издержки процессинга — из комиссии платформы (без surcharge фаундеру).
  4. USD-only бета; мультивалютность — конфигом после.
  5. Строгий RELEASED = payout.paid (деньги на банке команды), с тремя промежуточными состояниями в UI команды.
  6. Пакет юристу пополняется: обязанность фаундера решать претензии через платформенный арбитраж (анти-chargeback пункт договора B) + проверка списка стран Express для supply-географии.