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

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.

Metadata

ПолеЗначение
Сервис/контекстreferral
API areaadmin
Feature groupreferral-bond-corrections
Статусdraft
Документdocs/06-services/referral/api/admin/revoke-bond.md

Актор и доступ

ПроверкаЗначение
Actor typeOperator(Admin) (администратор платформы)
Auth policy / Permissionsreferral.registrations.view-all (привилегированный; каталог — permission.md)
Scope (RBAC)платформа (кросс-tenant: администратор отзывает связь любого реферера/инвестора)
Auditupdate (привилегированный): actor-stamping (UpdatedByUserId = AdminUserId) + событие ReferralBondRevokedEvent + ReferralBondClosedKafkaEvent; revokeReason пишется в access-audit

Тело/route — привилегированный ввод; всё равно валидируется (формат + проверки существования/состояния) до изменений.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/admin/referrals/bonds/{bondId}/revoke
Success status200 (тело AdminRevokeBondCommandResult); идемпотентный повтор → тот же 200 с сохранённым телом
Idempotency headerIdempotency-Key (обязателен; привилегированная мутация не должна повторяться при retry сети)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
BondIdrouteGuidДанепустой; связь существует и Status = Active
RevokeReasonbodystringДанепустой после trim, varchar(512) (INV-REF-B5)свободный текст оператора; политика без PII; в логах — обрезка 64 симв.
IdempotencyKeyheader Idempotency-KeystringДа8..128; ключ повтора привилегированной мутации
AdminUserIdidentity claims (sub)GuidДаRequired; id администратора для actor-stamping; валидный токен + permissionиз body не принимается; 401/403

Модель ответа AdminRevokeBondCommandResult

ПолеТипОбязательностьИсточникMaskingОписание
BondIdGuidДаReferralBondEntity.IdИдентификатор отозванной связи (подтверждение).
Statusstring (enum ReferralBondStatus)ДаReferralBondEntity.StatusИтоговый статус — всегда Revoked (терминальный, INV-REF-B6).

Связь наружу целиком не возвращается (проекция минимальна — подтверждение перехода).

Алгоритм

  1. Контекст и доступ. Оператор — администратор платформы; требуется permission referral.registrations.view-all (иначе 403). Идемпотентность по Idempotency-Key: при уже завершённой операции возвращается сохранённый результат (200); при незавершённой или ином payload с тем же ключом → IDEMPOTENCY_CONFLICT (409).
  2. Проверка входа. BondId непустой; RevokeReason непустой после trim и Length <= 512 (INV-REF-B5); IdempotencyKey 8..128; иначе ValidationError.
  3. Проверка сущности и состояния. Связь по BondId существует; иначе REFERRAL_BOND_NOT_FOUND (404). Её статус должен быть Active — иначе (Replaced/Revoked терминальны, INV-REF-B6) → REFERRAL_BOND_INVALID_TRANSITION. Параллельная коррекция той же связи (гонка с rebind/повторным revoke) сериализуется; конфликт → REFERRAL_BOND_CONCURRENCY_CONFLICT.
  4. Переход. Связь переводится Active → Revoked: RevokeReason (обязателен при Revoked, INV-REF-B5), UpdatedByUserId = AdminUserId. Immutable-ядро (ReferredUserId/ReferrerUserId/ReferralLinkId/BoundAt/AppliedRule/TenantId) не меняется (INV-REF-B3); ReplacedByBondId остаётся null (это отзыв, не замена). Это статусный переход, не soft-delete: история связи сохраняется для разбора выплат. Переход освобождает «единственную активную» для ReferredUserId — последующая привязка того же инвестора вновь допустима.
  5. События. В том же согласованном изменении фиксируется 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.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияReferralBondClosedKafkaEventpin.referral.bonds.v1 (ключ ReferredUserId, Status = Revoked), атомарно с изменением
Уведомленияопосредованно: rewards — потребитель pin.referral.bonds.v1 останавливает начисления по BondId; отдельного уведомления инвестору referral не шлёт

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
UNAUTHORIZED401нет/просрочен токен«Требуется авторизация»retry после повторной авторизации
FORBIDDEN403нет permission referral.registrations.view-all«Недостаточно прав»no retry
ValidationError400пустой revokeReason / revokeReason > 512 / bondId пуст / Idempotency-Key вне 8..128 (INV-REF-B5)перечень полейисправить
REFERRAL_BOND_NOT_FOUND404связь по bondId не существует«Связь не найдена»no retry
REFERRAL_BOND_INVALID_TRANSITION409Status != Active (Replaced/Revoked терминальны, INV-REF-B6)«Связь уже закрыта, отзыв невозможен»no retry
REFERRAL_BOND_CONCURRENCY_CONFLICT409конкурентная коррекция (гонка с rebind/повторным revoke)«Связь изменена параллельно, повторите»reload + retry
IDEMPOTENCY_CONFLICT409тот же 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 (будущий).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • Агрегат (поля, INV-REF-B3/B5/B6, метод Revoke, статусы): referral-bond.md; правило коррекций (T4/R6) и матрица: attach-rules.md.
  • Соседняя коррекция: rebind-bond.md (Replace — смена реферера с созданием связи-замены); admin-выдача связей: get-investor-referrals.md.
  • Событие: bond-closed.md (ReferralBondClosedKafkaEvent, остановка начислений rewards).
  • Реестр и конверт: README.md; permissions: permission.md.