[@ant] fix(controller): не фиксировать возврат паевого взноса в БД при ошибке блокчейна
Typecheck / desktop (pull_request) Successful in 13m22s
Typecheck / controller (pull_request) Successful in 15m49s

Баг: при «Получить возврат» с суммой больше L3-баланса платёж писался в
gateway-БД (AWAITING_AUTHORIZATION) ДО on-chain транзакции. Контракт wallet
отклонял заявку (недостаточно L3-средств), а в разделе «Платежи» оставался
висеть фантомный исходящий платёж со статусом FAILED.

Фикс — порядок операций в WalletInteractor.createWithdraw:
1. prepareWithdraw — все валидации (символ, дубликат hash, платёжный метод)
   и сборка записи, БЕЗ записи в БД;
2. on-chain wallet::createwthd — при недостатке средств падает здесь,
   ни одной записи о платеже не создаётся;
3. persistWithdraw — фиксация платежа в БД только после успешной заявки.

GatewayInteractor.createWithdraw разбит на prepareWithdraw/persistWithdraw
(старый combined-метод сохранён как обёртка). Порт и оба адаптера обновлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-08 06:00:02 +00:00
parent c28bb81c25
commit 29417dd425
5 changed files with 99 additions and 40 deletions
@@ -12,6 +12,7 @@ import type {
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
@Injectable()
export class GatewayInteractorAdapter implements GatewayInteractorPort {
@@ -36,6 +37,14 @@ export class GatewayInteractorAdapter implements GatewayInteractorPort {
return await this.gatewayInteractor.createWithdraw(data);
}
async prepareWithdraw(data: CreateWithdrawInputDomainInterface): Promise<PaymentDomainInterface> {
return await this.gatewayInteractor.prepareWithdraw(data);
}
async persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.persistWithdraw(paymentData);
}
async setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.setPaymentStatus(data);
}
@@ -509,9 +509,16 @@ export class GatewayInteractor {
}
/**
* Создать исходящий платеж (withdraw)
* Подготовить исходящий платеж (withdraw) к созданию: все валидации + сборка
* записи, но БЕЗ записи в БД.
*
* Вынесено отдельно от персиста, чтобы вызывающий мог сначала провести
* валидацию, затем выполнить on-chain транзакцию, и только при её успехе
* зафиксировать платёж (см. WalletInteractor.createWithdraw). Иначе при
* отклонении транзакции блокчейном (например, недостаточно L3-средств) в
* разделе «Платежи» оставался бы фантомный исходящий платёж со статусом FAILED.
*/
async createWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
async prepareWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainInterface> {
// Обновляем истекшие платежи перед созданием нового
await this.paymentRepository.expireOutdatedPayments();
@@ -580,7 +587,14 @@ export class GatewayInteractor {
hash: data.payment_hash, // Используем переданный payment_hash
};
// Создаем платеж в базе данных
return paymentData;
}
/**
* Зафиксировать ранее подготовленный исходящий платеж в БД.
* Вызывается только после успешной on-chain транзакции.
*/
async persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity> {
const createdPayment = await this.paymentRepository.create(paymentData);
if (!createdPayment.id) {
@@ -588,12 +602,24 @@ export class GatewayInteractor {
}
this.logger.log(
`Создан исходящий платеж ${data.payment_hash} для пользователя ${data.username} на сумму ${data.quantity} ${data.symbol} с платежным методом ${data.method_id}`
`Создан исходящий платеж ${paymentData.hash} для пользователя ${paymentData.username} на сумму ${paymentData.quantity} ${paymentData.symbol} с платежным методом ${paymentData.payment_method_id}`
);
return new PaymentDomainEntity(createdPayment);
}
/**
* Создать исходящий платеж (withdraw): подготовка + немедленный персист.
*
* Не использует on-chain проверку — подходит только там, где запись о платеже
* в БД должна существовать безусловно. Для возврата паевого взноса используется
* связка prepareWithdraw → on-chain транзакция → persistWithdraw.
*/
async createWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
const paymentData = await this.prepareWithdraw(data);
return await this.persistWithdraw(paymentData);
}
/**
* Обновить все истекшие платежи в статус EXPIRED
* Может вызываться периодически или по требованию
@@ -19,7 +19,6 @@ import type { CreateWithdrawInputDomainInterface } from '~/domain/wallet/interfa
import { GATEWAY_INTERACTOR_PORT, GatewayInteractorPort } from '~/domain/wallet/ports/gateway-interactor.port';
import type { CreateDepositPaymentInputDomainInterface } from '~/domain/gateway/interfaces/create-deposit-payment-input-domain.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { ProgramWalletFilterInputDTO } from '../dto/program-wallet-filter-input.dto';
import { PaginationResult, PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import { getProgramId, getProgramType } from '~/domain/wallet/enums/program-type.enum';
@@ -85,22 +84,25 @@ export class WalletInteractor {
// Используем payment_hash из параметров вместо генерации нового
const withdraw_hash = data.payment_hash;
let createdPayment: PaymentDomainEntity | null = null;
// 1. Валидируем и подготавливаем платёж БЕЗ записи в БД. Все проверки
// (символ, дубликат hash, наличие платёжного метода) выполняются здесь,
// до блокчейна, чтобы не создать on-chain заявку без записи о платеже.
const preparedPayment = await this.gatewayInteractorPort.prepareWithdraw({
coopname: data.coopname,
username: data.username,
quantity: data.quantity,
symbol: data.symbol,
method_id: data.method_id,
statement: data.statement,
payment_hash: data.payment_hash,
});
// 2. Создаём withdraw в wallet контракте. Если средств на L3 недостаточно
// (или иная on-chain ошибка) — упадёт здесь, и НИ ОДНОЙ записи о платеже
// в БД не появится. Раньше платёж писался до блокчейна и оставался висеть
// в разделе «Платежи» со статусом FAILED при отклонении транзакцией.
// wallet контракт автоматически создаст outcome в gateway контракте.
try {
// 1. Создаем исходящий платеж в gateway для отслеживания
createdPayment = await this.gatewayInteractorPort.createWithdraw({
coopname: data.coopname,
username: data.username,
quantity: data.quantity,
symbol: data.symbol,
method_id: data.method_id,
statement: data.statement,
payment_hash: data.payment_hash,
});
// 2. Создаем withdraw в wallet контракте
// wallet контракт автоматически создаст outcome в gateway контракте
await this.walletBlockchainPort.createWithdraw({
coopname: data.coopname,
username: data.username,
@@ -108,30 +110,28 @@ export class WalletInteractor {
quantity: `${data.quantity} ${data.symbol}`,
statement: data.statement,
});
this.logger.log(`Создан withdraw в wallet: ${withdraw_hash}, outcome будет создан автоматически в gateway`);
return { withdraw_hash };
} catch (error: any) {
this.logger.error(`Ошибка при создании withdraw: ${error.message}`, error);
// Если платеж был создан, но произошла ошибка при создании withdraw в блокчейне
if (createdPayment?.id) {
try {
// Обновляем статус платежа на FAILED с сообщением об ошибке
await this.gatewayInteractorPort.setPaymentStatus({
id: createdPayment.id,
status: PaymentStatusEnum.FAILED,
});
this.logger.log(`Платеж ${createdPayment.id} помечен как FAILED из-за ошибки создания withdraw`);
} catch (updateError: any) {
this.logger.error(`Ошибка при обновлении статуса платежа: ${updateError.message}`, updateError);
}
}
this.logger.error(`Ошибка при создании withdraw в блокчейне (платёж не зафиксирован): ${error.message}`, error);
throw error;
}
// 3. Только после успешной on-chain заявки фиксируем платёж в БД для
// отслеживания кассиром и последующего перехода статусов.
try {
await this.gatewayInteractorPort.persistWithdraw(preparedPayment);
} catch (error: any) {
// Редкий случай: on-chain заявка прошла, но запись в БД не удалась (например,
// недоступна БД). On-chain withdraw существует — требуется ручная сверка.
this.logger.error(
`КРИТИЧНО: on-chain withdraw ${withdraw_hash} создан, но не удалось зафиксировать платёж в БД: ${error.message}`,
error
);
throw error;
}
this.logger.log(`Создан withdraw в wallet: ${withdraw_hash}, платёж зафиксирован в gateway`);
return { withdraw_hash };
}
/**
@@ -9,6 +9,7 @@ import type {
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
export interface GatewayInteractorPort {
getPayments(
@@ -18,6 +19,20 @@ export interface GatewayInteractorPort {
createInitialPayment(data: CreateInitialPaymentInputDomainInterface): Promise<PaymentDomainEntity>;
createDeposit(data: CreateDepositPaymentInputDomainInterface): Promise<PaymentDomainEntity>;
createWithdraw(data: CreateWithdrawInputDomainInterface): Promise<PaymentDomainEntity>;
/**
* Подготовить (с валидацией) исходящий платеж к созданию БЕЗ записи в БД.
* Парный метод к persistWithdraw — даёт вызывающему провести on-chain
* транзакцию между валидацией и фиксацией платежа.
*/
prepareWithdraw(data: CreateWithdrawInputDomainInterface): Promise<PaymentDomainInterface>;
/**
* Зафиксировать в БД ранее подготовленный исходящий платеж.
* Вызывается только после успешной on-chain транзакции.
*/
persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity>;
setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity>;
/**
@@ -12,6 +12,7 @@ import type {
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
@Injectable()
export class GatewayInteractorAdapter implements GatewayInteractorPort {
@@ -36,6 +37,14 @@ export class GatewayInteractorAdapter implements GatewayInteractorPort {
return await this.gatewayInteractor.createWithdraw(data);
}
async prepareWithdraw(data: CreateWithdrawInputDomainInterface): Promise<PaymentDomainInterface> {
return await this.gatewayInteractor.prepareWithdraw(data);
}
async persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.persistWithdraw(paymentData);
}
async setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.setPaymentStatus(data);
}