Entity / Aggregate: ReferralBondEntity
Дом полей агрегата «Реферальная связь» контекста referral. Все проекции (модели
GetMyReferrerQueryResult, AdminReferredInvestorEntry, payload ReferralBondCreatedKafkaEvent,
будущие начисления в rewards) ссылаются сюда и описывают только дельту.
Назначение
ReferralBondEntity — действующее отношение «кто привёл кого, когда и через какую ссылку».
Создаётся правилами привязки (attach-rules.md) и является основанием будущей
выплаты вознаграждения за реферала: контекст rewards потребляет событие создания связи и на её
Id ссылаются будущие начисления. Сами правила вознаграждений, начисления и выплаты — вне этого
контекста: docs/06-services/rewards/README.md; referral владеет только фактом связи.
У пользователя — не более одной активной связи (INV-REF-001): getMyReferrer возвращает
единственного реферера (hasReferrer, referrerInvestorId, referralLinkId, boundAt), admin
getInvestorReferrals — список приглашённых реферером (AdminReferredInvestorEntry.boundAt).
Отличие от журнала регистраций: журнал — иммутабельная история всех
срабатываний, связь — текущее состояние с жизненным циклом (Active → Replaced/Revoked
admin-коррекциями).
Классификация
| Поле | Значение |
|---|---|
| Контекст | referral |
| Kind | AggregateRoot |
| Source of truth | referral |
| Таблица | referral.referral_bonds |
| Tenant isolation | TenantId = CompanyId реферера на момент привязки (ADR-0052); приглашённый инвестор — record-level по ReferredUserId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор связи; на него ссылаются начисления rewards. |
ReferredUserId | Guid | Да | partial unique ux_referral_bonds_referred_active (referred_user_id) where status = 'Active'; index ix_referral_bonds_referred_user_id; логическая ссылка на identity.users(id) | триггер-событие (UserId) | Приглашённый пользователь («кого привели»). Не более одной активной связи — INV-REF-001. |
ReferrerUserId | Guid | Да | index ix_referral_bonds_referrer_user_id; логическая ссылка на identity.users(id) | ReferralLinkEntity.OwnerUserId на момент привязки | Реферер («кто привёл»). Проекция referrerInvestorId в GetMyReferrerQueryResult. |
ReferralLinkId | Guid | Да | FK fk_referral_bonds_link → referral.referral_links(id), index ix_referral_bonds_referral_link_id | резолв slug (ReferrerUrl) → Id | Ссылка, через которую произошла привязка (проекция referralLinkId). |
RegistrationId | Guid? | Нет | FK fk_referral_bonds_registration → referral.referral_registrations(id), unique where not null | app | Запись журнала-основание (referral-registration.md). null только у admin-коррекций (Rebind), созданных без журнальной регистрации. |
TenantId | Guid? | Нет | index ix_referral_bonds_tenant_id where not null | копия ReferralLinkEntity.TenantId | CompanyId реферера; null — инвестор (ADR-0052). Фиксируется на момент привязки (выплата принадлежит историческому контексту). |
Status | ReferralBondStatus | Да | хранится как varchar(16), index ix_referral_bonds_status | app | Жизненный цикл ниже. |
BoundAt | DateTimeOffset | Да | index ix_referral_bonds_bound_at | время триггер-события | Момент привязки (проекция boundAt в GetMyReferrerQueryResult/AdminReferredInvestorEntry). |
AppliedRule | ReferralAttachRule | Да | хранится как varchar(16) | app (правила привязки) | Правило, создавшее связь: R1 (регистрация) или R2 (авторизация в окне lazy-регистрации); R6 — admin-коррекция. Дом enum — attach-rules.md. |
ReplacedByBondId | Guid? | Нет | FK self fk_referral_bonds_replaced_by → referral.referral_bonds(id) | app (admin rebind) | Ссылка на связь-замену при Status = Replaced (цепочка admin-коррекций). |
RevokeReason | string? | Нет | varchar(512); обязателен при Status = Revoked (check ck_referral_bonds_revoke_reason) | app (admin) | Причина отзыва (фрод, ошибка привязки, претензия). |
CreatedAt | DateTimeOffset | Да | — | app | Технический момент записи (= BoundAt c точностью до лага consumer-а). |
CreatedByUserId | Guid? | Нет | — | app | null — создана процессом привязки (system); id администратора — admin-коррекция. |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший (admin-коррекции). |
Version | long | Да | >= 1 | app | Монотонная доменная версия связи — guard для потребителей событий (rewards), инкремент на мутации. |
Enum ReferralBondStatus
| Значение | Описание |
|---|---|
Active | Действующая связь: учитывается в getMyReferrer, партнёрской статистике и будущих начислениях rewards. |
Replaced | Заменена admin-коррекцией (Rebind): ReplacedByBondId указывает на новую связь. Новые начисления по заменённой связи не создаются с момента замены; уже созданные сохраняются (referral-bond.md consumer rewards). |
Revoked | Отозвана администратором (RevokeReason обязателен). Новые начисления прекращаются; уже созданные сохраняются (отзыв по фроду — ручная отмена оператором ПИН, referral-accrual.md). |
Value objects
VO не используются: все поля скалярные. Отображаемые данные реферера в GetMyReferrerQueryResult
(referrerFirstName) и приглашённого в AdminReferredInvestorEntry (name, phoneNumber, email)
— join к реплике ReferralUserReplica (attach-rules.md) в момент запроса, не
колонки связи.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
ReferralLinkEntity | reference | ReferralLinkId (FK) | restrict |
ReferralRegistrationEntity | reference | RegistrationId (FK, nullable) | restrict |
ReferralBondEntity (замена) | reference self | ReplacedByBondId (FK) | restrict |
ReferralUserReplica (реферер/приглашённый) | replica | ReferrerUserId / ReferredUserId | — |
Начисления rewards | внешняя (по событию) | rewards.* → BondId (логическая) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-REF-001 SingleActiveBond | На ReferredUserId — не более одной связи Status = Active; автоматическая перепривязка запрещена (сработавшее правило при существующей активной связи связь не создаёт) | partial unique ux_referral_bonds_referred_active + проверка активной связи в процессе привязки | привязка → идемпотентный no-op; admin-создание второй активной → REFERRAL_BOND_ALREADY_EXISTS |
| INV-REF-B2 NoSelfBond | ReferredUserId != ReferrerUserId | создание связи | REFERRAL_BOND_SELF_FORBIDDEN |
| INV-REF-B3 ImmutableCore | ReferredUserId, ReferrerUserId, ReferralLinkId, BoundAt, AppliedRule, TenantId иммутабельны после создания (основание выплат не редактируется — только замена/отзыв целиком) | admin-коррекция = новый агрегат + замена старого | — |
| INV-REF-B4 ReplaceConsistency | Переход в Replaced допустим только с одновременным созданием связи-замены и заполнением ReplacedByBondId (в одном согласованном изменении) | операция замены | REFERRAL_BOND_INVALID_TRANSITION |
| INV-REF-B5 RevokeReasonRequired | Status = Revoked ⇒ RevokeReason непустой | check constraint + операция отзыва | ValidationError |
| INV-REF-B6 TerminalStates | Replaced и Revoked — терминальные: обратных переходов нет (восстановление = новая связь admin-коррекцией) | guard переходов | REFERRAL_BOND_INVALID_TRANSITION |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
ReferralBondCreatedEvent | доменное | [*] → Active | Id, ReferredUserId, ReferrerUserId, ReferralLinkId, AppliedRule |
ReferralBondReplacedEvent | доменное | Active → Replaced | Id, ReplacedByBondId, actor |
ReferralBondRevokedEvent | доменное | Active → Revoked | Id, RevokeReason, actor |
ReferralBondCreatedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.referral.bonds.v1, key = ReferredUserId) | [*] → Active | BondId, ReferredUserId, ReferrerUserId, ReferrerCompanyId (= TenantId), ReferralLinkId, Channel (из ссылки), BoundAt, AppliedRule, Version |
ReferralBondClosedKafkaEvent | интеграционное (тот же topic pin.referral.bonds.v1, key = ReferredUserId) | Active → Replaced и Active → Revoked | BondId, ReferredUserId, Status (Replaced/Revoked), ReplacedByBondId?, RevokeReason? (без PII), Version |
Потребители pin.referral.bonds.v1: rewards (реплика связи — основание будущих начислений за
реферала; правила/выплаты — docs/06-services/rewards/README.md), notifications (СМС/уведомление
рефереру «по вашей ссылке зарегистрировался пользователь»), messenger (контакт «агент ↔ инвестор»
по потоку «Реферальная привязка» из
service-boundaries.md). Детальный контракт —
bond-created.md. Интеграционные события публикуются в одном согласованном
изменении со сменой состояния агрегата. Событие о лиде — отдельный контракт
ReferralLeadAttachRequestedKafkaEvent (attach-rules.md): лид может требоваться и
без создания связи (правила R3/R4).
Гарантии для потребителей pin.referral.bonds.v1. ReferralBondCreatedKafkaEvent и
ReferralBondClosedKafkaEvent идут в один топик с одним ключом ReferredUserId — поэтому для
одного приглашённого потребитель видит события в порядке смены состояний (Created раньше любого
последующего Closed). Доставка at-least-once: потребитель обязан быть идемпотентным и применять
монотонный Version-guard (устаревшее/повторное событие с Version <= уже применённого —
отбрасывается). Channel/ReferrerCompanyId — денормализованный снапшот на момент публикации
(последующая правка/переименование ссылки эти поля в уже отправленном событии не меняет; выплата
принадлежит историческому контексту, INV-REF-B3).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK (id); ux_referral_bonds_referred_active unique btree (referred_user_id) where status = 'Active' (INV-REF-001); ux_referral_bonds_registration unique btree (registration_id) where not null; ix_referral_bonds_referred_user_id; ix_referral_bonds_referrer_user_id (список приглашённых getInvestorReferrals + партнёрская статистика); ix_referral_bonds_referral_link_id; ix_referral_bonds_status; ix_referral_bonds_bound_at; ix_referral_bonds_tenant_id where not null |
| Партиционирование / retention | none; связи — бессрочно (основание выплат: Replaced/Revoked сохраняются для разбора) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую) + монотонная Version; гонка двух одновременных привязок одного пользователя разрешается partial unique-индексом (проигравшая → идемпотентный no-op) |
| Permissions / PII | Связь PII не содержит (id + даты). getMyReferrer (свой реферер) — referral.links.view-self; список своих приглашённых (обезличенный) — referral.registrations.view-self; admin-выдачи с ФИО/телефоном (join реплики) и коррекции Replace/Revoke — referral.registrations.view-all через админ-панель (ADR-0053), телефоны в логах маскируются +7***1234 |
| Audit | Version + actor-stamping; каждая admin-коррекция — доменное событие (Replaced/Revoked) + ReferralBondClosedKafkaEvent; журнальная запись Rebind в referral-registration.md |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (5): Правила реферальной привязки (attach-rules) — дом доменных правил, Entity / Aggregate: ReferralAccrualEntity, Entity / Aggregate: ReferralLinkEntity, Entity / Aggregate: ReferralRateRuleEntity, Entity / Aggregate: ReferralRegistrationEntity
- API (9): GET /api/admin/referrals/investors/{investorId}/referrals — Список приглашённых реферером (getInvestorReferrals, admin), GET /api/investors/me/referrer — свой реферер пользователя, GET /api/v1/my/referral-accruals — Мои реферальные начисления (партнёр), GET /api/v1/partner/stats — Партнёрская сводка по каналам, GET /api/v1/referral-links/{referralLinkId}/stats — Статистика одной реферальной ссылки, POST /api/admin/referrals/bonds/{bondId}/rebind — коррекция связи (R6, Replace), POST /api/admin/referrals/bonds/{bondId}/revoke — Отзыв реферальной связи (adminRevokeBond), POST /api/v1/platform/referral-rate-rules — Создание ставки реферального вознаграждения (черновик), Referral API — реестр операций и общие контрактные модели
- Use Cases (6): UC-REF-003. Регистрация по реферальной ссылке → связь + лид (правило R1), UC-REF-004. Авторизация со ссылкой в окне lazy-регистрации → связь + лид (правило R2), UC-REF-006. Ссылка от инвестора (не агента/застройщика) → связь без лида (модификатор R5), UC-RWD-008. Платформа настраивает ставки реферального вознаграждения, UC-RWD-009. Жизненный цикл реферального начисления (связь → начисление → гейт → выплата), US-реестр: Реферальный партнёр (US-PRT)
- События (4): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested, Event Contract: pin.referral.bond-closed (ReferralBondClosedKafkaEvent), Event Contract: pin.referral.bond-created (ReferralBondCreatedKafkaEvent), Event Contract: pin.rewards.referral-accrual-created (ReferralAccrualCreatedKafkaEvent)
- Консюмеры (3): Consumer: Referral Bond Sync (pin-rewards-referral-bonds), Consumer: ReferralUserSignedInConsumer (pin.identity.sign-ins.v1), Consumer: ReferralUserUpsertedConsumer (pin.identity.users.v1)
- adr (1): ADR-0066 — удержание ПИН и реферальные ставки
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Referral Service
Связанные документы
- Правила создания (когда связь появляется, а когда нет) — attach-rules.md; журнал-основание — referral-registration.md; ссылка — referral-link.md.
- Выплаты вознаграждений за реферала — контекст
rewards:docs/06-services/rewards/README.md(структуры начислений ссылаются наBondId). - Проекции:
getMyReferrer(GetMyReferrerQueryResult), admingetInvestorReferrals(AdminReferredInvestorEntry) — реестр prototype-mapping.md. - ADR-0052 (tenancy), ADR-0053 (admin-коррекции).