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

CrewsForge — Шаг 2: Доменная модель (Prisma)

Статус: черновик на подтверждение · Сессия проектирования архитектуры · Шаг 2 из 9 Основание: функциональные требования (разделы 2–9), решения шагов 0–1

Документ описывает целевую схему данных: изменения существующих моделей, все новые модели по доменам, политику удаления и маппинг инвариантов ФТ на механизмы схемы. Названия моделей/полей — canonical, дальше по сессии используются они.


ПравилоРеализация
ДеньгиBigInt в минорных единицах (@db.BigInt). Никаких Decimal/Float. В JSON-payload’ах (снапшоты плана) — целые числа
ВалютаОдна на проект: Project.currency Char(3). У денежных записей валюта не дублируется
КомиссияProject.commissionRateBps Int (basis points, 1500 = 15%) — снапшот при контрактинге
PKUUID gen_random_uuid(), как сейчас
АудитcreatedAt/updatedAt везде; append-only таблицы (StateTransition, OutboxEvent, WebhookEvent, Notification) — только createdAt
Полиморфные ссылкиsubjectType (enum) + subjectId (uuid, без FK) в StateTransition, QueueItem, Timer, Notification. Цена — нет ссылочной целостности на уровне БД; компенсация — единственный пишущий модуль на таблицу + индекс [subjectType, subjectId]
Владение таблицейУ каждой таблицы один пишущий модуль (см. колонку «Пишет» в разделах ниже)

model Project {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
title String
description String
category ProjectCategoryType?
status ProjectStatus @default(DRAFT) // НОВЫЙ enum, 10 значений
contractingStage ContractingStage? // заполнен только в CONTRACTING
customerId String @map("customer_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid // назначается при мэтчинге
currency String @default("USD") @db.Char(3)
budgetMinMinor BigInt? @map("budget_min_minor") // вилка AI-этапа
budgetMaxMinor BigInt? @map("budget_max_minor")
belowBudgetGate Boolean @default(false) @map("below_budget_gate") // мягкий гейт: требует ручного одобрения для мэтчинга
commissionRateBps Int? @map("commission_rate_bps") // снапшот в контрактинге
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") // только DRAFT, service-level
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) // было Cascade
team Team? @relation(fields: [teamId], references: [id], onDelete: Restrict)
// связи: specifications, planVersions, milestones, contracts, parties, offers,
// terminations, walriderThread, trackerLinks
@@index([customerId])
@@index([teamId])
@@index([status])
@@index([deletedAt])
}
enum ProjectStatus {
DRAFT
AI_CONSULTATION
SPEC_READY
TEAM_MATCHING
TEAM_PROPOSED
CONTRACTING
ACTIVE
COMPLETED // терминальный, read-only (P-5)
DISPUTED // производный: есть открытый спор по этапу; переходы автоматические
CANCELLED // терминальный (через Termination)
}
enum ContractingStage {
PLAN_DRAFTING
PLAN_INTERNAL_REVIEW
KYC_PENDING
PLAN_SENT
SIGNING
}

Замечания:

  • Старый ProjectStatus (8 значений) заменяется, не расширяется. Миграция существующих строк: маппинг-таблица в шаге 8 (данных беты мало, риск низкий).
  • budgetMin/MaxMinor возвращаются в проект (были удалены миграцией 20260215) — но уже как BigInt minor units, а не Decimal.
  • Мягкий гейт бюджета (шаг 0): проект с budgetMaxMinor < порог получает belowBudgetGate = true и не попадает в очередь мэтчинга без задачи администратору.
enum RoleType {
CUSTOMER
EMPLOYEE
ADMIN // семантика: Administrator платформы (оставляем значение, чтобы не мигрировать токены)
OPERATOR // новое
ARBITER // новое
}

Guard’ы admin-api различают три платформенные роли по claim’у в jwt-admin. Домен-allowlist (ADMIN_ALLOWED_DOMAINS) распространяется на все три.

2.3 Политика удаления — глобальная правка каскадов

Заголовок раздела «2.3 Политика удаления — глобальная правка каскадов»
СвязьБылоСтановитсяПричина
Customer → ProjectCascadeRestrictL-1: проект — корень сделочной цепочки
User → Customer/EmployeeCascadeRestrictпод ними может быть сделочная история
Вся новая сделочная цепочка (Project → Milestone → Hold → Settlement, Contract, Dispute, Termination, Artifact, StateTransition)RestrictБД физически запрещает жёсткое удаление любого звена
Профильные сателлиты (Avatar, Skills-связки, Portfolio*, Session, Token, ProfileLanguageLink)CascadeCascade (без изменений)не несут сделочной/доказательной ценности

Удаление пользователя. Жёсткого удаления User не существует. Запрос на удаление аккаунта = User.deletedAt (добавляем поле) + анонимизация PII-полей (email → tombstone, имя, телефон), с сохранением всех сделочных записей. ⚠️ Границу «что анонимизируем, что храним как доказательство» должен подтвердить юрист (GDPR erasure vs хранение доказательств) — уходит в пакет к Privacy Policy. Модель под оба ответа готова.


model Team {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @db.VarChar(255)
slug String @unique @db.VarChar(255)
bio String? @db.Text
status TeamStatus @default(ONBOARDING)
externalExperience Json? @map("external_experience") // вопрос 10.3.9: внешние кейсы до истории на платформе
verifiedAt DateTime? @map("verified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members TeamMember[]
invitations TeamInvitation[]
payoutAccount PayoutAccount?
projects Project[]
offers Offer[]
@@index([status])
}
enum TeamStatus { ONBOARDING ACTIVE SUSPENDED ARCHIVED }
model TeamMember {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
teamId String @map("team_id") @db.Uuid
employeeId String @map("employee_id") @db.Uuid
role TeamMemberRole @default(VIEWER) // ось доступа
canSign Boolean @default(false) @map("can_sign") // ось полномочий (2.2 ФТ)
canSubmit Boolean @default(false) @map("can_submit")
status TeamMemberStatus @default(ACTIVE)
removedAt DateTime? @map("removed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Restrict)
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Restrict)
@@unique([teamId, employeeId])
@@index([employeeId])
}
enum TeamMemberRole { ADMIN EDITOR VIEWER }
enum TeamMemberStatus { ACTIVE REMOVED }
  • Оси rolecanSign/canSubmit — ровно по 2.2: полномочия передаются без роли admin.
  • Инвариант «последний admin не может снять роль / уйти» — service-level в team-модуле, единая точка изменения состава.
  • Ушедший участник — status REMOVED, строка не удаляется (история подписей и сдач ссылается на членство).
  • Мультичленство (вопрос 10.3.6): разрешено моделью; запрет конфликта интересов «фаундер = участник команды того же проекта» — guard при создании Offer и при приёме.
model TeamInvitation {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
teamId String @map("team_id") @db.Uuid
email String @db.VarChar(255)
role TeamMemberRole @default(EDITOR)
token String @unique @db.VarChar(255)
status InvitationStatus @default(PENDING)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([teamId])
}
enum InvitationStatus { PENDING ACCEPTED DECLINED EXPIRED REVOKED }

model Specification {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
version Int
content Json // структурированная спека; бюджет — ТОЛЬКО вилки {minMinor, maxMinor}
authorType AuthorType // AI | HUMAN — требование C-1 на уровне модели
authorUserId String? @map("author_user_id") @db.Uuid
status SpecificationStatus @default(DRAFT)
sourceThreadId String? @map("source_thread_id") @db.Uuid // WalriderThread
createdAt DateTime @default(now()) @map("created_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
@@unique([projectId, version])
}
enum AuthorType { AI HUMAN }
enum SpecificationStatus { DRAFT PROPOSED ACCEPTED SUPERSEDED }

4.2 PlanVersion — снапшот плана в контрактинге (C-6, K-2)

Заголовок раздела «4.2 PlanVersion — снапшот плана в контрактинге (C-6, K-2)»

Решение: на время контрактинга план живёт как версионируемый снапшот (Json-массив черновиков этапов с точными суммами); живые строки Milestone материализуются из принятой версии при переходе проекта в ACTIVE. Основание: до подписания план — предмет торга и документ-приложение к договору (K-2), а не набор рабочих сущностей; версия целиком, с diff между версиями (C-6), проще и честнее как снапшот.

model PlanVersion {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
version Int
items Json // [{orderIndex, title, description, amountMinor, durationDays, criteria: [text]}]
totalAmountMinor BigInt @map("total_amount_minor")
status PlanVersionStatus @default(DRAFT)
internalNotes String? @map("internal_notes") @db.Text // торг платформа↔команда. F-1: НИКОГДА не попадает в founder-DTO
authorMemberId String? @map("author_member_id") @db.Uuid
sentAt DateTime? @map("sent_at")
respondedAt DateTime? @map("responded_at")
rejectReason String? @map("reject_reason") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
@@unique([projectId, version])
}
enum PlanVersionStatus { DRAFT INTERNAL_REVIEW SENT ACCEPTED REJECTED SUPERSEDED }
  • F-1/L-4: версии со статусом DRAFT/INTERNAL_REVIEW и поле internalNotes не существуют в founder-поверхности API — правило шага 4 (авторизация), здесь модель это разделение поддерживает.
  • Суммы в items — целые минорные единицы (в пределах Number: $100k = 10⁷ центов, безопасно).

5. Milestone, критерии, артефакты, сдача, разбор (project-api)

Заголовок раздела «5. Milestone, критерии, артефакты, сдача, разбор (project-api)»
model Milestone {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
planVersionId String? @map("plan_version_id") @db.Uuid // происхождение: из какого плана материализован; null = создан из разбора (R-2)
orderIndex Int @map("order_index")
title String @db.VarChar(255)
description String @db.Text
amountMinor BigInt @map("amount_minor")
status MilestoneStatus @default(PLANNED)
dueAt DateTime? @map("due_at")
deadlinePausedAt DateTime? @map("deadline_paused_at") // R-3: разбор/спор останавливает срок; на выходе dueAt += (now − pausedAt)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
criteria AcceptanceCriterion[]
artifacts Artifact[]
submissions MilestoneSubmission[]
hold Hold?
dispute Dispute[]
reviews AcceptanceReview[]
@@unique([projectId, orderIndex])
@@index([status])
}
enum MilestoneStatus {
PLANNED
PLAN_APPROVED // первичный план — автоматически при ACTIVE (M-4); добавленные этапы — оператором
FUNDED // достижим только при hold.state = HELD
IN_PROGRESS
SUBMITTED
FOUNDER_APPROVED
VERIFIED
PAID // достижим только при hold.state = RELEASED
REJECTED // вход в разбор, не терминальный (M-5)
DISPUTED
}
model AcceptanceCriterion {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
milestoneId String @map("milestone_id") @db.Uuid
orderIndex Int @map("order_index")
text String @db.Text // проверяемое утверждение, не намерение (раздел 6 ФТ)
state CriterionState @default(UNCHECKED)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Restrict)
@@unique([milestoneId, orderIndex])
}
enum CriterionState { UNCHECKED MET DISPUTED CONFIRMED }
model Artifact {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
milestoneId String @map("milestone_id") @db.Uuid
type ArtifactType
title String @db.VarChar(255)
url String? @db.Text // LINK / DEMO
fileKey String? @map("file_key") @db.Text // FILE (S3)
uploaderMemberId String? @map("uploader_member_id") @db.Uuid
source ArtifactSource @default(PLATFORM) // PLATFORM | TRACKER (входящий вебхук)
createdAt DateTime @default(now()) @map("created_at")
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Restrict)
@@index([milestoneId])
}
enum ArtifactType { FILE LINK DEMO }
enum ArtifactSource { PLATFORM TRACKER }
  • X-4/L-3 (артефакты оплаченных этапов необратимо у фаундера): нет deletedAt, onDelete: Restrict, запрет удаления артефактов этапа в статусе ≥ FUNDED — service-level; доступ фаундера к артефактам COMPLETED/расторгнутых проектов — правило чтения, бессрочное.
model MilestoneSubmission {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
milestoneId String @map("milestone_id") @db.Uuid
attempt Int // 1, 2, ... — пересдачи после REJECTED, лимита нет (M-5)
submittedByMemberId String @map("submitted_by_member_id") @db.Uuid // требует canSubmit (M-3)
coverNote String? @map("cover_note") @db.Text
createdAt DateTime @default(now()) @map("created_at")
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Restrict)
@@unique([milestoneId, attempt])
}

Самопроверка M-3: сдача возможна, только когда все критерии этапа в MET — guard перехода IN_PROGRESS → SUBMITTED, снапшот состояний пишется в журнал переходов.

model AcceptanceReview {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
milestoneId String @map("milestone_id") @db.Uuid
submissionId String @map("submission_id") @db.Uuid
openedByUserId String @map("opened_by_user_id") @db.Uuid // фаундер
verdict ReviewVerdict?
reasoning String? @db.Text // L-2: обоснование обязательно при вердикте
decidedByUserId String? @map("decided_by_user_id") @db.Uuid // администратор
newMilestoneId String? @map("new_milestone_id") @db.Uuid // R-2: новый платный этап из разбора
decidedAt DateTime? @map("decided_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Restrict)
items ReviewRejectionItem[]
}
enum ReviewVerdict {
CRITERIA_UNMET_RETURN // критерий не соблюдён → IN_PROGRESS, доработка бесплатно
CRITERIA_MET_NEW_SCOPE // критерии соблюдены → этап принят, требование → новый платный этап
ESCALATED_TO_ARBITRATION
}
model ReviewRejectionItem {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
reviewId String @map("review_id") @db.Uuid
criterionId String @map("criterion_id") @db.Uuid // R-1: отказ ОБЯЗАТЕЛЬНО привязан к критерию
comment String? @db.Text // свободный текст — только дополнение
review AcceptanceReview @relation(fields: [reviewId], references: [id], onDelete: Restrict)
@@unique([reviewId, criterionId])
}

R-1 на уровне модели: AcceptanceReview не создаётся без хотя бы одного ReviewRejectionItem (service-level + транзакция).


model Party {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
side DealSide // FOUNDER | TEAM
legalName String @db.VarChar(512)
legalForm String? @db.VarChar(128)
countryCode String @map("country_code") @db.Char(2)
registrationNumber String? @map("registration_number") @db.VarChar(128)
taxId String? @map("tax_id") @db.VarChar(128)
address Json
kycState KycState @default(NOT_REQUIRED)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
documents PartyDocument[]
@@unique([projectId, side])
}
enum DealSide { FOUNDER TEAM }
enum KycState { NOT_REQUIRED PENDING SUBMITTED APPROVED REJECTED }
model PartyDocument {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
partyId String @map("party_id") @db.Uuid
kind String @db.VarChar(128)
fileKey String @map("file_key") @db.Text
status PartyDocumentStatus @default(UPLOADED)
reviewedByUserId String? @map("reviewed_by_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
party Party @relation(fields: [partyId], references: [id], onDelete: Restrict)
}
enum PartyDocumentStatus { UPLOADED APPROVED REJECTED }
  • Party — снапшот на проект (@@unique([projectId, side])): юрданные фиксируются под конкретные договоры (C-5), переиспользование между проектами — продуктовое удобство «скопировать из прошлого», не общая запись.
  • KYC команды платформа не делает (Y-5) — верифицирует Stripe; Party(TEAM) хранит только реквизиты для текста договора. KYC фаундера — конфиг-флаг (вопрос 10.3.1): выключен → NOT_REQUIRED, стадия KYC_PENDING проскакивается.
model Contract {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
side DealSide // TEAM = договор A, FOUNDER = договор B (K-1)
planVersionId String @map("plan_version_id") @db.Uuid // приложение к рамочному (K-2)
status ContractStatus @default(DRAFT)
envelopeExternalId String? @map("envelope_external_id") @db.Text // провайдер подписания
signerUserId String? @map("signer_user_id") @db.Uuid // фаундер
signerMemberId String? @map("signer_member_id") @db.Uuid // TeamMember с canSign (K-7)
signedAt DateTime? @map("signed_at")
signedFileKey String? @map("signed_file_key") @db.Text
terminatedAt DateTime? @map("terminated_at") // K-6: два расторжения
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
@@unique([projectId, side])
}
enum ContractStatus { DRAFT SENT_FOR_SIGNING SIGNED TERMINATED }
  • K-4 (сначала команда, потом фаундер) и K-5 (ACTIVE только при обоих SIGNED) — guard’ы переходов, не поля.
  • Договор Team↔Founder не существует по построению: обе строки — с платформой.
model Offer {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
teamId String @map("team_id") @db.Uuid
state OfferState @default(SENT_TO_TEAM) // P-1: порядок «сначала команда» зашит в машину
createdByUserId String @map("created_by_user_id") @db.Uuid // администратор (мэтчинг ручной)
declineReason String? @map("decline_reason") @db.Text // P-2: причина отказа сохраняется
respondedAt DateTime? @map("responded_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
team Team @relation(fields: [teamId], references: [id], onDelete: Restrict)
@@index([projectId])
@@index([teamId])
}
enum OfferState {
SENT_TO_TEAM
TEAM_ACCEPTED // теперь виден фаундеру (P-1)
DECLINED_BY_TEAM // → проект обратно в TEAM_MATCHING
ACCEPTED_BY_FOUNDER
DECLINED_BY_FOUNDER
WITHDRAWN
}

7. Денежный контур (billing-api — единственный пишущий)

Заголовок раздела «7. Денежный контур (billing-api — единственный пишущий)»
model PayoutAccount {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
teamId String @unique @map("team_id") @db.Uuid
provider String @default("stripe") @db.VarChar(32)
externalAccountId String? @map("external_account_id") @db.Text // acct_...
state PayoutAccountState @default(NOT_STARTED)
requirements Json? // requirements.currently_due из Stripe — для Y-4 «узнать за недели»
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Restrict)
}
enum PayoutAccountState { NOT_STARTED PENDING RESTRICTED ACTIVE }
model Hold {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
milestoneId String @unique @map("milestone_id") @db.Uuid
amountMinor BigInt @map("amount_minor")
state HoldState @default(PENDING_IN)
paymentIntentExternalId String? @map("payment_intent_external_id") @db.Text
chargeExternalId String? @map("charge_external_id") @db.Text
heldAt DateTime? @map("held_at")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Restrict)
settlements Settlement[]
}
enum HoldState { PENDING_IN HELD RELEASING RELEASED REFUNDED SPLIT }
model Settlement {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
holdId String @map("hold_id") @db.Uuid
teamAmountMinor BigInt @map("team_amount_minor")
founderAmountMinor BigInt @map("founder_amount_minor")
platformFeeMinor BigInt @map("platform_fee_minor")
basisType SettlementBasis @map("basis_type") // D-7: основание обязательно
basisId String @map("basis_id") @db.Uuid // reviewId | disputeId | terminationId | verificationTransitionId
executedByUserId String @map("executed_by_user_id") @db.Uuid // D-5: только администратор
status SettlementStatus @default(PENDING)
transferExternalId String? @map("transfer_external_id") @db.Text
refundExternalId String? @map("refund_external_id") @db.Text
executedAt DateTime? @map("executed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
hold Hold @relation(fields: [holdId], references: [id], onDelete: Restrict)
}
enum SettlementBasis { DUAL_APPROVAL REVIEW_VERDICT ARBITER_DECISION TERMINATION_ASSESSMENT }
enum SettlementStatus { PENDING EXECUTED FAILED }
  • CHECK-констрейнт (raw SQL в миграции, Prisma нативно не умеет): team_amount_minor + founder_amount_minor + platform_fee_minor = (select amount_minor from holds where id = hold_id) — реализуем как денормализованное hold_amount_minor поле в Settlement + CHECK (team + founder + fee = hold_amount_minor); сверка с холдом — в транзакции сервиса.
  • Правило округления сплитов — либа money, порядок из шага 5 §4: доля команды вниз, разница фаундеру, комиссия ceil от доли команды. Остаток минорной единицы при взятии комиссии всегда уходит платформе (детерминированно, документировано в договоре). Ревизия 2026-08-21, единица F1: раньше здесь был назван «метод наибольших остатков» — алгоритм, не соответствующий шагу 5 §4 и карточке F1.
  • Частичный исход спора: SPLIT → один Settlement с обеими ненулевыми суммами (5.5: «обе суммы обязательны»).
model WebhookEvent {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
provider WebhookProvider
externalEventId String @map("external_event_id") @db.Text
payload Json
status WebhookEventStatus @default(RECEIVED)
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
@@unique([provider, externalEventId]) // идемпотентность 7.3: повторное событие не порождает повторное движение
}
enum WebhookProvider { STRIPE SIGNING TRACKER }
enum WebhookEventStatus { RECEIVED PROCESSED SKIPPED_DUPLICATE FAILED }

8. Спор и расторжение (project-api, core-модули переиспользует admin-api)

Заголовок раздела «8. Спор и расторжение (project-api, core-модули переиспользует admin-api)»
model Dispute {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
milestoneId String @map("milestone_id") @db.Uuid // S-1: только этап в FUNDED+
state DisputeState @default(OPENED)
initiatorSide DealSide @map("initiator_side")
openedByUserId String @map("opened_by_user_id") @db.Uuid
positionDeadlineAt DateTime? @map("position_deadline_at") // окно из конфига
outcome DisputeOutcome?
teamShareBps Int? @map("team_share_bps") // при PARTIAL: доля холда команде
reasoning String? @db.Text // L-2
arbiterUserId String? @map("arbiter_user_id") @db.Uuid
resolvedAt DateTime? @map("resolved_at")
lciaEscalatedAt DateTime? @map("lcia_escalated_at") // S-4: долгая пауза, не расторжение
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Restrict)
positions DisputePosition[]
}
enum DisputeState { OPENED POSITIONS_GATHERING UNDER_ARBITRATION RESOLVED ESCALATED_LCIA }
enum DisputeOutcome { TEAM_FAVOR FOUNDER_FAVOR PARTIAL }
model DisputePosition {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
disputeId String @map("dispute_id") @db.Uuid
side DealSide
statement String? @db.Text // null + истёкший таймер = факт неответа (S-2, фиксируется журналом)
submittedByUserId String? @map("submitted_by_user_id") @db.Uuid
submittedAt DateTime? @map("submitted_at")
createdAt DateTime @default(now()) @map("created_at")
dispute Dispute @relation(fields: [disputeId], references: [id], onDelete: Restrict)
evidence DisputeEvidence[]
@@unique([disputeId, side])
}
model DisputeEvidence {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
positionId String @map("position_id") @db.Uuid
title String @db.VarChar(255)
fileKey String? @map("file_key") @db.Text
url String? @db.Text
createdAt DateTime @default(now()) @map("created_at") // L-5: временные метки
position DisputePosition @relation(fields: [positionId], references: [id], onDelete: Restrict)
}
  • T-1 (стороны не видят доказательства друг друга до решения) — правило авторизации (шаг 4): evidence чужой стороны исключается из DTO, пока state != RESOLVED.
  • A-1/S-3: решение арбитра = запись в Dispute (outcome, reasoning); движение денег — отдельный Settlement с basisType = ARBITER_DECISION, basisId = dispute.id, исполняет администратор. Арбитр не имеет доступа к созданию Settlement (модель прав шага 4).
model Termination {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
state TerminationState @default(REQUESTED)
initiatorSide DealSide @map("initiator_side")
requestedByUserId String @map("requested_by_user_id") @db.Uuid // X-1: подписанное заявление
basis TerminationBasis?
objectionDeadlineAt DateTime @map("objection_deadline_at") // окно 5 дней (конфиг)
objectionByUserId String? @map("objection_by_user_id") @db.Uuid // X-5: возражение → Dispute
objectionDisputeId String? @map("objection_dispute_id") @db.Uuid
completedCriteriaBps Int? @map("completed_criteria_bps") // X-2: доля met/confirmed критериев — мера объёма
assessedByUserId String? @map("assessed_by_user_id") @db.Uuid
reasoning String? @db.Text // L-2
sourceDisputeId String? @map("source_dispute_id") @db.Uuid // 5.6: этап был DISPUTED → исполняем решение арбитра, повторной оценки нет
executedAt DateTime? @map("executed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
}
enum TerminationState { REQUESTED UNDER_REVIEW EXECUTED WITHDRAWN }
enum TerminationBasis { TEAM_FAULT FOUNDER_INITIATIVE TEAM_INITIATIVE DISPUTE_DECISION }

X-3 (комиссия не возвращается при расторжении по инициативе фаундера без вины команды) — правило расчёта в billing при исполнении, использует basis.


9. Операционный контур (пишет только platform-worker; читают рабочие места admin-api)

Заголовок раздела «9. Операционный контур (пишет только platform-worker; читают рабочие места admin-api)»
model StateTransition {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
subjectType SubjectType @map("subject_type")
subjectId String @map("subject_id") @db.Uuid
fromState String? @map("from_state") @db.VarChar(64)
toState String @map("to_state") @db.VarChar(64)
actorType ActorType @map("actor_type") // USER | SYSTEM | TIMER
actorUserId String? @map("actor_user_id") @db.Uuid
actorRole String? @map("actor_role") @db.VarChar(32)
basisType String? @map("basis_type") @db.VarChar(64)
basisId String? @map("basis_id") @db.Uuid
reason String? @db.Text
metadata Json? // снапшоты: состояния критериев при сдаче, факт неответа и т.п.
createdAt DateTime @default(now()) @map("created_at")
// append-only: нет updatedAt, нет удаления. Пишет ТОЛЬКО либа state-machine.
@@index([subjectType, subjectId, createdAt])
}
enum SubjectType { PROJECT MILESTONE HOLD CONTRACT DISPUTE TERMINATION OFFER TEAM PAYOUT_ACCOUNT PLAN_VERSION CONCIERGE_THREAD }
enum ActorType { USER SYSTEM TIMER }
model OutboxEvent {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
eventType String @map("event_type") @db.VarChar(128) // = routing key: "milestone.status.changed"
aggregateType SubjectType @map("aggregate_type")
aggregateId String @map("aggregate_id") @db.Uuid
payload Json
publishedAt DateTime? @map("published_at")
attempts Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
@@index([publishedAt, createdAt]) // relay: WHERE published_at IS NULL ORDER BY created_at
}
model QueueItem {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
type QueueItemType
targetRole PlatformRole @map("target_role")
subjectType SubjectType @map("subject_type")
subjectId String @map("subject_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid // денормализация для скоупинга; ДЕНЕЖНЫХ ПОЛЕЙ НЕТ (Q-3: суммы — в DTO админа/арбитра из доменных таблиц)
status QueueItemStatus @default(OPEN)
dueAt DateTime @map("due_at") // SLA-срок из 8.2
overdueMarkedAt DateTime? @map("overdue_marked_at") // Q-2
assigneeUserId String? @map("assignee_user_id") @db.Uuid
closedByUserId String? @map("closed_by_user_id") @db.Uuid
resultTransitionId String? @map("result_transition_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") // Q-1: дефолтная сортировка — возраст
updatedAt DateTime @updatedAt @map("updated_at")
@@index([targetRole, status, createdAt])
@@index([subjectType, subjectId])
}
enum PlatformRole { OPERATOR ADMINISTRATOR ARBITER }
enum QueueItemStatus { OPEN IN_PROGRESS DONE CANCELLED }
enum QueueItemType {
PLAN_APPROVAL // operator: только этапы, добавленные после старта (M-4)
SUBMISSION_VERIFICATION // operator, SLA 4 раб. часа
CONCIERGE_FIRST_LINE // operator
ACCEPTANCE_REVIEW // administrator: разбор непринятия
PLAN_MEDIATION // administrator: фаундер отклонил план (C-4)
TEAM_MATCHING // administrator
PRICE_REVIEW // administrator: PLAN_INTERNAL_REVIEW
KYC_DOCUMENTS // administrator
TERMINATION_ASSESSMENT // administrator
TEAM_ONBOARDING // administrator
ESCALATED_INQUIRY // administrator
BUDGET_GATE_REVIEW // administrator: мягкий гейт бюджета
FOUNDER_SILENCE_DECISION // administrator: окно молчания истекло (8.3 — не автовыплата)
SETTLEMENT_EXECUTION // administrator: основание готово, требуется исполнение денег
DISPUTE_ARBITRATION // arbiter, SLA 5 дней
}
model Timer {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
kind TimerKind
subjectType SubjectType @map("subject_type")
subjectId String @map("subject_id") @db.Uuid
firesAt DateTime @map("fires_at")
firedAt DateTime? @map("fired_at")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([firesAt, firedAt, cancelledAt])
}
enum TimerKind { FOUNDER_SILENCE DISPUTE_POSITION_WINDOW TERMINATION_OBJECTION_WINDOW ENVELOPE_EXPIRY MILESTONE_DEADLINE SLA_TARGET REMINDER }
model Notification {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
userId String @map("user_id") @db.Uuid
type String @db.VarChar(128) // = eventType источника
subjectType SubjectType @map("subject_type")
subjectId String @map("subject_id") @db.Uuid
requiresAction Boolean @default(false) @map("requires_action") // 8.4
payload Json?
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, readAt, createdAt])
}

Ревизия 2026-08-21 (единица F1, подтверждена Stark’ом). Два enum’а этого раздела приведены в соответствие с более поздними шагами:

  • TimerKind — добавлены ENVELOPE_EXPIRY и MILESTONE_DEADLINE: шаг 7 §3 описывает семь таймеров, здесь было объявлено пять. Истечение конверта порождает задачу администратору (шаг 7 §2), дедлайн этапа — уведомление обеим сторонам без автоперехода.
  • SubjectType — добавлено CONCIERGE_THREAD: консьерж-тред введён шагом 7 §5 и публикует событие concierge.escalated, а OutboxEvent.aggregateType типизирован именно SubjectType.

Обе дельты реализует единица F4 (baseline-схема).

model TrackerLink {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
subjectType SubjectType @map("subject_type") // PROJECT | MILESTONE | TEAM
subjectId String @map("subject_id") @db.Uuid
externalType String @map("external_type") @db.VarChar(64) // workspace | board | member
externalId String @map("external_id") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([subjectType, subjectId, externalType])
}

11. Маппинг инвариантов ФТ → механизм схемы

Заголовок раздела «11. Маппинг инвариантов ФТ → механизм схемы»
Инвариант (раздел 11 ФТ)Механизм
1. Нет договора team↔founder; два договора; два расторженияContract @@unique([projectId, side]), обе строки с платформой; Contract.terminatedAt независимо на сторону
2. Рамочный договор + план-приложениеContract.planVersionIdPlanVersion
3. Деньги двигает только администратор, с основаниемSettlement.executedByUserId + basisType/basisId NOT NULL; создание Settlement — только billing-api под ролью ADMINISTRATOR
4. Подтверждение необратимых операцийUX + Settlement.status PENDING → EXECUTED двухфазно
5. Точечная заморозкаDISPUTED — статус этапа; проектный DISPUTED — производный; guard «нельзя фондировать новые этапы, пока есть открытый спор»
6. Оплаченные артефакты у фаундера навсегдаArtifact без delete, Restrict, правило чтения бессрочно
7. Разделение оператор/арбитр/администраторPlatformRole, изоляция billing-api, DTO без сумм для оператора (шаг 4)
8. Двойное подтверждениеПереходы FOUNDER_APPROVED → VERIFIED → PAID; PAID гейтится hold.RELEASED
9. Мера работы — доля критериевAcceptanceCriterion.state; Termination.completedCriteriaBps — снапшот расчёта
10. Сортировка очередей по возрастуQueueItem.createdAt + индекс [targetRole, status, createdAt]
11. Нет работы без холда; нет спора без холдаGuard’ы: IN_PROGRESS требует hold.HELD (M-1); Dispute создаётся только для этапа FUNDED+ (M-6, S-1)
12. Слово «escrow» отсутствуетНи одного вхождения в схеме: Hold, «milestone-gated», проверка линтером строк в шаге 9
  1. Анонимизация при удалении аккаунта — юридический вопрос (GDPR vs хранение доказательств), модель готова к обоим ответам. → в пакет юристу вместе со Stripe-моделью.
  2. CHECK-констрейнт суммы Settlement — через денормализацию hold_amount_minor (см. 7) — подтвердить приемлемость денормализации.
  3. Консьерж-чат (10.3.8) — сознательно не в этой схеме; модель тредов обращений добавим на шаге 7 после решения о маршрутизации.
  4. Старый ProjectStatus и данные беты — план миграции в шаге 8.