POST /api/admin/referrals/bonds/{bondId}/revoke — Отзыв реферальной связи (adminRevokeBond)
Назначение
Администратор платформы отзывает действующую реферальную связь
(ReferralBondEntity) при фроде, ошибке привязки или претензии: связь
переходит Active → Revoked, фиксируется причина (RevokeReason), и downstream-контекст rewards
прекращает начисления вознаграждения по BondId. В отличие от rebind-bond.md
(Replace — смена реферера с созданием связи-замены) отзыв закрывает связь без замены: реферера у
приглашённого больше нет. Терминальная операция (INV-REF-B6): повторный отзыв или отзыв уже
Replaced/Revoked связи запрещён. Общий конверт и реестр admin-операций — README.md;
правило коррекций (R6/T4) — attach-rules.md.
| Поле | Значение |
|---|
| Сервис/контекст | referral |
| API area | admin |
| Feature group | referral-bond-corrections |
| Статус | draft |
| Документ | docs/06-services/referral/api/admin/revoke-bond.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Operator(Admin) (администратор платформы) |
| Auth policy / Permissions | referral.registrations.view-all (привилегированный; каталог — permission.md) |
| Scope (RBAC) | платформа (кросс-tenant: администратор отзывает связь любого реферера/инвестора) |
| Audit | update (привилегированный): actor-stamping (UpdatedByUserId = AdminUserId) + событие ReferralBondRevokedEvent + ReferralBondClosedKafkaEvent; revokeReason пишется в access-audit |
Тело/route — привилегированный ввод; всё равно валидируется (формат + проверки существования/состояния)
до изменений.
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/admin/referrals/bonds/{bondId}/revoke |
| Success status | 200 (тело AdminRevokeBondCommandResult); идемпотентный повтор → тот же 200 с сохранённым телом |
| Idempotency header | Idempotency-Key (обязателен; привилегированная мутация не должна повторяться при retry сети) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
BondId | route | Guid | Да | непустой; связь существует и Status = Active | — |
RevokeReason | body | string | Да | непустой после trim, varchar(512) (INV-REF-B5) | свободный текст оператора; политика без PII; в логах — обрезка 64 симв. |
IdempotencyKey | header Idempotency-Key | string | Да | 8..128; ключ повтора привилегированной мутации | — |
AdminUserId | identity claims (sub) | Guid | Да | Required; id администратора для actor-stamping; валидный токен + permission | из body не принимается; 401/403 |
Модель ответа AdminRevokeBondCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
BondId | Guid | Да | ReferralBondEntity.Id | — | Идентификатор отозванной связи (подтверждение). |
Status | string (enum ReferralBondStatus) | Да | ReferralBondEntity.Status | — | Итоговый статус — всегда Revoked (терминальный, INV-REF-B6). |
Связь наружу целиком не возвращается (проекция минимальна — подтверждение перехода).
Алгоритм
- Контекст и доступ. Оператор — администратор платформы; требуется permission
referral.registrations.view-all (иначе 403). Идемпотентность по Idempotency-Key: при уже
завершённой операции возвращается сохранённый результат (200); при незавершённой или ином payload
с тем же ключом → IDEMPOTENCY_CONFLICT (409).
- Проверка входа.
BondId непустой; RevokeReason непустой после trim и Length <= 512
(INV-REF-B5); IdempotencyKey 8..128; иначе ValidationError.
- Проверка сущности и состояния. Связь по
BondId существует; иначе REFERRAL_BOND_NOT_FOUND
(404). Её статус должен быть Active — иначе (Replaced/Revoked терминальны, INV-REF-B6) →
REFERRAL_BOND_INVALID_TRANSITION. Параллельная коррекция той же связи (гонка с rebind/повторным
revoke) сериализуется; конфликт → REFERRAL_BOND_CONCURRENCY_CONFLICT.
- Переход. Связь переводится
Active → Revoked: RevokeReason (обязателен при Revoked,
INV-REF-B5), UpdatedByUserId = AdminUserId. Immutable-ядро
(ReferredUserId/ReferrerUserId/ReferralLinkId/BoundAt/AppliedRule/TenantId) не меняется
(INV-REF-B3); ReplacedByBondId остаётся null (это отзыв, не замена). Это статусный переход, не
soft-delete: история связи сохраняется для разбора выплат. Переход освобождает «единственную
активную» для ReferredUserId — последующая привязка того же инвестора вновь допустима.
- События. В том же согласованном изменении фиксируется
ReferralBondRevokedEvent (доменное) и
публикуется ReferralBondClosedKafkaEvent — топик pin.referral.bonds.v1 (ContractVersion = 1),
ключ ReferredUserId, payload { BondId, ReferredUserId, Status = "Revoked", ReplacedByBondId = null, RevokeReason (без PII), Version } (контракт
bond-closed.md). Лид-события нет (отзыв лид не запрашивает). Итог —
связь Revoked, без активной связи на ReferredUserId (INV-REF-001). Возвращаются BondId и
Status = Revoked. Downstream rewards по событию closed останавливает начисления по BondId.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | ReferralBondClosedKafkaEvent → pin.referral.bonds.v1 (ключ ReferredUserId, Status = Revoked), атомарно с изменением |
| Уведомления | опосредованно: rewards — потребитель pin.referral.bonds.v1 останавливает начисления по BondId; отдельного уведомления инвестору referral не шлёт |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
UNAUTHORIZED | 401 | нет/просрочен токен | «Требуется авторизация» | retry после повторной авторизации |
FORBIDDEN | 403 | нет permission referral.registrations.view-all | «Недостаточно прав» | no retry |
ValidationError | 400 | пустой revokeReason / revokeReason > 512 / bondId пуст / Idempotency-Key вне 8..128 (INV-REF-B5) | перечень полей | исправить |
REFERRAL_BOND_NOT_FOUND | 404 | связь по bondId не существует | «Связь не найдена» | no retry |
REFERRAL_BOND_INVALID_TRANSITION | 409 | Status != Active (Replaced/Revoked терминальны, INV-REF-B6) | «Связь уже закрыта, отзыв невозможен» | no retry |
REFERRAL_BOND_CONCURRENCY_CONFLICT | 409 | конкурентная коррекция (гонка с rebind/повторным revoke) | «Связь изменена параллельно, повторите» | reload + retry |
IDEMPOTENCY_CONFLICT | 409 | тот же Idempotency-Key, иной payload / операция в процессе | «Конфликт ключа идемпотентности» | no retry |
Предусловия и постусловия
Предусловия: связь по BondId существует и Status = Active; RevokeReason непустой (INV-REF-B5).
Отзыв — терминальный, без замены (в отличие от rebind): реферера у приглашённого больше не будет.
Постусловия (при успехе):
- связь
Active → Revoked c заполненным RevokeReason; immutable-ядро
(ReferredUserId/ReferrerUserId/ReferralLinkId/BoundAt/AppliedRule/TenantId) не изменено
(INV-REF-B3), ReplacedByBondId остался null, Version инкрементирован; история связи сохранена
(не soft-delete);
- на
ReferredUserId больше нет активной связи (INV-REF-001) — последующая привязка того же инвестора
вновь допустима;
- в
pin.referral.bonds.v1 (ключ ReferredUserId) ушёл ReferralBondClosedKafkaEvent
(Status = Revoked, ReplacedByBondId = null, RevokeReason без PII); rewards останавливает
начисления по BondId (eventual consistency, окно — лаг доставки);
- промежуточное состояние: переход синхронен — сразу после коммита
getMyReferrer приглашённого
возвращает «реферера нет» (hasReferrer = false), тогда как остановка начислений в rewards — в окне
доставки; отзыв освобождает слот «единственной активной», поэтому конкурентный/последующий
автоматический триггер привязки того же инвестора по другой ссылке может законно создать новую
Active-связь (это не конфликт — в отличие от rebind, отзыв замену не создаёт);
revokeReason записан в access-audit; результат сохранён под Idempotency-Key.
Acceptance Criteria
- Given активная связь и непустой
RevokeReason, When админ вызывает revoke со свежим
Idempotency-Key, Then 200 {bondId, status = Revoked}; связь Revoked, ReplacedByBondId = null,
событие ReferralBondClosedKafkaEvent (Status = Revoked) по ключу ReferredUserId.
- Given повтор с тем же
Idempotency-Key и payload, When retry сети, Then тот же 200 с
сохранённым телом; повторного перехода/события нет.
- Given тот же
Idempotency-Key, иной payload / операция в процессе, When запрос, Then 409 IDEMPOTENCY_CONFLICT.
- Given связь уже
Replaced/Revoked, When revoke, Then 409 REFERRAL_BOND_INVALID_TRANSITION (терминальность INV-REF-B6); связь не найдена → 404 REFERRAL_BOND_NOT_FOUND.
- Given параллельная коррекция той же связи (гонка с rebind/повторным revoke), When revoke,
Then проигравший получает
409 REFERRAL_BOND_CONCURRENCY_CONFLICT (reload + retry).
- Given пустой
RevokeReason / длина > 512 / пустой bondId / Idempotency-Key вне 8..128,
When запрос, Then 400 ValidationError; без permission — 403; без токена — 401.
Совместимость и наблюдаемость
- Новые additive-поля результата — без breaking change; удаление/переименование/смена типа — breaking
(migration + rollout admin-клиента).
- Payload
ReferralBondClosedKafkaEvent — совместимость по bond-closed.md
(общий топик с ReferralBondCreatedKafkaEvent, порядок per-ReferredUserId по ключу + Version-guard).
- Logs:
admin bond revoked (BondId, AdminUserId, AppliedRule исходной связи — без PII;
revokeReason как internal-текст с обрезкой); metrics: referral_admin_revoke_total{result},
referral_bonds_revoked_total; traces — сквозной trace через traceparent; dashboard —
referral-overview (README.md, Runtime); runbook —
runbooks/referral-bond-corrections.md (будущий).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы