fix(membership-exit): терминальный экран после выхода + гард повторного выхода
Typecheck / desktop (pull_request) Failing after 8m47s
Typecheck / controller (pull_request) Has been cancelled

После completexit строка registrator::exits стирается (терминал=erase) →
getMembershipExit возвращал null → оверлей пропадал, кабинет разблокировался,
пайщик видел стол и мог подать выход повторно (гарда не было).

- enum MembershipExitStatus += COMPLETED
- getMembershipExit: терминальная фаза по blocked-аккаунту + персистентному
  MEMBERSHIP_EXIT-платежу (сумма возврата + статус 'Оплачено')
- createMembershipExit: гард — заблокированный аккаунт не может выйти повторно
  (контракт уже блокирует через get_participant_or_fail; отсекаем раньше)
- ExitOverlay: терминальная фаза 'Вы вышли из кооператива' (сумма + 'Оплачено'
  + ожидайте поступления), она же UX-гард — перекрывает кабинет
- SDK Zeus regen под новый enum

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-16 08:57:54 +00:00
parent 560321853c
commit 39433753c2
5 changed files with 67 additions and 9 deletions
@@ -11,6 +11,10 @@ export enum MembershipExitStatus {
PENDING = 'pending',
// Совет одобрил, идёт возврат паевого взноса (зарезервирован, исходящий платёж).
AUTHORIZED = 'authorized',
// Выход завершён: возврат оплачен, аккаунт заблокирован, пайщик вышел.
// exits-строка на цепи уже стёрта (терминал=erase) — статус восстанавливаем
// по blocked-аккаунту и завершённому MEMBERSHIP_EXIT-платежу.
COMPLETED = 'completed',
}
registerEnumType(MembershipExitStatus, {
@@ -15,6 +15,7 @@ import { USER_DOMAIN_SERVICE, type UserDomainService } from '~/domain/user/servi
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { PAYMENT_METHOD_REPOSITORY, type PaymentMethodRepository } from '~/domain/common/repositories/payment-method.repository';
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
import { MembershipExitRequestEntity } from '~/infrastructure/database/typeorm/entities/membership-exit-request.entity';
import { tokenTypes } from '~/types/token.types';
import { CreateMembershipExitInputDTO } from '../dto/create-membership-exit-input.dto';
@@ -80,6 +81,15 @@ export class MembershipExitService {
throw new ForbiddenException('Подать заявление на выход можно только за себя');
}
// Гард повторного выхода: вышедший пайщик заблокирован on-chain (completexit →
// status=blocked + удалён из участников). Контракт exitcoop его уже не пропустит
// (get_participant_or_fail), но отсекаем раньше — чтобы не плодить off-chain
// черновик и не доводить пайщика до провала на шаге подтверждения по ссылке.
const account = await this.accountBlockchainPort.getUserAccount(data.username);
if (String(account?.status) === 'blocked') {
throw new BadRequestException('Вы уже вышли из кооператива — повторный выход невозможен.');
}
// Гейт реквизитов: выход нельзя запускать, пока у пайщика нет реквизитов для
// получения возврата паевого взноса — иначе при одобрении совета исходящий
// платёж возврата некуда будет создать (см. MembershipExitAuthorizationListener).
@@ -235,6 +245,30 @@ export class MembershipExitService {
};
}
// Терминальная фаза: выход завершён on-chain. После completexit строка
// registrator::exits СТЁРТА (терминал=erase), аккаунт переведён в blocked.
// exits-строки уже нет — источник терминального состояния: blocked-аккаунт +
// персистентный MEMBERSHIP_EXIT-платёж в gateway (сумма возврата + «оплачено»).
// Так кабинет остаётся заблокированным экраном «вы вышли» и после стирания
// exits, и при следующих входах (blocked-статус живёт on-chain).
const account = await this.accountBlockchainPort.getUserAccount(username);
if (String(account?.status) === 'blocked') {
const payment = await this.paymentRepository.findLatestByUsernameAndType(
username,
PaymentTypeEnum.MEMBERSHIP_EXIT
);
if (payment) {
const precision = config.blockchain.root_govern_precision ?? 4;
return {
exit_hash: payment.hash,
status: MembershipExitStatus.COMPLETED,
quantity: `${payment.quantity.toFixed(precision)} ${payment.symbol}`,
payment_status: payment.status,
created_at: (payment.completed_at ?? payment.created_at).toISOString(),
};
}
}
return null;
}
+5 -4
View File
@@ -25617,7 +25617,7 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
generateExpenseProposalStatementDocument: ModelTypes["GeneratedDocument"],
/** Сгенерировать протокол решения по предложенной повестке
/** Сгенерировать протокол решения по предложенно повестке
Требуемые роли: chairman, member. */
generateFreeDecision: ModelTypes["GeneratedDocument"],
@@ -27092,7 +27092,7 @@ export type ModelTypes = {
capitalResults: ModelTypes["PaginatedCapitalResultsPaginationResult"],
/** Получение одного сегмента кооператива по фильтрам */
capitalSegment?: ModelTypes["CapitalSegment"] | undefined | null,
/** Получение списка сегментов кооператива с фильтрацией и пагинацией */
/** Получение списка сементов кооператива с фильтрацией и пагинацией */
capitalSegments: ModelTypes["PaginatedCapitalSegmentsPaginationResult"],
/** Получение полного состояния CAPITAL контракта кооператива */
capitalState?: ModelTypes["CapitalState"] | undefined | null,
@@ -35477,7 +35477,7 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
generateExpenseProposalStatementDocument: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать протокол решения по предложенной повестке
/** Сгенерировать протокол решения по предложенно повестке
Требуемые роли: chairman, member. */
generateFreeDecision: GraphQLTypes["GeneratedDocument"],
@@ -37098,7 +37098,7 @@ export type GraphQLTypes = {
capitalResults: GraphQLTypes["PaginatedCapitalResultsPaginationResult"],
/** Получение одного сегмента кооператива по фильтрам */
capitalSegment?: GraphQLTypes["CapitalSegment"] | undefined | null,
/** Получение списка сегментов кооператива с фильтрацией и пагинацией */
/** Получение списка сементов кооператива с фильтрацией и пагинацией */
capitalSegments: GraphQLTypes["PaginatedCapitalSegmentsPaginationResult"],
/** Получение полного состояния CAPITAL контракта кооператива */
capitalState?: GraphQLTypes["CapitalState"] | undefined | null,
@@ -39433,6 +39433,7 @@ export enum ManagedRoomKind {
export enum MembershipExitStatus {
AUTHORIZED = "AUTHORIZED",
AWAITING_CONFIRMATION = "AWAITING_CONFIRMATION",
COMPLETED = "COMPLETED",
PENDING = "PENDING"
}
/** Тип комнаты вне проекта: пайщики, совет, комната секретаря */
@@ -74,6 +74,13 @@ const isAuthorized = computed(
() => exitStatus.value?.status === Zeus.MembershipExitStatus.AUTHORIZED,
);
// Терминал: выход завершён, возврат оплачен, аккаунт заблокирован. Этот экран —
// и есть защита от повторного выхода: пока он показан, кабинет перекрыт и кнопку
// «выход из кооператива» не достать (бэкенд и контракт повтор тоже отклоняют).
const isCompleted = computed(
() => exitStatus.value?.status === Zeus.MembershipExitStatus.COMPLETED,
);
// Статус исходящего платежа возврата → чип у суммы (виден кассиру и пайщику).
const PAYMENT_CHIPS: Record<string, { label: string; variant: BaseChipVariant }> = {
[Zeus.PaymentStatus.PENDING]: { label: 'Ожидает оплаты', variant: 'warn' },
@@ -91,8 +98,19 @@ const paymentChip = computed(() =>
paymentStatus.value ? PAYMENT_CHIPS[paymentStatus.value] ?? null : null,
);
// Содержимое экрана по фазе выхода: ожидание письма → рассмотрение советом → одобрено.
// Содержимое экрана по фазе выхода: ожидание письма → рассмотрение → одобрено → завершено.
const view = computed(() => {
if (isCompleted.value) {
return {
icon: 'check_circle',
tone: 'pos',
title: 'Вы вышли из кооператива',
body: 'Возврат паевого взноса оплачен — дождитесь поступления средств на указанные реквизиты. Аккаунт заблокирован. Благодарим за участие в кооперативе.',
amountLabel: 'Сумма возврата',
plannedHint: false,
canCancel: false,
};
}
if (isAwaitingConfirmation.value) {
return {
icon: 'mark_email_unread',
+5 -4
View File
@@ -25617,7 +25617,7 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
generateExpenseProposalStatementDocument: ModelTypes["GeneratedDocument"],
/** Сгенерировать протокол решения по предложенной повестке
/** Сгенерировать протокол решения по предложенно повестке
Требуемые роли: chairman, member. */
generateFreeDecision: ModelTypes["GeneratedDocument"],
@@ -27092,7 +27092,7 @@ export type ModelTypes = {
capitalResults: ModelTypes["PaginatedCapitalResultsPaginationResult"],
/** Получение одного сегмента кооператива по фильтрам */
capitalSegment?: ModelTypes["CapitalSegment"] | undefined | null,
/** Получение списка сегментов кооператива с фильтрацией и пагинацией */
/** Получение списка сементов кооператива с фильтрацией и пагинацией */
capitalSegments: ModelTypes["PaginatedCapitalSegmentsPaginationResult"],
/** Получение полного состояния CAPITAL контракта кооператива */
capitalState?: ModelTypes["CapitalState"] | undefined | null,
@@ -35477,7 +35477,7 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
generateExpenseProposalStatementDocument: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать протокол решения по предложенной повестке
/** Сгенерировать протокол решения по предложенно повестке
Требуемые роли: chairman, member. */
generateFreeDecision: GraphQLTypes["GeneratedDocument"],
@@ -37098,7 +37098,7 @@ export type GraphQLTypes = {
capitalResults: GraphQLTypes["PaginatedCapitalResultsPaginationResult"],
/** Получение одного сегмента кооператива по фильтрам */
capitalSegment?: GraphQLTypes["CapitalSegment"] | undefined | null,
/** Получение списка сегментов кооператива с фильтрацией и пагинацией */
/** Получение списка сементов кооператива с фильтрацией и пагинацией */
capitalSegments: GraphQLTypes["PaginatedCapitalSegmentsPaginationResult"],
/** Получение полного состояния CAPITAL контракта кооператива */
capitalState?: GraphQLTypes["CapitalState"] | undefined | null,
@@ -39433,6 +39433,7 @@ export enum ManagedRoomKind {
export enum MembershipExitStatus {
AUTHORIZED = "AUTHORIZED",
AWAITING_CONFIRMATION = "AWAITING_CONFIRMATION",
COMPLETED = "COMPLETED",
PENDING = "PENDING"
}
/** Тип комнаты вне проекта: пайщики, совет, комната секретаря */