CrewsForge — Шаг 5: Денежный контур (Stripe Connect)
Статус: черновик на подтверждение · Шаг 5 из 9 Основание: ФТ раздел 7; машины Hold/Settlement (шаг 3); решения 0.3 (separate charges & transfers), 0.4 (минорные единицы), шаг 4 раздел 6
1. Конфигурация Connect
Заголовок раздела «1. Конфигурация Connect»| Решение | Значение | Обоснование |
|---|---|---|
| Модель | Separate charges and transfers | Зафиксировано в 0.3: цикл холда нереализуем на destination charges |
| Тип connected-аккаунтов команд | Express | Stripe-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.updated | PayoutAccountState |
|---|---|
| аккаунт создан, онбординг не завершён | 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 — поле, порт выплат абстрагируем при появлении второго провайдера, не раньше.
2. Фондирование этапа (Hold: PENDING_IN → HELD)
Заголовок раздела «2. Фондирование этапа (Hold: PENDING_IN → HELD)»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).
4. Возвраты и сплиты
Заголовок раздела «4. Возвраты и сплиты»| Сценарий | Механика |
|---|---|
| Полный возврат фаундеру (спор 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.
5. Идемпотентность — три рубежа
Заголовок раздела «5. Идемпотентность — три рубежа»- Вход (вебхуки):
WebhookEvent @@unique([provider, externalEventId])— повтор доставки становитсяSKIPPED_DUPLICATEдо бизнес-логики. - Переходы: CAS +
idempotentTarget(шаг 3) — даже прорвавшийся дубль не проведёт второй переход. - Выход (вызовы Stripe): детерминированные idempotency keys из id наших сущностей (
hold:{id}:checkout,settlement:{id}:transfer,settlement:{id}:refund) — ретрай после сетевой ошибки не создаст второй интент/трансфер/возврат. Ключ живёт в коде, не генерируется случайно.
Порядок вебхуков не гарантирован — обработчики не предполагают последовательность: каждый сам проверяет предусловия через состояние холда (CAS отбросит невозможное).
6. Внешние chargeback’и — не наш «спор»
Заголовок раздела «6. Внешние chargeback’и — не наш «спор»»Карточный chargeback (charge.dispute.created от Stripe) — событие вне нашей арбитражной модели: фаундер оспорил списание у своего банка, минуя платформу. Коллизия имён опасна — в коде и UI это ExternalPaymentDispute, никогда не Dispute.
Реакция: задача администратору (высокий приоритет) + сбор evidence-пакета (договор B, критерии, артефакты, журнал приёмки — всё уже хранится по L-*) для ответа Stripe. Риск-окно: chargeback возможен после RELEASED, деньги уже у команды — экспозиция платформы. Митигации: ACH-дефолт (у ACH нет chargeback-механики карт; возвраты ACH существуют, но окно короче), договорная обязанность фаундера решать претензии через платформенный арбитраж (пункт в договор B), лимит карточных платежей на этап (конфиг). ⚠️ Формулировки в договор — в пакет юристу (туда же, где Stripe-модель и анонимизация).
7. Сверка (reconciliation)
Заголовок раздела «7. Сверка (reconciliation)»Ночной cron в platform-worker: (1) все HELD-холды имеют succeeded-интент на ту же сумму; (2) все EXECUTED-settlements имеют transfer/refund в терминальном статусе Stripe; (3) баланс платформы ≈ Σ(HELD) + нераспределённая комиссия − операционные списания; (4) «зависшие» состояния старше порога (PENDING_IN > 7 дней, RELEASING > 10 дней) → задача администратору. Расхождение любой проверки → задача + алерт. Сверка — единственный потребитель Stripe API «на чтение всего», у остальных модулей точечные вызовы.
8. Конфигурация и топология вебхуков
Заголовок раздела «8. Конфигурация и топология вебхуков»| Параметр | Значение |
|---|---|
| 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 |
9. На подтверждение
Заголовок раздела «9. На подтверждение»- Express-аккаунты для команд (не Custom).
- Hosted Checkout для фондирования в бете (не Elements).
- ACH-дефолт, карты допускаются, издержки процессинга — из комиссии платформы (без surcharge фаундеру).
- USD-only бета; мультивалютность — конфигом после.
- Строгий
RELEASED=payout.paid(деньги на банке команды), с тремя промежуточными состояниями в UI команды. - Пакет юристу пополняется: обязанность фаундера решать претензии через платформенный арбитраж (анти-chargeback пункт договора B) + проверка списка стран Express для supply-географии.