Манитон Docs

Погашение и вывод

Погашение CFA‑RUB и прекращение прав по инвестиционным ЦФА

Погашение и вывод

1) Погашение CFA‑RUB (Вывод в фиат)

Смысл операции

Погашение CFA‑RUB — это юридическое прекращение цифрового права требования с одновременной выплатой денежного эквивалента.

Статусная модель (State Machine)

Процесс вывода сложнее ввода, так как требует блокировки средств и асинхронного взаимодействия с банком.

Loading diagram...

Последовательность (Sequence)

Loading diagram...

Edge Cases (Сценарии отказов)

  1. Недостаточно средств (Race Condition):

    • Ситуация: Пользователь создал заявку на вывод, и параллельно купил актив.
    • Решение: Блокировка (HOLD) средств происходит в момент перехода в CREATED. Если баланса нет — ошибка сразу.
  2. Отказ банка (Invalid Account):

    • Ситуация: Реквизиты получателя закрыты или ошибочны.
    • Решение: Банк возвращает реестр ошибок. Система выполняет Unburn (восстановление) токенов CFA-RUB на счете пользователя. Статус заявки FAILED.
  3. Блокировка по 115-ФЗ:

    • Ситуация: AML-мониторинг выявил подозрительную операцию.
    • Решение: Статус BLOCKED. Средства остаются замороженными на счете до выяснения (запрос документов).

2) Погашение инвестиционных ЦФА

Погашение происходит при наступлении срока (Maturity Date) или при досрочном выкупе (Call Option).

Процесс (Автоматическое погашение)

  1. Триггер: Наступает дата погашения.
  2. Snapshot: Снимается слепок реестра владельцев на конец дня.
  3. Расчет: Вычисляется сумма (Номинал + Купон) для каждого владельца.
  4. Выплата:
    • Эмитент переводит полную сумму в CFA-RUB на спецсчет погашения.
    • Смарт-контракт распределяет CFA-RUB владельцам и сжигает инвестиционные ЦФА.
  5. Уведомление: Пользователи получают Push/Email о погашении.

Важно для учета

Погашение инвестиционного ЦФА — это обмен активами: CFA-BOND (Credit) -> CFA-RUB (Debit) Баланс пользователя не меняется в валюте оценки, меняется структура портфеля.

3) API (gRPC Connect)

RedeemCfaRub

Погашение CFA-RUB с выводом в фиат.

message RedeemCfaRubRequest {
  RequestContext context = 1;
  string user_id = 2;
  Money amount = 3;
  string payout_id = 4;
}

message RedeemCfaRubResponse {
  CfaOperation operation = 1;
}

Пример:

const redemption = await cfaCoreService.redeemCfaRub(
  { requestId, correlationId, idempotencyKey },
  'user-123',
  { amount: '10000', currencyCode: 'RUB' },
  'payout-456',
);

InitiatePayout

Инициация вывода средств пользователю.

message InitiatePayoutRequest {
  RequestContext context = 1;
  PayoutRequest payout = 2;
}

message PayoutRequest {
  string payout_id = 1;
  string user_id = 2;
  Money amount = 3;
  string bank_account = 4;
}

4) Use Cases

RedeemCfaRubUseCase

Погашение CFA-RUB с проверкой и блокировкой.

async execute(request: RedeemCfaRubRequest): Promise<RedeemCfaRubResponse> {
  const { context, userId, amount, payoutId } = request;

  // 1. Проверка идемпотентности
  const existing = await this.operationRepository.findByIdempotencyKey(
    context.idempotencyKey
  );

  if (existing) {
    return { operation: existing };
  }

  // 2. Проверка KYC
  const userProfile = await this.identityClient.getUserProfile(context, userId);

  if (userProfile.kycStatus < KycStatus.STANDARD) {
    throw new Error('KYC level too low for withdrawal');
  }

  // 3. Проверка лимитов
  const limitsCheck = await this.identityClient.checkLimits(
    context,
    userId,
    LimitType.WITHDRAWAL,
    amount,
    OperationType.WITHDRAWAL
  );

  if (!limitsCheck.allowed) {
    const rejectionReason = limitsCheck.reason?.includes('KYC')
      ? RejectionReasonCode.KYC_REQUIRED
      : RejectionReasonCode.LIMIT_EXCEEDED;

    const operation = new CfaOperation(
      crypto.randomUUID(),
      OperationType.REDEMPTION,
      OperationStatus.REJECTED,
      userId,
      null,
      amount.amount.value,
      amount.currencyCode,
      context.idempotencyKey,
      payoutId
    );
    operation.rejectionReason = rejectionReason;
    operation.rejectionMessage = limitsCheck.reason;

    await this.operationRepository.save(operation);
    return { operation };
  }

  // 4. Создание холда
  const hold = await this.ledgerClient.createHold({
    context,
    subaccountId: `${userId}:CFA-RUB`,
    amount,
    reason: `Redemption ${payoutId}`,
  });

  // 5. Создание операции
  const operation = new CfaOperation(
    crypto.randomUUID(),
    OperationType.REDEMPTION,
    OperationStatus.PROCESSING,
    userId,
    hold.holdId,
    amount.amount.value,
    amount.currencyCode,
    context.idempotencyKey,
    payoutId
  );

  await this.operationRepository.save(operation);

  // 6. Сжигание токенов
  const txPayload = this.transactionBuilder.buildBurnTx({
    from: '0x' + userId.replace(/-/g, '').substring(0, 40),
    value: amount.amount.value,
  });

  const signedTx = await this.web3Signer.sign({
    payload: txPayload,
    chainId: this.configService.get('besu.chainId'),
  });

  const txHash = await this.besuCommand.submitTransaction({
    context,
    payload: signedTx,
  });

  operation.status = OperationStatus.ONCHAIN_SUBMITTED;
  operation.onchainTxHash = txHash;
  await this.operationRepository.save(operation);

  // 7. Инициация выплаты
  await this.paymentsService.initiatePayout({
    context,
    payout: {
      payoutId,
      userId,
      amount,
      bankAccount: userProfile.bankAccount,
    },
  });

  return { operation };
}

FinalizeRedemptionUseCase

Финализация погашения после подтверждения выплаты.

async execute(payoutId: string): Promise<void> {
  // 1. Получение операции
  const operation = await this.operationRepository.findByExternalRef(payoutId);

  if (!operation) {
    throw new Error('Operation not found');
  }

  // 2. Проверка статуса выплаты
  const payout = await this.paymentsService.getPayout(payoutId);

  if (payout.status === PaymentStatus.FAILED) {
    // Возврат токенов
    const txPayload = this.transactionBuilder.buildMintTx({
      to: '0x' + operation.userId.replace(/-/g, '').substring(0, 40),
      value: operation.amount,
    });

    const signedTx = await this.web3Signer.sign({
      payload: txPayload,
      chainId: this.configService.get('besu.chainId'),
    });

    const txHash = await this.besuCommand.submitTransaction({
      context: { requestId, correlationId, idempotencyKey },
      payload: signedTx,
    });

    operation.status = OperationStatus.FAILED;
    operation.rejectionReason = RejectionReasonCode.INTEGRATION_ERROR;
    operation.rejectionMessage = 'Payout failed, tokens returned';

    await this.operationRepository.save(operation);

    throw new Error('Payout failed');
  }

  // 3. Финализация операции
  operation.status = OperationStatus.FINALIZED;
  operation.finalizedAt = new Date();
  await this.operationRepository.save(operation);

  // 4. Публикация события
  await this.eventPublisher.publish({
    type: 'CfaRedeemed',
    payload: {
      operationId: operation.operationId,
      instrumentId: 'CFA-RUB',
      holderId: operation.userId,
      amount: operation.amount,
      txHash: operation.onchainTxHash,
      redeemedAt: new Date(),
    },
  });

  // 5. Потребление холда
  await this.ledgerClient.consumeHold(operation.holdId);
}

5) События Kafka

Исходящие (maniton.cfa.events.v1)

CfaRedeemedEvent

message CfaRedeemedEvent {
  RequestContext context = 1;
  string operation_id = 2;
  string instrument_id = 3;
  string holder_id = 4;
  MonetaryAmount amount = 5;
  string tx_hash = 6;
  google.protobuf.Timestamp redeemed_at = 7;
}

Входящие

  • maniton.payments.events.v1: PayoutConfirmedEvent

6) Мониторинг

Метрики

export const redemptionMetrics = {
  redemptionsTotal: new Counter({
    name: 'redemptions_total',
    help: 'Total number of redemptions',
    labelNames: ['type', 'status'],
  }),

  redemptionDuration: new Histogram({
    name: 'redemption_duration_seconds',
    help: 'Time spent on redemptions',
    buckets: [1, 5, 10, 30, 60, 120, 300, 600],
  }),
};

7) Troubleshooting

Проблема: Погашение не проходит

Диагностика:

# Проверка операции
curl http://cfa-core-service:3002/operations/$OPERATION_ID

# Проверка статуса транзакции
curl -X POST http://besu-connector:3004/getTransactionReceipt \
  -H "Content-Type: application/json" \
  -d '{"tx_hash": "0x..."}'

# Проверка выплаты
curl http://payments-service:3005/payouts/$PAYOUT_ID

Решение:

  1. Проверьте, что транзакция прошла в блокчейне
  2. Проверьте, что payout прошел в банке
  3. Проверьте, что холд потреблен

Проблема: Возврат токенов не работает

Решение:

  1. Проверьте, что контракт поддерживает mint
  2. Проверьте баланс контракта
  3. Проверьте, что транзакция прошла

Дополнительные ресурсы

On this page