[@ant] fix(controller): не фиксировать возврат паевого взноса в БД при ошибке блокчейна
Баг: при «Получить возврат» с суммой больше 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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user