Files
mono/components/controller/src/infrastructure/blockchain/adapters/account.adapter.ts
T
coopops 56cd422440
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
feat(membership-exit): исходящий платёж возврата паевого + гейт реквизитов
Закрываем пробел: при одобрении советом выхода (on-chain confirmexit) кассир
не видел платёж возврата — его никто не создавал в реестре gateway.

- Гейт реквизитов: createMembershipExit отклоняет подачу, если у пайщика нет
  ни одного платёжного метода («установите реквизиты для возврата паевого»).
- MembershipExitAuthorizationListener (@OnEvent action::registrator::confirmexit):
  по exit_hash берёт из таблицы exits username + сумму возврата, заводит
  исходящий платёж MEMBERSHIP_EXIT (PENDING — совет одобрил, сразу кассиру) по
  реквизитам пайщика (метод по умолчанию). hash платежа = exit_hash, поэтому
  подтверждение кассой через default-ветку processOutgoingPayment вызовет
  completeOutcome → registrator::completexit (списание + блокировка).
- getExitByHash в account-порт/адаптер: confirmexit отдаёт только coopname+exit_hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:13:38 +00:00

312 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadGatewayException, HttpStatus, Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
import { HttpApiError } from '~/utils/httpApiError';
import { BlockchainService } from '../blockchain.service';
import { GatewayContract, RegistratorContract, SovietContract, WalletContract } from 'cooptypes';
import type { BlockchainAccountInterface } from '~/types/shared';
import type { AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
import { VaultDomainService, VAULT_DOMAIN_SERVICE } from '~/domain/vault/services/vault-domain.service';
import { Name } from '@wharfkit/antelope';
import config from '~/config/config';
import { DomainToBlockchainUtils } from '../../../shared/utils/domain-to-blockchain.utils';
import { CandidateDomainInterface } from '~/domain/account/interfaces/candidate-domain.interface';
import { Classes } from '@coopenomics/sdk';
import {
AGREEMENT_CONFIGURATION_SERVICE,
AgreementConfigurationService,
} from '~/domain/registration/services/agreement-configuration.service';
import { SOVIET_BLOCKCHAIN_PORT, type SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import type { AccountType } from '~/application/account/enum/account-type.enum';
@Injectable()
export class AccountBlockchainAdapter implements AccountBlockchainPort {
private readonly logger = new Logger(AccountBlockchainAdapter.name);
constructor(
private readonly blockchainService: BlockchainService,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
@Inject(forwardRef(() => AGREEMENT_CONFIGURATION_SERVICE))
private readonly agreementConfigService: AgreementConfigurationService,
@Inject(VAULT_DOMAIN_SERVICE) private readonly vaultDomainService: VaultDomainService,
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
) {}
async registerBlockchainAccount(candidate: CandidateDomainInterface): Promise<void> {
// Проверяем наличие заявления (обязательно для всех)
if (!candidate.documents?.statement) {
throw new HttpApiError(HttpStatus.BAD_REQUEST, 'Не найдено заявление на вступление');
}
// Получаем конфигурацию соглашений для типа аккаунта кандидата и выбранной программы
const accountType = candidate.type as AccountType;
const blockchainAgreements = this.agreementConfigService.getBlockchainAgreements(accountType, config.coopname, candidate.program_key);
// Проверяем наличие всех требуемых документов на основе конфигурации
for (const agreementConfig of blockchainAgreements) {
const documentKey = agreementConfig.id as keyof typeof candidate.documents;
if (!candidate.documents?.[documentKey]) {
throw new HttpApiError(HttpStatus.BAD_REQUEST, `Не найден документ: ${agreementConfig.title}`);
}
}
const wif = await this.vaultDomainService.getWif(config.coopname);
if (!wif) throw new BadGatewayException('Не найден приватный ключ для совершения операции');
await this.blockchainService.initialize(config.coopname, wif);
const actions: any[] = [];
// Повторная подача на том же аккаунте (после отказа совета и возврата взноса):
// eosio-аккаунт уже существует — повторный CreateAccount упал бы. Создаём аккаунт
// только если его ещё нет; карточку участника на цепи снял refundpay (type=""),
// поэтому следующий RegisterUser проходит.
const existingAccount = await this.getBlockchainAccount(candidate.username);
if (!existingAccount) {
// Создаем объект registerAccountData с данными из кандидата
const registerAccountData: RegistratorContract.Actions.CreateAccount.ICreateAccount = {
coopname: config.coopname,
username: candidate.username,
referer: candidate.referer || '',
public_key: candidate.public_key,
meta: candidate.meta || '{}',
};
actions.push({
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.CreateAccount.actionName,
authorization: [
{
actor: config.coopname,
permission: 'active',
},
],
data: registerAccountData,
});
}
// Создаем объект registerUserData с данными из кандидата
const registerUserData: RegistratorContract.Actions.RegisterUser.IRegisterUser = {
coopname: config.coopname,
username: candidate.username,
type: candidate.type,
braname: candidate.braname || '',
statement: Classes.Document.finalize(candidate.documents.statement),
registration_hash: candidate.registration_hash,
};
actions.push({
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.RegisterUser.actionName,
authorization: [
{
actor: config.coopname,
permission: 'active',
},
],
data: registerUserData,
});
// Создаем completeDeposit
const completeDeposit: GatewayContract.Actions.CompleteIncome.ICompleteIncome = {
coopname: config.coopname,
income_hash: candidate.registration_hash,
};
actions.push({
account: GatewayContract.contractName.production,
name: GatewayContract.Actions.CompleteIncome.actionName,
authorization: [
{
actor: config.coopname,
permission: 'active',
},
],
data: completeDeposit,
});
// Эпик 2 (компонент 48): программные соглашения (program_id > 0) пишет
// wallet::signagree в `wallet::users.programs[]`; soviet::sndagreement
// оставлен только для непрограммных типов и активно их отвергает.
// Один lookup `coagreements` на bundle — даёт ровно один источник истины.
const coagreements = await this.sovietBlockchainPort.getCoagreements(config.coopname);
const coagreementByType = new Map<string, SovietContract.Tables.CoopAgreements.ICoopAgreement>();
for (const ca of coagreements) coagreementByType.set(ca.type, ca);
for (const agreementConfig of blockchainAgreements) {
const documentKey = agreementConfig.id as keyof typeof candidate.documents;
const document = candidate.documents?.[documentKey] as ISignedDocumentDomainInterface | undefined;
if (!document) continue;
const coagreement = coagreementByType.get(agreementConfig.agreement_type);
const programId = coagreement ? Number(coagreement.program_id) : 0;
if (programId > 0) {
const draftId = Number(coagreement!.draft_id);
this.logger.log(
`register: wallet::signagree (${candidate.username} type=${agreementConfig.agreement_type} program_id=${programId} draft_id=${draftId})`
);
actions.push(this.createSignAgreementAction(candidate.username, programId, draftId, document));
} else {
actions.push(this.createSendAgreementAction(candidate.username, agreementConfig.agreement_type, document));
}
}
await this.blockchainService.transact(actions);
}
/**
* soviet::sndagreement — для непрограммных соглашений (program_id == 0).
*/
private createSendAgreementAction(username: string, agreementType: string, document: ISignedDocumentDomainInterface): any {
const agreementData: SovietContract.Actions.Agreements.SendAgreement.ISendAgreement = {
coopname: config.coopname,
administrator: config.coopname,
username,
agreement_type: agreementType,
document: Classes.Document.finalize(document),
};
return {
account: SovietContract.contractName.production,
name: SovietContract.Actions.Agreements.SendAgreement.actionName,
authorization: [
{
actor: config.coopname,
permission: 'active',
},
],
data: agreementData,
};
}
/**
* wallet::signagree — для программных соглашений (program_id > 0).
* Контракт требует существующего пайщика; в bundle регистрации это
* гарантировано предыдущим registeruser action в той же транзакции.
*/
private createSignAgreementAction(
username: string,
programId: number,
draftId: number,
document: ISignedDocumentDomainInterface
): any {
const data: WalletContract.Actions.SignAgreement.ISignAgreement = {
coopname: config.coopname,
username,
program_id: programId,
draft_id: draftId,
document: Classes.Document.finalize(document),
};
return {
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
authorization: [
{
actor: config.coopname,
permission: 'active',
},
],
data,
};
}
async exitCoop(data: import('~/domain/account/interfaces/account-blockchain.port').ExitCoopDomainInterface): Promise<void> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new BadGatewayException('Не найден приватный ключ для совершения операции');
await this.blockchainService.initialize(data.coopname, wif);
const exitData: RegistratorContract.Actions.ExitCoop.IExitCoop = {
coopname: data.coopname,
username: data.username,
exit_hash: data.exit_hash,
statement: Classes.Document.finalize(data.statement),
};
await this.blockchainService.transact({
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.ExitCoop.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: exitData,
});
}
async addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new BadGatewayException('Не найден приватный ключ для совершения операции');
await this.blockchainService.initialize(data.coopname, wif);
await this.blockchainService.transact({
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.AddUser.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
getBlockchainAccount(username: string): Promise<BlockchainAccountInterface | null> {
return this.blockchainService.getAccount(username);
}
getExit(
coopname: string,
username: string
): Promise<RegistratorContract.Tables.Exits.IExit | null> {
return this.blockchainService.getSingleRow(
RegistratorContract.contractName.production,
coopname,
RegistratorContract.Tables.Exits.tableName,
Name.from(username)
);
}
async getExitByHash(
coopname: string,
exit_hash: string
): Promise<RegistratorContract.Tables.Exits.IExit | null> {
// Таблица exits в scope=coopname мала (одна запись на выходящего пайщика,
// удаляется при завершении), поэтому проще выбрать все строки и найти по
// exit_hash, чем ходить во вторичный индекс byhash.
const rows = await this.blockchainService.getAllRows<RegistratorContract.Tables.Exits.IExit>(
RegistratorContract.contractName.production,
coopname,
RegistratorContract.Tables.Exits.tableName
);
const target = exit_hash.toLowerCase();
return rows.find((r) => String(r.exit_hash).toLowerCase() === target) ?? null;
}
getParticipantAccount(
coopname: string,
username: string
): Promise<SovietContract.Tables.Participants.IParticipants | null> {
return this.blockchainService.getSingleRow(
SovietContract.contractName.production,
coopname,
SovietContract.Tables.Participants.tableName,
Name.from(username)
);
}
getUserAccount(username: string): Promise<RegistratorContract.Tables.Accounts.IAccount | null> {
return this.blockchainService.getSingleRow(
RegistratorContract.contractName.production,
RegistratorContract.contractName.production,
RegistratorContract.Tables.Accounts.tableName,
Name.from(username)
);
}
getCooperatorAccount(coopname: string): Promise<RegistratorContract.Tables.Cooperatives.ICooperative | null> {
return this.blockchainService.getSingleRow(
RegistratorContract.contractName.production,
RegistratorContract.contractName.production,
RegistratorContract.Tables.Cooperatives.tableName,
Name.from(coopname)
);
}
}