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

Consumer: Rewards Referral Accrual Status Changed

Назначение

Consumer слушает статусные события реферальных начислений (топик pin.rewards.referral-accruals.v1) и ведёт баланс счёта ReferralCommission партнёра: переход начисления в Approved (родительское комиссионное начисление прошло гейт получения денег, ADR-0067) кредитует счёт транзакцией ReferralCredit; отмена начисления после Approved компенсирует ранее проведённый кредит транзакцией Correction. Дебет при выплате — не здесь: его проводит consumer поручений (rewards-payout-order-status-changed.md).

Owning-сервис и триггер

ПолеЗначение
Сервис/контекстwallet
Обрабатываемое событиеpin.rewards.referral-accrual-status-changed (ReferralAccrualStatusChangedKafkaEvent); соседний тип топика ReferralAccrualCreatedKafkaEvent — пропускается (ack, no-op)
Topicpin.rewards.referral-accruals.v1 (key = id начисления — строгий порядок переходов per-начисление)
ContractVersion1; при несовпадении версии — warn-and-proceed
Consumer grouppin-wallet-rewards-referral-accruals
Producerrewards: авто-апрув при регистрации акта (../../rewards/domain/platform-receipt.md), отмена начисления (../../rewards/domain/referral-accrual.md)
Эффект (событие → что меняется)транзакции ReferralCredit / Correction в журнале (../domain/ledger-transaction.md); баланс счёта ReferralCommission партнёра

Контекст события

  • Из метаданных доступны: идентификатор сообщения (дедупликация), AggregateId = id начисления, время события; корреляция трейса — из метаданных.
  • Обработка — под системным актором вне tenant-контекста (реферальные начисления — платформенные).
  • Ключи бизнес-идемпотентности: кредит — (ReferralCredit, SourceId = Id начисления); компенсация — (Correction, SourceId = Id исходной ReferralCredit-транзакции).

Входные данные / payload события

Контракт — ../../rewards/events/referral-accrual-status-changed.md. Ниже — поля, которые consumer читает.

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаId реферального начисления → SourceId кредита; поиск исходной транзакции при отмене; = Kafka key
PartnerUserIdGuidДаХолдер счёта ReferralCommission
FromStatus / ToStatusstring (enum ReferralAccrualStatus)ДаВыбор эффекта (таблица ниже)
AmountMoney (Value + CurrencyId + CurrencySnapshot)ДаСумма кредита; при компенсации сумма берётся из исходной транзакции (зеркально)
CancelReasonstring? (enum ReferralCancelReason)Да при CancelledПричина — фиксируется в Correction-транзакции (аудит)
ApprovedAt / PaidAtDateTimeOffset?НетApprovedAtOccurredAt кредита

Маппинг «переход → эффект»

ToStatusЭффектПроводки
ApprovedТранзакция ReferralCredit (SourceId = Id начисления)Дебет PlatformSettlement → кредит ReferralCommission партнёра на Amount
Cancelled, кредит был проведён (отмена после Approved)Компенсирующая транзакция Correction (SourceId = CorrectedTransactionId = id исходной ReferralCredit-транзакции; причина CancelReason — в Comment)Зеркально исходной: дебет ReferralCommission партнёра → кредит PlatformSettlement на сумму исходной транзакции
Cancelled, кредита не было (отмена из Pending)No-op (ack) — деньги в кошелёк не попадали
PaidNo-op (ack) — дебет проводится consumer-ом поручений по позиции (PayoutDebit), не статусным событием
неизвестное значениеDLQ + алерт (WALLET_UNKNOWN_ENUM): переход с возможным денежным эффектом не распознан — молча не ack-аем, чтобы проводка не потерялась

Компенсация при отмене после Approved штатно не встречает недостатка баланса: отмена в rewards допустима только пока начисление не включено в поручение (../../rewards/domain/referral-accrual.md, INV-RA-5), то есть выплата не проводилась. Если баланс всё же меньше суммы компенсации (INV-WLT-3, WALLET_BALANCE_NEGATIVE_FORBIDDEN) — это детектор рассинхрона: не проводить, DLQ + алерт (ADR-0021).

Алгоритм

  1. Контекст события. Дедупликация по идентификатору сообщения; ContractVersion — warn-and-proceed; событие типа ReferralAccrualCreatedKafkaEvent → ack, no-op.
  2. Выбор эффекта. По ToStatus — таблица маппинга; Approved → шаг 3, Cancelled → шаг 4, Paid и прочие известные без эффекта → ack, no-op. Неизвестное значение ToStatus (не входит в enum ReferralAccrualStatus) → non-retriable, DLQ + алерт (WALLET_UNKNOWN_ENUM): переход с потенциальным денежным эффектом не распознан, молчаливый ack запрещён (проводка не должна теряться).
  3. Кредит (Approved). Идемпотентность: транзакция (ReferralCredit, Id начисления) уже есть → дубль, ack (INV-WLT-7). Проверить CurrencyId (RUB-only — иначе DLQ WALLET_CURRENCY_MISMATCH, INV-WLT-4), Amount.Value > 0. Получить-или-создать счёт ReferralCommission (HolderUserId = PartnerUserId; гонка lazy-создания — по уникальности «холдер + вид», INV-WLT-2). Провести ReferralCredit: дебет PlatformSettlement, кредит счёта партнёра, OccurredAt = ApprovedAt.
  4. Компенсация (Cancelled). Найти проведённую ReferralCredit-транзакцию по SourceId = Id начисления: нет → кредита не было, ack (no-op). Есть → идемпотентность: (Correction, id исходной транзакции) уже есть → дубль, ack (INV-WLT-6). Баланс счёта партнёра меньше суммы исходной транзакции → DLQ + алерт (WALLET_BALANCE_NEGATIVE_FORBIDDEN, рассинхрон). Иначе провести Correction зеркально исходной (CorrectedTransactionId) с причиной CancelReason в Comment.
  5. Какие факты фиксируются. По каждой проведённой транзакции публикуется pin.wallet.transaction-recorded.v1 (../events/wallet-transaction-recorded.md) — потребитель notifications («вам начислено» / «начисление отменено»).
  6. Согласованность. Транзакция, балансы и исходящее событие — в одном согласованном изменении; offset подтверждается после фиксации.
  7. Результат. Успех → ack; контролируемая ошибка → retry/DLQ по политике ниже.

Идемпотентность и replay

  • Business keys: (ReferralCredit, Id начисления) и (Correction, id исходной транзакции) — unique в журнале; дубль любого события — no-op.
  • Порядок: key топика = id начисления — Approved всегда раньше Cancelled-после-апрува; перестановки между разными начислениями безразличны.
  • Version-guard: warn-and-proceed по ContractVersion; неизвестные additive-поля игнорируются; монотонность переходов дополнительно страхуется Version payload (устаревший повтор упирается в идемпотентность).
  • Replay: да, безопасен — кредиты и компенсации пропускаются по unique-ключам. Событие с неизвестным ToStatus при replay детерминированно попадает в DLQ + алерт (а не тихо ack-ается), пока эффект перехода не определён и маппинг/версия не дотянуты.

Retry и DLQ

СбойRetryDLQРучное действие
Транзиентная ошибка хранилищаэкспоненциальный retryпосле исчерпания — даreplay после восстановления
Валюта вне допустимых (WALLET_CURRENCY_MISMATCH, INV-WLT-4)нетДаразбор контракта (ADR-0048)
Баланс партнёра < суммы компенсации (WALLET_BALANCE_NEGATIVE_FORBIDDEN, INV-WLT-3)нетДа + алертreconciliation-issue (ADR-0021): сверка rewardswallet, исправление командой rewards либо Adjustment
Amount.Value <= 0 при Approved / отсутствие обязательных полейнетДаразбор контракта с rewards
Неизвестное значение ToStatus (не в enum ReferralAccrualStatus) (WALLET_UNKNOWN_ENUM)нетДа + алертразбор контракта с rewards: определить эффект перехода, дотянуть маппинг/версию, ручной replay из DLQ после фикса

Неизвестный ToStatusnon-retriable DLQ + алерт (WALLET_UNKNOWN_ENUM), единообразно с commission-money-received (неизвестный enum → DLQ): переход с возможным денежным эффектом не проводится и не ack-ается молча, а разбирается вручную (см. маппинг и Алгоритм, шаг 2).

Наблюдаемость и безопасность

  • Logs: consumed / skipped-duplicate / posted / compensated / failed — Id начисления, PartnerUserId, id транзакции, CancelReason, без сумм. Metrics: wallet_transactions_recorded_total{kind="ReferralCredit"|"Correction"}, wallet_unknown_status_total (каждый инкремент = событие в DLQ + алерт, целевое значение 0), DLQ, lag.
  • Traces: span приёма → span проведения; корреляция из метаданных события.
  • PII нет (InvestorUserId в событии отсутствует); суммы — Sensitive-класс.

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

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

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