Merge pull request '[reg-fsm] Откат незавершённой регистрации + server-truth шаг (recovery, plan B)' (#95) from feat/registration-fsm into feat/registration-payment-decline

Reviewed-on: #95
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
This commit was merged in pull request #95.
This commit is contained in:
2026-06-05 11:08:26 +00:00
17 changed files with 250 additions and 8 deletions
@@ -39,6 +39,7 @@ import { CANDIDATE_REPOSITORY, CandidateRepository } from '~/domain/account/repo
import { userStatus } from '~/types/user.types';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { CandidateStatus } from '~/domain/registration/enum';
import { sha256 } from '~/utils/sha256';
import { registrationProfileFingerprint } from '~/utils/registration-profile-fingerprint';
@@ -377,6 +378,87 @@ export class AccountInteractor {
}
}
/**
* Откат регистрации к редактированию данных (recovery drift/decline).
*
* Применим ТОЛЬКО до создания аккаунта в блокчейне: после reguser username
* необратимо занят, и нужна перерегистрация — это отдельная ветка. Здесь же,
* пока аккаунта в цепи нет, безопасно вернуть пайщика к началу:
* - снимаем заморозку профиля и e-mail (users.status → created);
* - сбрасываем кандидата (документы и отпечаток заявления), чтобы заявление
* пришлось переподписать под актуальные данные;
* - удаляем непринятую попытку регистрационного платежа, чтобы экран
* ожидания не показывал старый отказ.
*
* Если средства уже приняты (PAID/COMPLETED) — откат запрещён: нужен возврат
* средств (отдельная ветка refund).
*/
async resetRegistration(username: string): Promise<AccountDomainEntity> {
const user = await this.userDomainService.getUserByUsername(username);
if (!user) {
throw new HttpApiError(HttpStatus.NOT_FOUND, `Пользователь ${username} не найден`);
}
// Аккаунт уже в блокчейне → откат к редактированию невозможен (username
// занят необратимо).
const blockchainAccount = await this.accountDomainService.getBlockchainAccount(username);
if (
blockchainAccount ||
user.status === userStatus['4_Registered'] ||
user.status === userStatus['5_Active']
) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Регистрация уже отправлена в блокчейн — откат к редактированию невозможен, требуется перерегистрация.'
);
}
// Регистрационный платёж: принятые средства откатывать нельзя (нужен возврат).
const payment = await this.paymentRepository.findLatestByUsernameAndType(
username,
PaymentTypeEnum.REGISTRATION
);
if (payment) {
if (payment.status === PaymentStatusEnum.PAID || payment.status === PaymentStatusEnum.COMPLETED) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Вступительный взнос уже принят — для отката требуется возврат средств.'
);
}
if (payment.id) {
await this.paymentRepository.delete(payment.id);
}
}
// Кандидат: чистим документы и отпечаток заявления (потребуется переподпись).
const candidate = await this.candidateRepository.findByUsername(username);
if (candidate) {
let candidateMeta: Record<string, any> = {};
try {
candidateMeta = candidate.meta ? JSON.parse(candidate.meta) : {};
} catch {
candidateMeta = {};
}
delete candidateMeta.registration_profile_fingerprint;
await this.candidateRepository.update(username, {
status: CandidateStatus.PENDING,
documents: {},
meta: JSON.stringify(candidateMeta),
});
}
// Снимаем заморозку: профиль и e-mail снова можно менять.
await this.userDomainService.updateUserByUsername(username, {
status: userStatus['1_Created'],
is_registered: false,
has_account: false,
});
this.logger.log(`Регистрация ${username} откатана к редактированию данных`);
return await this.accountDomainService.getAccount(username);
}
/**
* Поиск приватных аккаунтов по запросу
* @param input Входные данные для поиска
@@ -14,6 +14,8 @@ import { RegisteredAccountDTO } from '../dto/registered-account.dto';
import { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
import { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
export const AccountsPaginationResult = createPaginationResult(AccountDTO, 'Accounts');
@@ -84,6 +86,16 @@ export class AccountResolver {
// return true;
// }
@Mutation(() => AccountDTO, {
name: 'resetRegistration',
description:
'Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств.',
})
@UseGuards(GqlJwtAuthGuard)
async resetRegistration(@CurrentUser() currentUser: MonoAccountDomainInterface): Promise<AccountDTO> {
return await this.accountService.resetRegistration(currentUser.username);
}
@Mutation(() => AccountDTO, {
name: 'updateAccount',
description:
@@ -30,6 +30,12 @@ export class AccountService {
return new AccountDTO(account);
}
public async resetRegistration(username: string): Promise<AccountDTO> {
const account = await this.accountInteractor.resetRegistration(username);
return new AccountDTO(account);
}
public async getAccounts(
data?: GetAccountsInputDTO,
options?: PaginationInputDTO
@@ -11,6 +11,7 @@ export interface PaymentRepository {
// Общие методы
findById(id: string): Promise<PaymentDomainInterface | null>;
findByHash(hash: string): Promise<PaymentDomainInterface | null>;
delete(id: string): Promise<boolean>;
create(data: PaymentDomainInterface): Promise<PaymentDomainInterface>;
update(id: string, data: Partial<PaymentDomainInterface>): Promise<PaymentDomainInterface | null>;
@@ -31,6 +31,11 @@ export class TypeOrmPaymentRepository implements PaymentRepository {
return this.mapToDomainEntity(payment);
}
async delete(id: string): Promise<boolean> {
const result = await this.paymentRepository.delete({ id });
return (result.affected ?? 0) > 0;
}
async create(data: PaymentDomainInterface): Promise<PaymentDomainInterface> {
const payment = new PaymentEntity();
payment.hash = data.hash;
+1
View File
@@ -3667,6 +3667,7 @@ export const ReturnTypes: Record<string,any> = {
registerAccount:"RegisteredAccount",
registerParticipant:"Account",
resetKey:"Boolean",
resetRegistration:"Account",
restartAnnualGeneralMeet:"MeetAggregate",
saveReportDraft:"ReportDraft",
selectBranch:"Boolean",
+8
View File
@@ -6584,6 +6584,8 @@ refresh?: [{ data: ValueTypes["RefreshInput"] | Variable<any, string>},ValueType
registerAccount?: [{ data: ValueTypes["RegisterAccountInput"] | Variable<any, string>},ValueTypes["RegisteredAccount"]],
registerParticipant?: [{ data: ValueTypes["RegisterParticipantInput"] | Variable<any, string>},ValueTypes["Account"]],
resetKey?: [{ data: ValueTypes["ResetKeyInput"] | Variable<any, string>},boolean | `@${string}`],
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration?:ValueTypes["Account"],
restartAnnualGeneralMeet?: [{ data: ValueTypes["RestartAnnualGeneralMeetInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
saveReportDraft?: [{ input: ValueTypes["SaveReportDraftInput"] | Variable<any, string>},ValueTypes["ReportDraft"]],
selectBranch?: [{ data: ValueTypes["SelectBranchInput"] | Variable<any, string>},boolean | `@${string}`],
@@ -14942,6 +14944,8 @@ refresh?: [{ data: ResolverInputTypes["RefreshInput"]},ResolverInputTypes["Regis
registerAccount?: [{ data: ResolverInputTypes["RegisterAccountInput"]},ResolverInputTypes["RegisteredAccount"]],
registerParticipant?: [{ data: ResolverInputTypes["RegisterParticipantInput"]},ResolverInputTypes["Account"]],
resetKey?: [{ data: ResolverInputTypes["ResetKeyInput"]},boolean | `@${string}`],
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration?:ResolverInputTypes["Account"],
restartAnnualGeneralMeet?: [{ data: ResolverInputTypes["RestartAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]],
saveReportDraft?: [{ input: ResolverInputTypes["SaveReportDraftInput"]},ResolverInputTypes["ReportDraft"]],
selectBranch?: [{ data: ResolverInputTypes["SelectBranchInput"]},boolean | `@${string}`],
@@ -23473,6 +23477,8 @@ export type ModelTypes = {
registerParticipant: ModelTypes["Account"],
/** Заменить приватный ключ аккаунта */
resetKey: boolean,
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration: ModelTypes["Account"],
/** Перезапуск общего собрания пайщиков
Требуемые роли: chairman. */
@@ -32418,6 +32424,8 @@ export type GraphQLTypes = {
registerParticipant: GraphQLTypes["Account"],
/** Заменить приватный ключ аккаунта */
resetKey: boolean,
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration: GraphQLTypes["Account"],
/** Перезапуск общего собрания пайщиков
Требуемые роли: chairman. */
@@ -0,0 +1,17 @@
import { client } from 'src/shared/api/client'
import { Mutations } from '@coopenomics/sdk'
async function resetRegistration(): Promise<
Mutations.Accounts.ResetRegistration.IOutput[typeof Mutations.Accounts.ResetRegistration.name]
> {
const { [Mutations.Accounts.ResetRegistration.name]: result } = await client.Mutation(
Mutations.Accounts.ResetRegistration.mutation,
{}
)
return result
}
export const api = {
resetRegistration,
}
@@ -0,0 +1 @@
export * from './model'
@@ -0,0 +1,13 @@
import { api } from '../api'
import { IAccount } from 'src/entities/Account/types';
export function useResetRegistration() {
// Откат собственной незавершённой регистрации к редактированию данных.
// Бэкенд снимает заморозку профиля/e-mail, сбрасывает подписанное заявление
// и непринятый вступительный платёж. Возвращает обновлённый аккаунт.
async function resetRegistration(): Promise<IAccount> {
return await api.resetRegistration();
}
return { resetRegistration };
}
@@ -20,7 +20,7 @@ q-step(
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, onBeforeUnmount } from 'vue';
import { useCreateUser } from 'src/features/User/CreateUser';
import { FailAlert } from 'src/shared/api';
import { useSystemStore } from 'src/entities/System/model';
@@ -28,16 +28,51 @@ const { info } = useSystemStore();
import { useCooperativeStore } from 'src/entities/Cooperative';
import { useRegistratorStore } from 'src/entities/Registrator';
import { useSessionStore } from 'src/entities/Session';
import { useAccountStore } from 'src/entities/Account';
import { PayWithProvider } from 'src/shared/ui/PayWithProvider';
import { Loader } from 'src/shared/ui/Loader';
import { Zeus } from '@coopenomics/sdk';
const store = useRegistratorStore();
const api = useCreateUser();
const session = useSessionStore();
const accountStore = useAccountStore();
const step = computed(() => store.state.step);
const coop = useCooperativeStore();
const isCreatingPayment = ref(false);
// Поллинг приёма оплаты. Провайдер QR (Bank) не эмитит success-колбэк, а деньги
// подтверждаются вебхуком на бэке асинхронно — без поллинга экран висит на форме
// оплаты до перезагрузки. Каждые 10с подтягиваем аккаунт; как только вступительный
// платёж покинул статус PENDING (принят/отклонён) — уходим на шаг ожидания решения
// совета, который сам показывает «платёж принят» либо причину отказа.
const pollInterval = ref();
const POLL_TERMINAL_STATUSES = [
Zeus.PaymentStatus.PAID,
Zeus.PaymentStatus.COMPLETED,
Zeus.PaymentStatus.CANCELLED,
Zeus.PaymentStatus.EXPIRED,
Zeus.PaymentStatus.FAILED,
];
const pollPaymentStatus = async () => {
if (!session.username) return;
try {
const account = await accountStore.getAccount(session.username);
session.setCurrentUserAccount(account);
const status = session.registrationPayment?.status;
if (status && POLL_TERMINAL_STATUSES.includes(status)) {
clearInterval(pollInterval.value);
store.goTo('WaitingRegistration');
}
} catch (e) {
console.error('Ошибка опроса статуса оплаты:', e);
}
};
// Computed property для payment с правильной типизацией
const payment = computed(() => store.state.payment);
@@ -67,9 +102,19 @@ const createInitialPayment = async () => {
// Используем только watch с immediate: true вместо onMounted + watch
watch(step, (newValue) => {
if (newValue === currentStep) createInitialPayment();
if (newValue === currentStep) {
createInitialPayment();
clearInterval(pollInterval.value);
pollInterval.value = setInterval(() => pollPaymentStatus(), 10000);
} else {
clearInterval(pollInterval.value);
}
}, { immediate: true });
onBeforeUnmount(() => {
clearInterval(pollInterval.value);
});
watch(
() => store.state.is_paid,
(newValue) => {
@@ -85,10 +85,19 @@ onMounted(() => {
// Есть незавершённый вступительный платёж (ожидание подтверждения денег или
// отклонение председателем) — восстанавливаем экран ожидания/отказа даже
// после перезагрузки и в любой вкладке. Процесс может тянуться днями.
// Приоритет над статусом: при отказе платежа показываем причину, а не гоним
// «оплатить заново».
if (session.registrationPayment) {
store.step = steps.WaitingRegistration;
return;
}
// Сервер — источник истины о шаге: заявление уже подписано (Joined), но
// оплаты ещё нет → возвращаем на шаг оплаты. Так процесс переживает
// перезагрузку и открытие в другой вкладке, не завися от localStorage.
if (userStatus === Zeus.UserStatus.Joined) {
store.step = steps.PayInitial;
return;
}
}
});
@@ -13,8 +13,8 @@ div
p.q-mt-xs.q-mb-none(v-if='declineMessage') Причина: {{ declineMessage }}
p.q-mt-xs.q-mb-none(v-else) Председатель отклонил поступление взноса. Вы можете повторить оплату или поправить данные и подать заявку снова.
.row.q-gutter-sm
BaseButton(variant='primary', @click='retryPayment') Повторить оплату
BaseButton(variant='secondary', @click='fixData') Исправить данные
BaseButton(variant='primary', @click='retryPayment', :disable='isResetting') Повторить оплату
BaseButton(variant='secondary', @click='fixData', :loading='isResetting') Исправить данные
//- Техническая ошибка обработки — направляем в поддержку.
template(v-else-if='isFailed')
p Произошла ошибка при регистрации. Пожалуйста, обратитесь в поддержку для устранения проблемы.
@@ -33,10 +33,14 @@ import { Loader } from 'src/shared/ui/Loader';
import { BaseBanner, BaseButton } from 'src/shared/ui/base';
import { useRegistratorStore } from 'src/entities/Registrator';
import { Zeus } from '@coopenomics/sdk';
import { useResetRegistration } from 'src/features/Account/ResetRegistration';
import { FailAlert } from 'src/shared/api';
const store = useRegistratorStore();
const session = useSessionStore();
const accountStore = useAccountStore();
const { resetRegistration } = useResetRegistration();
const isResetting = ref(false);
const currentStep = store.steps.WaitingRegistration;
const step = computed(() => store.state.step);
@@ -67,10 +71,24 @@ const retryPayment = () => {
store.goTo('PayInitial');
};
const fixData = () => {
store.state.is_paid = false;
store.state.payment = null;
store.goTo('SetUserData');
// «Исправить данные» = откат регистрации на бэкенде: после подписи заявления
// профиль и e-mail заморожены (users.status=joined), локального возврата мало —
// сервер снимает заморозку, сбрасывает заявление и непринятый платёж. Только
// после этого возвращаем пайщика к редактированию данных.
const fixData = async () => {
isResetting.value = true;
try {
const account = await resetRegistration();
session.setCurrentUserAccount(account);
store.state.is_paid = false;
store.state.payment = null;
store.state.signature = '';
store.goTo('SetUserData');
} catch (e: any) {
FailAlert(`Не удалось вернуться к редактированию: ${e.message}`);
} finally {
isResetting.value = false;
}
};
// Опрос аккаунта: пока пайщик ждёт, подтягиваем актуальный статус платежа и
@@ -59,3 +59,6 @@ export * as StartResetKey from './startResetKey'
/** Обновить аккаунт в системе провайдера. Обновление аккаунта пользователя производится по username. Мутация позволяет изменить приватные данные пользователя, а также, адрес электронной почты в MONO. Использовать мутацию может только председатель совета. */
export * as UpdateAccount from './updateAccount'
/** Откатить собственную незавершённую регистрацию к редактированию данных (снимает заморозку профиля/e-mail, сбрасывает заявление и непринятый вступительный платёж). Доступно до отправки регистрации в блокчейн. */
export * as ResetRegistration from './resetRegistration'
@@ -0,0 +1,12 @@
import { accountSelector } from '../../selectors'
import { type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'resetRegistration'
// Мутация без аргументов: бэкенд берёт пайщика из токена (self-scoped),
// откатывая его собственную незавершённую регистрацию к редактированию данных.
export const mutation = Selector('Mutation')({
[name]: accountSelector,
})
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
+1
View File
@@ -3667,6 +3667,7 @@ export const ReturnTypes: Record<string,any> = {
registerAccount:"RegisteredAccount",
registerParticipant:"Account",
resetKey:"Boolean",
resetRegistration:"Account",
restartAnnualGeneralMeet:"MeetAggregate",
saveReportDraft:"ReportDraft",
selectBranch:"Boolean",
+8
View File
@@ -6584,6 +6584,8 @@ refresh?: [{ data: ValueTypes["RefreshInput"] | Variable<any, string>},ValueType
registerAccount?: [{ data: ValueTypes["RegisterAccountInput"] | Variable<any, string>},ValueTypes["RegisteredAccount"]],
registerParticipant?: [{ data: ValueTypes["RegisterParticipantInput"] | Variable<any, string>},ValueTypes["Account"]],
resetKey?: [{ data: ValueTypes["ResetKeyInput"] | Variable<any, string>},boolean | `@${string}`],
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration?:ValueTypes["Account"],
restartAnnualGeneralMeet?: [{ data: ValueTypes["RestartAnnualGeneralMeetInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
saveReportDraft?: [{ input: ValueTypes["SaveReportDraftInput"] | Variable<any, string>},ValueTypes["ReportDraft"]],
selectBranch?: [{ data: ValueTypes["SelectBranchInput"] | Variable<any, string>},boolean | `@${string}`],
@@ -14942,6 +14944,8 @@ refresh?: [{ data: ResolverInputTypes["RefreshInput"]},ResolverInputTypes["Regis
registerAccount?: [{ data: ResolverInputTypes["RegisterAccountInput"]},ResolverInputTypes["RegisteredAccount"]],
registerParticipant?: [{ data: ResolverInputTypes["RegisterParticipantInput"]},ResolverInputTypes["Account"]],
resetKey?: [{ data: ResolverInputTypes["ResetKeyInput"]},boolean | `@${string}`],
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration?:ResolverInputTypes["Account"],
restartAnnualGeneralMeet?: [{ data: ResolverInputTypes["RestartAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]],
saveReportDraft?: [{ input: ResolverInputTypes["SaveReportDraftInput"]},ResolverInputTypes["ReportDraft"]],
selectBranch?: [{ data: ResolverInputTypes["SelectBranchInput"]},boolean | `@${string}`],
@@ -23473,6 +23477,8 @@ export type ModelTypes = {
registerParticipant: ModelTypes["Account"],
/** Заменить приватный ключ аккаунта */
resetKey: boolean,
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration: ModelTypes["Account"],
/** Перезапуск общего собрания пайщиков
Требуемые роли: chairman. */
@@ -32418,6 +32424,8 @@ export type GraphQLTypes = {
registerParticipant: GraphQLTypes["Account"],
/** Заменить приватный ключ аккаунта */
resetKey: boolean,
/** Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств. */
resetRegistration: GraphQLTypes["Account"],
/** Перезапуск общего собрания пайщиков
Требуемые роли: chairman. */