Реестр инвариантов PIN
Ключевые доменные инварианты и переходы состояний. Дом инварианта — раздел «Инварианты и переходы
состояний» в docs/06-services/<context>/domain/<entity>.md; сложные — отдельным документом по
domain-invariant-template.
Формат: | ID (INV-<PFX>-NNN) | Инвариант | Сущность | Где проверяется | Документ |
catalog,promotion,dev-statsсобственныхINV-*не объявляют (проекции/справочники/ ClickHouse-facts — согласованность через upsert-идемпотентность и валидацию команд).
Известные заранее (из источников; детализация по батчам)
| ID | Инвариант | Контекст | Источник |
|---|---|---|---|
| INV-CRM-001 | На пару (ExecutorAgentUserId,PrimaryInvestorUserId) — не более одного лида Active | crm | lead.md |
| INV-FIX-001 | На тройку (OriginAgentUserId,InvestorUserId,DeveloperCompanyId) — не более одной активной фиксации; тройка на иммутабельном инициаторе, не на исполнителе (ADR-0071) | crm | fixation-request.md |
| INV-BKG-001 | Резерв/Бронь имеют таймер; по истечении — событие в чат и решение менеджера | crm | ТЗ.md:465–473 |
| INV-CHS-001 | Статусы объекта: Свободен→Резерв→Бронь→Продан; Скрыт — вне витрины | chessboard | ТЗ.md:171–175, 465+ |
| INV-REF-001 | Реферальная связь не создаётся, если Имя заполнено и регистрация >4 часов | referral | ТЗ.md:335–345 |
| INV-REF-002 | Лид не создаётся, если создатель ссылки не агент/застройщик | referral/crm | ТЗ.md:343 |
| INV-SEL-001 | Копия подборки в посте иммутабельна (блок можно только заменить) | selections/news | ТЗ.md:281–283 |
| INV-MSG-001 | Правка/удаление сообщения ≤1 минуты; история правок хранится, не показывается | messenger | ТЗ.md:305–307 |
| INV-OWN-001 | InvestorId = OwnerId (сопоставление по PhoneNumber) | investor-owner | ТЗ.md:229–231 |
| INV-IDN-001 | Профиль менеджера застройщика не редактируется в PIN (источник — Talent) | identity/profile | ТЗ.md:77 |
По контекстам
identity
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-S1 | Один Pending на (Phone,Purpose); новый гасит прежний (Superseded) | SignInConfirmationRequest | request-handler | user-session.md |
| INV-S2 | После 5 неверных кодов — Exhausted | SignInConfirmationRequest | confirm-handler | user-session.md |
| INV-S3 | Confirm только при Pending и ExpiresAt > now | SignInConfirmationRequest | confirm-handler | user-session.md |
| INV-S4 | ≤ 1 отправка кода/60 с и ≤ 5/час на номер+IP | SignInConfirmationRequest | request-handler + Redis | user-session.md |
| INV-S5 | Сравнение кодов constant-time по CodeHash | SignInConfirmationRequest | метод Confirm | user-session.md |
| INV-S6 | Использование Rotated refresh → отзыв всей цепочки | RefreshSession | refresh-handler | user-session.md |
| INV-S7 | Смена пароля/телефона/блокировка → отзыв Active-сессий | RefreshSession | handler операции | user-session.md |
| INV-S8 | NewEmail/NewPhoneNumber обязательны/запрещены по Purpose | SignInConfirmationRequest | фабрика | user-session.md |
| INV-R1 | IsSystem роль неизменяема компанией | RoleDefinition | guard агрегата | role.md |
| INV-R2 | Роль с CompanyId назначается только в scope своей компании | RoleAssignment | RoleAssignment.Create | role.md |
| INV-R3 | Platform-scope только для системных ролей (investor/agent/pin-manager/care) | RoleAssignment | RoleAssignment.Create | role.md |
| INV-R4 | Комбинация полей ResourceScope строго по Type | ResourceScope | конструктор VO | role.md |
| INV-R5 | Archive() роли отзывает все Active-назначения | RoleDefinition | метод + handler | role.md |
| INV-R6 | ≤ 1 Active-назначение на (user, role, scope) | RoleAssignment | unique + Any(spec) | role.md |
| INV-R7 | GrantPermission — только для существующего PermissionDefinition | RoleDefinition | handler | role.md |
| INV-R8 | Выдающий имеет role-assignments.manage в покрывающем scope | RoleAssignment | authorization behavior | role.md |
| INV-P1 | Permissions создаются/меняются только SEED-миграцией | PermissionDefinition | нет write-API | permission.md |
| INV-P2 | Code иммутабелен (переименование = deprecate + новый) | PermissionDefinition | правило релиза | permission.md |
| INV-P3 | Code-pattern; <context>-сегмент = Context | PermissionDefinition | SEED-валидация | permission.md |
| INV-P4 | В роли компании — только IsAssignableByCompanies и не deprecated | PermissionDefinition | GrantPermission | permission.md |
| INV-CI1 | Переход только из Pending; терминальные иммутабельны | CompanyInvitationEntity | guard методов | company-invitation.md |
| INV-CI2 | Accept только если телефон == PhoneNumber приглашения | CompanyInvitationEntity | accept-handler | company-invitation.md |
| INV-CI3 | ≤ 1 Pending на (CompanyId,PhoneNumber,TargetRoleId) | CompanyInvitationEntity | unique partial + Any | company-invitation.md |
| INV-CI4 | Accept/Decline только при now < ExpiresAt | CompanyInvitationEntity | валидация + job | company-invitation.md |
| INV-CI5 | Revoke — только актором приглашающей компании | CompanyInvitationEntity | authorization behavior | company-invitation.md |
| INV-CI6 | Создание только при Company.Status = Active, Type ∈ {Agency,Broker,Partner} | CompanyInvitationEntity | GetById(CompanyId) | company-invitation.md |
| INV-CI7 | TargetRoleId — Active/системная agent/внутренняя роль компании | CompanyInvitationEntity | invite/accept валидация | company-invitation.md |
| INV-AM1 | ≤ 1 Active членство на пару (AgentUserId,CompanyId); несколько активных по разным агентствам допускаются (ADR-0070) | AgentMembershipEntity | unique partial + Any(spec) по паре | agent-membership.md |
| INV-AM2 | Leave/terminate только ставит Status=Left+LeftAt+LeaveReason (без удаления/перезаписи) | AgentMembershipEntity | метод завершения членства | agent-membership.md |
| INV-AM3 | Status=Left ⇔ LeftAt != null ∧ LeaveReason != null, LeftAt > JoinedAt | AgentMembershipEntity | guard завершения | agent-membership.md |
| INV-AM4 | AgentUserId — учётка UserType = Agent (инвестор входит через become-agent) | AgentMembershipEntity | join-handler | agent-membership.md |
| INV-AM5 | CompanyId.Type ∈ {Agency,Broker,Partner} и Active | AgentMembershipEntity | GetById(CompanyId) | agent-membership.md |
| INV-AM6 | Зеркало UserEntity.CompanyId = выбранное активное агентство (ActiveCompanyId, ADR-0070); scope роли agent — Organization при ≥1 Active, Platform при нуле; завершение выбранного сбрасывает зеркало | AgentMembershipEntity | join/leave/terminate (мульти-агрегатная транзакция) | agent-membership.md |
profile
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-VRF-1 | ≤ 1 заявка в Draft/Submitted/InReview на OwnerUserId | VerificationRequestEntity | unique partial + FindBy | verification-request.md |
| INV-VRF-2 | Submit требует ФИО+ДР+активный паспорт + ≥ 1 скан | VerificationRequestEntity | метод Submit | verification-request.md |
| INV-VRF-3 | PersonId — активная персона Relation = Self | VerificationRequestEntity | handler создания | verification-request.md |
| INV-VRF-4 | Возраст ≥ 18 на момент Submit | VerificationRequestEntity | метод Submit | verification-request.md |
| INV-VRF-5 | Заявку создаёт только UserType = Investor c permission | VerificationRequestEntity | guard | verification-request.md |
| INV-VRF-6 | TakeInReview только из Submitted; гонка — RowVersion | VerificationRequestEntity | метод + optimistic | verification-request.md |
| INV-VRF-7 | После Submit снапшот иммутабелен (доработка — Revoke→Draft) | VerificationRequestEntity | методы агрегата | verification-request.md |
| INV-VRF-8 | Approve/Reject — только ReviewerUserId; Reject требует причину | VerificationRequestEntity | методы решения | verification-request.md |
| INV-VRF-9 | Новая заявка запрещена при VerificationStatus = Verified | VerificationRequestEntity | handler создания | verification-request.md |
| INV-RQT-1 | Персона и документы состава — того же OwnerUserId | RequisiteTemplateEntity | handler + сверка | requisite-template.md |
| INV-RQT-2 | IdentityDocumentId — паспортный тип; InnDocumentId — Inn | RequisiteTemplateEntity | handler | requisite-template.md |
| INV-RQT-3 | Все ссылки состава — Status = Active | RequisiteTemplateEntity | handler | requisite-template.md |
| INV-RQT-4 | Имя шаблона уникально среди активных владельца (ci) | RequisiteTemplateEntity | unique partial + FindBy | requisite-template.md |
| INV-RQT-5 | ≤ 1 IsDefault среди активных; SetDefault снимает прежний | RequisiteTemplateEntity | unique partial + метод | requisite-template.md |
| INV-RQT-6 | Управление шаблонами — только UserType = Investor | RequisiteTemplateEntity | guard | requisite-template.md |
| INV-RQT-7 | ≤ 20 активных шаблонов на владельца | RequisiteTemplateEntity | handler (Filter(count)) | requisite-template.md |
| INV-RQT-8 | Снапшот для CRM — только по Status = Active | RequisiteTemplateEntity | internal API | requisite-template.md |
| INV-PRF-1 | Событие identity применяется только при Version > ReplicaVersion | ProfileEntity | consumer | profile.md |
| INV-PRF-2 | При ContactsManagedBy = TalentSync self-операции запрещены | ProfileEntity | guard EnsureSelfServiceAllowed | profile.md |
| INV-PRF-3 | Self-операции только при Status = Active | ProfileEntity | тот же guard | profile.md |
| INV-PRF-4 | Персоны/документы/шаблоны/верификация — только Investor | ProfileEntity | guard EnsureInvestorFeatures | profile.md |
| INV-PRF-5 | DefaultRequisiteTemplateId — активный шаблон владельца с IsDefault | ProfileEntity | метод SetDefaultTemplate | profile.md |
| INV-PRF-6 | VerificationStatus меняется только событиями заявки | ProfileEntity | notification-handler | profile.md |
| INV-PRS-1 | ≤ 1 персона Relation = Self Active на OwnerUserId | PersonEntity | unique partial + фабрика | person.md |
| INV-PRS-2 | Персоны создаёт/меняет только UserType = Investor | PersonEntity | guard | person.md |
| INV-PRS-3 | При ContactsManagedBy = TalentSync правки запрещены | PersonEntity | guard | person.md |
| INV-PRS-4 | Архив запрещён при активных шаблонах с PersonId | PersonEntity | метод Archive + Filter | person.md |
| INV-PRS-5 | Архив запрещён при активной верификации персоны | PersonEntity | метод Archive + Filter | person.md |
| INV-PRS-6 | Персона Self не архивируется | PersonEntity | метод Archive | person.md |
| INV-PRS-7 | ≤ 20 активных персон на аккаунт | PersonEntity | handler (Filter(count)) | person.md |
| INV-PRS-8 | Все cipher-поля записи — один KeyVersion | PersonEntity | методы шифрования | person.md |
| INV-DOC-1 | PayloadCipher расшифровывается строго в модель по Type | PersonDocumentEntity | AddDocument/UpdateDocument | person-document.md |
| INV-DOC-2 | CountryId согласован с типом и payload | PersonDocumentEntity | AddDocument/UpdateDocument | person-document.md |
| INV-DOC-3 | Custom — Title вводится; прочие — генерируется | PersonDocumentEntity | AddDocument | person-document.md |
| INV-DOC-4 | Активный дубль (NumberHash+PersonId+Type) запрещён | PersonDocumentEntity | unique partial + FindBy | person-document.md |
| INV-DOC-5 | ≤ 10 сканов, ≤ 20 МБ, ContentType из белого списка | PersonDocumentEntity | FluentValidation | person-document.md |
| INV-DOC-6 | Архив запрещён, пока документ в активном шаблоне | PersonDocumentEntity | ArchiveDocument + Filter | person-document.md |
| INV-DOC-7 | Type иммутабелен (смена = новый документ) | PersonDocumentEntity | нет setter | person-document.md |
| INV-DOC-8 | ≤ 20 активных документов на персону | PersonDocumentEntity | AddDocument | person-document.md |
chessboard
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-CHS-001 | Переходы Status по матрице; Free→Reserved→Booked→Sold — только события CRM/job | PropertyObjectEntity | методы переходов (guard) | property-object.md |
| INV-CHS-002 | ReservedUntil⇔Reserved, BookedUntil⇔Booked; иначе null | PropertyObjectEntity | методы переходов | property-object.md |
| INV-CHS-003 | Release/Book/MarkSold только при DealId == HoldDealId | PropertyObjectEntity | consumer + метод | property-object.md |
| INV-CHS-004 | TenantId == Project.TenantId == Building.TenantId | PropertyObjectEntity | конструктор | property-object.md |
| INV-CHS-005 | Building.ProjectId == ProjectId | PropertyObjectEntity | handler создания | property-object.md |
| INV-CHS-006 | Number уникален среди живых объектов здания | PropertyObjectEntity | unique + FindBy | property-object.md |
| INV-CHS-007 | Layout.ProjectId == ProjectId при привязке | PropertyObjectEntity | handler | property-object.md |
| INV-CHS-008 | Смена Price запрещена при Sold; при hold — только Developers API | PropertyObjectEntity | метод ChangePrice | property-object.md |
| INV-CHS-009 | Hide() только из Free | PropertyObjectEntity | метод Hide | property-object.md |
| INV-PRJ-1 | TenantId == DeveloperId | ProjectEntity | конструктор | project.md |
| INV-PRJ-2 | DeveloperId существует и не удалён | ProjectEntity | GetById(DeveloperId) | project.md |
| INV-PRJ-3 | Publish() требует заполненные поля и Developer.Published | ProjectEntity | метод Publish | project.md |
| INV-PRJ-4 | Для ApartHotel/Hotel публикация требует HotelierId | ProjectEntity | метод Publish | project.md |
| INV-PRJ-5 | Синк применяется только при Version > ReplicaVersion | ProjectEntity | consumer | project.md |
| INV-PRJ-6 | Все объекты проекта — Currency = Project.Currency | ProjectEntity | валидатор мутаций | project.md |
| INV-PRJ-7 | Все банки в Financing существуют (GetExistIds) | ProjectEntity | handler Financing | project.md |
| INV-PRJ-8 | Архив ⇔ DeletedAt; запрещён при hold-объектах | ProjectEntity | метод Archive + Any | project.md |
| INV-PRJ-9 | Реплика-поля — только синком, PIN-поля — только API | ProjectEntity | раздельные методы | project.md |
| INV-BLD-1 | TenantId == Project.TenantId | BuildingEntity | конструктор | building.md |
| INV-BLD-2 | Name уникально среди живых зданий проекта | BuildingEntity | unique + FindBy | building.md |
| INV-BLD-3 | 1 ≤ Floor ≤ FloorsTotal; нельзя опустить ниже занятого | BuildingEntity | метод UpdateFloors + Any | building.md |
| INV-BLD-4 | Publish() требует Project ∈ {Draft,Published} + поля | BuildingEntity | метод Publish | building.md |
| INV-BLD-5 | Синк только при Version > ReplicaVersion | BuildingEntity | consumer | building.md |
| INV-BLD-6 | Delivered ⇔ DeliveredAt != null и ReadinessPercent = 100 | BuildingEntity | метод MarkDelivered | building.md |
| INV-BLD-7 | Реплика-поля — синком, PIN-поля — API | BuildingEntity | раздельные методы | building.md |
| INV-LAY-1 | TenantId == Project.TenantId | LayoutEntity | конструктор | layout.md |
| INV-LAY-2 | Name уникально среди живых планировок проекта | LayoutEntity | unique + FindBy | layout.md |
| INV-LAY-3 | LayoutGroup.ProjectId == ProjectId при привязке | LayoutEntity | handler | layout.md |
| INV-LAY-4 | Синк только при Version > ReplicaVersion | LayoutEntity | consumer | layout.md |
| INV-LAY-5 | Архив запрещён при живых объектах с LayoutId | LayoutEntity | метод Archive + Any | layout.md |
| INV-LAY-6 | Реплика-поля — синком, PIN-поля — API | LayoutEntity | раздельные методы | layout.md |
| INV-LGR-1 | TenantId == Project.TenantId | LayoutGroupEntity | конструктор | layout-group.md |
| INV-LGR-2 | Name уникально среди живых групп проекта | LayoutGroupEntity | unique + FindBy | layout-group.md |
| INV-LGR-3 | Архив запрещён при живых планировках группы | LayoutGroupEntity | метод Archive + Any | layout-group.md |
| INV-LGR-4 | Синк только при Version > ReplicaVersion | LayoutGroupEntity | consumer | layout-group.md |
| INV-DEV-1 | Id = CompanyEntity.Id с Type = Developer в identity | DeveloperEntity | consumer/admin | developer.md |
| INV-DEV-2 | ExternalTalentDeveloperId уникален | DeveloperEntity | unique + FindBy | developer.md |
| INV-DEV-3 | Talent-поля синком, PIN-поля API | DeveloperEntity | раздельные методы | developer.md |
| INV-DEV-4 | Синк только при Version > ReplicaVersion | DeveloperEntity | consumer | developer.md |
| INV-DEV-5 | Publish() требует Name,LogoFileId,Description | DeveloperEntity | метод Publish | developer.md |
| INV-DEV-6 | Hidden скрывает public-проекции всех проектов | DeveloperEntity | query-слой + событие | developer.md |
| INV-HTL-1 | Name уникально среди живых отельеров | HotelierEntity | unique + FindBy | hotelier.md |
| INV-HTL-2 | Архив запрещён при живых проектах отельера | HotelierEntity | handler + Any | hotelier.md |
| INV-BNK-1 | Name уникально среди живых банков | BankEntity | unique + FindBy | bank.md |
| INV-BNK-2 | Архив не каскадирует; ссылки остаются валидными | BankEntity | query-слой (фильтр) | bank.md |
invest-analytics
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-MC1 | В model_config ровно одна строка (singleton) | EconomicModelConfig | unique-индекс + Upsert | economic-model-config.md |
| INV-MC2 | Диапазоны коэффициентов (доли 0..1, темпы, горизонт) | EconomicModelConfig | FluentValidation + check | economic-model-config.md |
| INV-MC3 | Все значения — конечные числа (нет NaN/Inf) | EconomicModelConfig | тип numeric + валидатор | economic-model-config.md |
| INV-MC4 | Каждое сохранение инкрементирует Version | EconomicModelConfig | handler | economic-model-config.md |
| INV-LT1 | GroupId существует как группа планировок в chessboard | PropertyObjectTypeConfig | handler upsert | property-object-type-config.md |
| INV-LT2 | Заполненные коэффициенты — в диапазонах INV-MC2 | PropertyObjectTypeConfig | FluentValidation + check | property-object-type-config.md |
| INV-LT3 | Значения — конечные числа | PropertyObjectTypeConfig | numeric + валидатор | property-object-type-config.md |
| INV-LT4 | Name непустое, ≤ 128 | PropertyObjectTypeConfig | валидатор | property-object-type-config.md |
| INV-MO1 | ≤ 1 строка на (GroupId,Year,Month) | PropertyObjectTypeMonthOverride | composite PK | property-object-type-month-override.md |
| INV-MO2 | GroupId имеет строку PropertyObjectTypeConfig | PropertyObjectTypeMonthOverride | FK + GetById | property-object-type-month-override.md |
| INV-MO3 | Диапазоны (месяц 1–12, год, доли, налоги) | PropertyObjectTypeMonthOverride | FluentValidation + check | property-object-type-month-override.md |
| INV-MO4 | Строка содержит ≥ 1 not-null оверрайд | PropertyObjectTypeMonthOverride | handler | property-object-type-month-override.md |
| INV-MO5 | В payload replace-all нет дублей (Year,Month) | PropertyObjectTypeMonthOverride | FluentValidation | property-object-type-month-override.md |
| INV-MO6 | Replace-all — атомарно (delete+addRange+touch) | PropertyObjectTypeMonthOverride | handler (SaveChanges) | property-object-type-month-override.md |
| INV-CALC-1 | Ни одно поле результата не NaN/Inf (делители проверены) | расчётные модели | функции + guard | investor-series-models.md |
| INV-CALC-2 | Абсолютные оверрайды применяются только при > 0 | расчётные модели | правило positiveOr | investor-series-models.md |
| INV-CALC-3 | До DeliveryTime — Rent=Expenses=Ebitda=0 | расчётные модели | флаг inOperation | investor-series-models.md |
| INV-CALC-4 | Months.Count == HorizonMonths + 1 | расчётные модели | конструктор результата | investor-series-models.md |
| INV-CALC-5 | Ставки налогов: оверрайд месяца → ставка режима | расчётные модели | формулы ставок | investor-series-models.md |
| INV-CALC-6 | Результат детерминирован по входу | расчётные модели | чистые функции | investor-series-models.md |
crm
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-CLI-1 | Телефон уникален в рамках агентства (живые карточки) | ClientEntity | unique + FindBy | client.md |
| INV-CLI-2 | ActiveLeadId указывает на Active-лид этого клиента | ClientEntity | методы агрегата | client.md |
| INV-CLI-3 | Зарегистрированный пользователь ≤ 1 раз на агентство | ClientEntity | unique + FindBy | client.md |
| INV-CLI-4 | Архив только без активных лидов/фиксаций/сделок | ClientEntity | метод Archive + Any | client.md |
| INV-LEAD-2 | События/фиксации/сделки — только у Active-лида | LeadEntity | методы агрегата | lead.md |
| INV-LEAD-3 | Продление: ExpiresAt = max(now,ExpiresAt)+1 мес, ExtensionsCount++; продлевать можно Active/Expired (реактивация с повторной проверкой INV-CRM-001) | LeadEntity | метод Extend | lead.md |
| INV-LEAD-4 | Expired ставит только worker при ExpiresAt < now и Active; активные сделки не трогаются | LeadEntity | job lead-expiration | lead.md |
| INV-LEAD-5 | Wishes редактирует участник агентской роли лида / Менеджер ПИН; фиксация делает snapshot-override | LeadEntity | метод UpdateWishes | lead.md |
| INV-LEAD-6 | Иммутабельны (set-once) только Origin: TenantId(=AgencyCompanyId), OriginAgentUserId — не пересчитываются при смене агентства/передаче ведения (ADR-0071); ExecutorAgentUserId мутабелен | LeadEntity | фабрика (private-set) | lead.md |
| INV-LEAD-7 | Среди активных участников: ровно один Executor, один InvestorPrimary; прочие роли по составу; UserId уникален | LeadEntity | unique partial + методы | lead.md |
| INV-LEAD-8 | Лид нельзя оставить без активного Executor; смена исполнителя — только атомарной передачей ведения (производные пересчитываются) | LeadEntity | методы состава/передачи | lead.md |
| INV-FIX-2 | Создание — из Active-лида агентом-владельцем (или Менеджером ПИН) | FixationRequestEntity | валидация + GetById | fixation-request.md |
| INV-FIX-3 | Решение — из Pending менеджером компании TenantId | FixationRequestEntity | методы Approve/Reject | fixation-request.md |
| INV-FIX-4 | Отмена — из Pending агентом-инициатором (или руководителем агентства/Менеджером ПИН) | FixationRequestEntity | метод Cancel | fixation-request.md |
| INV-FIX-5 | Rejected требует причину; FixedUnique только при NoDuplicates (иначе FixedNonUnique/forceUnique с audit) | FixationRequestEntity | check + Approve | fixation-request.md |
| INV-FIX-6 | Одобрение — только авторизация, не сделка: Pending → FixedUnique/FixedNonUnique подтверждает право агента вести клиента по объектам застройщика; сделка при одобрении не создаётся, заявка ссылки на сделку не хранит. Одна одобренная фиксация авторизует много сделок пары «клиент × застройщик» (связь 1:N со стороны сделки — DealEntity.FixationRequestId) | FixationRequestEntity | handler одобрения | fixation-request.md |
| INV-FIX-7 | Иммутабельна только атрибуция OriginAgencyCompanyId/OriginAgentUserId (set-once, ADR-0071); текущий ExecutorAgentUserId мутабелен (reassign полномочными) | FixationRequestEntity | фабрика + reassign | fixation-request.md |
| INV-STEP-1 | Каждый этап привязан к системной группе; Group иммутабелен | PipelineStepEntity | конструктор/Update | pipeline-step.md |
| INV-STEP-2 | Живые этапы уникальны по имени (ci) и Order в TenantId | PipelineStepEntity | unique + Any | pipeline-step.md |
| INV-STEP-3 | На каждую группу — ≥ 1 живой этап | PipelineStepEntity | архив: Count > 1 | pipeline-step.md |
| INV-STEP-4 | Архив этапа с сделками требует этап-приёмник | PipelineStepEntity | ArchiveStep(target) | pipeline-step.md |
| INV-STEP-5 | Системные группы не редактируются через API | PipelineStepGroup | нет command-операций | pipeline-step.md |
| INV-DEAL-1 | Сделка — из Active-лида; PropertyObjectId обязателен; TenantId=застройщик объекта (set-once). Ветка по гейту вовлечения застройщика: создание сразу в Application требует PaymentMethod; создание в Draft (гейт не выполнен) — PaymentMethod необязателен (требование откладывается до Draft → Application) | DealEntity | command создания + GetById | deal.md |
| INV-DEAL-2 | Начальный состав по статусу создания: Draft — Executor+InvestorPrimary+auto-PinManager без SellManager (застройщик не вовлечён); Application — те же + SellManager. SellManager появляется только с переходом в Application | DealEntity | фабрика Deal.Create | deal.md |
| INV-DEAL-3 | Среди активных: один Executor, один InvestorPrimary, ≤1 PinManager; UserId уникален; произвольно CoAgent/Assistant/Lawyer/InvestorSecondary; без активного Executor нельзя — смена только атомарной передачей ведения | DealEntity | методы Member + unique | deal.md |
| INV-DEAL-4 | ChangePropertyObject — в Application/Booking (при Booking — с отменой брони), тот же TenantId, Price перечитывается | DealEntity | метод ChangePropertyObject | deal.md |
| INV-DEAL-5 | MoveToStep: этап TenantId, не архивный; группа Cancelled требует CancelReason | DealEntity | метод MoveToStep | deal.md |
| INV-DEAL-6 | С Sale обязательны ContractType+PaymentMethod; Installment→InstallmentPresetId | DealEntity | guard StartSale | deal.md |
| INV-DEAL-7 | Даты оформления (договоры→эскроу→Росреестр→завершение) — менеджером TenantId строго по порядку | DealEntity | методы этапов | deal.md |
| INV-DEAL-8 | Cancelled из нетерминального; активная бронь отменяется тем же изменением | DealEntity | метод Cancel | deal.md |
| INV-DEAL-9 | Терминальные Completed/Cancelled не редактируются | DealEntity | командные методы | deal.md |
| INV-DEAL-10 | Иммутабельны только Origin-поля OriginAgencyCompanyId/OriginAgentUserId (set-once, ADR-0071 — основание комиссии/воронка); Executor/Responsible/PrimaryInvestor — мутабельные производные состава | DealEntity | фабрика + guard правки Origin | deal.md |
| INV-DEAL-11 | Гейт Draft → Application (вовлечение застройщика): переход только при одновременно (а) LeadBookingIntentEntity.ConfirmedByInvestor = true (иначе CRM_DEAL_INTENT_NOT_CONFIRMED), (б) одобренная фиксация пары «инвестор × застройщик» (иначе CRM_DEAL_FIXATION_REQUIRED), (в) задан PaymentMethod (иначе CRM_DEAL_PAYMENT_METHOD_REQUIRED); при выполнении добавляется SellManager, проставляется IntentConfirmedAt. Обратного перехода нет | DealEntity | метод EngageDeveloper (engage-developer) | deal.md |
| INV-INTENT-1 | На пару (LeadId,PropertyObjectId) — не более одного намерения Active; снятых (Cancelled) — сколько угодно | LeadBookingIntentEntity | unique partial ux_lead_booking_intents_active_object | lead.md |
| INV-INTENT-2 | Добавление/подтверждение/снятие намерения — только у лида Status = Active | LeadBookingIntentEntity | команды над намерением | lead.md |
| INV-INTENT-3 | При создании: InvestorAdded → ConfirmedByInvestor = true, ConfirmedAt = CreatedAt; AgentProposed → ConfirmedByInvestor = false, ConfirmedAt = null | LeadBookingIntentEntity | фабрика намерения | lead.md |
| INV-INTENT-4 | Подтвердить намерение (ConfirmedByInvestor: false → true, ConfirmedAt = now) может только участник-инвестор пары; повторное подтверждение — no-op | LeadBookingIntentEntity | метод подтверждения (Flow B) | lead.md |
| INV-INTENT-5 | PropertyObjectId/ProjectId/DeveloperCompanyId/Source — set-once снапшот из объекта, иммутабельны | LeadBookingIntentEntity | фабрика намерения | lead.md |
| INV-INTENT-6 | DealId set-once; связь активного намерения и сделки — 1:1 | LeadBookingIntentEntity | связывание намерения со сделкой | lead.md |
| INV-BKG-2 | Создание брони — у сделки ∈ {Application,Booking}; сделка→Booking | BookingEntity | command + OnBookingCreated | booking.md |
| INV-BKG-3 | ConfirmBooking: платная — после Paid; бесплатная — из Reserved | BookingEntity | метод ConfirmBooking | booking.md |
| INV-BKG-4 | ConfirmPayment — менеджер TenantId, из AwaitingPayment, при чеке | BookingEntity | метод ConfirmPayment | booking.md |
| INV-BKG-5 | Истечение — только worker (Reserve/Booking таймеры) | BookingEntity | job booking-expiration | booking.md |
| INV-BKG-6 | Extend — только Booked менеджером TenantId | BookingEntity | метод Extend | booking.md |
| INV-BKG-7 | Complete — из Booked, одной транзакцией с Deal.StartSale | BookingEntity | handler StartSale | booking.md |
| INV-BKG-8 | Терминальные статусы иммутабельны | BookingEntity | командные методы | booking.md |
| INV-BPR-1 | IsPaid ⇒ Price > 0; !IsPaid ⇒ Price = null | BookingProgramEntity | check + FluentValidation | booking-program.md |
| INV-BPR-2 | ≤ 1 живой default на застройщика; SetDefault снимает прежний | BookingProgramEntity | unique partial + SetDefault | booking-program.md |
| INV-BPR-3 | Имя уникально в TenantId среди живых | BookingProgramEntity | unique | booking-program.md |
| INV-BPR-4 | Правка условий не затрагивает существующие брони (snapshot) | BookingProgramEntity | архитектурное правило | booking-program.md |
| INV-BPR-5 | ≥ 1 живая программа у застройщика (SEED default) | BookingProgramEntity | архив: Count > 1 | booking-program.md |
| INV-PPR-1 | Один живой агрегат программы покупки на застройщика | PurchaseProgramEntity | unique | purchase-program.md |
| INV-PPR-2 | Проценты 0–100; сроки > 0 | PurchaseProgramEntity | FluentValidation + check | purchase-program.md |
| INV-PPR-3 | ≤ 1 remainder в MilestonePayments, он последний | InstallmentPresetEntity | валидатор пресета | purchase-program.md |
| INV-PPR-4 | График пресета погашает 100% discountedPrice | InstallmentPresetEntity | валидатор пресета | purchase-program.md |
| INV-PPR-5 | Пресет в активных сделках нельзя архивировать | InstallmentPresetEntity | архив: Any(deals) | purchase-program.md |
| INV-DOC-1 | Загружать тип может только сторона-владелец типа | DealDocumentEntity | command загрузки | deal-document.md |
| INV-DOC-2 | Accepted/Rejected — только проверяющий; Rejected требует причину | DealDocumentEntity | метод Review + check | deal-document.md |
| INV-DOC-3 | С Uploaded обязательны FileId/имя/тип/размер | DealDocumentEntity | check + FluentValidation | deal-document.md |
| INV-DOC-4 | Повтор после Rejected — новая запись (append-only) | DealDocumentEntity | command загрузки | deal-document.md |
| INV-DOC-5 | Документы только у сделки в нетерминальном статусе | DealDocumentEntity | GetById(DealId) + guard | deal-document.md |
| INV-DOC-6 | Смена статуса → уведомление + таймлайн той же транзакцией | DealDocumentEntity | handler Review/Upload | deal-document.md |
| INV-TLE-1 | Append-only: контент события иммутабелен | DealTimelineEventEntity | EF-конфигурация | deal-timeline-event.md |
| INV-TLE-2 | Запись — в одной транзакции с породившей мутацией | DealTimelineEventEntity | handler-ы CRM | deal-timeline-event.md |
| INV-TLE-3 | InProgress требует DueAt или парное закрытие | DealTimelineEventEntity | фабрики событий | deal-timeline-event.md |
| INV-TLE-4 | Overdue ставит только worker timeline-overdue | DealTimelineEventEntity | background job | deal-timeline-event.md |
| INV-TLE-5 | ManualNote — только PinManager/Служба заботы | DealTimelineEventEntity | валидация actor | deal-timeline-event.md |
referral
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-REF-001 | ≤ 1 Active-связь на ReferredUserId; автоперепривязка запрещена | ReferralBondEntity | unique partial + FindBy | referral-bond.md |
| INV-REF-002 | Лид создаётся только если владелец ссылки — Agent/DeveloperManager | процесс привязки | handler | attach-rules.md |
| INV-REF-003 | «Имя заполнено И регистрация ≥ 4 ч» ⇒ связь не создаётся | процесс привязки | матрица R3 | attach-rules.md |
| INV-REF-004 | Владелец ссылки не может стать своим рефералом | процесс привязки | guard G3 | attach-rules.md |
| INV-REF-005 | Привязка и лид — только Type = Investor | процесс привязки | guard G2 | attach-rules.md |
| INV-REF-B2 | ReferredUserId != ReferrerUserId | ReferralBondEntity | guard Create | referral-bond.md |
| INV-REF-B3 | Основные поля связи иммутабельны после создания | ReferralBondEntity | нет сеттеров | referral-bond.md |
| INV-REF-B4 | Replaced — только с одновременным созданием замены | ReferralBondEntity | метод Replace | referral-bond.md |
| INV-REF-B5 | Revoked ⇒ RevokeReason непустой | ReferralBondEntity | check + Revoke | referral-bond.md |
| INV-REF-B6 | Replaced/Revoked — терминальные | ReferralBondEntity | guard методов | referral-bond.md |
investor-owner
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-OWN-001 | InvestorUserId = Id при LinkKind = InvestorCreated (Id = OwnerId) | OwnerMappingEntity | метод LinkToCreatedInvestor | owner-mapping.md |
favorites
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-FAV-001 | Заполнен ровно один из UserId/AnonymousId | Favorite | фабрика + DB check | favorite.md |
| INV-FAV-002 | Одна запись на «владелец + объект» (идемпотентность) | Favorite | FindBy + unique | favorite.md |
| INV-FAV-003 | Note — null или 1..4000 после trim | Favorite | FluentValidation + метод | favorite.md |
| INV-FAV-004 | PropertyObjectId — валидный Guid (без sync-проверки) | Favorite | валидатор формата | favorite.md |
| INV-FAV-005 | Читать/менять запись может только владелец | Favorite | record-level фильтр | favorite.md |
| INV-FAV-006 | Смена владельца — только слиянием/импортом | Favorite | нет мутатора; merge-handler | favorite.md |
| INV-FAV-101 | ≤ 1 merge-операция на (UserId,AnonymousId), идемпотентна | FavoritesMergeOperation | FindBy + unique | merge-strategy.md |
| INV-FAV-102 | Сессия AnonymousId связана именно с этим UserId | FavoritesMergeOperation | internal-запрос user-activity | merge-strategy.md |
| INV-FAV-103 | strategy ∈ FavoritesMergeStrategy | FavoritesMergeOperation | FluentValidation | merge-strategy.md |
| INV-FAV-104 | Слияние — одна транзакция (нет частичного) | FavoritesMergeOperation | SaveChanges | merge-strategy.md |
| INV-FAV-105 | Слияние не читает/не трогает чужие записи | FavoritesMergeOperation | предикаты Filter | merge-strategy.md |
selections
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-SEL-002 | Мутации — только OwnerUserId; чтение по PublicCode — любой | Selection | record-level фильтр | selection.md |
| INV-SEL-003 | Объект входит в подборку ≤ 1 раза | Selection | метод + unique | selection.md |
| INV-SEL-004 | ≤ 200 элементов | Selection | метод + FluentValidation | selection.md |
| INV-SEL-005 | PublicCode генерируется при создании и не меняется | Selection | нет мутатора | selection.md |
| INV-SEL-006 | SortOrder — плотная последовательность 0..N-1 | Selection | метод SetItems | selection.md |
| INV-SEL-007 | ≤ 100 живых подборок на владельца | Selection | create-handler Count | selection.md |
| INV-SEL-008 | PropertyObjectId без sync-проверки | Selection | валидатор формата | selection.md |
| INV-SEL-009 | DeletedAt — не читается/не редактируется публично | Selection | guard всех handler | selection.md |
| INV-SEL-010 | Копия из избранного отвязана (без синка) | Selection | create-from-favorites | selection.md |
| INV-SEL-001 | Content snapshot-а иммутабелен (замена блока целиком) | SelectionSnapshot | нет мутатора Content | selection-snapshot.md |
| INV-SNP-002 | Snapshot — только из живой подборки | SelectionSnapshot | create-handler + guard | selection-snapshot.md |
| INV-SNP-003 | Создание/detach — только m2m-актор news | SelectionSnapshot | authorization policy | selection-snapshot.md |
| INV-SNP-004 | В слепке 1..200 элементов | SelectionSnapshot | create-handler | selection-snapshot.md |
| INV-SNP-005 | Active → Detached однократен (идемпотентно) | SelectionSnapshot | метод Detach + RowVersion | selection-snapshot.md |
| INV-SNP-006 | Snapshot не удаляется (история постов) | SelectionSnapshot | нет delete | selection-snapshot.md |
user-activity
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-SES-001 | ≤ 1 Active-сессия на AnonymousId | Session | start-handler + unique | session.md |
| INV-SES-002 | UserId ставится один раз; вход другого — форс-закрытие | Session | link-handler / consumer | session.md |
| INV-SES-003 | Heartbeat обновляет только Active; иначе новая сессия | Session | heartbeat-handler | session.md |
| INV-SES-004 | Все timestamp ставит сервер | Session | все handlers | session.md |
| INV-SES-005 | Active→Inactive — worker/link: Duration,EndedAt, закрытие просмотров | Session | worker / link-handler | session.md |
| INV-SES-006 | Inactive-сессия неизменяема | Session | worker, handlers | session.md |
| INV-LV-001 | ≤ 1 Active-просмотр на (SessionId,PropertyObjectId) | PropertyObjectView | open-handler + unique | property-object-view.md |
| INV-LV-002 | Просмотр только в Active-сессии (иначе ensure-старт) | PropertyObjectView | open-handler | property-object-view.md |
| INV-LV-003 | Heartbeat только Active; иначе новая запись | PropertyObjectView | heartbeat-handler | property-object-view.md |
| INV-LV-004 | Active→Inactive — worker: Duration,EndedAt, outbox | PropertyObjectView | worker activity-closer | property-object-view.md |
| INV-LV-005 | FilterSnapshot фиксируется при создании | PropertyObjectView | нет мутатора | property-object-view.md |
| INV-LV-006 | PropertyObjectId — Guid без sync-проверки | PropertyObjectView | валидатор формата | property-object-view.md |
| INV-LV-007 | У Inactive-сессии нет Active-просмотров | PropertyObjectView | worker / link-handler | property-object-view.md |
| INV-LV-008 | Inactive-запись неизменяема | PropertyObjectView | worker | property-object-view.md |
| INV-LVS-001 | Ровно одна строка на PropertyObjectId | PropertyObjectViewStats | PK + Upsert | property-object-view-stats.md |
| INV-LVS-002 | ViewsCount/TotalDuration только растут (атомарный UPDATE) | PropertyObjectViewStats | консюмер + check | property-object-view-stats.md |
| INV-LVS-003 | Одно событие инкрементит ≤ 1 раза (PropertyObjectViewId) | PropertyObjectViewStats | консюмер + IdempotencyKey | property-object-view-stats.md |
| INV-LVS-004 | LastViewedAt = greatest(…) (вне порядка) | PropertyObjectViewStats | консюмер (SQL greatest) | property-object-view-stats.md |
| INV-LVS-005 | AvgDuration не хранится — считается на чтении | PropertyObjectViewStats | query-handler | property-object-view-stats.md |
| INV-LVS-006 | Пишет только консюмер (нет public-мутаций) | PropertyObjectViewStats | нет command-API | property-object-view-stats.md |
| INV-ACT-001 | Событие append-only (retention батчами) | ActionEvent | нет update/delete-API | action-event.md |
| INV-ACT-002 | Обязательные поля подтипа заполнены | ActionEvent | конструкторы подтипов + check | action-event.md |
| INV-ACT-003 | Каждое событие привязано к сессии (ensure) | ActionEvent | handlers/consumers | action-event.md |
| INV-ACT-004 | Consumer-записи уникальны по SourceMessageId | ActionEvent | FindBy + unique | action-event.md |
| INV-ACT-005 | OccurredAt для track ставит сервер | ActionEvent | handlers/consumers | action-event.md |
| INV-ACT-006 | ApplyFilter — ≥ 1 фильтр-поле | ActionEvent | FluentValidation | action-event.md |
| INV-ACT-007 | SearchQuery 1..512; диапазоны Min ≤ Max | ActionEvent | FluentValidation | action-event.md |
news
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-NWS-001 | Suffix уникален среди живых (kebab-case) | Post | unique + validator | post.md |
| INV-NWS-002 | Publish только при AuthorType ∈ AllowedAuthorTypes + permission | Post | create/publish-handler | post.md |
| INV-NWS-003 | AuthorCompanyId — только менеджеры; ProjectRefs — DeveloperManager | Post | validator + метод | post.md |
| INV-NWS-004 | Publish требует Title + ≥ 1 блок + Active-snapshot embed | Post | метод Publish() | post.md |
| INV-NWS-005 | Каждая публикация пишет новый PostVersion | Post | Publish() + транзакция | post.md |
| INV-NWS-006 | Комментарий — только Published + CommentsEnabled | Post | Comment.Create guard | post.md |
| INV-NWS-007 | Мутации — автор/менеджер компании/permission | Post | record-level + handler | post.md |
| INV-NWS-008 | SearchText/ES-проекция пересчитываются при правках | Post | обработчик события + индексатор | post.md |
| INV-CAT-001 | Slug уникален, неизменяем | PostCategory | unique + FindBy | post-category.md |
| INV-CAT-002 | Публикация только если UserType ∈ AllowedAuthorTypes | PostCategory | handler публикации | post-category.md |
| INV-CAT-003 | При наличии постов — только Archived, не удаление | PostCategory | handler + Any | post-category.md |
| INV-CAT-004 | CRUD категорий — только admin-актор | PostCategory | authorization pipeline | post-category.md |
| INV-REP-001 | Событие применяется только при Version > SourceVersion | AuthorReplica/CompanyReplica | consumer | identity-replicas.md |
| INV-REP-002 | Author.Blocked → подпись помечается (посты остаются) | AuthorReplica | query-слой выдачи | identity-replicas.md |
| INV-REP-003 | Company.Suspended → посты вне публичных лент | CompanyReplica | query-слой публичных лент | identity-replicas.md |
| INV-REP-004 | Upsert по Id идемпотентен | AuthorReplica/CompanyReplica | consumer + IdempotencyKey | identity-replicas.md |
| INV-CMT-001 | Создание — Published + CommentsEnabled | Comment | validator + GetById | comment.md |
| INV-CMT-002 | ParentComment.PostId == PostId | Comment | метод Reply | comment.md |
| INV-CMT-003 | Depth ≤ 6 | Comment | check + Reply | comment.md |
| INV-CMT-004 | Delete — автор; hide — permission moderate | Comment | authorization | comment.md |
| INV-CMT-005 | CommentsCount инкремент/декремент (reconcile-job) | Comment | обработчик события | comment.md |
| INV-CMT-006 | Из deleted/hidden обратно в visible нельзя | Comment | guard статуса | comment.md |
| INV-SUB-001 | ≤ 1 живая подписка на цель | AuthorSubscription | partial unique + FindBy | author-subscription.md |
| INV-SUB-002 | Ровно одно из TargetUserId/TargetCompanyId | AuthorSubscription | check + validator | author-subscription.md |
| INV-SUB-003 | Нельзя подписаться на себя | AuthorSubscription | validator | author-subscription.md |
| INV-SUB-004 | Управляет строкой только сам подписчик (JWT) | AuthorSubscription | actor-контекст | author-subscription.md |
messenger
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-CHAT-1 | Direct уникален на неупорядоченную пару (DirectKey); OpenDirect идемпотентен | ChatEntity | OpenDirect + unique | chat.md |
| INV-CHAT-2 | Deal-чат уникален на DealId; провизия из deal-created идемпотентна | ChatEntity | консюмер + unique | chat.md |
| INV-CHAT-2L | Lead-чат уникален на LeadId; провизия идемпотентна | ChatEntity | консюмер + unique | chat.md |
| INV-CHAT-3 | Писать = нет блокировки сторонами; звонить = accepted Contact | ChatEntity | policy PostMessage | chat.md |
| INV-CHAT-4 | Доступ = активный Participant (UserId = actor); tenant-фильтра нет | ChatEntity | guard операций | chat.md |
| INV-CHAT-5 | При посте атомарно: Seq++, обновление Last* | ChatEntity | RegisterPosted под xmin | chat.md |
| INV-CHAT-6 | Состав Lead/Deal-чата зеркалит активных LeadParticipant/DealMember (ADR-0071); пользовательских ручек состава нет | ChatEntity | консюмеры crm | chat.md |
| INV-CHAT-7 | Direct — ровно 2 активных Member; Lead/Deal — ≥ 1; число по ролям не ограничено (несколько агентов/инвесторов); ролевой состав валидирует crm | ChatEntity | OpenDirect/синк состава | chat.md |
| INV-CHAT-8 | Closed-чат: сообщения пользователей запрещены (системные карточки допустимы) | ChatEntity | guard PostMessage | chat.md |
| INV-PART-1 | Уникальность активного (ChatId,UserId); повтор = реактивация | ParticipantEntity | unique + AddParticipant | participant.md |
| INV-PART-2 | LastReadSeq монотонен (max, ≤ LastMessageSeq) | ParticipantEntity | MarkRead (GREATEST) | participant.md |
| INV-PART-3 | Состав по типу чата (Direct 2 / Deal = DealMember) | ParticipantEntity | фабрика/методы Chat | participant.md |
| INV-PART-4 | Удаление участия — soft (RemovedAt), теряет доступ | ParticipantEntity | RemoveParticipant | participant.md |
| INV-PART-5 | Read-receipt виден автору (по LastReadSeq) | ParticipantEntity | проекция read-receipt | participant.md |
| INV-MU-1 | Snapshot применяется только при Version > IdentityUserVersion | MessengerUserEntity | version-guard консюмера | messenger-user.md |
| INV-MU-2 | PhoneNumber E.164 и уникален | MessengerUserEntity | нормализация + unique | messenger-user.md |
| INV-MU-3 | Поиск отдаёт маску фамилии до Accepted-контакта | MessengerUserEntity | проекция результата | messenger-user.md |
| INV-MU-4 | Реплика read-only для пользовательских ручек | MessengerUserEntity | нет write-хендлеров | messenger-user.md |
| INV-MSG-1 | Править/удалять — только автор, пока now ≤ EditableUntil (≤60с) | MessageEntity | методы Edit/Delete | message.md |
| INV-MSG-2 | Каждая правка пишет MessageEditEntity (append-only) | MessageEntity | Edit/Delete | message.md |
| INV-MSG-3 | Seq атомарно из Chat.LastSeqCounter; порядок по Seq | MessageEntity | RegisterPosted + unique | message.md |
| INV-MSG-4 | EventCard/системные: SenderUserId = null, не правятся | MessageEntity | фабрика + guard | message.md |
| INV-MSG-5 | Видимость EventCard: нет recipient-строк ⇒ всем | MessageEntity | list-messages фильтр | message.md |
| INV-MSG-6 | IP/UA обязательны для user-сообщений (Sensitive) | MessageEntity | command + маппинг | message.md |
| INV-MSG-7 | Attachment хранит только FileId + метаданные | MessageEntity | валидатор Attachment | message.md |
| INV-MSG-8 | Идемпотентность карточек из crm (ux по SourceEventId) | MessageEntity | консюмер + unique | message.md |
| INV-CE-1 | Append-only: контент события иммутабелен | ChatEventEntity | EF-конфигурация | chat-event.md |
| INV-CE-2 | Запись — в одной транзакции с мутацией | ChatEventEntity | handler-ы messenger | chat-event.md |
| INV-CE-3 | Не влияет на Seq/unread (отдельная лента) | ChatEventEntity | конфигурация ленты | chat-event.md |
| INV-CE-4 | Членские события пишет консюмер deal-members идемпотентно | ChatEventEntity | KafkaEventConsumer | chat-event.md |
notifications
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-NTF-1 | Fan-out идемпотентен по (SourceMessageId,Recipient,Channel,Kind) | NotificationEntity | unique + Create | notification.md |
| INV-NTF-2 | Создание: Pending,Attempts=0,RenderedBody не пуст | NotificationEntity | фабрика Create | notification.md |
| INV-NTF-3 | Канал создаётся только если разрешён настройками | NotificationEntity | consumer перед Create | notification.md |
| INV-NTF-4 | Тихие часы (не Critical) → ScheduledAt | NotificationEntity | ApplyQuietHours | notification.md |
| INV-NTF-5 | Переходы Pending→Sent/Failed; Sent внешнего — терминально | NotificationEntity | методы статуса | notification.md |
| INV-NTF-6 | Read только InApp и Recipient = actor | NotificationEntity | MarkRead(actor) | notification.md |
| INV-NTF-7 | Retry прекращается при Attempts ≥ MaxAttempts | NotificationEntity | retry-worker | notification.md |
| INV-UNS-1 | Одна запись на UserId; правит владелец | UserNotificationSettingsEntity | unique + handler | user-notification-settings.md |
| INV-UNS-2 | (Kind,Channel) в Preferences уникальны | UserNotificationSettingsEntity | unique + SetPreference | user-notification-settings.md |
| INV-UNS-3 | Резолв каналов: явное IsEnabled иначе дефолт; GlobalMute→Critical | UserNotificationSettingsEntity | ResolveChannels | user-notification-settings.md |
| INV-UNS-4 | Urgency = Critical нельзя отключить (канал сменить можно) | UserNotificationSettingsEntity | ResolveChannels | user-notification-settings.md |
| INV-UNS-5 | Тихие часы — только interruptive-каналам | UserNotificationSettingsEntity | IsInQuietHours | user-notification-settings.md |
| INV-TPL-1 | Code = "{Kind}:{Locale}" уникален; один Active на (Kind,Locale) | NotificationTemplateEntity | unique + Publish | notification-template.md |
| INV-TPL-2 | Плейсхолдеры тела ⊆ допустимых для Kind | NotificationTemplateEntity | SetChannelBody | notification-template.md |
| INV-TPL-3 | Переход в Active требует ≥ 1 канал с IsEnabled | NotificationTemplateEntity | Publish | notification-template.md |
| INV-TPL-4 | IsSystem-шаблон нельзя архивировать/удалить | NotificationTemplateEntity | командные методы | notification-template.md |
| INV-TPL-5 | Резолв: Active (Kind,Locale) → fallback ru → skip канала | NotificationTemplateEntity | ResolveTemplate | notification-template.md |
| INV-CHN-1 | Один ChannelConfigEntity на канал (SEED) | ChannelConfigEntity | unique + SEED | channel.md |
| INV-CHN-2 | Диспатч только при IsEnabled; иначе skip/pending | ChannelConfigEntity | consumer + dispatcher | channel.md |
| INV-CHN-3 | MaxBodyLength → усечение с многоточием | ChannelConfigEntity | рендер | channel.md |
| INV-CHN-4 | MaxAttempts/RetryBackoff — порог retry-worker | ChannelConfigEntity | retry-worker | channel.md |
support
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-TKT-1 | Создание: New, Assignee = null, QueueId по маршрутизации | TicketEntity | фабрика + GetById(QueueId) | ticket.md |
| INV-TKT-2 | Take: только New→InWork, Assignee = actor | TicketEntity | метод Take | ticket.md |
| INV-TKT-3 | Assign: супервизор задаёт Assignee в New/InWork | TicketEntity | метод Assign | ticket.md |
| INV-TKT-4 | Переходы New→InWork→Resolved→Closed(+реопен); Resolved требует Assignee | TicketEntity | метод ChangeStatus | ticket.md |
| INV-TKT-5 | FirstResponseAt — раз, при первом публичном ответе | TicketEntity | обработчик переписки | ticket.md |
| INV-TKT-6 | SLA-переходы ставит только worker | TicketEntity | SLA-worker | ticket.md |
| INV-TKT-7 | Терминальный Closed неизменяем (кроме реопена) | TicketEntity | командные методы | ticket.md |
| INV-MSG-1 | Requester-сообщение обязано Public | TicketMessageEntity | AddMessage + validator | ticket-message.md |
| INV-MSG-2 | Internal только при Operator | TicketMessageEntity | AddMessage + validator | ticket-message.md |
| INV-MSG-3 | Автор — участник тикета либо оператор с permission | TicketMessageEntity | AddMessage | ticket-message.md |
| INV-MSG-4 | Нельзя добавить сообщение к Closed без реопена | TicketMessageEntity | guard AddMessage | ticket-message.md |
| INV-MSG-5 | Первое публичное сообщение оператора → FirstResponseAt | TicketMessageEntity | AddMessage | ticket-message.md |
| INV-Q-1 | Code уникален в TenantId | TicketQueueEntity | unique + Any | ticket-queue.md |
| INV-Q-2 | Ровно одна активная IsDefault-очередь | TicketQueueEntity | unique partial + SetDefault | ticket-queue.md |
| INV-Q-3 | RoutingCategory уникальна среди активных | TicketQueueEntity | unique partial + Any | ticket-queue.md |
| INV-Q-4 | Архив запрещён при активных тикетах / последней default | TicketQueueEntity | ArchiveQueue + Count | ticket-queue.md |
| INV-Q-5 | Маршрутизация: категория → активная очередь, иначе default | TicketQueueEntity | ResolveQueue(category) | ticket-queue.md |
| INV-TPL-1 | Code уникален в TenantId (живые) | TicketTemplateEntity | unique + проверка | ticket-template.md |
| INV-TPL-2 | Плейсхолдеры из белого списка | TicketTemplateEntity | FluentValidation | ticket-template.md |
| INV-TPL-3 | apply: рендер плейсхолдеров → TicketMessage.Body, UsageCount++ | TicketTemplateEntity | handler reply-ticket | ticket-template.md |
| INV-TPL-4 | Архивированный шаблон не применяется | TicketTemplateEntity | guard apply | ticket-template.md |
| INV-KB-1 | Slug уникален среди живых | KnowledgeBaseArticleEntity | unique + FindBy | knowledge-base-article.md |
| INV-KB-2 | Публикация требует непустые Title/Body | KnowledgeBaseArticleEntity | метод Publish | knowledge-base-article.md |
| INV-KB-3 | Public отдаёт только Published с подходящей Audience | KnowledgeBaseArticleEntity | спецификация выдачи | knowledge-base-article.md |
| INV-KB-4 | ViewsCount растёт только при чтении Published (приблизит.) | KnowledgeBaseArticleEntity | обработчик чтения | knowledge-base-article.md |
| INV-ARB-1 | Переходы Registered→UnderInvestigation→(Resolved/Escalated)→Closed | ArbitrageCaseEntity | метод ChangeStatus | arbitrage-case.md |
| INV-ARB-2 | Resolve требует Assignee + Decision | ArbitrageCaseEntity | метод Resolve | arbitrage-case.md |
| INV-ARB-3 | Escalate — permission супервизора, ставит поля эскалации | ArbitrageCaseEntity | метод Escalate | arbitrage-case.md |
| INV-ARB-4 | Привязки иммутабельны после Resolved/Escalated | ArbitrageCaseEntity | сеттеры/методы | arbitrage-case.md |
| INV-ARB-5 | Заметки — только в Registered/UnderInvestigation | ArbitrageCaseEntity | метод AddNote | arbitrage-case.md |
handbook
Справочники — реплики внешнего Talent Handbook (ADR-0063): пишет только синк, монотонность — по времени события (per-record версии у источника нет).
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-HND-1 | Создание и правка реплика-полей — только синк источника; админу доступны лишь archive/restore | справочники (country/region/city/currency/language) | состав операций (нет write-API) | country.md |
| INV-HND-2 | ISO-коды уникальны среди неархивных записей своего справочника | Country/Currency/Language | синк/restore + unique | country.md |
| INV-HND-3 | Гео-цепочка city↔region↔country согласована (город не ссылается на регион чужой страны) | City/Region | применение city-события | city.md |
| INV-HND-4 | Монотонность по времени: событие применяется только при envelope.CreatedAt > LastEventAt | все справочники | консюмер (skip stale) | country.md |
| INV-HND-5 | Публичный lookup — только неархивные; internal resolve — включая архивные (исторические ссылки не ломаются) | все справочники | чтение lookup/resolve | country.md |
| INV-HND-6 | IsoCode валюты после trim — ровно 3 буквы ISO-4217 (в реплике char(3)) | Currency | применение события | currency.md |
calendar
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-CAL-1 | Интервал: EndAt > StartAt; EndAt = null только для Kind ∈ {Task,BookingDeadline} | CalendarEventEntity | создание/изменение/перенос | calendar-event.md |
| INV-CAL-2 | Переходы статуса по матрице; start→InProgress | CalendarEventEntity | операции/каскады | calendar-event.md |
| INV-CAL-3 | Origin=Appointment меняется только каскадами записи (прямые операции агента отклоняются) | CalendarEventEntity | все операции изменения | calendar-event.md |
| INV-CAL-4 | Origin=CrmBooking меняется только консюмерами CRM (booking/deal-события) | CalendarEventEntity | все операции изменения | calendar-event.md |
| INV-CAL-5 | Kind-обязательности: BookingDeadline⇒BookingId+DealId; MeetingLink — только Consultation/Meeting | CalendarEventEntity | создание/изменение | calendar-event.md |
| INV-CAL-6 | Пересечение интервалов одного OwnerUserId — soft-conflict (HasConflicts=true, не блокирует) | CalendarEventEntity | выборки календаря | calendar-event.md |
| INV-CAL-7 | Терминальные Completed/Cancelled иммутабельны; Cancelled требует CancelReason | CalendarEventEntity | все операции изменения | calendar-event.md |
| INV-CAL-8 | Санкц. переназначение OwnerUserId открытых deal-событий консюмером deal-событий (смена исполнителя/уход агента), в том же TenantId; Origin=Appointment не переназначается | CalendarEventEntity | консюмер deal-событий | calendar-event.md |
| INV-SLT-1 | StartAt < EndAt; слот только batch-публикацией с StartAt ≥ now+MinLeadTime | AvailabilitySlotEntity | публикация батча | availability-slot.md |
| INV-SLT-2 | Слоты одного агента в Available/Booked не пересекаются (пересекающиеся кандидаты пропускаются) | AvailabilitySlotEntity | публикация батча | availability-slot.md |
| INV-SLT-3 | Занятие слота — условный переход Available→Booked под блокировкой, атомарно с записью и событием; побеждает один | AvailabilitySlotEntity | бронирование | availability-slot.md |
| INV-SLT-4 | Booked-слот управляется только каскадами записи (прямая архивация запрещена) | AvailabilitySlotEntity | archive/каскады | availability-slot.md |
| INV-SLT-5 | Бронирование пока now ≤ StartAt−MinLeadTime; Expired ставит worker/каскад; Archived терминален | AvailabilitySlotEntity | бронирование/worker | availability-slot.md |
| INV-APT-1 | ≤ 1 активной записи (Pending/Confirmed) на слот; создание атомарно со слотом и событием | AppointmentEntity | unique partial + INV-SLT-3 | appointment.md |
| INV-APT-2 | Антиспам: ≤ 1 активной на пару (Investor,Agent,Kind) и ≤ 3 активных на инвестора | AppointmentEntity | unique partial + подсчёт | appointment.md |
| INV-APT-3 | Отмена/перенос инвестором — пока now ≤ StartAt−CancellationCutoff; агент не связан | AppointmentEntity | cancel/reschedule | appointment.md |
| INV-APT-4 | Исход (Completed/NoShow) — только агент, из Confirmed, после StartAt | AppointmentEntity | complete-appointment | appointment.md |
| INV-APT-5 | Снапшот слота (время/Kind/ProjectId/PropertyObjectId) иммутабелен; «перенос» = отмена+новая запись со взаимными ссылками | AppointmentEntity | все операции; перенос | appointment.md |
| INV-APT-6 | Терминальные иммутабельны; каскады на слот/событие в одном изменении; CancelledBy обязателен при Cancelled; Expired — только worker | AppointmentEntity | все операции | appointment.md |
| INV-REM-1 | ≤ 1 Pending-строка на офсет; DueAt = StartAt−OffsetMinutes согласован (пересоздание при переносе) | EventReminderEntity | материализация/перенос | event-reminder.md |
| INV-REM-2 | Терминальные Sent/Skipped иммутабельны; Sent — только диспетчер однократно | EventReminderEntity | диспетчеризация/каскады | event-reminder.md |
wallet
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-WLT-1 | Форма холдера по виду счёта (XOR): холдерский — ровно один холдер, системный — ни одного | WalletAccountEntity | создание + db check | wallet-account.md |
| INV-WLT-2 | ≤ 1 счёт на (холдер,AccountKind); один системный счёт каждого вида на платформу; гонка lazy — по unique | WalletAccountEntity | lazy-создание + unique | wallet-account.md |
| INV-WLT-3 | После проведения баланс холдерских счетов >= 0; системные штатно отрицательны; дебет сверх баланса — детектор рассинхрона | WalletAccountEntity | проведение транзакции | wallet-account.md |
| INV-WLT-4 | Валюта проводки = валюте счёта; все счета RUB (ADR-0048) | WalletAccountEntity | проведение (VO-guard) | wallet-account.md |
| INV-WLT-5 | В транзакции ≥ 2 проводок и Σ дебетов = Σ кредитов, одна валюта | LedgerTransactionEntity | проведение (до применения) | ledger-transaction.md |
| INV-WLT-6 | Транзакции/проводки append-only; исправление — только Correction с причиной; двойная компенсация исключена unique | LedgerTransactionEntity | модель операций + unique | ledger-transaction.md |
| INV-WLT-7 | ≤ 1 транзакция на (OperationKind, SourceId); повтор — no-op (идемпотентность) | LedgerTransactionEntity | unique | ledger-transaction.md |
| INV-WLT-UDR-1 | Snapshot пользователя применяется только при user-upserted.Version > IdentityUserVersion | UserDisplayReplicaEntity | version-guard консюмера | user-display-replica.md |
| INV-WLT-UDR-2 | Snapshot названия компании — при company-upserted.Version > IdentityCompanyVersion и совпадении CompanyId | UserDisplayReplicaEntity | version-guard консюмера | user-display-replica.md |
| INV-WLT-UDR-3 | DisplayName — детерминированное схлопывание FullNameSnapshot; пустой snapshot → пустая строка | UserDisplayReplicaEntity | проекция в консюмере | user-display-replica.md |
| INV-WLT-UDR-4 | При смене CompanyId CompanyName берётся из известной строки того же агентства, иначе null до company-upserted | UserDisplayReplicaEntity | обработка user-upserted | user-display-replica.md |
| INV-WLT-UDR-5 | Реплика read-only (пишет только консюмер identity); отсутствие строки на read-path — не ошибка | UserDisplayReplicaEntity | нет write вне консюмера | user-display-replica.md |
rewards
Финансовый контур комиссий/выплат (ADR-0067/0068/0069). Часть агрегатов нумерует инварианты локально
(INV-N Name) — дом инварианта разрешает колонка «Документ».
| ID | Инвариант | Сущность | Где проверяется | Документ |
|---|---|---|---|---|
| INV-1 PercentRange | 0 < CommissionPercent ≤ 100 | RewardRuleEntity | вход + db check | reward-rule.md |
| INV-2 ConsistentRanges | Диапазоны комнат/площади согласованы; EffectiveFrom < EffectiveTo | RewardRuleEntity | вход + db check | reward-rule.md |
| INV-3 UniqueActivePriority | Priority уникален среди активных правил tenant-а (тай-брейк одной специфичности) | RewardRuleEntity | partial-unique + Activate | reward-rule.md |
| INV-4 ValidReferences | ProjectIds/LayoutGroupIds/LayoutIds — в реплике chessboard и того застройщика | RewardRuleEntity | Activate (сверка) | reward-rule.md |
| INV-5 ImmutableWhenActive | В Active — только сокращение EffectiveTo; прочее — в Draft | RewardRuleEntity | guard по Status | reward-rule.md |
| INV-6 ArchiveConsistency | Status = Archived ⇔ DeletedAt != null | RewardRuleEntity | операция архивирования | reward-rule.md |
| INV-7 TenantOwnership | Актор — менеджер TenantId c rewards.rules.manage | RewardRuleEntity | permission-policy | reward-rule.md |
| INV-8 SpecificityResolution | Подбор по специфичности оси каталога (Layout>LayoutGroup>Project>застройщик); Priority — тай-брейк внутри специфичности | RewardRuleEntity | алгоритм ResolveRule | reward-rule.md |
| INV-9 NoOverlapWithinSpecificity | Периоды активных правил одной сигнатуры специфичности не пересекаются | RewardRuleEntity | Activate под блокировкой | reward-rule.md |
| INV-PT-1 RetentionRange | 0.25 ≤ RetentionPercentagePoints ≤ 1.50 | PlatformCommissionTermsEntity | вход + db check | platform-commission-terms.md |
| INV-PT-2 NoOverlapWithinSpecificity | Периоды активных условий одной специфичности не пересекаются | PlatformCommissionTermsEntity | Activate под блокировкой | platform-commission-terms.md |
| INV-PT-3 ImmutableWhenActive | В Active — только сокращение EffectiveTo; измерения/Retention — в Draft | PlatformCommissionTermsEntity | guard по Status | platform-commission-terms.md |
| INV-PT-4 RetentionBelowRulePercent | Retention < CommissionPercent правила (пул агентской стороны > 0) | PlatformCommissionTermsEntity | создание начисления (резолюция) | platform-commission-terms.md |
| INV-PT-5 ObjectAxisTopDown | Ось объекта сверху вниз: ProjectId⇒DeveloperCompanyId, LayoutGroupId⇒ProjectId | PlatformCommissionTermsEntity | Create/Activate | platform-commission-terms.md |
| INV-RR-1 PercentRange | 0 < Percent ≤ 100 | ReferralRateRuleEntity | вход + db check | referral-rate-rule.md |
| INV-RR-2 NoOverlapWithinSpecificity | Периоды активных ставок одной комбинации измерений (вкл. null) не пересекаются | ReferralRateRuleEntity | Activate под блокировкой | referral-rate-rule.md |
| INV-RR-3 ImmutableWhenActive | В Active — только сокращение EffectiveTo; прочее — в Draft | ReferralRateRuleEntity | guard по Status | referral-rate-rule.md |
| INV-RR-4 ScopeHierarchyConsistent | Вложенность измерений (ProjectId⇒DeveloperCompanyId, LayoutGroupId⇒ProjectId); PartnerUserId ортогонален | ReferralRateRuleEntity | вход + db check | referral-rate-rule.md |
| INV-1 OneAccrualPerDeal | ≤ 1 начисление на DealId (идемпотентность consumer-а) | CommissionAccrualEntity | unique + проверка до создания | commission-accrual.md |
| INV-2 AmountFormula | CommissionAmount = база × CommissionPercent/100 (banker's, 2 знака) | CommissionAccrualEntity | Create/CreateManual | commission-accrual.md |
| INV-3 ConfirmGuard | Confirm из PendingConfirmation менеджером DeveloperCompanyId | CommissionAccrualEntity | метод + policy | commission-accrual.md |
| INV-4 DistributionOnConfirm | Три строки распределения строятся ровно при Confirm | CommissionAccrualEntity | метод Confirm | commission-accrual.md |
| INV-5 DistributionSum | Σ Distribution = CommissionAmount; Platform=fee, Agency+Agent=пул (FixedAmount>пул → доля агента 0) | CommissionAccrualEntity | метод Confirm | commission-accrual.md |
| INV-6 SingleCurrency | Все Money-поля начисления и строк — один CurrencyId | CommissionAccrualEntity | Create + VO-guard | commission-accrual.md |
| INV-7 PaidOnlyViaPayout | Paid только из MoneyReceived каскадом поручения (все Agent-строки выплачены) | CommissionAccrualEntity | MarkPaid (internal) | commission-accrual.md |
| INV-8 CancelGuard | Cancel из PendingConfirmation/Confirmed (строки свободны и не в акте); из MoneyReceived/Paid запрещён | CommissionAccrualEntity | метод Cancel | commission-accrual.md |
| INV-9 RecipientConsistency | Ровно 3 строки Platform/Agency/Agent; у Platform/Agency RecipientUserId=null | CommissionAccrualEntity | Confirm + db check | commission-accrual.md |
| INV-10 PlatformFeeFormula | PlatformFeeAmount = база × RetentionPP/100; политика агентства делит только пул | CommissionAccrualEntity | Create + пересчёт | commission-accrual.md |
| INV-11 AdjustmentGuards | Корректировка gross/распределения по ролям (застройщик/ПИН-админ/руководитель; агент — только заявка); каждая — append-only строка с Reason | CommissionAccrualEntity | операции корректировки + policy | commission-accrual.md |
| INV-12 AdjustmentRequestLifecycle | Заявка агента на долю: ≤ 1 Pending на начисление; одобрение руководителем исполняет Distribution; Approved/Rejected терминальны | CommissionAdjustmentRequestEntity | заявочные операции + policy | commission-accrual.md |
| INV-1 OnlyFreeSourceRows | Источник строк по Kind: AgencyCommission→Agent-строки MoneyReceived; ReferralReward→рефералки Approved; свободные (PayoutOrderItemId is null) | PayoutOrderEntity | сборка состава под блокировкой | payout-order.md |
| INV-2 OneItemPerRecipient | Один получатель — одна позиция | PayoutOrderEntity | unique + агрегация | payout-order.md |
| INV-3 SumConsistency | Item = Σ строк; TotalAmount = Σ Item; ItemsCount = count(Items) | PayoutOrderEntity | пересчёт при мутации состава | payout-order.md |
| INV-4 SingleCurrency | Единая CurrencyId позиций и TotalAmount | PayoutOrderEntity | сборка (VO-guard) | payout-order.md |
| INV-5 ComposeOnlyInDraft | Правка состава — только в Draft | PayoutOrderEntity | guard по Status | payout-order.md |
| INV-6 ApproveGuard | Approve из Draft при ItemsCount ≥ 1, rewards.payouts.approve | PayoutOrderEntity | метод + policy | payout-order.md |
| INV-7 MarkPaidCascade | MarkPaid каскадит PaidAt позициям/строкам и переводит начисления MoneyReceived/Approved → Paid | PayoutOrderEntity | переход MarkPaid | payout-order.md |
| INV-8 ReleaseOnTerminate | Reject/Cancel освобождают строки (PayoutOrderItemId → null); Reject требует причину | PayoutOrderEntity | переходы Reject/Cancel | payout-order.md |
| INV-9 TenantOwnership | Строки принадлежат TenantId; ReferralReward — tenant ПИН | PayoutOrderEntity | изоляция по tenant | payout-order.md |
| INV-10 KindHomogeneity | Все позиции/строки одного Kind (агентские и реферальные не смешиваются) | PayoutOrderEntity | сборка состава | payout-order.md |
| INV-RA-1 OneAccrualPerDeal | ≤ 1 реферальное начисление на DealId | ReferralAccrualEntity | unique + проверка до создания | referral-accrual.md |
| INV-RA-2 AmountFormula | Amount = FinalPrice × Percent/100 (banker's); валюта единая (на момент создания) | ReferralAccrualEntity | создание/корректировка | referral-accrual.md |
| INV-RA-3 ApprovedOnlyViaMoneyReceived | Pending→Approved только каскадом MoneyReceived родителя (прямого API нет) | ReferralAccrualEntity | регистрация акта (каскад) | referral-accrual.md |
| INV-RA-4 NoSelfReferral | ReferrerUserId ≠ AgentUserId сделки (иначе skip) | ReferralAccrualEntity | создание (consumer) | referral-accrual.md |
| INV-RA-5 TerminalDiscipline | Paid только каскадом реф-поручения; Cancel из Pending/свободного Approved; из Paid — нет | ReferralAccrualEntity | каскад выплаты + guard | referral-accrual.md |
| INV-RA-6 AmountAdjustable | Сумма корректируется first-class (причина+актор+append-only) пока свободно/не выплачено; из Approved — дельта проводкой Correction | ReferralAccrualEntity | корректировка суммы | referral-accrual.md |
| INV-RC-1 OnlyConfirmedSameDeveloper | Позиция — начисление Confirmed того же застройщика | PlatformReceiptEntity | включение позиции + Register | platform-receipt.md |
| INV-RC-2 SumConsistency | Σ Item = Amount акта; ItemsCount ≥ 1 при регистрации | PlatformReceiptEntity | операция Register | platform-receipt.md |
| INV-RC-3 ExclusiveMembership | Начисление — максимум в одном не-отменённом акте | PlatformReceiptEntity | unique при включении | platform-receipt.md |
| INV-RC-4 RegisteredImmutableAtomicCascade | Registered терминален/неизменяем; регистрация атомарна (позиции→MoneyReceived, рефералки→Approved, события) | PlatformReceiptEntity | Register + guard по Status | platform-receipt.md |
| INV-1 SingleActivePolicy | ≤ 1 Active политика на агентство; Activate архивирует прежнюю | AgencyCommissionPolicyEntity | partial-unique + Activate | agency-commission-policy.md |
| INV-2 ValidRuleValues | Значения правил в диапазонах; FixedAmount требует CurrencyId | AgencyCommissionPolicyEntity | проверка документа + resolve | agency-commission-policy.md |
| INV-3 UniqueOverrides | AgentUserId уникален в AgentOverrides, активный пользователь TenantId | AgencyCommissionPolicyEntity | документ + Activate | agency-commission-policy.md |
| INV-4 ImmutableWhenActive | Rules/Name в Active не меняются (правка — новый Draft) | AgencyCommissionPolicyEntity | guard по Status | agency-commission-policy.md |
| INV-5 ArchiveConsistency | Status = Archived ⇔ DeletedAt != null | AgencyCommissionPolicyEntity | операция архивирования | agency-commission-policy.md |
| INV-6 NoPolicyFallback | Без активной политики удержание агентства = 0, весь пул — агенту (удержание ПИН применяется независимо) | AgencyCommissionPolicyEntity | Confirm начисления | agency-commission-policy.md |
| INV-7 TenantOwnership | Актор — руководитель TenantId c rewards.commission-policy.manage | AgencyCommissionPolicyEntity | permission-policy | agency-commission-policy.md |
| INV-8 BoostRuleKind | BoostRule.Kind ∈ {AgentPercentOfPool,AgentPercentOfPrice}, 0 ≤ Value ≤ 100 | AgentBoostCampaign (owned) | проверка документа | agency-commission-policy.md |
| INV-9 NoBoostOverlapPerAgent | По каждому агенту периоды активных boost-кампаний не пересекаются | AgentBoostCampaign (owned) | Activate под блокировкой | agency-commission-policy.md |
| INV-10 BoostImmutableWhenActive | В Active — только сокращение EffectiveTo; прочее и набор агентов — в Draft | AgentBoostCampaign (owned) | guard по Status | agency-commission-policy.md |
| INV-11 BoostTargetsValid | AgentUserIds непуст, ≤ 1000, без повторов, активные пользователи TenantId | AgentBoostCampaign (owned) | документ + Activate | agency-commission-policy.md |
| INV-12 BoostPeriodValid | EffectiveTo is null или EffectiveTo > EffectiveFrom | AgentBoostCampaign (owned) | вход + db check | agency-commission-policy.md |
| INV-AP1 | Счётчики (FixationsApprovedCount/DealsWonCount/DealsLostCount) и суммы — >= 0 | AgentPerformanceEntity | консюмеры + check | agent-performance.md |
| INV-AP2 | CommissionPaidTotal ≤ CommissionAccruedTotal | AgentPerformanceEntity | консюмеры + check | agent-performance.md |
| INV-AP3 | ≤ 1 строка на пару (AgentUserId, CompanyId) (композитный PK) | AgentPerformanceEntity | PK + upsert | agent-performance.md |
| INV-AP4 | Left ⇒ LeftAt/LeaveReason/EffectiveAt заполнены; Active ⇒ они null | AgentPerformanceEntity | консюмер membership | agent-performance.md |
| INV-AP5 | У агента допустимо несколько Active-строк (по одной на агентство; INV-AM1 снят, ADR-0070) | AgentPerformanceEntity | модель мульти-членства | agent-performance.md |
| INV-AP6 | Display-статус InAgency ⇔ ≥ 1 Active-строка, иначе Independent (на чтении) | AgentPerformanceEntity | проекция чтения | agent-performance.md |
| INV-AP7 | Перевод строки в Left не меняет её earned-показатели | AgentPerformanceEntity | консюмер membership | agent-performance.md |
Обратные ссылки
Автоссылки: где используется этот документ.
- index.md (1): Документация PIN
- registries (1): Реестры PIN (registries)