CrewsForge — Шаг 2: Доменная модель (Prisma)
Статус: черновик на подтверждение · Сессия проектирования архитектуры · Шаг 2 из 9 Основание: функциональные требования (разделы 2–9), решения шагов 0–1
Документ описывает целевую схему данных: изменения существующих моделей, все новые модели по доменам, политику удаления и маппинг инвариантов ФТ на механизмы схемы. Названия моделей/полей — canonical, дальше по сессии используются они.
1. Сквозные конвенции схемы
Заголовок раздела «1. Сквозные конвенции схемы»| Правило | Реализация |
|---|---|
| Деньги | BigInt в минорных единицах (@db.BigInt). Никаких Decimal/Float. В JSON-payload’ах (снапшоты плана) — целые числа |
| Валюта | Одна на проект: Project.currency Char(3). У денежных записей валюта не дублируется |
| Комиссия | Project.commissionRateBps Int (basis points, 1500 = 15%) — снапшот при контрактинге |
| PK | UUID gen_random_uuid(), как сейчас |
| Аудит | createdAt/updatedAt везде; append-only таблицы (StateTransition, OutboxEvent, WebhookEvent, Notification) — только createdAt |
| Полиморфные ссылки | subjectType (enum) + subjectId (uuid, без FK) в StateTransition, QueueItem, Timer, Notification. Цена — нет ссылочной целостности на уровне БД; компенсация — единственный пишущий модуль на таблицу + индекс [subjectType, subjectId] |
| Владение таблицей | У каждой таблицы один пишущий модуль (см. колонку «Пишет» в разделах ниже) |
2. Изменения существующих моделей
Заголовок раздела «2. Изменения существующих моделей»2.1 Project — переработка
Заголовок раздела «2.1 Project — переработка»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) — но уже какBigIntminor units, а неDecimal.- Мягкий гейт бюджета (шаг 0): проект с
budgetMaxMinor < порогполучаетbelowBudgetGate = trueи не попадает в очередь мэтчинга без задачи администратору.
2.2 RoleType — расширение
Заголовок раздела «2.2 RoleType — расширение»enum RoleType { CUSTOMER EMPLOYEE ADMIN // семантика: Administrator платформы (оставляем значение, чтобы не мигрировать токены) OPERATOR // новое ARBITER // новое}Guard’ы admin-api различают три платформенные роли по claim’у в jwt-admin. Домен-allowlist (ADMIN_ALLOWED_DOMAINS) распространяется на все три.
2.3 Политика удаления — глобальная правка каскадов
Заголовок раздела «2.3 Политика удаления — глобальная правка каскадов»| Связь | Было | Становится | Причина |
|---|---|---|---|
Customer → Project | Cascade | Restrict | L-1: проект — корень сделочной цепочки |
User → Customer/Employee | Cascade | Restrict | под ними может быть сделочная история |
| Вся новая сделочная цепочка (Project → Milestone → Hold → Settlement, Contract, Dispute, Termination, Artifact, StateTransition) | — | Restrict | БД физически запрещает жёсткое удаление любого звена |
| Профильные сателлиты (Avatar, Skills-связки, Portfolio*, Session, Token, ProfileLanguageLink) | Cascade | Cascade (без изменений) | не несут сделочной/доказательной ценности |
Удаление пользователя. Жёсткого удаления User не существует. Запрос на удаление аккаунта = User.deletedAt (добавляем поле) + анонимизация PII-полей (email → tombstone, имя, телефон), с сохранением всех сделочных записей. ⚠️ Границу «что анонимизируем, что храним как доказательство» должен подтвердить юрист (GDPR erasure vs хранение доказательств) — уходит в пакет к Privacy Policy. Модель под оба ответа готова.
3. Домен Team (либы team-*, монтируются в user-api)
Заголовок раздела «3. Домен Team (либы team-*, монтируются в user-api)»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 }- Оси
role⟂canSign/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 }4. Спецификация и версии плана (project-api)
Заголовок раздела «4. Спецификация и версии плана (project-api)»4.1 Specification — версионируемая (L-6)
Заголовок раздела «4.1 Specification — версионируемая (L-6)»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, снапшот состояний пишется в журнал переходов.
Разбор непринятия (5.4 ФТ)
Заголовок раздела «Разбор непринятия (5.4 ФТ)»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 + транзакция).
6. Контрактинг: Party, Contract, Offer (project-api)
Заголовок раздела «6. Контрактинг: Party, Contract, Offer (project-api)»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-схема).
10. Интеграция трекера
Заголовок раздела «10. Интеграция трекера»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.planVersionId → PlanVersion |
| 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 |
12. Открытые пункты шага 2
Заголовок раздела «12. Открытые пункты шага 2»- Анонимизация при удалении аккаунта — юридический вопрос (GDPR vs хранение доказательств), модель готова к обоим ответам. → в пакет юристу вместе со Stripe-моделью.
- CHECK-констрейнт суммы Settlement — через денормализацию
hold_amount_minor(см. 7) — подтвердить приемлемость денормализации. - Консьерж-чат (10.3.8) — сознательно не в этой схеме; модель тредов обращений добавим на шаге 7 после решения о маршрутизации.
- Старый
ProjectStatusи данные беты — план миграции в шаге 8.