Merge pull request 'feat: выход из кооператива (выход пайщика, возврат паевого взноса)' (#137) from feat/membership-exit into dev
Reviewed-on: #137
This commit was merged in pull request #137.
This commit is contained in:
@@ -85,7 +85,8 @@ static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелё
|
||||
|
||||
static const std::set<eosio::name> soviet_actions = {
|
||||
"joincoop"_n, //регистрация пайщика
|
||||
|
||||
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
|
||||
|
||||
//MEET
|
||||
"creategm"_n,//предложение повестки планового общего собрание
|
||||
"completegm"_n, //решение общего собрания пайщиков
|
||||
|
||||
@@ -61,6 +61,7 @@ namespace operations {
|
||||
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
|
||||
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
|
||||
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
|
||||
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
|
||||
}
|
||||
|
||||
// wallet
|
||||
@@ -242,6 +243,15 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат регистрационного взноса при отказе совета" },
|
||||
|
||||
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
|
||||
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
|
||||
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
|
||||
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
|
||||
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
|
||||
0, 0,
|
||||
"Перенос минимального паевого на главный при выходе из кооператива" },
|
||||
|
||||
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
|
||||
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
|
||||
@@ -252,6 +252,37 @@ inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
|
||||
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
|
||||
*
|
||||
* Единый источник истины для суммы возврата. При одобрении выхода советом
|
||||
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
|
||||
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
|
||||
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
|
||||
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
|
||||
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
|
||||
* тем, что реально вернёт контракт.
|
||||
*
|
||||
* Состав — только паевые/возвратные USER_SHARED-кошельки:
|
||||
* w.reg.minshr — минимальный паевой взнос;
|
||||
* w.wal.share — целевой паевой взнос (ЦК);
|
||||
* w.cap.blago — паевой взнос в ЦПП «Благорост».
|
||||
*
|
||||
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
|
||||
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
|
||||
* w.cap.preimp (пред-импорт-учёт РИД).
|
||||
*
|
||||
* Каждому не-главному кошельку сета должна соответствовать операция переноса
|
||||
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
|
||||
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
|
||||
*/
|
||||
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
|
||||
ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_wallets::BLAGOROST_FUND,
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Возвращает required program_id для USER_SHARED-кошелька.
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "table_registrator_verification.hpp"
|
||||
#include "table_registrator_candidates.hpp"
|
||||
#include "table_registrator_candidates_legacy.hpp"
|
||||
#include "table_registrator_exits.hpp"
|
||||
|
||||
// coops (soviet)
|
||||
#include "coops_access_helpers.hpp"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "document_core.hpp"
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @ingroup public_tables
|
||||
* @ingroup public_registrator_tables
|
||||
* @par table: exits (registrator)
|
||||
*
|
||||
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
|
||||
* процесс возврата паевого взноса.
|
||||
*
|
||||
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
|
||||
* wallet::createwthd→authwthd→completewthd):
|
||||
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
|
||||
* - confirmexit → совет одобрил: статус `authorized`, консолидация
|
||||
* минимального паевого на главный (o.reg.mvmin), резерв
|
||||
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
|
||||
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
|
||||
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
|
||||
* запись стирается;
|
||||
* - declinexit → отказ совета или отклонение платежа: снятие резерва
|
||||
* (o.wal.wthdec, если он был сделан) и стирание записи.
|
||||
*
|
||||
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
|
||||
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
|
||||
* ledger2), а не доверяется клиенту.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
|
||||
name username;
|
||||
name coopname;
|
||||
name status; ///< pending | authorized
|
||||
time_point_sec created_at;
|
||||
document2 statement; ///< заявление о выходе (registry 200)
|
||||
document2 approved_statement; ///< решение совета о выходе (авторизация)
|
||||
checksum256 exit_hash;
|
||||
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
|
||||
|
||||
uint64_t primary_key() const { return username.value; }
|
||||
checksum256 by_hash() const { return exit_hash; }
|
||||
};
|
||||
|
||||
typedef multi_index<
|
||||
"exits"_n, exit,
|
||||
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
|
||||
exits_index;
|
||||
|
||||
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
|
||||
exits_index primary_index(_registrator, coopname.value);
|
||||
auto secondary_index = primary_index.get_index<"byhash"_n>();
|
||||
|
||||
auto itr = secondary_index.find(hash);
|
||||
if (itr == secondary_index.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return *itr;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -12,6 +12,12 @@
|
||||
#include "src/user/declinerfnd.cpp"
|
||||
#include "src/user/reguser.cpp"
|
||||
|
||||
#include "src/exit/exit_helpers.hpp"
|
||||
#include "src/exit/exitcoop.cpp"
|
||||
#include "src/exit/confirmexit.cpp"
|
||||
#include "src/exit/completexit.cpp"
|
||||
#include "src/exit/declinexit.cpp"
|
||||
|
||||
#include "src/account/createbranch.cpp"
|
||||
#include "src/account/newaccount.cpp"
|
||||
#include "src/account/updateaccnt.cpp"
|
||||
|
||||
@@ -85,6 +85,12 @@ public:
|
||||
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
|
||||
|
||||
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
|
||||
|
||||
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
|
||||
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
|
||||
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
|
||||
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
|
||||
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
|
||||
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @brief Завершение возврата паевого взноса при выходе из кооператива.
|
||||
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
|
||||
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
|
||||
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
|
||||
* Возврат назад невозможен — повторное участие только через новую регистрацию.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
|
||||
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
|
||||
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @brief Одобрение советом выхода пайщика из кооператива.
|
||||
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
|
||||
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
|
||||
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
|
||||
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
|
||||
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
|
||||
* без платежа.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param authorization Документ-решение совета о выходе
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
|
||||
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
|
||||
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
|
||||
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
|
||||
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
|
||||
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
|
||||
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
|
||||
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
|
||||
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
|
||||
if (balance.amount <= 0) continue;
|
||||
total_return += balance;
|
||||
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
|
||||
}
|
||||
|
||||
exits.modify(e, _soviet, [&](auto &row) {
|
||||
row.status = "authorized"_n;
|
||||
row.approved_statement = authorization;
|
||||
row.quantity = total_return;
|
||||
});
|
||||
|
||||
if (total_return.amount > 0) {
|
||||
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
|
||||
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
|
||||
total_return, username, exit_hash, memo_req);
|
||||
|
||||
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
|
||||
Action::send<createoutpay_interface>(
|
||||
_gateway,
|
||||
"createoutpay"_n,
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
exit_hash,
|
||||
total_return,
|
||||
_registrator,
|
||||
"completexit"_n,
|
||||
"declinexit"_n
|
||||
);
|
||||
} else {
|
||||
// Возвращать нечего — финализируем выход без платежа.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
exits.erase(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @brief Отказ в выходе из кооператива.
|
||||
* Вызывается либо советом при отклонении заявления (до резервирования средств),
|
||||
* либо gateway при отклонении исходящего платежа (после резервирования). Если
|
||||
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
|
||||
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
|
||||
*/
|
||||
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
|
||||
check_auth_and_get_payer_or_fail({_soviet, _gateway});
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
|
||||
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
|
||||
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
|
||||
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
|
||||
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
|
||||
if (e->status == "authorized"_n && e->quantity.amount > 0) {
|
||||
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../../../lib/index.hpp"
|
||||
|
||||
/**
|
||||
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
|
||||
*
|
||||
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
|
||||
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
|
||||
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
|
||||
* весь lib/index.hpp уже доступен.
|
||||
*/
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
|
||||
*
|
||||
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
|
||||
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
|
||||
* валюте управления.
|
||||
*/
|
||||
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
|
||||
userwallets_index userwallets(_ledger2, coopname.value);
|
||||
auto idx = userwallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(wallet_name.value, username.value));
|
||||
if (it == idx.end()) {
|
||||
return asset(0, _root_govern_symbol);
|
||||
}
|
||||
return it->available;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Консолидация доступного паевого кошелька пайщика на главный
|
||||
* (`w.wal.share`) перед резервом возврата при выходе.
|
||||
*
|
||||
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
|
||||
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
|
||||
* главный:
|
||||
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
|
||||
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
|
||||
*
|
||||
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
|
||||
* валит транзакцию с явным сообщением — защита от тихой потери средств.
|
||||
*/
|
||||
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
|
||||
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
|
||||
|
||||
eosio::name op;
|
||||
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
|
||||
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
|
||||
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
|
||||
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
|
||||
} else {
|
||||
eosio::check(false,
|
||||
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
|
||||
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
|
||||
}
|
||||
|
||||
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
|
||||
wallet_name.to_string() + ", username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
|
||||
* аккаунта в registrator.
|
||||
*
|
||||
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
|
||||
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
|
||||
* для пайщика начинает падать — он лишён права подавать заявления.
|
||||
*/
|
||||
inline void finalize_member_exit(name coopname, name username) {
|
||||
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
|
||||
action(
|
||||
permission_level{_registrator, "active"_n},
|
||||
_soviet,
|
||||
"delpartcpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
|
||||
// блокируем аккаунт в картотеке registrator
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account = accounts.find(username.value);
|
||||
eosio::check(account != accounts.end(), "Аккаунт не найден");
|
||||
accounts.modify(account, _registrator, [&](auto &a) {
|
||||
a.status = "blocked"_n;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @brief Подача заявления на выход из кооператива.
|
||||
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
|
||||
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
|
||||
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Имя пайщика, выходящего из кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param statement Подписанное заявление о выходе (registry 200)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
// выйти может только действующий пайщик (не заблокированный)
|
||||
get_participant_or_fail(coopname, username);
|
||||
|
||||
// повторная подача запрещена — у пайщика может быть только один процесс выхода
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto existing = exits.find(username.value);
|
||||
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
|
||||
|
||||
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
|
||||
|
||||
// проверяем подпись заявления о выходе
|
||||
verify_document_or_fail(statement);
|
||||
|
||||
exits.emplace(coopname, [&](auto &e) {
|
||||
e.username = username;
|
||||
e.coopname = coopname;
|
||||
e.status = "pending"_n;
|
||||
e.created_at = current_time_point();
|
||||
e.statement = statement;
|
||||
e.exit_hash = exit_hash;
|
||||
e.quantity = asset(0, _root_govern_symbol);
|
||||
});
|
||||
|
||||
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
|
||||
::Soviet::create_agenda(
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("leavecoop"_n),
|
||||
exit_hash,
|
||||
_registrator, // callback_contract
|
||||
"confirmexit"_n, // callback при одобрении
|
||||
"declinexit"_n, // callback при отказе
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
#include "src/fund/fundwithdraw.cpp"
|
||||
|
||||
#include "src/participant/addparticipant.cpp"
|
||||
#include "src/participant/delparticipant.cpp"
|
||||
#include "src/participant/setminamt.cpp"
|
||||
#include "src/participant/block.cpp"
|
||||
#include "src/participant/cancelreg.cpp"
|
||||
|
||||
@@ -139,6 +139,9 @@ public:
|
||||
//regaccount.cpp
|
||||
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
|
||||
|
||||
//delparticipant.cpp
|
||||
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
|
||||
|
||||
//setminamt.cpp
|
||||
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @brief Удаление пайщика из кооператива при выходе
|
||||
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
|
||||
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
|
||||
* пайщик был активным и имел право голоса — уменьшает счётчик активных
|
||||
* пайщиков в registrator (как это делает `block`).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Наименование выбывающего пайщика
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта в белом списке контрактов
|
||||
*/
|
||||
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
|
||||
check_auth_and_get_payer_or_fail(contracts_whitelist);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
participants_index participants(_soviet, coopname.value);
|
||||
auto participant = participants.find(username.value);
|
||||
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
|
||||
|
||||
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
|
||||
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
|
||||
bool had_vote = participant->has_vote;
|
||||
bool was_active = participant->status == "accepted"_n;
|
||||
|
||||
if (was_active && had_vote) {
|
||||
action(
|
||||
permission_level{_soviet, "active"_n},
|
||||
_registrator,
|
||||
"decparticpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
}
|
||||
|
||||
participants.erase(participant);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import { UserModule } from './application/user/user.module';
|
||||
import { TokenApplicationModule } from './application/token/token-application.module';
|
||||
import { SettingsApplicationModule } from './application/settings/settings.module';
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { MembershipExitModule } from './application/membership-exit/membership-exit.module';
|
||||
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
|
||||
import { SearchModule } from './application/search/search.module';
|
||||
import { SignedDocumentsModule } from './application/signed-documents/signed-documents.module';
|
||||
@@ -169,6 +170,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
TokenApplicationModule,
|
||||
SettingsApplicationModule,
|
||||
RegistrationModule,
|
||||
MembershipExitModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
SignedDocumentsModule,
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
import type { ExcludeCommonProps } from '~/application/document/types';
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.ParticipantExitApplication.Action;
|
||||
|
||||
@InputType(`BaseMembershipExitApplicationMetaDocumentInput`)
|
||||
class BaseMembershipExitApplicationMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({
|
||||
description:
|
||||
'Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю)',
|
||||
})
|
||||
@IsBoolean()
|
||||
skip_save!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitApplicationGenerateDocumentInput`)
|
||||
export class MembershipExitApplicationGenerateDocumentInputDTO
|
||||
extends IntersectionType(
|
||||
BaseMembershipExitApplicationMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
)
|
||||
implements action
|
||||
{
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitApplicationSignedMetaDocumentInput`)
|
||||
export class MembershipExitApplicationSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseMembershipExitApplicationMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`MembershipExitApplicationSignedDocumentInput`)
|
||||
export class MembershipExitApplicationSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => MembershipExitApplicationSignedMetaDocumentInputDTO)
|
||||
public readonly meta!: MembershipExitApplicationSignedMetaDocumentInputDTO;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsNumber } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
import type { ExcludeCommonProps } from '~/application/document/types';
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.DecisionOfParticipantExit.Action;
|
||||
|
||||
@InputType(`BaseMembershipExitDecisionMetaDocumentInput`)
|
||||
class BaseMembershipExitDecisionMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Идентификатор протокола решения собрания совета' })
|
||||
@IsNumber()
|
||||
decision_id!: number;
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitDecisionGenerateDocumentInput`)
|
||||
export class MembershipExitDecisionGenerateDocumentInputDTO
|
||||
extends IntersectionType(
|
||||
BaseMembershipExitDecisionMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
)
|
||||
implements action
|
||||
{
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitDecisionSignedMetaDocumentInput`)
|
||||
export class MembershipExitDecisionSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseMembershipExitDecisionMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`MembershipExitDecisionSignedDocumentInput`)
|
||||
export class MembershipExitDecisionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => MembershipExitDecisionSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация решения собрания совета о выходе пайщика',
|
||||
})
|
||||
public readonly meta!: MembershipExitDecisionSignedMetaDocumentInputDTO;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
|
||||
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
|
||||
|
||||
/**
|
||||
* Output DTO записи о файле платежа (чеке об оплате) в MinIO-бакете.
|
||||
*
|
||||
* `read_url` — короткоживущий HMAC-signed URL (TTL = `defaultUrlTtlSeconds` бакета),
|
||||
* выдаётся после проверки прав; держатель URL может скачать файл до истечения TTL.
|
||||
*/
|
||||
@ObjectType('PaymentFile', { description: 'Запись о файле, приложенном к платежу (чек об оплате).' })
|
||||
export class PaymentFileOutputDTO {
|
||||
@Field(() => Int, { description: 'Внутренний ID записи.' })
|
||||
id!: number;
|
||||
|
||||
@Field(() => String, { description: 'Имя кооператива (scope).' })
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Хеш платежа.' })
|
||||
payment_hash!: string;
|
||||
|
||||
@Field(() => PaymentFileKind, { description: 'Назначение файла.' })
|
||||
kind!: PaymentFileKind;
|
||||
|
||||
@Field(() => String, { description: 'SHA-256 содержимого, hex-lowercase.' })
|
||||
checksum_sha256!: string;
|
||||
|
||||
@Field(() => String, { description: 'MIME-тип содержимого.' })
|
||||
mime_type!: string;
|
||||
|
||||
@Field(() => Int, { description: 'Размер файла в байтах.' })
|
||||
size_bytes!: number;
|
||||
|
||||
@Field(() => String, { description: 'MinIO-ключ внутри бакета.' })
|
||||
storage_key!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Оригинальное имя загруженного файла.' })
|
||||
original_filename?: string;
|
||||
|
||||
@Field(() => String, { description: 'Кто загрузил (username).' })
|
||||
uploaded_by_username!: string;
|
||||
|
||||
@Field(() => Date, { description: 'Когда загружено.' })
|
||||
uploaded_at!: Date;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Короткоживущий URL на скачивание (HMAC-signed).' })
|
||||
read_url?: string;
|
||||
|
||||
static fromDomain(data: IPaymentFileDatabaseData, readUrl?: string): PaymentFileOutputDTO {
|
||||
const dto = new PaymentFileOutputDTO();
|
||||
dto.id = data.id ?? 0;
|
||||
dto.coopname = data.coopname;
|
||||
dto.payment_hash = data.payment_hash;
|
||||
dto.kind = data.kind;
|
||||
dto.checksum_sha256 = data.checksum_sha256;
|
||||
dto.mime_type = data.mime_type;
|
||||
dto.size_bytes = data.size_bytes;
|
||||
dto.storage_key = data.storage_key;
|
||||
dto.original_filename = data.original_filename ?? undefined;
|
||||
dto.uploaded_by_username = data.uploaded_by_username;
|
||||
dto.uploaded_at = data.uploaded_at;
|
||||
dto.read_url = readUrl;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsBase64, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'application/pdf'] as const;
|
||||
|
||||
/**
|
||||
* Input для `uploadPaymentProof` — приложить чек об оплате к платежу.
|
||||
*
|
||||
* Привязка по `payment_hash`. MVP-передача: base64-содержимое внутри mutation
|
||||
* (до 20 МБ — хватает для фото/PDF чека). Вид файла всегда PAYMENT_PROOF
|
||||
* (чек об оплате) — поэтому в input не выносится.
|
||||
*/
|
||||
@InputType('UploadPaymentProofInput')
|
||||
export class UploadPaymentProofInputDTO {
|
||||
@Field(() => String, { description: 'Имя кооператива.' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Хеш платежа, к которому прикладывается чек.' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
payment_hash!: string;
|
||||
|
||||
@Field(() => String, { description: 'MIME-тип содержимого.' })
|
||||
@IsIn([...ALLOWED_MIME], { message: 'mime_type должен быть одним из allowed.' })
|
||||
mime_type!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Оригинальное имя файла — для отображения и поиска.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
original_filename?: string;
|
||||
|
||||
@Field(() => Number, { description: 'Размер файла в байтах (для серверной валидации).' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20 * 1024 * 1024)
|
||||
size_bytes!: number;
|
||||
|
||||
@Field(() => String, { description: 'SHA-256 содержимого, hex-lowercase (64 hex-символа).' })
|
||||
@Matches(/^[a-f0-9]{64}$/, { message: 'checksum_sha256 должен быть 64-символьным lowercase hex.' })
|
||||
checksum_sha256!: string;
|
||||
|
||||
@Field(() => String, { description: 'Содержимое файла, base64 без префикса data:.' })
|
||||
@IsBase64()
|
||||
content_base64!: string;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { GatewayResolver } from './resolvers/gateway.resolver';
|
||||
import { PaymentFilesResolver } from './resolvers/payment-files.resolver';
|
||||
import { GatewayService } from './services/gateway.service';
|
||||
import { PaymentFilesService } from './services/payment-files.service';
|
||||
import { PaymentNotificationService } from './services/payment-notification.service';
|
||||
import { WithdrawAuthorizationListener } from './services/withdraw-authorization.listener';
|
||||
import { PaymentController } from './controllers/payment.controller';
|
||||
@@ -13,6 +15,7 @@ import { SystemModule } from '~/application/system/system.module';
|
||||
import { GatewayInfrastructureModule } from '~/infrastructure/gateway/gateway-infrastructure.module';
|
||||
import { UserInfrastructureModule } from '~/infrastructure/user/user-infrastructure.module';
|
||||
import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,16 +27,20 @@ import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
AccountInfrastructureModule,
|
||||
SystemModule,
|
||||
RedisModule,
|
||||
// Чек об оплате (gateway:files) — ядровый механизм, бакет по @UseBucket.
|
||||
FileStorageInfrastructureModule.forFeature([PaymentFilesService]),
|
||||
],
|
||||
controllers: [PaymentController],
|
||||
providers: [
|
||||
GatewayResolver,
|
||||
PaymentFilesResolver,
|
||||
GatewayService,
|
||||
PaymentFilesService,
|
||||
PaymentNotificationService,
|
||||
GatewayInteractor,
|
||||
GatewayNotificationHandler,
|
||||
WithdrawAuthorizationListener,
|
||||
],
|
||||
exports: [GatewayService, PaymentNotificationService, GatewayInteractor],
|
||||
exports: [GatewayService, PaymentNotificationService, GatewayInteractor, PaymentFilesService],
|
||||
})
|
||||
export class GatewayModule {}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { PaymentFilesService } from '../services/payment-files.service';
|
||||
import { UploadPaymentProofInputDTO } from '../dto/upload-payment-proof.input';
|
||||
import { PaymentFileOutputDTO } from '../dto/payment-file.output';
|
||||
|
||||
/**
|
||||
* GraphQL для чеков об оплате платежей (ядро gateway).
|
||||
*
|
||||
* - Прикладывает чек кассир (chairman/member) — подтверждение исполненной оплаты.
|
||||
* - Списки и read-URL видят те же роли и пайщик (свой чек). ACL уточнится после E2E.
|
||||
*/
|
||||
@Resolver(() => PaymentFileOutputDTO)
|
||||
export class PaymentFilesResolver {
|
||||
constructor(private readonly paymentFiles: PaymentFilesService) {}
|
||||
|
||||
@Mutation(() => PaymentFileOutputDTO, {
|
||||
name: 'uploadPaymentProof',
|
||||
description: 'Приложить чек об оплате к платежу (бакет gateway:files).',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async uploadPaymentProof(
|
||||
@Args('data', { type: () => UploadPaymentProofInputDTO }) data: UploadPaymentProofInputDTO,
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<PaymentFileOutputDTO> {
|
||||
const { data: saved, readUrl } = await this.paymentFiles.uploadProof(data, user.username);
|
||||
return PaymentFileOutputDTO.fromDomain(saved, readUrl);
|
||||
}
|
||||
|
||||
@Query(() => PaymentFileOutputDTO, {
|
||||
name: 'paymentFile',
|
||||
description: 'Получить запись о файле платежа + свежий короткоживущий read-URL.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async getPaymentFile(@Args('id', { type: () => Int }) id: number): Promise<PaymentFileOutputDTO> {
|
||||
const { data, readUrl } = await this.paymentFiles.getReadUrl(id);
|
||||
return PaymentFileOutputDTO.fromDomain(data, readUrl);
|
||||
}
|
||||
|
||||
@Query(() => [PaymentFileOutputDTO], {
|
||||
name: 'paymentProofs',
|
||||
description: 'Список чеков об оплате платежа (без read-URL — запрос отдельно по id).',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async listByPayment(
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('payment_hash', { type: () => String }) paymentHash: string
|
||||
): Promise<PaymentFileOutputDTO[]> {
|
||||
const items = await this.paymentFiles.listByPayment(coopname, paymentHash);
|
||||
return items.map((d) => PaymentFileOutputDTO.fromDomain(d));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import type { InterFileStorageBucket } from '@coopenomics/inter';
|
||||
import { InjectBucket, UseBucket } from '~/infrastructure/file-storage';
|
||||
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import {
|
||||
PAYMENT_FILE_REPOSITORY,
|
||||
type PaymentFileRepository,
|
||||
} from '~/domain/gateway/repositories/payment-file.repository';
|
||||
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
|
||||
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
|
||||
import { GATEWAY_BUCKET } from '~/domain/gateway/constants/gateway-bucket';
|
||||
import { UploadPaymentProofInputDTO } from '../dto/upload-payment-proof.input';
|
||||
|
||||
const EXTENSION_BY_MIME: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/heic': 'heic',
|
||||
'application/pdf': 'pdf',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ядровый сервис файлов платежей: чек об оплате исходящего платежа.
|
||||
*
|
||||
* «Культура денег»: входящие подтверждаем, исходящие сопровождаем чеком.
|
||||
* Привязка по `payment_hash` — единый ключ любого платежа, поэтому механизм
|
||||
* один на все исходящие (возврат паевого/withdrawal/registration-refund/аванс
|
||||
* расхода) и переиспользуется расширениями. Контроль мягкий: статус платежа не
|
||||
* трогаем (деньги уже ушли on-chain), но зеркалим число чеков в
|
||||
* `blockchain_data.proof_count` — реестр по нему рисует «чек приложен / нет».
|
||||
*
|
||||
* Ключ MinIO детерминирован: `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
|
||||
* Повторная загрузка того же содержимого идемпотентна на бакете, в БД защищаемся
|
||||
* явным `findByChecksum` (409 при дубле).
|
||||
*/
|
||||
@UseBucket(GATEWAY_BUCKET)
|
||||
@Injectable()
|
||||
export class PaymentFilesService {
|
||||
constructor(
|
||||
@InjectBucket() private readonly bucket: InterFileStorageBucket,
|
||||
@Inject(PAYMENT_FILE_REPOSITORY) private readonly files: PaymentFileRepository,
|
||||
@Inject(PAYMENT_REPOSITORY) private readonly payments: PaymentRepository
|
||||
) {}
|
||||
|
||||
async uploadProof(
|
||||
input: UploadPaymentProofInputDTO,
|
||||
uploadedByUsername: string
|
||||
): Promise<{ data: IPaymentFileDatabaseData; readUrl: string }> {
|
||||
const body = Buffer.from(input.content_base64, 'base64');
|
||||
if (body.byteLength !== input.size_bytes) {
|
||||
throw new BadRequestException(
|
||||
`size_bytes (${input.size_bytes}) не совпадает с фактическим размером base64-контента (${body.byteLength}).`
|
||||
);
|
||||
}
|
||||
const actualChecksum = createHash('sha256').update(body).digest('hex');
|
||||
if (actualChecksum !== input.checksum_sha256.toLowerCase()) {
|
||||
throw new BadRequestException(`checksum_sha256 не совпадает с реальным SHA-256 содержимого.`);
|
||||
}
|
||||
|
||||
const payment = await this.payments.findByHash(input.payment_hash);
|
||||
if (!payment) {
|
||||
throw new NotFoundException(`Платёж ${input.payment_hash} не найден.`);
|
||||
}
|
||||
|
||||
const existing = await this.files.findByChecksum(input.coopname, actualChecksum);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Файл с таким SHA-256 уже зарегистрирован в этом кооперативе (id=${existing.id}).`
|
||||
);
|
||||
}
|
||||
|
||||
const storageKey = this.buildKey({
|
||||
coopname: input.coopname,
|
||||
paymentHash: input.payment_hash,
|
||||
kind: PaymentFileKind.PAYMENT_PROOF,
|
||||
checksum: actualChecksum,
|
||||
mimeType: input.mime_type,
|
||||
});
|
||||
|
||||
await this.bucket.put(storageKey, new Uint8Array(body), { contentType: input.mime_type });
|
||||
|
||||
const saved = await this.files.create({
|
||||
coopname: input.coopname,
|
||||
payment_hash: input.payment_hash.toLowerCase(),
|
||||
kind: PaymentFileKind.PAYMENT_PROOF,
|
||||
checksum_sha256: actualChecksum,
|
||||
mime_type: input.mime_type,
|
||||
size_bytes: body.byteLength,
|
||||
storage_key: storageKey,
|
||||
original_filename: input.original_filename ?? null,
|
||||
uploaded_by_username: uploadedByUsername,
|
||||
uploaded_at: new Date(),
|
||||
});
|
||||
|
||||
await this.syncProofMark(saved.coopname, saved.payment_hash);
|
||||
|
||||
const readUrl = await this.bucket.getReadUrl(storageKey);
|
||||
return { data: saved, readUrl };
|
||||
}
|
||||
|
||||
async getReadUrl(fileId: number): Promise<{ data: IPaymentFileDatabaseData; readUrl: string }> {
|
||||
const file = await this.files.findById(fileId);
|
||||
if (!file) throw new NotFoundException(`Файл платежа #${fileId} не найден.`);
|
||||
const readUrl = await this.bucket.getReadUrl(file.storage_key);
|
||||
return { data: file, readUrl };
|
||||
}
|
||||
|
||||
async listByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]> {
|
||||
return this.files.findByPayment(coopname, paymentHash.toLowerCase());
|
||||
}
|
||||
|
||||
async deleteFile(fileId: number): Promise<void> {
|
||||
const file = await this.files.findById(fileId);
|
||||
if (!file) throw new NotFoundException(`Файл платежа #${fileId} не найден.`);
|
||||
await this.bucket.delete(file.storage_key);
|
||||
await this.files.delete(fileId);
|
||||
await this.syncProofMark(file.coopname, file.payment_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Зеркалит число приложенных чеков в платёж (`blockchain_data.proof_count`) —
|
||||
* реестр показывает по нему «чек приложен / не приложен», не дёргая хранилище
|
||||
* файлов на каждую строку. Статус платежа не трогаем (им управляет gateway).
|
||||
*/
|
||||
private async syncProofMark(coopname: string, paymentHash: string): Promise<void> {
|
||||
const payment = await this.payments.findByHash(paymentHash);
|
||||
if (!payment?.id) return;
|
||||
const proofs = await this.files.findByPayment(coopname, paymentHash);
|
||||
await this.payments.update(payment.id, {
|
||||
blockchain_data: { ...(payment.blockchain_data ?? {}), proof_count: proofs.length },
|
||||
});
|
||||
}
|
||||
|
||||
private buildKey(params: {
|
||||
coopname: string;
|
||||
paymentHash: string;
|
||||
kind: PaymentFileKind;
|
||||
checksum: string;
|
||||
mimeType: string;
|
||||
}): string {
|
||||
const ext = EXTENSION_BY_MIME[params.mimeType] ?? 'bin';
|
||||
return `${params.coopname}/gateway/${params.paymentHash.toLowerCase()}/${params.kind}/${params.checksum}.${ext}`;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsString, ValidateNested, IsNotEmpty } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import type { CreateMembershipExitInputDomainInterface } from '~/domain/account/interfaces/create-membership-exit-input.interface';
|
||||
import { MembershipExitApplicationSignedDocumentInputDTO } from '~/application/document/documents-dto/membership-exit-application-document.dto';
|
||||
|
||||
/**
|
||||
* DTO подачи заявления на выход пайщика из кооператива.
|
||||
*/
|
||||
@InputType('CreateMembershipExitInput')
|
||||
export class CreateMembershipExitInputDTO implements CreateMembershipExitInputDomainInterface {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя пайщика, выходящего из кооператива' })
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@Field(() => String, { description: 'Хеш процесса выхода (генерируется на клиенте)' })
|
||||
@IsString()
|
||||
exit_hash!: string;
|
||||
|
||||
@Field(() => MembershipExitApplicationSignedDocumentInputDTO, {
|
||||
description: 'Подписанное пайщиком заявление о выходе из кооператива',
|
||||
})
|
||||
@ValidateNested()
|
||||
@IsNotEmpty({ message: 'Поле "statement" обязательно для заполнения.' })
|
||||
@Type(() => MembershipExitApplicationSignedDocumentInputDTO)
|
||||
statement!: MembershipExitApplicationSignedDocumentInputDTO;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { MembershipExitStatus } from '../enums/membership-exit-status.enum';
|
||||
|
||||
/**
|
||||
* Результат подачи заявления на выход из кооператива.
|
||||
*/
|
||||
@ObjectType('MembershipExitResult')
|
||||
export class MembershipExitResultDTO {
|
||||
@Field(() => String, { description: 'Хеш созданного процесса выхода' })
|
||||
exit_hash!: string;
|
||||
|
||||
@Field(() => MembershipExitStatus, {
|
||||
description: 'Статус процесса выхода после подачи (ожидает подтверждения по ссылке из письма)',
|
||||
})
|
||||
status!: MembershipExitStatus;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Предварительный расчёт суммы возврата паевого взноса при выходе.
|
||||
*
|
||||
* Считается по L3-балансам ledger2 на момент запроса. Итоговую сумму при
|
||||
* одобрении совет фиксирует контракт (registrator::confirmexit), поэтому это
|
||||
* ориентир для пайщика, а не обязательство.
|
||||
*/
|
||||
@ObjectType('MembershipExitReturnPreview')
|
||||
export class MembershipExitReturnPreviewDTO {
|
||||
@Field(() => String, { description: 'Итоговая сумма к возврату (минимальный + целевой паевой)' })
|
||||
total!: string;
|
||||
|
||||
@Field(() => String, { description: 'Целевой паевой взнос пайщика' })
|
||||
share_contribution!: string;
|
||||
|
||||
@Field(() => String, { description: 'Минимальный паевой взнос пайщика' })
|
||||
minimum_contribution!: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import { MembershipExitStatus } from '../enums/membership-exit-status.enum';
|
||||
|
||||
/**
|
||||
* Текущий процесс выхода пайщика из кооператива (если активен).
|
||||
*/
|
||||
@ObjectType('MembershipExit')
|
||||
export class MembershipExitDTO {
|
||||
@Field(() => String, { description: 'Хеш процесса выхода' })
|
||||
exit_hash!: string;
|
||||
|
||||
@Field(() => MembershipExitStatus, { description: 'Статус процесса выхода' })
|
||||
status!: MembershipExitStatus;
|
||||
|
||||
@Field(() => String, { description: 'Сумма к возврату (фиксируется советом при одобрении; до одобрения — 0)' })
|
||||
quantity!: string;
|
||||
|
||||
@Field(() => PaymentStatusEnum, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Статус исходящего платежа возврата паевого взноса в реестре кассира. Создаётся при одобрении советом; null — платёж ещё не заведён.',
|
||||
})
|
||||
payment_status?: PaymentStatusEnum;
|
||||
|
||||
@Field(() => String, { description: 'Дата подачи заявления на выход' })
|
||||
created_at!: string;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Статус процесса выхода пайщика из кооператива (registrator::exits.status).
|
||||
*/
|
||||
export enum MembershipExitStatus {
|
||||
// Заявление подписано и принято, но в блокчейн ещё не отправлено —
|
||||
// ожидает подтверждения пайщиком по ссылке из письма (off-chain фаза).
|
||||
AWAITING_CONFIRMATION = 'awaiting_confirmation',
|
||||
// Заявление подано в блокчейн, ожидает решения совета.
|
||||
PENDING = 'pending',
|
||||
// Совет одобрил, идёт возврат паевого взноса (зарезервирован, исходящий платёж).
|
||||
AUTHORIZED = 'authorized',
|
||||
// Выход завершён: возврат оплачен, аккаунт заблокирован, пайщик вышел.
|
||||
// exits-строка на цепи уже стёрта (терминал=erase) — статус восстанавливаем
|
||||
// по blocked-аккаунту и завершённому MEMBERSHIP_EXIT-платежу.
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
registerEnumType(MembershipExitStatus, {
|
||||
name: 'MembershipExitStatus',
|
||||
description: 'Статус процесса выхода пайщика из кооператива',
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MembershipExitResolver } from './resolvers/membership-exit.resolver';
|
||||
import { MembershipExitService } from './services/membership-exit.service';
|
||||
import { MembershipExitAuthorizationListener } from './services/membership-exit-authorization.listener';
|
||||
import { ParticipantModule } from '../participant/participant.module';
|
||||
import { TokenApplicationModule } from '../token/token-application.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { SystemModule } from '../system/system.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { EventsInfrastructureModule } from '~/infrastructure/events/events.module';
|
||||
import { MembershipExitRequestEntity } from '~/infrastructure/database/typeorm/entities/membership-exit-request.entity';
|
||||
|
||||
/**
|
||||
* Модуль выхода пайщика из кооператива: генерация документов выхода (200/201),
|
||||
* подача заявления с подтверждением по email (off-chain черновик → токен →
|
||||
* письмо → confirm → registrator::exitcoop) и предрасчёт суммы возврата паевого.
|
||||
*
|
||||
* Порты ACCOUNT_BLOCKCHAIN_PORT / BLOCKCHAIN_PORT / USER_WALLET_REPOSITORY
|
||||
* предоставляются глобальными модулями (blockchain.module, typeorm.module).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ParticipantModule,
|
||||
TypeOrmModule.forFeature([MembershipExitRequestEntity]),
|
||||
forwardRef(() => TokenApplicationModule),
|
||||
forwardRef(() => NotificationModule),
|
||||
SystemModule,
|
||||
UserDomainModule,
|
||||
EventsInfrastructureModule,
|
||||
],
|
||||
providers: [MembershipExitResolver, MembershipExitService, MembershipExitAuthorizationListener],
|
||||
exports: [MembershipExitService],
|
||||
})
|
||||
export class MembershipExitModule {}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
import { MembershipExitApplicationGenerateDocumentInputDTO } from '~/application/document/documents-dto/membership-exit-application-document.dto';
|
||||
import { MembershipExitDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/membership-exit-decision-document.dto';
|
||||
import { MembershipExitService } from '../services/membership-exit.service';
|
||||
import { CreateMembershipExitInputDTO } from '../dto/create-membership-exit-input.dto';
|
||||
import { MembershipExitResultDTO } from '../dto/membership-exit-result.dto';
|
||||
import { MembershipExitReturnPreviewDTO } from '../dto/membership-exit-return-preview.dto';
|
||||
import { MembershipExitDTO } from '../dto/membership-exit.dto';
|
||||
|
||||
/**
|
||||
* GraphQL резолвер выхода пайщика из кооператива.
|
||||
*/
|
||||
@Resolver()
|
||||
export class MembershipExitResolver {
|
||||
constructor(private readonly membershipExitService: MembershipExitService) {}
|
||||
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'generateMembershipExitApplication',
|
||||
description: 'Сгенерировать документ заявления о выходе из кооператива.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async generateMembershipExitApplication(
|
||||
@Args('data', { type: () => MembershipExitApplicationGenerateDocumentInputDTO })
|
||||
data: MembershipExitApplicationGenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.membershipExitService.generateMembershipExitApplication(data, options);
|
||||
}
|
||||
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'generateMembershipExitDecision',
|
||||
description: 'Сгенерировать документ решения собрания совета о выходе пайщика.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async generateMembershipExitDecision(
|
||||
@Args('data', { type: () => MembershipExitDecisionGenerateDocumentInputDTO })
|
||||
data: MembershipExitDecisionGenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.membershipExitService.generateMembershipExitDecision(data, options);
|
||||
}
|
||||
|
||||
@Mutation(() => MembershipExitResultDTO, {
|
||||
name: 'createMembershipExit',
|
||||
description:
|
||||
'Подать подписанное заявление на выход из кооператива. Запускает рассмотрение советом и последующий возврат паевого взноса.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async createMembershipExit(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('data', { type: () => CreateMembershipExitInputDTO }) data: CreateMembershipExitInputDTO
|
||||
): Promise<MembershipExitResultDTO> {
|
||||
return this.membershipExitService.createMembershipExit(data, currentUser);
|
||||
}
|
||||
|
||||
@Mutation(() => MembershipExitResultDTO, {
|
||||
name: 'confirmMembershipExit',
|
||||
description:
|
||||
'Подтвердить выход из кооператива по ссылке из письма. Проверяет токен и отправляет ранее подписанное заявление в блокчейн.',
|
||||
})
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async confirmMembershipExit(
|
||||
@Args('token', { type: () => String }) token: string
|
||||
): Promise<MembershipExitResultDTO> {
|
||||
return this.membershipExitService.confirmMembershipExit(token);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'cancelMembershipExit',
|
||||
description: 'Отменить заявление на выход до подтверждения по email.',
|
||||
})
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async cancelMembershipExit(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('username', { type: () => String }) username: string
|
||||
): Promise<boolean> {
|
||||
return this.membershipExitService.cancelMembershipExit(coopname, username, currentUser);
|
||||
}
|
||||
|
||||
@Query(() => MembershipExitDTO, {
|
||||
name: 'membershipExit',
|
||||
nullable: true,
|
||||
description:
|
||||
'Текущий процесс выхода пайщика из кооператива (статус заявления и планируемая сумма возврата). null — активного выхода нет.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async membershipExit(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('username', { type: () => String }) username: string
|
||||
): Promise<MembershipExitDTO | null> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && username !== currentUser.username) {
|
||||
throw new ForbiddenException('Доступен только собственный статус выхода');
|
||||
}
|
||||
return this.membershipExitService.getMembershipExit(coopname, username);
|
||||
}
|
||||
|
||||
@Query(() => MembershipExitReturnPreviewDTO, {
|
||||
name: 'membershipExitReturnPreview',
|
||||
description:
|
||||
'Предварительный расчёт суммы возврата паевого взноса при выходе пайщика (минимальный + целевой паевой). Ориентир для пайщика; итог фиксирует совет.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async membershipExitReturnPreview(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('username', { type: () => String }) username: string
|
||||
): Promise<MembershipExitReturnPreviewDTO> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && username !== currentUser.username) {
|
||||
throw new ForbiddenException('Доступен только собственный расчёт возврата');
|
||||
}
|
||||
return this.membershipExitService.getReturnPreview(coopname, username);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { RegistratorContract } from 'cooptypes';
|
||||
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { PAYMENT_METHOD_REPOSITORY, type PaymentMethodRepository } from '~/domain/common/repositories/payment-method.repository';
|
||||
import { ACCOUNT_BLOCKCHAIN_PORT, type AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
|
||||
import { SYSTEM_DOMAIN_PORT, type SystemDomainPort } from '~/domain/system/interfaces/system-domain.port';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import { PaymentTypeEnum, PaymentDirectionEnum, VAT_EXEMPT_NOTE } from '~/domain/gateway/enums/payment-type.enum';
|
||||
import type {
|
||||
PaymentDomainInterface,
|
||||
PaymentDetailsDomainInterface,
|
||||
} from '~/domain/gateway/interfaces/payment-domain.interface';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
import { generateUniqueHash } from '~/utils/generate-hash.util';
|
||||
|
||||
// confirmexit — callback-экшен совета (одобрение повестки leavecoop). В cooptypes
|
||||
// SDK-обёртки нет (не подаётся клиентом), поэтому имя экшена — как в контракте.
|
||||
const REGISTRATOR = RegistratorContract.contractName.production;
|
||||
const CONFIRM_EXIT_EVENT = `action::${REGISTRATOR}::confirmexit`;
|
||||
|
||||
/**
|
||||
* Заводит исходящий платёж-возврат паевого взноса при одобрении советом выхода
|
||||
* пайщика из кооператива.
|
||||
*
|
||||
* On-chain registrator::confirmexit (одобрение повестки leavecoop) сам считает
|
||||
* сумму возврата по L3-балансам (минимальный + целевой паевой), резервирует её
|
||||
* (o.wal.wthreq) и создаёт исходящий объект в gateway через createoutpay с
|
||||
* confirm-callback completexit. Но платёж в реестре gateway, который УВИДИТ
|
||||
* кассир, on-chain не создаётся — заводим его здесь по реквизитам пайщика.
|
||||
*
|
||||
* Подтверждение кассиром проводит on-chain завершение: default-ветка
|
||||
* gateway.interactor.processOutgoingPayment вызывает completeOutcome →
|
||||
* gateway::outcomplete → registrator::completexit (списание Дт80/Кт51 +
|
||||
* блокировка аккаунта). Поэтому hash платежа = exit_hash (= outcome_hash
|
||||
* on-chain объекта), иначе completeOutcome не найдёт объект.
|
||||
*
|
||||
* Реквизиты обязательны на момент подачи заявления (гейт в
|
||||
* MembershipExitService.createMembershipExit); здесь берём метод по умолчанию.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MembershipExitAuthorizationListener {
|
||||
private readonly logger = new Logger(MembershipExitAuthorizationListener.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository,
|
||||
@Inject(PAYMENT_METHOD_REPOSITORY) private readonly paymentMethodRepository: PaymentMethodRepository,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(SYSTEM_DOMAIN_PORT) private readonly systemDomainPort: SystemDomainPort
|
||||
) {}
|
||||
|
||||
@OnEvent(CONFIRM_EXIT_EVENT)
|
||||
async onConfirmExit(action: ActionDomainInterface): Promise<void> {
|
||||
const coopname = action?.data?.coopname as string | undefined;
|
||||
const exitHashFromAction = action?.data?.exit_hash as string | undefined;
|
||||
if (!coopname || !exitHashFromAction) return;
|
||||
|
||||
// exit_hash на входе confirmexit есть, но username и итоговую сумму берём из
|
||||
// таблицы (контракт сам посчитал quantity и перевёл статус в authorized).
|
||||
const exit = await this.accountBlockchainPort.getExitByHash(coopname, exitHashFromAction);
|
||||
if (!exit) {
|
||||
// Запись могла быть стёрта контрактом — это штатно при total_return == 0
|
||||
// (возвращать нечего, выход финализирован без платежа).
|
||||
this.logger.debug(`confirmexit: запись выхода по exit_hash=${exitHashFromAction} не найдена — пропуск`);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = String(exit.status);
|
||||
if (status !== 'authorized') {
|
||||
this.logger.debug(`confirmexit: выход ${exit.exit_hash} в статусе "${status}" (не authorized) — пропуск`);
|
||||
return;
|
||||
}
|
||||
|
||||
const exitHash = String(exit.exit_hash);
|
||||
const username = String(exit.username);
|
||||
|
||||
// Идемпотентность: повторный приход события (replay/форк) не плодит платежи.
|
||||
const existing = await this.paymentRepository.findByHash(exitHash);
|
||||
if (existing) {
|
||||
this.logger.debug(`confirmexit: платёж возврата по выходу ${exitHash} уже создан (${existing.id}) — пропуск`);
|
||||
return;
|
||||
}
|
||||
|
||||
// quantity на чейне — asset-строка вида "300.0000 RUB".
|
||||
const [amountStr, symbol] = String(exit.quantity).split(' ');
|
||||
const quantity = Number(amountStr);
|
||||
if (!symbol || !(quantity > 0)) {
|
||||
this.logger.warn(`confirmexit: нулевая/некорректная сумма возврата (${exit.quantity}) по выходу ${exitHash} — платёж не создаём`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Метод по умолчанию (или первый) — на момент подачи заявления реквизиты были
|
||||
// обязательны (гейт createMembershipExit). Если пайщик удалил их после подачи
|
||||
// — платёж создать не из чего, логируем ошибку (вырожденный случай).
|
||||
const methods = await this.paymentMethodRepository.list({
|
||||
username,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
sortOrder: 'DESC',
|
||||
});
|
||||
const method = methods.items.find((m) => m.is_default) ?? methods.items[0];
|
||||
if (!method) {
|
||||
this.logger.error(
|
||||
`confirmexit: у пайщика ${username} нет реквизитов — невозможно создать возврат паевого по выходу ${exitHash}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await this.systemDomainPort.getSettings();
|
||||
const now = new Date();
|
||||
|
||||
const paymentDetails: PaymentDetailsDomainInterface = {
|
||||
data: method.data,
|
||||
// Для исходящих платежей комиссия не применяется.
|
||||
amount_plus_fee: quantity.toString(),
|
||||
amount_without_fee: quantity.toString(),
|
||||
fee_amount: '0',
|
||||
fee_percent: 0,
|
||||
fact_fee_percent: 0,
|
||||
tolerance_percent: 0,
|
||||
};
|
||||
|
||||
const payment: PaymentDomainInterface = {
|
||||
id: '',
|
||||
coopname,
|
||||
username,
|
||||
quantity,
|
||||
symbol,
|
||||
type: PaymentTypeEnum.MEMBERSHIP_EXIT,
|
||||
direction: PaymentDirectionEnum.OUTGOING,
|
||||
// Совет уже одобрил (confirmexit) — повторная авторизация не нужна, платёж
|
||||
// сразу готов к выплате кассиром (PENDING виден кассиру с кнопкой «Подтвердить»).
|
||||
status: PaymentStatusEnum.PENDING,
|
||||
provider: settings.provider_name,
|
||||
payment_method_id: method.method_id,
|
||||
payment_details: paymentDetails,
|
||||
memo: `Возврат паевого взноса при выходе из кооператива №${exitHash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
|
||||
secret: generateUniqueHash(),
|
||||
// hash = exit_hash = outcome_hash on-chain объекта (createoutpay в confirmexit),
|
||||
// чтобы completeOutcome при подтверждении кассой вызвал completexit.
|
||||
hash: exitHash,
|
||||
expired_at: undefined, // бессрочный — как и прочие исходящие платежи
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const created = await this.paymentRepository.create(payment);
|
||||
this.logger.log(
|
||||
`confirmexit: создан возврат паевого взноса ${created.id} для ${username} на ${quantity} ${symbol} (выход ${exitHash})`
|
||||
);
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Cooperative, Ledger2 } from 'cooptypes';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
import { config } from '~/config';
|
||||
import { ParticipantInteractor } from '~/application/participant/interactors/participant.interactor';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { NotificationSenderService } from '~/application/notification/services/notification-sender.service';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { ACCOUNT_BLOCKCHAIN_PORT, type AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
|
||||
import { USER_WALLET_REPOSITORY, type UserWalletRepository } from '~/domain/wallet/repositories/user-wallet.repository';
|
||||
import { BLOCKCHAIN_PORT, type BlockchainPort } from '~/domain/common/ports/blockchain.port';
|
||||
import { USER_DOMAIN_SERVICE, type UserDomainService } from '~/domain/user/services/user-domain.service';
|
||||
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';
|
||||
import { MembershipExitResultDTO } from '../dto/membership-exit-result.dto';
|
||||
import { MembershipExitReturnPreviewDTO } from '../dto/membership-exit-return-preview.dto';
|
||||
import { MembershipExitDTO } from '../dto/membership-exit.dto';
|
||||
import { MembershipExitStatus } from '../enums/membership-exit-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class MembershipExitService {
|
||||
private readonly logger = new Logger(MembershipExitService.name);
|
||||
|
||||
constructor(
|
||||
private readonly participantInteractor: ParticipantInteractor,
|
||||
private readonly tokenApplicationService: TokenApplicationService,
|
||||
private readonly notificationSenderService: NotificationSenderService,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT)
|
||||
private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(USER_WALLET_REPOSITORY)
|
||||
private readonly userWalletRepository: UserWalletRepository,
|
||||
@Inject(BLOCKCHAIN_PORT)
|
||||
private readonly blockchainPort: BlockchainPort,
|
||||
@Inject(USER_DOMAIN_SERVICE)
|
||||
private readonly userDomainService: UserDomainService,
|
||||
@Inject(PAYMENT_METHOD_REPOSITORY)
|
||||
private readonly paymentMethodRepository: PaymentMethodRepository,
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepository: PaymentRepository,
|
||||
@InjectRepository(MembershipExitRequestEntity)
|
||||
private readonly exitRequestRepository: Repository<MembershipExitRequestEntity>
|
||||
) {}
|
||||
|
||||
async generateMembershipExitApplication(
|
||||
data: Cooperative.Registry.ParticipantExitApplication.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const document = await this.participantInteractor.generateMembershipExitApplication(data, options);
|
||||
return document as unknown as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
async generateMembershipExitDecision(
|
||||
data: Cooperative.Registry.DecisionOfParticipantExit.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const document = await this.participantInteractor.generateMembershipExitDecision(data, options);
|
||||
return document as unknown as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подача заявления на выход. Заявление подписывается пайщиком на клиенте и
|
||||
* сразу принимается: сохраняется off-chain и требует подтверждения по ссылке
|
||||
* из письма. В блокчейн (registrator::exitcoop) отправляется только после
|
||||
* перехода по ссылке — см. confirmMembershipExit. Это двойное подтверждение
|
||||
* необратимого действия (закрытие аккаунта). Пайщик подаёт выход за себя;
|
||||
* председатель/член совета — за любого пайщика (операторская подача).
|
||||
*/
|
||||
async createMembershipExit(
|
||||
data: CreateMembershipExitInputDTO,
|
||||
currentUser: MonoAccountDomainInterface
|
||||
): Promise<MembershipExitResultDTO> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && data.username !== currentUser.username) {
|
||||
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).
|
||||
const methods = await this.paymentMethodRepository.list({
|
||||
username: data.username,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
sortOrder: 'DESC',
|
||||
});
|
||||
if (!methods || methods.items.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'Для выхода из кооператива установите реквизиты для получения возврата паевого взноса.'
|
||||
);
|
||||
}
|
||||
|
||||
// Уже идёт выход on-chain?
|
||||
const onchain = await this.accountBlockchainPort.getExit(data.coopname, data.username);
|
||||
if (onchain) {
|
||||
throw new BadRequestException('Процесс выхода уже запущен и отправлен в блокчейн');
|
||||
}
|
||||
|
||||
// Уже есть заявление, ожидающее подтверждения по email?
|
||||
const existing = await this.exitRequestRepository.findOne({
|
||||
where: { coopname: data.coopname, username: data.username },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
'Заявление на выход уже подано и ожидает подтверждения по ссылке из письма'
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userDomainService.getUserByUsername(data.username);
|
||||
const confirmToken = await this.tokenApplicationService.generateConfirmExitToken(user.id);
|
||||
|
||||
await this.exitRequestRepository.save(
|
||||
this.exitRequestRepository.create({
|
||||
coopname: data.coopname,
|
||||
username: data.username,
|
||||
exit_hash: data.exit_hash,
|
||||
statement: data.statement as unknown as Record<string, any>,
|
||||
token: confirmToken,
|
||||
})
|
||||
);
|
||||
|
||||
const confirmationUrl = `${config.frontend_url}/${config.coopname}/user/membership-exit/confirm?token=${confirmToken}`;
|
||||
this.logger.debug(`Ссылка подтверждения выхода (${data.username}): ${confirmationUrl}`);
|
||||
|
||||
// Письмо — не критично для приёма заявления: черновик уже сохранён, и если
|
||||
// провайдер писем недоступен, пайщик не теряет заявление (можно отменить).
|
||||
try {
|
||||
await this.notificationSenderService.sendNotificationToUser(
|
||||
data.username,
|
||||
Workflows.MembershipExitConfirmation.id,
|
||||
{ confirmationUrl }
|
||||
);
|
||||
} catch (e: any) {
|
||||
this.logger.error(
|
||||
`Не удалось отправить письмо с подтверждением выхода (${data.username}): ${e?.message ?? e}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Принято заявление на выход (ожидает email-подтверждения): ${data.exit_hash} (username=${data.username})`
|
||||
);
|
||||
|
||||
return { exit_hash: data.exit_hash, status: MembershipExitStatus.AWAITING_CONFIRMATION };
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение выхода по ссылке из письма. Проверяет токен, берёт сохранённое
|
||||
* подписанное заявление и только теперь отправляет его в блокчейн
|
||||
* (registrator::exitcoop ключом кооператива). Запись-черновик удаляется —
|
||||
* далее источником истины служит on-chain registrator::exits.
|
||||
*/
|
||||
async confirmMembershipExit(token: string): Promise<MembershipExitResultDTO> {
|
||||
await this.tokenApplicationService.verifyToken({ token, types: [tokenTypes.CONFIRM_EXIT] });
|
||||
|
||||
const request = await this.exitRequestRepository.findOne({ where: { token } });
|
||||
if (!request) {
|
||||
throw new NotFoundException('Заявление на выход не найдено или уже подтверждено');
|
||||
}
|
||||
|
||||
await this.accountBlockchainPort.exitCoop({
|
||||
coopname: request.coopname,
|
||||
username: request.username,
|
||||
exit_hash: request.exit_hash,
|
||||
statement: request.statement as any,
|
||||
});
|
||||
|
||||
await this.exitRequestRepository.delete({ id: request.id });
|
||||
await this.tokenApplicationService.findOneAndDelete(token, tokenTypes.CONFIRM_EXIT);
|
||||
|
||||
this.logger.log(
|
||||
`Выход подтверждён и отправлен в блокчейн: ${request.exit_hash} (username=${request.username})`
|
||||
);
|
||||
|
||||
return { exit_hash: request.exit_hash, status: MembershipExitStatus.PENDING };
|
||||
}
|
||||
|
||||
/**
|
||||
* Отмена заявления на выход до подтверждения по email. Удаляет черновик и
|
||||
* аннулирует токен подтверждения. Доступно пайщику (за себя) и оператору.
|
||||
*/
|
||||
async cancelMembershipExit(
|
||||
coopname: string,
|
||||
username: string,
|
||||
currentUser: MonoAccountDomainInterface
|
||||
): Promise<boolean> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && username !== currentUser.username) {
|
||||
throw new ForbiddenException('Отменить выход можно только за себя');
|
||||
}
|
||||
|
||||
const request = await this.exitRequestRepository.findOne({ where: { coopname, username } });
|
||||
if (!request) {
|
||||
throw new BadRequestException('Нет заявления на выход, ожидающего подтверждения');
|
||||
}
|
||||
|
||||
await this.exitRequestRepository.delete({ id: request.id });
|
||||
await this.tokenApplicationService.findOneAndDelete(request.token, tokenTypes.CONFIRM_EXIT);
|
||||
|
||||
this.logger.log(`Заявление на выход отменено до подтверждения (username=${username})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий процесс выхода пайщика (если активен) — для блокировки кабинета и
|
||||
* отображения статуса заявления и планируемой суммы возврата. null — выхода нет.
|
||||
*/
|
||||
async getMembershipExit(coopname: string, username: string): Promise<MembershipExitDTO | null> {
|
||||
const exit = await this.accountBlockchainPort.getExit(coopname, username);
|
||||
if (exit) {
|
||||
// Статус исходящего платежа возврата (заводится при одобрении советом —
|
||||
// см. MembershipExitAuthorizationListener). hash платежа = exit_hash.
|
||||
const payment = await this.paymentRepository.findByHash(exit.exit_hash);
|
||||
return {
|
||||
exit_hash: exit.exit_hash,
|
||||
status: exit.status as MembershipExitStatus,
|
||||
quantity: exit.quantity,
|
||||
payment_status: payment?.status,
|
||||
created_at: exit.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Off-chain фаза: заявление подписано, но ещё не подтверждено по email.
|
||||
const pending = await this.exitRequestRepository.findOne({ where: { coopname, username } });
|
||||
if (pending) {
|
||||
return {
|
||||
exit_hash: pending.exit_hash,
|
||||
status: MembershipExitStatus.AWAITING_CONFIRMATION,
|
||||
quantity: '',
|
||||
created_at: pending.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Терминальная фаза: выход завершён 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Предварительный расчёт суммы возврата паевого взноса при выходе пайщика.
|
||||
*
|
||||
* Считается обходом сета паевых кошельков Ledger2.EXIT_REFUND_WALLET_NAMES
|
||||
* (w.reg.minshr + w.wal.share + w.cap.blago) по L3-балансам — тот же сет, что
|
||||
* обходит контракт `confirmexit` при одобрении выхода советом, поэтому preview
|
||||
* совпадает с суммой, которую реально вернёт контракт.
|
||||
*/
|
||||
async getReturnPreview(coopname: string, username: string): Promise<MembershipExitReturnPreviewDTO> {
|
||||
const wallets = Ledger2.EXIT_REFUND_WALLET_NAMES;
|
||||
const rows = await Promise.all(
|
||||
wallets.map((wallet) => this.userWalletRepository.findByWalletAndUsername(coopname, wallet, username)),
|
||||
);
|
||||
|
||||
// available учитываем только для существующих (present) кошельков
|
||||
const balances = rows.map((row) => (row?.present !== false ? row?.available : undefined));
|
||||
|
||||
const template = await this.resolveAssetTemplate(coopname, balances.find((b) => !!b));
|
||||
const zero = this.zeroAssetLike(template);
|
||||
|
||||
const assets = balances.map((b) => b ?? zero);
|
||||
const total = assets.reduce((acc, a) => this.sumAssets(acc, a), zero);
|
||||
|
||||
// индивидуальные паевые отдаём для информации; фронт показывает total
|
||||
const balanceOf = (wallet: string): string => {
|
||||
const idx = wallets.indexOf(wallet);
|
||||
return idx >= 0 ? assets[idx] : zero;
|
||||
};
|
||||
|
||||
return {
|
||||
total,
|
||||
share_contribution: balanceOf(Ledger2.SHARE_WALLET_NAME),
|
||||
minimum_contribution: balanceOf(Ledger2.MIN_SHARE_WALLET_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
/** Сумма двух asset-строк одного символа («100.0000 RUB»). */
|
||||
private sumAssets(a: string, b: string): string {
|
||||
const [amountA, sym] = a.split(' ');
|
||||
const [amountB] = b.split(' ');
|
||||
const precision = (amountA.split('.')[1] || '').length;
|
||||
const total = Number(amountA) + Number(amountB);
|
||||
return `${total.toFixed(precision)} ${sym}`;
|
||||
}
|
||||
|
||||
/** Нулевой asset в символе/точности образца. */
|
||||
private zeroAssetLike(template: string): string {
|
||||
const [amount, sym] = template.split(' ');
|
||||
const precision = (amount.split('.')[1] || '').length;
|
||||
return `${(0).toFixed(precision)} ${sym}`;
|
||||
}
|
||||
|
||||
/** Образец asset для нулей — из имеющегося баланса либо из настроек кооператива. */
|
||||
private async resolveAssetTemplate(coopname: string, sample?: string): Promise<string> {
|
||||
if (sample) return sample;
|
||||
const coop = await this.blockchainPort.getCooperative(coopname);
|
||||
const precision = config.blockchain.root_govern_precision ?? 4;
|
||||
const symbol = config.blockchain.root_govern_symbol ?? 'RUB';
|
||||
return (coop?.initial as string | undefined) ?? `${(0).toFixed(precision)} ${symbol}`;
|
||||
}
|
||||
}
|
||||
+16
@@ -73,6 +73,22 @@ export class ParticipantInteractor {
|
||||
return await this.documentDomainService.generateDocument({ data, options });
|
||||
}
|
||||
|
||||
async generateMembershipExitApplication(
|
||||
data: Cooperative.Registry.ParticipantExitApplication.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<DocumentDomainEntity> {
|
||||
data.registry_id = Cooperative.Registry.ParticipantExitApplication.registry_id;
|
||||
return await this.documentDomainService.generateDocument({ data, options });
|
||||
}
|
||||
|
||||
async generateMembershipExitDecision(
|
||||
data: Cooperative.Registry.DecisionOfParticipantExit.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<DocumentDomainEntity> {
|
||||
data.registry_id = Cooperative.Registry.DecisionOfParticipantExit.registry_id;
|
||||
return await this.documentDomainService.generateDocument({ data, options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет подпись документа
|
||||
* @private
|
||||
|
||||
@@ -61,6 +61,15 @@ export class TokenApplicationService {
|
||||
return this.tokenDomainService.generateVerifyEmailToken(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует токен подтверждения выхода из кооператива
|
||||
* @param userId - ID пользователя
|
||||
* @returns Токен подтверждения выхода
|
||||
*/
|
||||
async generateConfirmExitToken(userId: string): Promise<string> {
|
||||
return this.tokenDomainService.generateConfirmExitToken(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует токен
|
||||
* @param input - данные для верификации
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { RegistratorContract, SovietContract } from 'cooptypes';
|
||||
import type { BlockchainAccountInterface } from '~/types/shared';
|
||||
import type { CandidateDomainInterface } from '../interfaces/candidate-domain.interface';
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
|
||||
/**
|
||||
* Входные данные подачи заявления на выход пайщика из кооператива (registrator::exitcoop).
|
||||
*/
|
||||
export interface ExitCoopDomainInterface {
|
||||
coopname: string;
|
||||
username: string;
|
||||
exit_hash: string;
|
||||
statement: ISignedDocumentDomainInterface;
|
||||
}
|
||||
|
||||
export interface AccountBlockchainPort {
|
||||
getBlockchainAccount(username: string): Promise<BlockchainAccountInterface | null>;
|
||||
@@ -12,6 +23,13 @@ export interface AccountBlockchainPort {
|
||||
getUserAccount(username: string): Promise<RegistratorContract.Tables.Accounts.IAccount | null>;
|
||||
addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void>;
|
||||
registerBlockchainAccount(candidate: CandidateDomainInterface): Promise<void>;
|
||||
// Подача заявления на выход пайщика из кооператива (registrator::exitcoop)
|
||||
exitCoop(data: ExitCoopDomainInterface): Promise<void>;
|
||||
// Текущий процесс выхода пайщика (registrator::exits), либо null
|
||||
getExit(coopname: string, username: string): Promise<RegistratorContract.Tables.Exits.IExit | null>;
|
||||
// Процесс выхода по exit_hash (registrator::exits): on-chain confirmexit отдаёт
|
||||
// только coopname+exit_hash, username и сумму возврата берём из таблицы по хэшу.
|
||||
getExitByHash(coopname: string, exit_hash: string): Promise<RegistratorContract.Tables.Exits.IExit | null>;
|
||||
}
|
||||
|
||||
export const ACCOUNT_BLOCKCHAIN_PORT = Symbol('AccountBlockchainPort');
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
|
||||
/**
|
||||
* Входные данные подачи заявления на выход пайщика из кооператива.
|
||||
*/
|
||||
export interface CreateMembershipExitInputDomainInterface {
|
||||
coopname: string;
|
||||
username: string;
|
||||
exit_hash: string;
|
||||
statement: ISignedDocumentDomainInterface;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Spec MinIO-бакета `gateway:files` — ядровое хранилище файлов платежей.
|
||||
*
|
||||
* Содержимое: чеки об оплате (PAYMENT_PROOF) по исходящим платежам — единая
|
||||
* «культура денег» (входящие подтверждаем, исходящие сопровождаем чеком).
|
||||
* Привязка по `payment_hash`; переиспользуется всеми расширениями.
|
||||
*
|
||||
* Ключ объекта: `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
|
||||
*/
|
||||
export const GATEWAY_BUCKET = {
|
||||
name: 'gateway:files',
|
||||
maxBytes: 20 * 1024 * 1024,
|
||||
allowedMime: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/heic',
|
||||
'application/pdf',
|
||||
] as const,
|
||||
defaultUrlTtlSeconds: 600,
|
||||
} as const;
|
||||
|
||||
export type GatewayBucketAllowedMime = (typeof GATEWAY_BUCKET.allowedMime)[number];
|
||||
@@ -113,6 +113,7 @@ export class PaymentDomainEntity implements PaymentDomainInterface {
|
||||
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
|
||||
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат взноса',
|
||||
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
|
||||
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
|
||||
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
|
||||
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
|
||||
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Назначение файла, приложенного к платежу (бакет `gateway:files`).
|
||||
*
|
||||
* Ядровая «культура денег»: каждый ИСХОДЯЩИЙ платёж сопровождается чеком об
|
||||
* оплате (подтверждающий документ — для кассира «чем оплатил», для пайщика «на
|
||||
* что»). Сейчас единственный вид — PAYMENT_PROOF; enum оставлен расширяемым.
|
||||
*/
|
||||
export enum PaymentFileKind {
|
||||
PAYMENT_PROOF = 'PAYMENT_PROOF',
|
||||
}
|
||||
|
||||
registerEnumType(PaymentFileKind, {
|
||||
name: 'PaymentFileKind',
|
||||
description: 'Тип файла, приложенного к платежу.',
|
||||
valuesMap: {
|
||||
PAYMENT_PROOF: { description: 'Чек об оплате — подтверждающий документ по исполненному платежу.' },
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,10 @@ export enum PaymentTypeEnum {
|
||||
// Исходящий возврат вступительного и мин. паевого взносов при отказе совета
|
||||
// в приёме. Отдельный тип, не WITHDRAWAL — чтобы не путать с возвратом паевого.
|
||||
REGISTRATION_REFUND = 'registration_refund',
|
||||
// Исходящий возврат всего паевого взноса при выходе пайщика из кооператива.
|
||||
// Отдельный тип, не WITHDRAWAL — это полный выход с блокировкой аккаунта,
|
||||
// а не частичный возврат паевого действующему пайщику.
|
||||
MEMBERSHIP_EXIT = 'membership_exit',
|
||||
// Исходящая оплата позиции служебной записки-сметы (шасси expense). Создаётся
|
||||
// автоматически после авторизации СЗ советом; подтверждение кассиром проводит
|
||||
// on-chain оплату expense::payexp по позиции.
|
||||
@@ -41,6 +45,7 @@ export const PAYMENT_TYPE_LABELS: Record<PaymentTypeEnum, string> = {
|
||||
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
|
||||
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат паевого взноса',
|
||||
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
|
||||
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
|
||||
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
|
||||
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
|
||||
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
|
||||
@@ -84,6 +89,7 @@ export const INCOMING_PAYMENT_TYPES = [
|
||||
export const OUTGOING_PAYMENT_TYPES = [
|
||||
PaymentTypeEnum.WITHDRAWAL,
|
||||
PaymentTypeEnum.REGISTRATION_REFUND,
|
||||
PaymentTypeEnum.MEMBERSHIP_EXIT,
|
||||
PaymentTypeEnum.EXPENSE,
|
||||
PaymentTypeEnum.EXPENSE_OVERSPEND,
|
||||
];
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { PaymentFileKind } from '../enums/payment-file-kind.enum';
|
||||
|
||||
/**
|
||||
* Запись о файле, приложенном к платежу (чек об оплате). Сам бинарь лежит в
|
||||
* MinIO-бакете `gateway:files`; в блокчейне файла нет — это чистая БД-сущность,
|
||||
* для проверки целостности используется `checksum_sha256`.
|
||||
*
|
||||
* Привязка — по `payment_hash` (единый ключ любого платежа). Ключ MinIO:
|
||||
* `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
|
||||
*/
|
||||
export interface IPaymentFileDatabaseData {
|
||||
id?: number;
|
||||
coopname: string;
|
||||
payment_hash: string;
|
||||
kind: PaymentFileKind;
|
||||
checksum_sha256: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_key: string;
|
||||
/** Оригинальное имя загруженного файла — для отображения и поиска */
|
||||
original_filename?: string | null;
|
||||
uploaded_by_username: string;
|
||||
uploaded_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IPaymentFileDatabaseData } from '../interfaces/payment-file-database.interface';
|
||||
|
||||
/**
|
||||
* Реестр файлов, приложенных к платежам (чеки об оплате). Файл — чистая БД-сущность
|
||||
* (MinIO + метаданные), блокчейна за ним нет, поэтому интерфейс не наследует
|
||||
* sync-репозиторий.
|
||||
*/
|
||||
export interface PaymentFileRepository {
|
||||
create(data: IPaymentFileDatabaseData): Promise<IPaymentFileDatabaseData>;
|
||||
findById(id: number): Promise<IPaymentFileDatabaseData | null>;
|
||||
findByChecksum(coopname: string, checksum: string): Promise<IPaymentFileDatabaseData | null>;
|
||||
findByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]>;
|
||||
delete(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
export const PAYMENT_FILE_REPOSITORY = Symbol('PaymentFileRepository');
|
||||
@@ -190,6 +190,22 @@ export class TokenDomainService {
|
||||
return verifyEmailToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует токен подтверждения выхода из кооператива (ссылка в письме).
|
||||
* Срок жизни — как у верификации email (это тоже подтверждение по почте).
|
||||
*/
|
||||
async generateConfirmExitToken(userId: string): Promise<string> {
|
||||
const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes');
|
||||
const confirmExitToken = this.generateToken({
|
||||
userId,
|
||||
expires: expires.toDate(),
|
||||
type: tokenTypes.CONFIRM_EXIT,
|
||||
});
|
||||
|
||||
await this.saveToken(confirmExitToken, userId, expires.toDate(), tokenTypes.CONFIRM_EXIT);
|
||||
return confirmExitToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет токены по критериям
|
||||
*/
|
||||
|
||||
@@ -211,6 +211,27 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -230,6 +251,34 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort {
|
||||
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
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Заявление пайщика на выход, принятое и подписанное, но ещё НЕ отправленное в
|
||||
* блокчейн — ожидает подтверждения по ссылке из письма. После подтверждения
|
||||
* запись удаляется (источником истины становится on-chain registrator::exits),
|
||||
* при отмене — тоже удаляется. На один (coopname, username) — один активный
|
||||
* процесс выхода.
|
||||
*
|
||||
* statement хранит подписанный документ заявления (как пришёл с клиента),
|
||||
* чтобы при подтверждении отправить его в registrator::exitcoop без повторной
|
||||
* подписи пайщиком.
|
||||
*/
|
||||
@Entity('membership_exit_requests')
|
||||
@Index(['coopname', 'username'], { unique: true })
|
||||
export class MembershipExitRequestEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column()
|
||||
coopname!: string;
|
||||
|
||||
@Column()
|
||||
username!: string;
|
||||
|
||||
@Column({ name: 'exit_hash', length: 64 })
|
||||
exit_hash!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
statement!: Record<string, any>;
|
||||
|
||||
@Column({ name: 'token', length: 1024 })
|
||||
token!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
created_at!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updated_at!: Date;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
|
||||
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
|
||||
|
||||
export const EntityName = 'payment_files';
|
||||
|
||||
/**
|
||||
* Файл, приложенный к платежу (чек об оплате). Бинарь — в MinIO `gateway:files`,
|
||||
* здесь только метаданные. Привязка — по `payment_hash` (единый ключ платежа).
|
||||
*/
|
||||
@Entity(EntityName)
|
||||
@Index(`uq_${EntityName}_checksum`, ['coopname', 'checksum_sha256'], { unique: true })
|
||||
@Index(`idx_${EntityName}_payment`, ['coopname', 'payment_hash'])
|
||||
export class PaymentFileEntity {
|
||||
static getTableName(): string {
|
||||
return EntityName;
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
coopname!: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
payment_hash!: string;
|
||||
|
||||
@Column({ type: 'enum', enum: PaymentFileKind })
|
||||
kind!: PaymentFileKind;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
checksum_sha256!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
mime_type!: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
size_bytes!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 512 })
|
||||
storage_key!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true, comment: 'Оригинальное имя загруженного файла — для отображения и поиска' })
|
||||
original_filename!: string | null;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
uploaded_by_username!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
uploaded_at!: Date;
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PaymentFileEntity } from '../entities/payment-file.entity';
|
||||
import type { PaymentFileRepository } from '~/domain/gateway/repositories/payment-file.repository';
|
||||
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
|
||||
|
||||
/**
|
||||
* TypeORM-репозиторий файлов платежей (чеков об оплате). БД-only сущность —
|
||||
* маппинг симметричный, без доменной логики.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TypeormPaymentFileRepository implements PaymentFileRepository {
|
||||
constructor(
|
||||
@InjectRepository(PaymentFileEntity)
|
||||
private readonly repository: Repository<PaymentFileEntity>
|
||||
) {}
|
||||
|
||||
async create(data: IPaymentFileDatabaseData): Promise<IPaymentFileDatabaseData> {
|
||||
const entity = this.repository.create({
|
||||
coopname: data.coopname,
|
||||
payment_hash: data.payment_hash,
|
||||
kind: data.kind,
|
||||
checksum_sha256: data.checksum_sha256,
|
||||
mime_type: data.mime_type,
|
||||
size_bytes: data.size_bytes,
|
||||
storage_key: data.storage_key,
|
||||
original_filename: data.original_filename ?? null,
|
||||
uploaded_by_username: data.uploaded_by_username,
|
||||
uploaded_at: data.uploaded_at,
|
||||
});
|
||||
const saved = await this.repository.save(entity);
|
||||
return this.toDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<IPaymentFileDatabaseData | null> {
|
||||
const entity = await this.repository.findOne({ where: { id } });
|
||||
return entity ? this.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByChecksum(coopname: string, checksum: string): Promise<IPaymentFileDatabaseData | null> {
|
||||
const entity = await this.repository.findOne({ where: { coopname, checksum_sha256: checksum } });
|
||||
return entity ? this.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]> {
|
||||
const entities = await this.repository.find({
|
||||
where: { coopname, payment_hash: paymentHash.toLowerCase() },
|
||||
order: { uploaded_at: 'DESC' },
|
||||
});
|
||||
return entities.map((e) => this.toDomain(e));
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.repository.delete({ id });
|
||||
}
|
||||
|
||||
private toDomain(entity: PaymentFileEntity): IPaymentFileDatabaseData {
|
||||
return {
|
||||
id: entity.id,
|
||||
coopname: entity.coopname,
|
||||
payment_hash: entity.payment_hash,
|
||||
kind: entity.kind,
|
||||
checksum_sha256: entity.checksum_sha256,
|
||||
mime_type: entity.mime_type,
|
||||
size_bytes: entity.size_bytes,
|
||||
storage_key: entity.storage_key,
|
||||
original_filename: entity.original_filename,
|
||||
uploaded_by_username: entity.uploaded_by_username,
|
||||
uploaded_at: entity.uploaded_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
|
||||
import { PaymentEntity } from './entities/payment.entity';
|
||||
import { PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.repository';
|
||||
import { PaymentFileEntity } from './entities/payment-file.entity';
|
||||
import { PAYMENT_FILE_REPOSITORY } from '~/domain/gateway/repositories/payment-file.repository';
|
||||
import { TypeormPaymentFileRepository } from './repositories/typeorm-payment-file.repository';
|
||||
import { WebPushSubscriptionEntity } from './entities/web-push-subscription.entity';
|
||||
import { NOTIFICATION_SUBSCRIPTION_PORT } from '~/domain/notification/interfaces/web-push-subscription.port';
|
||||
import { TypeOrmWebPushSubscriptionRepository } from './repositories/typeorm-web-push-subscription.repository';
|
||||
@@ -121,6 +124,7 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
|
||||
MigrationEntity,
|
||||
CandidateEntity,
|
||||
PaymentEntity,
|
||||
PaymentFileEntity,
|
||||
WebPushSubscriptionEntity,
|
||||
LedgerOperationEntity,
|
||||
AgreementTypeormEntity,
|
||||
@@ -175,6 +179,10 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
|
||||
provide: PAYMENT_REPOSITORY,
|
||||
useClass: TypeOrmPaymentRepository,
|
||||
},
|
||||
{
|
||||
provide: PAYMENT_FILE_REPOSITORY,
|
||||
useClass: TypeormPaymentFileRepository,
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_SUBSCRIPTION_PORT,
|
||||
useClass: TypeOrmWebPushSubscriptionRepository,
|
||||
@@ -276,6 +284,7 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
|
||||
MIGRATION_REPOSITORY,
|
||||
CANDIDATE_REPOSITORY,
|
||||
PAYMENT_REPOSITORY,
|
||||
PAYMENT_FILE_REPOSITORY,
|
||||
NOTIFICATION_SUBSCRIPTION_PORT,
|
||||
LEDGER_OPERATION_REPOSITORY,
|
||||
AGREEMENT_REPOSITORY,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const tokenTypes = {
|
||||
RESET_KEY: 'resetPassword',
|
||||
VERIFY_EMAIL: 'verifyEmail',
|
||||
INVITE: 'invite',
|
||||
CONFIRM_EXIT: 'confirmExit',
|
||||
} as const;
|
||||
|
||||
export type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes];
|
||||
|
||||
@@ -326,6 +326,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
CreateMatrixAccountInputDTO:{
|
||||
|
||||
},
|
||||
CreateMembershipExitInput:{
|
||||
statement:"MembershipExitApplicationSignedDocumentInput"
|
||||
},
|
||||
CreateOrganizationDataInput:{
|
||||
bank_account:"BankAccountInput",
|
||||
@@ -694,6 +697,20 @@ export const AllTypesProps: Record<string,any> = {
|
||||
mark:"ReportSubmissionMark",
|
||||
reportType:"ReportType"
|
||||
},
|
||||
MembershipExitApplicationGenerateDocumentInput:{
|
||||
|
||||
},
|
||||
MembershipExitApplicationSignedDocumentInput:{
|
||||
meta:"MembershipExitApplicationSignedMetaDocumentInput",
|
||||
signatures:"SignatureInfoInput"
|
||||
},
|
||||
MembershipExitApplicationSignedMetaDocumentInput:{
|
||||
|
||||
},
|
||||
MembershipExitDecisionGenerateDocumentInput:{
|
||||
|
||||
},
|
||||
MembershipExitStatus: "enum" as const,
|
||||
ModerateRequestInput:{
|
||||
|
||||
},
|
||||
@@ -715,6 +732,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
authorizeDecision:{
|
||||
data:"AuthorizeDecisionInput"
|
||||
},
|
||||
cancelMembershipExit:{
|
||||
|
||||
},
|
||||
cancelRequest:{
|
||||
data:"CancelRequestInput"
|
||||
@@ -1013,6 +1033,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
confirmAgreement:{
|
||||
data:"ConfirmAgreementInput"
|
||||
},
|
||||
confirmMembershipExit:{
|
||||
|
||||
},
|
||||
confirmReceiveOnRequest:{
|
||||
data:"ConfirmReceiveOnRequestInput"
|
||||
@@ -1038,6 +1061,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
createInitialPayment:{
|
||||
data:"CreateInitialPaymentInput"
|
||||
},
|
||||
createMembershipExit:{
|
||||
data:"CreateMembershipExitInput"
|
||||
},
|
||||
createParentOffer:{
|
||||
data:"CreateParentOfferInput"
|
||||
},
|
||||
@@ -1133,6 +1159,14 @@ export const AllTypesProps: Record<string,any> = {
|
||||
data:"FreeDecisionGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
generateMembershipExitApplication:{
|
||||
data:"MembershipExitApplicationGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
generateMembershipExitDecision:{
|
||||
data:"MembershipExitDecisionGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
generateParticipantApplication:{
|
||||
data:"ParticipantApplicationGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
@@ -1336,6 +1370,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
uploadExpenseFile:{
|
||||
data:"UploadExpenseFileInput"
|
||||
},
|
||||
uploadPaymentProof:{
|
||||
data:"UploadPaymentProofInput"
|
||||
},
|
||||
verifyEmail:{
|
||||
data:"VerifyEmailInputDTO"
|
||||
},
|
||||
@@ -1393,6 +1430,7 @@ export const AllTypesProps: Record<string,any> = {
|
||||
|
||||
},
|
||||
PaymentDirection: "enum" as const,
|
||||
PaymentFileKind: "enum" as const,
|
||||
PaymentFiltersInput:{
|
||||
direction:"PaymentDirection",
|
||||
status:"PaymentStatus",
|
||||
@@ -1778,9 +1816,21 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
listReportDrafts:{
|
||||
filter:"ListReportDraftsFilterInput"
|
||||
},
|
||||
membershipExit:{
|
||||
|
||||
},
|
||||
membershipExitReturnPreview:{
|
||||
|
||||
},
|
||||
onecoopGetDocuments:{
|
||||
data:"GetOneCoopDocumentsInput"
|
||||
},
|
||||
paymentFile:{
|
||||
|
||||
},
|
||||
paymentProofs:{
|
||||
|
||||
},
|
||||
process:{
|
||||
|
||||
@@ -2080,6 +2130,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
UploadExpenseFileInput:{
|
||||
kind:"ExpenseFileKind"
|
||||
},
|
||||
UploadPaymentProofInput:{
|
||||
|
||||
},
|
||||
UserStatus: "enum" as const,
|
||||
VarsInput:{
|
||||
@@ -3778,6 +3831,22 @@ export const ReturnTypes: Record<string,any> = {
|
||||
votes_against:"Int",
|
||||
votes_for:"Int"
|
||||
},
|
||||
MembershipExit:{
|
||||
created_at:"String",
|
||||
exit_hash:"String",
|
||||
payment_status:"PaymentStatus",
|
||||
quantity:"String",
|
||||
status:"MembershipExitStatus"
|
||||
},
|
||||
MembershipExitResult:{
|
||||
exit_hash:"String",
|
||||
status:"MembershipExitStatus"
|
||||
},
|
||||
MembershipExitReturnPreview:{
|
||||
minimum_contribution:"String",
|
||||
share_contribution:"String",
|
||||
total:"String"
|
||||
},
|
||||
MissingRequisiteField:{
|
||||
key:"String",
|
||||
label:"String",
|
||||
@@ -3806,6 +3875,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
addPaymentMethod:"PaymentMethod",
|
||||
addTrustedAccount:"Branch",
|
||||
authorizeDecision:"Transaction",
|
||||
cancelMembershipExit:"Boolean",
|
||||
cancelRequest:"Transaction",
|
||||
capitalAddAuthor:"CapitalProject",
|
||||
capitalApproveCommit:"CapitalCommit",
|
||||
@@ -3899,6 +3969,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
completeExtensionOnboardingStep:"ExtensionOnboardingState",
|
||||
completeRequest:"Transaction",
|
||||
confirmAgreement:"Transaction",
|
||||
confirmMembershipExit:"MembershipExitResult",
|
||||
confirmReceiveOnRequest:"Transaction",
|
||||
confirmSupplyOnRequest:"Transaction",
|
||||
createAnnualGeneralMeet:"MeetAggregate",
|
||||
@@ -3907,6 +3978,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
createDepositPayment:"GatewayPayment",
|
||||
createExpenseProposal:"Transaction",
|
||||
createInitialPayment:"GatewayPayment",
|
||||
createMembershipExit:"MembershipExitResult",
|
||||
createParentOffer:"Transaction",
|
||||
createProjectOfFreeDecision:"CreatedProjectFreeDecision",
|
||||
createWebPushSubscription:"CreateSubscriptionResponse",
|
||||
@@ -3935,6 +4007,8 @@ export const ReturnTypes: Record<string,any> = {
|
||||
generateExpenseProposalDecisionDocument:"GeneratedDocument",
|
||||
generateExpenseProposalStatementDocument:"GeneratedDocument",
|
||||
generateFreeDecision:"GeneratedDocument",
|
||||
generateMembershipExitApplication:"GeneratedDocument",
|
||||
generateMembershipExitDecision:"GeneratedDocument",
|
||||
generateParticipantApplication:"GeneratedDocument",
|
||||
generateParticipantApplicationDecision:"GeneratedDocument",
|
||||
generatePrivacyAgreement:"GeneratedDocument",
|
||||
@@ -3999,6 +4073,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
updateSettings:"Settings",
|
||||
updateSystem:"SystemInfo",
|
||||
uploadExpenseFile:"ExpenseFile",
|
||||
uploadPaymentProof:"PaymentFile",
|
||||
verifyEmail:"Boolean",
|
||||
voteOnAnnualGeneralMeet:"MeetAggregate",
|
||||
walmoveWallets:"Ledger2AdjustmentResult"
|
||||
@@ -4280,6 +4355,20 @@ export const ReturnTypes: Record<string,any> = {
|
||||
fee_percent:"Float",
|
||||
tolerance_percent:"Float"
|
||||
},
|
||||
PaymentFile:{
|
||||
checksum_sha256:"String",
|
||||
coopname:"String",
|
||||
id:"Int",
|
||||
kind:"PaymentFileKind",
|
||||
mime_type:"String",
|
||||
original_filename:"String",
|
||||
payment_hash:"String",
|
||||
read_url:"String",
|
||||
size_bytes:"Int",
|
||||
storage_key:"String",
|
||||
uploaded_at:"DateTime",
|
||||
uploaded_by_username:"String"
|
||||
},
|
||||
PaymentMethod:{
|
||||
created_at:"DateTime",
|
||||
data:"PaymentMethodData",
|
||||
@@ -4592,7 +4681,11 @@ export const ReturnTypes: Record<string,any> = {
|
||||
getUserWebPushSubscriptions:"WebPushSubscriptionDto",
|
||||
getWebPushSubscriptionStats:"SubscriptionStatsDto",
|
||||
listReportDrafts:"ReportDraft",
|
||||
membershipExit:"MembershipExit",
|
||||
membershipExitReturnPreview:"MembershipExitReturnPreview",
|
||||
onecoopGetDocuments:"OneCoopDocumentsResponse",
|
||||
paymentFile:"PaymentFile",
|
||||
paymentProofs:"PaymentFile",
|
||||
process:"ProcessView",
|
||||
processes:"ProcessSummaryPaginationResult",
|
||||
searchDocuments:"SearchResult",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,29 @@ const programMapping: ProgramMappingEntry[] = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5b. LEDGER2_EXIT_REFUND_WALLETS ──────────────────────────────────────
|
||||
// Сет паевых кошельков, возвращаемых пайщику при выходе. Простой массив имён
|
||||
// `ledger2_wallets::NAME` — единый источник для контракта (confirmexit) и
|
||||
// backend-preview.
|
||||
const exitRefundWallets: string[] = []
|
||||
{
|
||||
const body = extractArrayBody('LEDGER2_EXIT_REFUND_WALLETS')
|
||||
const re = /ledger2_wallets::(\w+)/g
|
||||
for (const m of body.matchAll(re)) {
|
||||
const wallet_name = nameMap.get(m[1]!)
|
||||
if (!wallet_name) throw new Error(`gen-from-cpp: ledger2_wallets::${m[1]} не найден среди имён`)
|
||||
exitRefundWallets.push(wallet_name)
|
||||
}
|
||||
if (exitRefundWallets.length === 0) throw new Error('gen-from-cpp: LEDGER2_EXIT_REFUND_WALLETS пуст')
|
||||
const sizeMatch = /std::array<eosio::name,\s*(\d+)>\s+LEDGER2_EXIT_REFUND_WALLETS/.exec(hpp)
|
||||
if (sizeMatch && Number(sizeMatch[1]) !== exitRefundWallets.length)
|
||||
throw new Error(`gen-from-cpp: распарсено ${exitRefundWallets.length} exit-refund-кошельков, объявлено ${sizeMatch[1]}`)
|
||||
const known = new Set(walletRegistry.map(w => w.name))
|
||||
for (const w of exitRefundWallets) {
|
||||
if (!known.has(w)) throw new Error(`gen-from-cpp: exit-refund ссылается на отсутствующий в реестре кошелёк "${w}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. emit TS ───────────────────────────────────────────────────────────
|
||||
const lines: string[] = []
|
||||
lines.push('// AUTO-GENERATED by cooptypes/scripts/gen-from-cpp.ts — DO NOT EDIT.')
|
||||
@@ -153,6 +176,18 @@ for (const m of programMapping) {
|
||||
}
|
||||
lines.push('] as const')
|
||||
lines.push('')
|
||||
lines.push('/**')
|
||||
lines.push(' * Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.')
|
||||
lines.push(' * Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит')
|
||||
lines.push(' * этот сет, собирает доступные балансы и ставит их на возврат; backend-preview')
|
||||
lines.push(' * считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.')
|
||||
lines.push(' */')
|
||||
lines.push('export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [')
|
||||
for (const w of exitRefundWallets) {
|
||||
lines.push(` ${JSON.stringify(w)},`)
|
||||
}
|
||||
lines.push('] as const')
|
||||
lines.push('')
|
||||
|
||||
writeFileSync(OUT_PATH, lines.join('\n'), 'utf8')
|
||||
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length})`)
|
||||
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length}, exitRefund=${exitRefundWallets.length})`)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Registrator from '../../../interfaces/registrator'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active, Permissions.special], actor: Actors._admin }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'exitcoop'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IExitCoop = Registrator.IExitcoop
|
||||
@@ -28,6 +28,11 @@ export * as CreateAccount from './createAccount'
|
||||
*/
|
||||
export * as RegisterUser from './registerUser'
|
||||
|
||||
/**
|
||||
* Действие подачи заявления на выход пайщика из кооператива (возврат паевого взноса)
|
||||
*/
|
||||
export * as ExitCoop from './exitCoop'
|
||||
|
||||
/**
|
||||
* Действие регистрации карточки организации в кооперативе
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type * as Registrator from '../../../interfaces/registrator'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
/**
|
||||
* Имя таблицы
|
||||
*/
|
||||
export const tableName = 'exits'
|
||||
|
||||
/**
|
||||
* Таблица хранится в области памяти {@link Actors._coopname | кооператива}.
|
||||
*/
|
||||
export const scope = Actors._coopname
|
||||
|
||||
/**
|
||||
* @interface
|
||||
* Реестр заявлений пайщиков на выход из кооператива и сопровождающих их
|
||||
* процессов возврата паевого взноса.
|
||||
*/
|
||||
export type IExit = Registrator.IExit
|
||||
@@ -8,3 +8,8 @@ export * as Accounts from './accounts'
|
||||
* Таблица содержит реестр кооперативов системы.
|
||||
*/
|
||||
export * as Cooperatives from './cooperatives'
|
||||
|
||||
/**
|
||||
* Таблица содержит реестр заявлений пайщиков на выход из кооператива.
|
||||
*/
|
||||
export * as Exits from './exits'
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import type { IGenerate, IMetaDocument } from '../../document'
|
||||
import type { ICooperativeData, IVars } from '../../model'
|
||||
import type { IEntrepreneurData, IIndividualData, IOrganizationData } from '../../users'
|
||||
|
||||
export const registry_id = 200
|
||||
|
||||
/**
|
||||
* Интерфейс генерации заявления на выход из состава пайщиков кооператива
|
||||
*/
|
||||
export interface Action extends IGenerate {
|
||||
skip_save: boolean
|
||||
}
|
||||
|
||||
export type Meta = IMetaDocument & Action
|
||||
|
||||
// Модель данных
|
||||
export interface Model {
|
||||
type: string
|
||||
individual?: IIndividualData
|
||||
organization?: IOrganizationData
|
||||
entrepreneur?: IEntrepreneurData
|
||||
coop: ICooperativeData
|
||||
meta: IMetaDocument
|
||||
vars: IVars
|
||||
}
|
||||
|
||||
export const title = 'Заявление на выход из кооператива'
|
||||
export const description = 'Форма заявления физического лица, индивидуального предпринимателя или юридического лица о выходе из состава пайщиков потребительского кооператива'
|
||||
export const context = `<style>
|
||||
.digital-document h1 {
|
||||
margin: 0px;
|
||||
text-align:center;
|
||||
}
|
||||
.digital-document {padding: 20px;}
|
||||
.subheader {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.signature {
|
||||
padding-top: 30px;
|
||||
}
|
||||
</style>
|
||||
<div class="digital-document">
|
||||
|
||||
{% if type == 'individual' %}
|
||||
<h1 class="header">{% trans 'application_exit_individual' %}</h1>
|
||||
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
|
||||
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
|
||||
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from' %} {{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}, {% trans 'birthdate' %} {{ individual.birthdate }}, {% trans 'registration_address' %} {{ individual.full_address }}, {% trans 'phone_and_email_notice', individual.phone, individual.email %}{% if vars.passport_request == 'yes' %} {% trans 'passport' %} № {{ individual.passport.series }} {{ individual.passport.number }}, {% trans 'passport_code' %} {{ individual.passport.code }}, {% trans 'passport_issued' %} {{ individual.passport.issued_by }} {% trans 'passport_from' %} {{ individual.passport.issued_at }}.{% endif %}</p>
|
||||
<p>{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}</p>
|
||||
<p>{% trans 'obligation_to_settle_individual' %}</p>
|
||||
<div class="signature">
|
||||
<p>{% trans 'signed_electronically' %}</p>
|
||||
<p style="text-align: right;">{{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}</p>
|
||||
</div>
|
||||
|
||||
{% elif type == 'entrepreneur' %}
|
||||
<h1 class="header">{% trans 'application_exit_entrepreneur' %}</h1>
|
||||
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
|
||||
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
|
||||
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from_entrepreneur' %} {{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}, {% trans 'birthdate' %} {{ entrepreneur.birthdate }}, {% trans 'registration_address' %} {{ entrepreneur.full_address }}, {% trans 'entrepreneur_details', entrepreneur.details.inn, entrepreneur.details.ogrn %}, {% trans 'phone_and_email_notice', entrepreneur.phone, entrepreneur.email %}</p>
|
||||
<p>{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}</p>
|
||||
<p>{% trans 'obligation_to_settle_individual' %}</p>
|
||||
<div class="signature">
|
||||
<p>{% trans 'signed_electronically' %}</p>
|
||||
<p style="text-align: right;">{{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}</p>
|
||||
</div>
|
||||
|
||||
{% elif type == 'organization' %}
|
||||
<h1 class="header">{% trans 'application_exit_legal_entity' %}</h1>
|
||||
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
|
||||
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
|
||||
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from_legal_entity' %} {{ organization.full_name }}, {% trans 'legal_address' %}: {{ organization.full_address }}, {% trans 'fact_address' %}: {{ organization.fact_address }}, {% trans 'legal_entity_details', organization.details.inn, organization.details.ogrn, organization.details.kpp %}, {% trans 'phone_and_email_notice', organization.phone, organization.email %}</p>
|
||||
<p>{% trans 'request_to_exit_legal_entity', organization.represented_by.position, organization.represented_by.last_name, organization.represented_by.first_name, organization.represented_by.middle_name, organization.represented_by.based_on, organization.full_name, coop.full_name %}</p>
|
||||
<p>{% trans 'obligation_to_settle_legal_entity' %}</p>
|
||||
<div class="signature">
|
||||
<p>{% trans 'signed_electronically' %}</p>
|
||||
<p style="text-align: right;">{{ organization.represented_by.last_name }} {{ organization.represented_by.first_name }} {{ organization.represented_by.middle_name }}</p>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>`
|
||||
|
||||
export const translations = {
|
||||
ru: {
|
||||
from: 'от',
|
||||
of_consumer_cooperative: 'Потребительского кооператива',
|
||||
to_council_of: 'В Совет',
|
||||
birthdate: 'дата рождения',
|
||||
registration_address: 'адрес регистрации (как в паспорте): ',
|
||||
phone_and_email_notice: 'номер телефона: {0}, адрес электронной почты: {1}.',
|
||||
application_exit_individual: 'Заявление физического лица о выходе из состава пайщиков',
|
||||
application_exit_entrepreneur: 'Заявление индивидуального предпринимателя о выходе из состава пайщиков',
|
||||
application_exit_legal_entity: 'Заявление юридического лица о выходе из состава пайщиков',
|
||||
from_entrepreneur: 'от индивидуального предпринимателя',
|
||||
from_legal_entity: 'от юридического лица',
|
||||
legal_address: 'юридический адрес',
|
||||
fact_address: 'фактический адрес',
|
||||
entrepreneur_details: 'ИНН {0}, ОГРНИП {1}',
|
||||
legal_entity_details: 'ИНН {0}, ОГРН {1}, КПП {2}',
|
||||
request_to_exit: 'В соответствии с Уставом {0} (ОГРН {1}, ИНН {2}, КПП {3}) (далее по тексту Общество) прошу вывести меня из состава пайщиков Общества.',
|
||||
request_to_exit_legal_entity: 'Заявитель, в лице представителя юридического лица — {0} {1} {2} {3}, действующего на основании {4}, в соответствии с Уставом Общества просит вывести {5} из состава пайщиков {6}.',
|
||||
obligation_to_settle_individual: 'Обязуюсь погасить все свои обязательства перед Обществом, при наличии таковых, а также получить возврат своих паевых взносов в связи с моим участием в хозяйственной деятельности и целевых потребительских программах Общества, предусмотренном Уставом Общества и его действующих Положений и в соответствии с действующим законодательством РФ.',
|
||||
obligation_to_settle_legal_entity: 'Заявитель обязуется погасить все свои обязательства перед Обществом, при наличии таковых, а также получить возврат своих паевых взносов в связи с участием в хозяйственной деятельности и целевых потребительских программах Общества, предусмотренном Уставом Общества и его действующих Положений и в соответствии с действующим законодательством РФ.',
|
||||
signed_electronically: 'Документ подписан электронной подписью.',
|
||||
passport: 'Паспорт',
|
||||
passport_code: 'код подразделения',
|
||||
passport_issued: 'выдан',
|
||||
passport_from: 'от',
|
||||
},
|
||||
}
|
||||
|
||||
export const exampleData = {
|
||||
coop: {
|
||||
short_name: 'ПК "ВОСХОД"',
|
||||
full_name: 'потребительский кооператив "Восход"',
|
||||
city: 'Москва',
|
||||
details: {
|
||||
inn: '9728130611',
|
||||
ogrn: '1247700283346',
|
||||
kpp: '772801001',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
created_at: '04.03.2024 10:54',
|
||||
},
|
||||
individual: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
birthdate: '04.03.1990',
|
||||
phone: '790343432423',
|
||||
email: 'email@gmail.com',
|
||||
full_address: 'Советов 3-84',
|
||||
passport: {
|
||||
series: '7712',
|
||||
number: '122112',
|
||||
issued_by: 'отделом УФМС г. Москва',
|
||||
issued_at: '10.05.2010',
|
||||
code: '220-220',
|
||||
},
|
||||
},
|
||||
entrepreneur: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
birthdate: '04.03.1990',
|
||||
full_address: 'Советов 3-84',
|
||||
details: {
|
||||
inn: '34534534534',
|
||||
ogrn: '345345345',
|
||||
},
|
||||
phone: '+734534534534',
|
||||
email: 'email@gmail.com',
|
||||
},
|
||||
organization: {
|
||||
full_name: 'ООО РОМАШКА',
|
||||
full_address: 'Советов 3-84',
|
||||
fact_address: 'Советов 3-84',
|
||||
represented_by: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
position: 'директор',
|
||||
based_on: 'решения учредителей №1 от 05.03.2024',
|
||||
},
|
||||
details: {
|
||||
inn: '234234234',
|
||||
ogrn: '234234234234',
|
||||
kpp: '772801001',
|
||||
},
|
||||
phone: '+35345345345',
|
||||
email: 'an.mddf@gmail.com',
|
||||
},
|
||||
type: 'individual',
|
||||
vars: {
|
||||
passport_request: 'yes',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { IDecisionData, IGenerate, IMetaDocument } from '../../document'
|
||||
import type { ICooperativeData, IVars } from '../../model'
|
||||
import type { IEntrepreneurData, IIndividualData, IOrganizationData } from '../../users'
|
||||
|
||||
export const registry_id = 201
|
||||
|
||||
/**
|
||||
* Интерфейс генерации решения совета о выходе пайщика из кооператива
|
||||
*/
|
||||
export interface Action extends IGenerate {
|
||||
decision_id: number
|
||||
}
|
||||
|
||||
// Модель данных
|
||||
export interface Model {
|
||||
type: string
|
||||
|
||||
individual?: IIndividualData
|
||||
organization?: IOrganizationData
|
||||
entrepreneur?: IEntrepreneurData
|
||||
coop: ICooperativeData
|
||||
meta: IMetaDocument
|
||||
decision: IDecisionData
|
||||
vars: IVars
|
||||
}
|
||||
|
||||
export const title = 'Решение совета о выходе пайщика из кооператива'
|
||||
export const description = 'Форма решения совета о выходе пайщика из состава потребительского кооператива'
|
||||
export const context = '<style> \nh1 {\nmargin: 0px; \ntext-align:center;\n}\nh3{\nmargin: 0px;\npadding-top: 15px;\n}\n.about {\npadding: 20px;\n}\n.digital-document {\npadding: 20px;\nwhite-space: pre-wrap;\n}\n.subheader {\npadding-bottom: 20px; \n}\ntable {\n width: 100%;\n border-collapse: collapse;\n}\nth, td {\n border: 1px solid #ccc;\n padding: 8px;\n text-align: left;\n word-wrap: break-word; \n overflow-wrap: break-word; \n}\nth {\n background-color: #f4f4f4;\n width: 30%;\n}\n</style>\n\n<div class="digital-document">\n <h1 class="header">{% trans \'protocol_number\', decision.id %}</h1>\n <p style="text-align:center" class="subheader">{% trans \'council_meeting_name\' %} {{vars.full_abbr_genitive}} "{{vars.name}}"</p>\n <p style="text-align: right"> {{ meta.created_at }}, {{ coop.city }}</p>\n <table class="about">\n <tbody>\n <tr>\n <th>{% trans \'meeting_format\' %}</th>\n <td>{% trans \'meeting_format_value\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'meeting_place\' %}</th>\n <td>{{ coop.full_address }}</td>\n </tr>\n <tr>\n <th>{% trans \'meeting_date\' %}</th>\n <td>{{ decision.date }}</td>\n </tr>\n <tr>\n <th>{% trans \'opening_time\' %}</th>\n <td>{{ decision.time }}</td>\n </tr>\n </tbody>\n </table>\n <h3>{% trans \'council_members\' %}</h3>\n <table>\n <tbody>\n {% for member in coop.members %}\n <tr>\n <th>{% if member.is_chairman %}{% trans \'chairman_of_the_council\' %}{% else %}{% trans \'member_of_the_council\' %}{% endif %}</th>\n <td>{{ member.last_name }} {{ member.first_name }} {{ member.middle_name }}</td>\n</tr>\n {% endfor %}\n </tbody>\n </table>\n <h3>{% trans \'meeting_legality\' %}</h3>\n <p>{% trans \'voting_results\', decision.voters_percent %} {% trans \'quorum\' %} {% trans \'chairman_of_the_meeting\', coop.chairman.last_name, coop.chairman.first_name, coop.chairman.middle_name %}.</p>\n <h3>{% trans \'agenda\' %}</h3>\n <table>\n <tbody>\n <tr>\n <th>№</th>\n <td>{% trans \'question\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'agenda_item\' %}</th>\n <td>{% trans \'decision_leavecoop1\' %} {% if type == \'individual\' %}{{individual.last_name}} {{individual.first_name}} {{individual.middle_name}} ({{individual.birthdate}} {% trans \'birthdate\' %}){% endif %}{% if type == \'entrepreneur\' %}{% trans \'entrepreneur\' %} {{entrepreneur.last_name}} {{entrepreneur.first_name}} {{entrepreneur.middle_name}} ({% trans \'ogrnip\' %} {{entrepreneur.details.ogrn}}){% endif %}{% if type == \'organization\' %} {{organization.short_name}} ({% trans \'ogrn\' %} {{organization.details.ogrn}}){% endif %}{% trans \'in_participants\' %} {{ coop.short_name }}.\n </td>\n </tr>\n </tbody>\n </table>\n<h3>{% trans \'voting\' %}</h3>\n<p>{% trans \'vote_results\' %}</p><table>\n <tbody>\n <tr>\n <th>{% trans \'votes_for\' %}</th>\n <td>{{ decision.votes_for }}</td>\n </tr>\n <tr>\n <th>{% trans \'votes_against\' %}</th>\n <td>{{ decision.votes_against }}</td>\n </tr>\n <tr>\n <th>{% trans \'votes_abstained\' %}</th>\n <td>{{ decision.votes_abstained }}</td>\n </tr>\n </tbody>\n </table>\n <h3>{% trans \'decision_made\' %}</h3>\n <table>\n <tbody>\n <tr>\n <tr>\n <th>№</th>\n <td>{% trans \'question\' %}</td>\n </tr>\n\n <th>{% trans \'decision\' %}</th>\n <td>{% trans \'decision_leavecoop2\' %}\n{% if type == \'individual\' %}{{individual.last_name}} {{individual.first_name}} {{individual.middle_name}} {{individual.birthdate}} {% trans \'birthdate\' %} {% endif %}{% if type == \'entrepreneur\' %}{% trans \'entrepreneur\' %} {{entrepreneur.last_name}} {{entrepreneur.first_name}} {{entrepreneur.middle_name}}, {% trans \'ogrnip\' %} {{entrepreneur.details.ogrn}}{% endif %}{% if type == \'organization\' %}{{organization.short_name}}, {% trans \'ogrn\' %} {{organization.details.ogrn}}{% endif %}\n </td>\n </tr>\n </tbody>\n </table>\n <hr>\n<p>{% trans \'closing_time\', decision.time %}</p>\n<div class="signature"><p>{% trans \'signature\' %}</p><p>{% trans \'chairman\' %} {{ coop.chairman.last_name }} {{ coop.chairman.first_name }} {{ coop.chairman.middle_name }}</p></div>\n</div>\n'
|
||||
|
||||
export const translations = {
|
||||
ru: {
|
||||
meeting_format: 'Форма',
|
||||
meeting_date: 'Дата',
|
||||
meeting_place: 'Место',
|
||||
opening_time: 'Время открытия',
|
||||
council_members: 'ЧЛЕНЫ СОВЕТА',
|
||||
voting_results: 'Количество голосов составляет {0}% от общего числа членов Совета.',
|
||||
meeting_legality: 'СОБРАНИЕ ПРАВОМОЧНО',
|
||||
chairman_of_the_meeting: 'Председатель собрания совета: {0} {1} {2}',
|
||||
agenda: 'ПОВЕСТКА ДНЯ',
|
||||
decision_made: 'РЕШИЛИ',
|
||||
closing_time: 'Время закрытия собрания совета: {0}.',
|
||||
protocol_number: 'ПРОТОКОЛ № {0}',
|
||||
council_meeting_name: 'Собрания Совета',
|
||||
chairman_of_the_council: 'Председатель совета',
|
||||
signature: 'Документ подписан электронной подписью.',
|
||||
chairman: 'Председатель',
|
||||
birthdate: 'г.р.',
|
||||
ogrnip: 'ОГРНИП',
|
||||
entrepreneur: 'ИП',
|
||||
ogrn: 'ОГРН',
|
||||
quorum: 'Кворум для решения поставленных на повестку дня вопросов имеется.',
|
||||
voting: 'ГОЛОСОВАНИЕ',
|
||||
in_participants: ' о выходе из состава пайщиков',
|
||||
decision_leavecoop1: 'Рассмотреть заявление о выходе',
|
||||
decision_leavecoop2: 'Вывести из состава пайщиков',
|
||||
meeting_format_value: 'заочная',
|
||||
agenda_item: '1',
|
||||
votes_for: 'ЗА',
|
||||
votes_against: 'ПРОТИВ',
|
||||
votes_abstained: 'ВОЗДЕРЖАЛСЯ',
|
||||
decision: '1',
|
||||
question: 'Вопрос',
|
||||
vote_results: 'По первому вопросу повестки дня проголосовали:',
|
||||
member_of_the_council: 'Член совета',
|
||||
},
|
||||
}
|
||||
|
||||
export const exampleData = {
|
||||
meta: {
|
||||
created_at: '12.02.2024 00:01',
|
||||
},
|
||||
coop: {
|
||||
city: 'Москва',
|
||||
full_address: 'Смольная 3-84',
|
||||
chairman: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
},
|
||||
short_name: 'ПК "Восход"',
|
||||
},
|
||||
decision: {
|
||||
time: '00:01',
|
||||
date: '12.02.2024',
|
||||
votes_for: '3',
|
||||
votes_against: '0',
|
||||
votes_abstained: '0',
|
||||
voters_percent: '100',
|
||||
id: '1',
|
||||
},
|
||||
member: {
|
||||
is_chairman: true,
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
},
|
||||
organization: {
|
||||
details: {
|
||||
ogrn: '2222222222',
|
||||
},
|
||||
short_name: 'ООО "Ромашка"',
|
||||
},
|
||||
type: 'organization',
|
||||
individual: {
|
||||
last_name: 'Мартин',
|
||||
first_name: 'Роберт',
|
||||
middle_name: 'Иванович',
|
||||
birthdate: '04.04.2000',
|
||||
},
|
||||
entrepreneur: {
|
||||
last_name: 'Мартин',
|
||||
first_name: 'Роберт',
|
||||
middle_name: 'Иванович',
|
||||
details: {
|
||||
ogrn: '11111111111',
|
||||
},
|
||||
},
|
||||
vars: {
|
||||
full_abbr_genitive: 'Потребительского Кооператива',
|
||||
name: 'ВОСХОД',
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export * as ConvertToAxonStatement from './51.ConvertToAxonStatement'
|
||||
export * as ParticipantApplication from './100.ParticipantApplication'
|
||||
export * as DecisionOfParticipantApplication from './501.DecisionOfParticipantApplication'
|
||||
|
||||
export * as ParticipantExitApplication from './200.ParticipantExitApplication'
|
||||
export * as DecisionOfParticipantExit from './201.DecisionOfParticipantExit'
|
||||
|
||||
export * as SelectBranchStatement from './101.SelectBranchStatement'
|
||||
export * as ProjectFreeDecision from './599.ProjectFreeDecision'
|
||||
export * as FreeDecision from './600.FreeDecision'
|
||||
|
||||
@@ -53,6 +53,17 @@ export interface IChangekey {
|
||||
public_key: IPublicKey
|
||||
}
|
||||
|
||||
export interface ICompletexit {
|
||||
coopname: IName
|
||||
exit_hash: IChecksum256
|
||||
}
|
||||
|
||||
export interface IConfirmexit {
|
||||
coopname: IName
|
||||
exit_hash: IChecksum256
|
||||
authorization: IDocument2
|
||||
}
|
||||
|
||||
export interface IConfirmpay {
|
||||
coopname: IName
|
||||
registration_hash: IChecksum256
|
||||
@@ -122,6 +133,12 @@ export interface IDeclinereg {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface IDeclinexit {
|
||||
coopname: IName
|
||||
exit_hash: IChecksum256
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface IDelcoop {
|
||||
registrator: IName
|
||||
coopname: IName
|
||||
@@ -151,6 +168,24 @@ export interface IEnabranches {
|
||||
coopname: IName
|
||||
}
|
||||
|
||||
export interface IExit {
|
||||
username: IName
|
||||
coopname: IName
|
||||
status: IName
|
||||
created_at: ITimePointSec
|
||||
statement: IDocument2
|
||||
approved_statement: IDocument2
|
||||
exit_hash: IChecksum256
|
||||
quantity: IAsset
|
||||
}
|
||||
|
||||
export interface IExitcoop {
|
||||
coopname: IName
|
||||
username: IName
|
||||
exit_hash: IChecksum256
|
||||
statement: IDocument2
|
||||
}
|
||||
|
||||
export interface IInit {
|
||||
}
|
||||
|
||||
|
||||
@@ -343,6 +343,11 @@ export interface IDeletebranch {
|
||||
braname: IName
|
||||
}
|
||||
|
||||
export interface IDelpartcpnt {
|
||||
coopname: IName
|
||||
username: IName
|
||||
}
|
||||
|
||||
export interface IDisableprog {
|
||||
coopname: IName
|
||||
program_id: IUint64
|
||||
|
||||
@@ -57,120 +57,56 @@ export interface OperationMeta {
|
||||
|
||||
export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
|
||||
// registrator
|
||||
{ code: 'o.reg.payent', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'PAY_ENTRANCE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry',
|
||||
debit: 51, credit: 86,
|
||||
human_name: 'Вступительный взнос пайщика' },
|
||||
{ code: 'o.reg.payent', process_type: 'p.reg.accept', contract: 'registrator', name: 'PAY_ENTRANCE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry', debit: 51, credit: 86, human_name: 'Вступительный взнос пайщика' },
|
||||
|
||||
{ code: 'o.reg.putmin', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'PUT_MINSHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Минимальный паевой взнос пайщика при регистрации' },
|
||||
{ code: 'o.reg.putmin', process_type: 'p.reg.accept', contract: 'registrator', name: 'PUT_MINSHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr', debit: 51, credit: 80, human_name: 'Минимальный паевой взнос пайщика при регистрации' },
|
||||
|
||||
// Двухфазный путь через совет (reguser → confirmpay → confirmreg/declinereg)
|
||||
{ code: 'o.reg.inpay', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'RECEIVE_PAYMENT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.pend',
|
||||
debit: 51, credit: 76,
|
||||
human_name: 'Приём регистрационного взноса в ожидание решения совета' },
|
||||
{ code: 'o.reg.inpay', process_type: 'p.reg.accept', contract: 'registrator', name: 'RECEIVE_PAYMENT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.pend', debit: 51, credit: 76, human_name: 'Приём регистрационного взноса в ожидание решения совета' },
|
||||
|
||||
{ code: 'o.reg.setmin', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'SETTLE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.minshr',
|
||||
debit: 76, credit: 80,
|
||||
human_name: 'Зачисление минимального паевого взноса по решению совета' },
|
||||
{ code: 'o.reg.setmin', process_type: 'p.reg.accept', contract: 'registrator', name: 'SETTLE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.minshr', debit: 76, credit: 80, human_name: 'Зачисление минимального паевого взноса по решению совета' },
|
||||
|
||||
{ code: 'o.reg.setent', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'SETTLE_ENTRANCE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.entry',
|
||||
debit: 76, credit: 86,
|
||||
human_name: 'Зачисление вступительного взноса по решению совета' },
|
||||
{ code: 'o.reg.setent', process_type: 'p.reg.accept', contract: 'registrator', name: 'SETTLE_ENTRANCE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.entry', debit: 76, credit: 86, human_name: 'Зачисление вступительного взноса по решению совета' },
|
||||
|
||||
{ code: 'o.reg.refund', process_type: 'p.reg.refund', contract: 'registrator',
|
||||
name: 'REFUND', wallet_op: 'BURN', wallet_from: 'w.reg.pend', wallet_to: null,
|
||||
debit: 76, credit: 51,
|
||||
human_name: 'Возврат регистрационного взноса при отказе совета' },
|
||||
{ code: 'o.reg.refund', process_type: 'p.reg.refund', contract: 'registrator', name: 'REFUND', wallet_op: 'BURN', wallet_from: 'w.reg.pend', wallet_to: null, debit: 76, credit: 51, human_name: 'Возврат регистрационного взноса при отказе совета' },
|
||||
|
||||
{ code: 'o.reg.mvmin', process_type: 'p.wal.wthdrw', contract: 'registrator', name: 'MOVE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.minshr', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Перенос минимального паевого на главный при выходе из кооператива' },
|
||||
|
||||
// wallet
|
||||
{ code: 'o.wal.depcpl', process_type: 'p.wal.depo', contract: 'wallet',
|
||||
name: 'COMPLETE_DEPOSIT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Внесение пайщиком паевого взноса' },
|
||||
{ code: 'o.wal.depcpl', process_type: 'p.wal.depo', contract: 'wallet', name: 'COMPLETE_DEPOSIT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Внесение пайщиком паевого взноса' },
|
||||
|
||||
{ code: 'o.wal.wthreq', process_type: 'p.wal.wthdrw', contract: 'wallet',
|
||||
name: 'REQUEST_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.wal.wpend',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Резервирование паевого под запрос на возврат' },
|
||||
{ code: 'o.wal.wthreq', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'REQUEST_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.wal.wpend', debit: null, credit: null, human_name: 'Резервирование паевого под запрос на возврат' },
|
||||
|
||||
{ code: 'o.wal.wthdec', process_type: 'p.wal.wthdrw', contract: 'wallet',
|
||||
name: 'DECLINE_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.wpend', wallet_to: 'w.wal.share',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Снятие резерва паевого после отклонения запроса на возврат' },
|
||||
{ code: 'o.wal.wthdec', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'DECLINE_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.wpend', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Снятие резерва паевого после отклонения запроса на возврат' },
|
||||
|
||||
{ code: 'o.wal.wthcpl', process_type: 'p.wal.wthdrw', contract: 'wallet',
|
||||
name: 'COMPLETE_WITHDRAW', wallet_op: 'BURN', wallet_from: 'w.wal.wpend', wallet_to: null,
|
||||
debit: 80, credit: 51,
|
||||
human_name: 'Возврат паевого взноса пайщику' },
|
||||
{ code: 'o.wal.wthcpl', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'COMPLETE_WITHDRAW', wallet_op: 'BURN', wallet_from: 'w.wal.wpend', wallet_to: null, debit: 80, credit: 51, human_name: 'Возврат паевого взноса пайщику' },
|
||||
|
||||
// capital (ADR-009: единые программные кошельки `w.cap.blago`/`w.cap.gen`)
|
||||
// IMPORT и ACCEPT_PROPERTY — Dr 04 (НМА), не Dr 51: импорт/акт-2 фиксируют
|
||||
// имущественный вклад как РИД, не деньги. Денежные взносы в Благорост — INVEST.
|
||||
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital',
|
||||
name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
|
||||
debit: 4, credit: 80,
|
||||
human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
|
||||
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital', name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago', debit: 4, credit: 80, human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
|
||||
|
||||
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital',
|
||||
name: 'INVEST', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.cap.blago',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Инвестиция в ЦПП «Благорост»' },
|
||||
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital', name: 'INVEST', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.cap.blago', debit: null, credit: null, human_name: 'Инвестиция в ЦПП «Благорост»' },
|
||||
|
||||
{ code: 'o.cap.commit', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'COMMIT_RID', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.gen',
|
||||
debit: 8, credit: 80,
|
||||
human_name: 'Коммит РИД по программе «Генератор»' },
|
||||
{ code: 'o.cap.commit', process_type: 'p.cap.rid', contract: 'capital', name: 'COMMIT_RID', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.gen', debit: 8, credit: 80, human_name: 'Коммит РИД по программе «Генератор»' },
|
||||
|
||||
{ code: 'o.cap.accept', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'ACCEPT_RID', wallet_op: 'NONE', wallet_from: null, wallet_to: null,
|
||||
debit: 4, credit: 8,
|
||||
human_name: 'Приём РИД в паевой фонд' },
|
||||
{ code: 'o.cap.accept', process_type: 'p.cap.rid', contract: 'capital', name: 'ACCEPT_RID', wallet_op: 'NONE', wallet_from: null, wallet_to: null, debit: 4, credit: 8, human_name: 'Приём РИД в паевой фонд' },
|
||||
|
||||
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital',
|
||||
name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
|
||||
debit: 4, credit: 80,
|
||||
human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
|
||||
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital', name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago', debit: 4, credit: 80, human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
|
||||
|
||||
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital',
|
||||
name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp',
|
||||
debit: 4, credit: 80,
|
||||
human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
|
||||
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital', name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp', debit: 4, credit: 80, human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
|
||||
|
||||
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital',
|
||||
name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null,
|
||||
debit: 80, credit: 4,
|
||||
human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
|
||||
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital', name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null, debit: 80, credit: 4, human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
|
||||
|
||||
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital',
|
||||
name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan',
|
||||
debit: 58, credit: 51,
|
||||
human_name: 'Выдача пайщику беспроцентного займа' },
|
||||
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital', name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan', debit: 58, credit: 51, human_name: 'Выдача пайщику беспроцентного займа' },
|
||||
|
||||
{ code: 'o.cap.repay', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'REPAY', wallet_op: 'TRANSFER', wallet_from: 'w.cap.loan', wallet_to: 'w.wal.share',
|
||||
debit: 80, credit: 58,
|
||||
human_name: 'Возврат беспроцентного займа пайщика по акту-2' },
|
||||
{ code: 'o.cap.repay', process_type: 'p.cap.rid', contract: 'capital', name: 'REPAY', wallet_op: 'TRANSFER', wallet_from: 'w.cap.loan', wallet_to: 'w.wal.share', debit: 80, credit: 58, human_name: 'Возврат беспроцентного займа пайщика по акту-2' },
|
||||
|
||||
{ code: 'o.cap.wthcap', process_type: 'p.cap.wthcap', contract: 'capital',
|
||||
name: 'WITHDRAW_FROM_CAPITAL', wallet_op: 'TRANSFER', wallet_from: 'w.cap.blago', wallet_to: 'w.wal.share',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Возврат паевого из ЦПП «Благорост» в Цифровой Кошелёк' },
|
||||
{ code: 'o.cap.wthcap', process_type: 'p.cap.wthcap', contract: 'capital', name: 'WITHDRAW_FROM_CAPITAL', wallet_op: 'TRANSFER', wallet_from: 'w.cap.blago', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Возврат паевого из ЦПП «Благорост» в Цифровой Кошелёк' },
|
||||
|
||||
{ code: 'o.cap.cnvshr', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'CONVERT_TO_SHARE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.wal.share',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Конвертация сегмента: РИД → главный кошелёк' },
|
||||
{ code: 'o.cap.cnvshr', process_type: 'p.cap.rid', contract: 'capital', name: 'CONVERT_TO_SHARE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Конвертация сегмента: РИД → главный кошелёк' },
|
||||
|
||||
{ code: 'o.cap.cnvbl', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'CONVERT_TO_BLAGO', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.cap.blago',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
|
||||
{ code: 'o.cap.cnvbl', process_type: 'p.cap.rid', contract: 'capital', name: 'CONVERT_TO_BLAGO', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.cap.blago', debit: null, credit: null, human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
|
||||
|
||||
{ code: 'o.cap.pgtop', process_type: 'p.cap.pgexp', contract: 'capital',
|
||||
name: 'PROGRAM_EXPENSE_TOPUP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.pgexp',
|
||||
@@ -204,59 +140,34 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
|
||||
human_name: 'Доплата сверх подотчёта (перерасход)' },
|
||||
|
||||
// marketplace
|
||||
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace',
|
||||
name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Подтверждение поставки' },
|
||||
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace', name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Подтверждение поставки' },
|
||||
|
||||
{ code: 'o.mkt.recv', process_type: 'p.mkt.reqst', contract: 'marketplace',
|
||||
name: 'CONFIRM_RECEIPT', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.payout',
|
||||
debit: 80, credit: 51,
|
||||
human_name: 'Подтверждение получения — выплата поставщику' },
|
||||
{ code: 'o.mkt.recv', process_type: 'p.mkt.reqst', contract: 'marketplace', name: 'CONFIRM_RECEIPT', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.payout', debit: 80, credit: 51, human_name: 'Подтверждение получения — выплата поставщику' },
|
||||
|
||||
// soviet
|
||||
{ code: 'o.sov.axncnv', process_type: 'p.sov.axncnv', contract: 'soviet',
|
||||
name: 'CONVERT_AXN', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.sov.delgte',
|
||||
debit: 80, credit: 86,
|
||||
human_name: 'Трансляция паевого в членский взнос инфраструктуры' },
|
||||
{ code: 'o.sov.axncnv', process_type: 'p.sov.axncnv', contract: 'soviet', name: 'CONVERT_AXN', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.sov.delgte', debit: 80, credit: 86, human_name: 'Трансляция паевого в членский взнос инфраструктуры' },
|
||||
|
||||
// migration
|
||||
{ code: 'o.mig.minshr', process_type: 'p.mig.trans', contract: 'migration',
|
||||
name: 'MIN_SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Транзит: минимальные паевые взносы' },
|
||||
{ code: 'o.mig.minshr', process_type: 'p.mig.trans', contract: 'migration', name: 'MIN_SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr', debit: 51, credit: 80, human_name: 'Транзит: минимальные паевые взносы' },
|
||||
|
||||
{ code: 'o.mig.share', process_type: 'p.mig.trans', contract: 'migration',
|
||||
name: 'SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Транзит: остаток паевых взносов деньгами' },
|
||||
{ code: 'o.mig.share', process_type: 'p.mig.trans', contract: 'migration', name: 'SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Транзит: остаток паевых взносов деньгами' },
|
||||
|
||||
{ code: 'o.mig.entry', process_type: 'p.mig.trans', contract: 'migration',
|
||||
name: 'ENTRY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry',
|
||||
debit: 51, credit: 86,
|
||||
human_name: 'Транзит: вступительные взносы' },
|
||||
{ code: 'o.mig.entry', process_type: 'p.mig.trans', contract: 'migration', name: 'ENTRY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry', debit: 51, credit: 86, human_name: 'Транзит: вступительные взносы' },
|
||||
|
||||
// adjustment (ручные корректировки председателя — динамические параметры,
|
||||
// не идут через ledger2::apply; см. operations.hpp `OPERATION_ADJUSTMENT_REGISTRY`).
|
||||
{ code: 'o.adj.walmove', process_type: 'p.adj.fix', contract: 'ledger2',
|
||||
name: 'WALMOVE', wallet_op: null, wallet_from: null, wallet_to: null,
|
||||
debit: null, credit: null,
|
||||
human_name: 'Перевод между кошельками',
|
||||
kind: 'adjustment' },
|
||||
{ code: 'o.adj.walmove', process_type: 'p.adj.fix', contract: 'ledger2', name: 'WALMOVE', wallet_op: null, wallet_from: null, wallet_to: null, debit: null, credit: null, human_name: 'Перевод между кошельками', kind: 'adjustment' },
|
||||
|
||||
{ code: 'o.adj.rev', process_type: 'p.adj.fix', contract: 'ledger2',
|
||||
name: 'REVERSAL', wallet_op: null, wallet_from: null, wallet_to: null,
|
||||
debit: null, credit: null,
|
||||
human_name: 'Откат операции',
|
||||
kind: 'adjustment' },
|
||||
{ code: 'o.adj.rev', process_type: 'p.adj.fix', contract: 'ledger2', name: 'REVERSAL', wallet_op: null, wallet_from: null, wallet_to: null, debit: null, credit: null, human_name: 'Откат операции', kind: 'adjustment' },
|
||||
] as const
|
||||
|
||||
const opByCode = new Map<string, OperationMeta>(
|
||||
LEDGER2_OPERATION_REGISTRY.map((o) => [o.code, o]),
|
||||
LEDGER2_OPERATION_REGISTRY.map(o => [o.code, o]),
|
||||
)
|
||||
|
||||
export function getOperationMeta(code: string | null | undefined): OperationMeta | undefined {
|
||||
if (!code) return undefined
|
||||
if (!code)
|
||||
return undefined
|
||||
return opByCode.get(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,3 +62,15 @@ export const LEDGER2_USER_SHARED_PROGRAM_MAPPING: readonly ProgramWalletMapping[
|
||||
{ wallet_name: "w.exp.adv", required_program_id: 0, program_label: null },
|
||||
{ wallet_name: "w.reg.pend", required_program_id: 0, program_label: null },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.
|
||||
* Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит
|
||||
* этот сет, собирает доступные балансы и ставит их на возврат; backend-preview
|
||||
* считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.
|
||||
*/
|
||||
export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [
|
||||
"w.reg.minshr",
|
||||
"w.wal.share",
|
||||
"w.cap.blago",
|
||||
] as const
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { IName } from '../interfaces/ledger2'
|
||||
|
||||
export {
|
||||
LEDGER2_EXIT_REFUND_WALLETS,
|
||||
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
|
||||
LEDGER2_WALLET_REGISTRY,
|
||||
} from './wallets.generated'
|
||||
@@ -28,6 +29,7 @@ export type {
|
||||
} from './wallets.generated'
|
||||
|
||||
import {
|
||||
LEDGER2_EXIT_REFUND_WALLETS,
|
||||
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
|
||||
LEDGER2_WALLET_REGISTRY,
|
||||
} from './wallets.generated'
|
||||
@@ -64,6 +66,26 @@ const walletNamesByProgramId = (() => {
|
||||
*/
|
||||
export const MEMBERSHIP_WALLET_NAME = 'w.wal.member'
|
||||
|
||||
/**
|
||||
* wallet_name целевого паевого взноса пайщика (program_id=1, ЦК).
|
||||
*/
|
||||
export const SHARE_WALLET_NAME = 'w.wal.share'
|
||||
|
||||
/**
|
||||
* wallet_name минимального паевого взноса пайщика (required_program_id=0 —
|
||||
* вне программ, заполняется при регистрации). Возвращается пайщику при выходе.
|
||||
*/
|
||||
export const MIN_SHARE_WALLET_NAME = 'w.reg.minshr'
|
||||
|
||||
/**
|
||||
* Сет паевых («боевых») кошельков, возвращаемых пайщику при выходе из кооператива
|
||||
* (`w.reg.minshr` + `w.wal.share` + `w.cap.blago`). Источник истины — контракт
|
||||
* (`LEDGER2_EXIT_REFUND_WALLETS` в wallets.hpp), сгенерирован в wallets.generated.
|
||||
* Контракт `confirmexit` пылесосит эти кошельки на возврат; backend-preview
|
||||
* суммирует их же — расчёт всегда совпадает с тем, что вернёт контракт.
|
||||
*/
|
||||
export const EXIT_REFUND_WALLET_NAMES: readonly string[] = LEDGER2_EXIT_REFUND_WALLETS
|
||||
|
||||
/**
|
||||
* Все wallet_name'ы, привязанные к какой-либо программе (program_id > 0).
|
||||
* Исключает кошельки с required_program_id == 0 (например, `w.reg.minshr`).
|
||||
|
||||
@@ -32,6 +32,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_USER_SHARED_PRO
|
||||
"required_program_id": 0,
|
||||
"wallet_name": "w.cap.preimp",
|
||||
},
|
||||
{
|
||||
"program_label": null,
|
||||
"required_program_id": 0,
|
||||
"wallet_name": "w.exp.adv",
|
||||
},
|
||||
{
|
||||
"program_label": null,
|
||||
"required_program_id": 0,
|
||||
@@ -67,6 +72,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
|
||||
"kind": "USER_SHARED",
|
||||
"name": "w.cap.preimp",
|
||||
},
|
||||
{
|
||||
"human_name": "Подотчётные средства пайщика",
|
||||
"kind": "USER_SHARED",
|
||||
"name": "w.exp.adv",
|
||||
},
|
||||
{
|
||||
"human_name": "Регистрационный взнос в ожидании решения совета",
|
||||
"kind": "USER_SHARED",
|
||||
@@ -122,5 +132,10 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.mkt.payout",
|
||||
},
|
||||
{
|
||||
"human_name": "Пул программных расходов ЦПП «Благорост»",
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.cap.pgexp",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EXIT_REFUND_WALLET_NAMES,
|
||||
LEDGER2_EXIT_REFUND_WALLETS,
|
||||
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
|
||||
LEDGER2_WALLET_REGISTRY,
|
||||
programIdForWallet,
|
||||
@@ -35,4 +37,13 @@ describe('ledger2 wallets registry (generated from C++)', () => {
|
||||
it('ЦК split: program_id=1 → share + member', () => {
|
||||
expect(walletNamesForProgram(1).sort()).toEqual(['w.wal.member', 'w.wal.share'])
|
||||
})
|
||||
|
||||
it('exit-refund сет: ровно три паевых кошелька (minshr + share + blago)', () => {
|
||||
expect(LEDGER2_EXIT_REFUND_WALLETS).toEqual(['w.reg.minshr', 'w.wal.share', 'w.cap.blago'])
|
||||
// алиас-обёртка ссылается на тот же сет
|
||||
expect(EXIT_REFUND_WALLET_NAMES).toEqual(LEDGER2_EXIT_REFUND_WALLETS)
|
||||
// каждый кошелёк сета зарегистрирован в реестре
|
||||
const known = new Set(LEDGER2_WALLET_REGISTRY.map(w => w.name))
|
||||
for (const w of LEDGER2_EXIT_REFUND_WALLETS) expect(known.has(w)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ProfilePage } from 'src/pages/User/ProfilePage';
|
||||
import { WalletPage } from 'src/pages/User/WalletPage';
|
||||
import { MembershipExitPage } from 'src/pages/User/MembershipExitPage';
|
||||
import { MembershipExitConfirmPage } from 'src/pages/User/MembershipExitConfirmPage';
|
||||
import { ConnectionAgreementPage, InstallationCompletedPage } from 'src/pages/Union/ConnectionAgreement';
|
||||
import { UserPaymentMethodsPage } from 'src/pages/User/PaymentMethodsPage';
|
||||
import { ContactsPage } from 'src/pages/Contacts';
|
||||
@@ -43,6 +45,22 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
component: markRaw(WalletPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
// Подтверждение авторизуется токеном из ссылки (мутация публичная),
|
||||
// поэтому страница доступна БЕЗ входа — иначе навигационный гард
|
||||
// редиректит на login-redirect. requiresAuth:false = исключение из auth-гейта.
|
||||
meta: {
|
||||
title: 'Подтверждение выхода',
|
||||
icon: 'logout',
|
||||
roles: [],
|
||||
requiresAuth: false,
|
||||
hidden: true,
|
||||
},
|
||||
path: 'membership-exit/confirm',
|
||||
name: 'membership-exit-confirm',
|
||||
component: markRaw(MembershipExitConfirmPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Удостоверение',
|
||||
@@ -58,7 +76,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
{
|
||||
meta: {
|
||||
title: 'Подключение',
|
||||
icon: 'fas fa-link',
|
||||
icon: 'link',
|
||||
roles: ['user'],
|
||||
conditions: 'isCoop === true && coopname === "voskhod"',
|
||||
requiresAuth: true,
|
||||
@@ -85,7 +103,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
{
|
||||
meta: {
|
||||
title: 'Реквизиты',
|
||||
icon: 'fas fa-link',
|
||||
icon: 'account_balance',
|
||||
roles: ['user', 'member', 'chairman'],
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -161,6 +179,20 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Предпоследний пункт: выход из кооператива (не путать с «Выйти из
|
||||
// кабинета» в RailUserCard). Иконка group_remove — не logout.
|
||||
meta: {
|
||||
title: 'Выход из кооператива',
|
||||
icon: 'group_remove',
|
||||
roles: [],
|
||||
requiresAuth: true,
|
||||
},
|
||||
path: 'membership-exit',
|
||||
name: 'membership-exit',
|
||||
component: markRaw(MembershipExitPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Поддержка',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
RequireAgreements
|
||||
SelectBranchOverlay
|
||||
ExitOverlay
|
||||
NotificationPermissionDialog
|
||||
</template>
|
||||
|
||||
@@ -12,6 +13,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { FailAlert } from 'src/shared/api/alerts';
|
||||
import { RequireAgreements } from 'src/widgets/RequireAgreements';
|
||||
import { SelectBranchOverlay } from 'src/features/Branch/SelectBranch';
|
||||
import { ExitOverlay } from 'src/features/Membership/ExitFromCoop';
|
||||
import {
|
||||
NotificationPermissionDialog,
|
||||
useNotificationPermissionDialog,
|
||||
|
||||
@@ -28,6 +28,14 @@ export function getAccountStatusBadge(account: IAccount): AccountStatusBadge {
|
||||
return { label: 'Активный пайщик', variant: 'pos' };
|
||||
}
|
||||
|
||||
// 1b. Вышел из кооператива: запись пайщика удалена (delpartcpnt), а аккаунт в
|
||||
// registrator переведён в blocked (finalize_member_exit). participant_account
|
||||
// уже нет — ловим терминал по user_account.status, иначе воронка ниже
|
||||
// показала бы «Активный пайщик» (provider-статус остаётся Active).
|
||||
if (String(account.user_account?.status) === 'blocked') {
|
||||
return { label: 'Вышел из кооператива', variant: 'neutral' };
|
||||
}
|
||||
|
||||
const status = account.provider_account?.status;
|
||||
|
||||
// 2. Отклонён советом после оплаты: PROCESSING — возврат ещё идёт, REFUNDED —
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ui';
|
||||
export * from './model';
|
||||
@@ -0,0 +1,169 @@
|
||||
export * from './useExitDialog';
|
||||
export * from './useExitGate';
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { Mutations, Queries } from '@coopenomics/sdk';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
import { DigitalDocument } from 'src/shared/lib/document';
|
||||
import type { Cooperative } from 'cooptypes';
|
||||
import { generateUniqueHash } from 'src/shared/lib/utils/generateUniqueHash';
|
||||
|
||||
export type IGenerateMembershipExitApplicationData =
|
||||
Mutations.MembershipExit.GenerateMembershipExitApplication.IInput['data'];
|
||||
export type IGenerateMembershipExitApplicationResult =
|
||||
Mutations.MembershipExit.GenerateMembershipExitApplication.IOutput[typeof Mutations.MembershipExit.GenerateMembershipExitApplication.name];
|
||||
|
||||
export type ICreateMembershipExitData =
|
||||
Mutations.MembershipExit.CreateMembershipExit.IInput['data'];
|
||||
export type ICreateMembershipExitResult =
|
||||
Mutations.MembershipExit.CreateMembershipExit.IOutput[typeof Mutations.MembershipExit.CreateMembershipExit.name];
|
||||
|
||||
export type IMembershipExitReturnPreview =
|
||||
Queries.MembershipExit.MembershipExitReturnPreview.IOutput[typeof Queries.MembershipExit.MembershipExitReturnPreview.name];
|
||||
|
||||
/**
|
||||
* Композабл выхода пайщика из кооператива.
|
||||
*/
|
||||
export function useMembershipExit() {
|
||||
const { info } = useSystemStore();
|
||||
const session = useSessionStore();
|
||||
|
||||
/**
|
||||
* Генерирует документ заявления о выходе из кооператива (registry 200).
|
||||
*/
|
||||
async function generateMembershipExitApplication(
|
||||
data: Omit<IGenerateMembershipExitApplicationData, 'coopname'>,
|
||||
): Promise<IGenerateMembershipExitApplicationResult> {
|
||||
const {
|
||||
[Mutations.MembershipExit.GenerateMembershipExitApplication.name]: result,
|
||||
} = await client.Mutation(
|
||||
Mutations.MembershipExit.GenerateMembershipExitApplication.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
coopname: info.coopname,
|
||||
...data,
|
||||
},
|
||||
options: {
|
||||
lang: 'ru',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подаёт заявление на выход (push registrator::exitcoop).
|
||||
*/
|
||||
async function createMembershipExit(
|
||||
input: Omit<ICreateMembershipExitData, 'coopname' | 'username'>,
|
||||
): Promise<ICreateMembershipExitResult> {
|
||||
const { [Mutations.MembershipExit.CreateMembershipExit.name]: result } =
|
||||
await client.Mutation(Mutations.MembershipExit.CreateMembershipExit.mutation, {
|
||||
variables: {
|
||||
data: {
|
||||
coopname: info.coopname,
|
||||
username: session.username,
|
||||
...input,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у пайщика реквизиты для получения возврата паевого взноса.
|
||||
* Выход блокируется, пока их нет (бэкенд это же проверяет при подаче) — без
|
||||
* реквизитов исходящий платёж возврата некуда будет создать.
|
||||
*/
|
||||
async function hasRequisites(): Promise<boolean> {
|
||||
const response = await client.Query(Queries.PaymentMethods.GetPaymentMethods.query, {
|
||||
variables: {
|
||||
data: { username: session.username, limit: 1, page: 1 },
|
||||
},
|
||||
});
|
||||
const result = response?.[Queries.PaymentMethods.GetPaymentMethods.name];
|
||||
// Блокируем выход ТОЛЬКО при достоверно пустом списке методов. Если ответ
|
||||
// непонятный (null/не массив) — НЕ блокируем: авторитетную проверку сделает
|
||||
// бэкенд при подаче (createMembershipExit). Так исключаем ложный блок.
|
||||
if (!result || !Array.isArray(result.items)) return true;
|
||||
return result.items.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Предварительный расчёт суммы возврата паевого взноса при выходе.
|
||||
*/
|
||||
async function getReturnPreview(): Promise<IMembershipExitReturnPreview> {
|
||||
const {
|
||||
[Queries.MembershipExit.MembershipExitReturnPreview.name]: result,
|
||||
} = await client.Query(
|
||||
Queries.MembershipExit.MembershipExitReturnPreview.query,
|
||||
{
|
||||
variables: {
|
||||
coopname: info.coopname,
|
||||
username: session.username,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг 1: генерирует документ заявления о выходе (200) — показываем пайщику
|
||||
* перед подписанием («читайте внимательно»).
|
||||
*/
|
||||
async function generateApplication(): Promise<IGenerateMembershipExitApplicationResult> {
|
||||
return generateMembershipExitApplication({
|
||||
username: session.username,
|
||||
skip_save: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг 2: подписывает показанный документ приватным ключом пайщика и подаёт
|
||||
* заявление. На бэкенде заявление принимается и уходит письмо с подтверждением —
|
||||
* в блокчейн отправится только после перехода по ссылке (confirmExit).
|
||||
*/
|
||||
async function submitSignedApplication(
|
||||
document: IGenerateMembershipExitApplicationResult,
|
||||
): Promise<ICreateMembershipExitResult> {
|
||||
const exit_hash = await generateUniqueHash();
|
||||
|
||||
const digitalDocument = new DigitalDocument(document);
|
||||
const signedDocument =
|
||||
await digitalDocument.sign<Cooperative.Registry.ParticipantExitApplication.Meta>(
|
||||
session.username,
|
||||
);
|
||||
|
||||
return createMembershipExit({
|
||||
exit_hash,
|
||||
statement: signedDocument,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение выхода по токену из письма — отправляет ранее подписанное
|
||||
* заявление в блокчейн.
|
||||
*/
|
||||
async function confirmExit(token: string): Promise<ICreateMembershipExitResult> {
|
||||
const { [Mutations.MembershipExit.ConfirmMembershipExit.name]: result } =
|
||||
await client.Mutation(Mutations.MembershipExit.ConfirmMembershipExit.mutation, {
|
||||
variables: { token },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
generateMembershipExitApplication,
|
||||
generateApplication,
|
||||
submitSignedApplication,
|
||||
createMembershipExit,
|
||||
confirmExit,
|
||||
getReturnPreview,
|
||||
hasRequisites,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
const showExitDialog = ref(false);
|
||||
|
||||
export function useExitDialog() {
|
||||
const open = () => {
|
||||
showExitDialog.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
showExitDialog.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
showDialog: showExitDialog,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { Mutations, Queries, Zeus } from '@coopenomics/sdk';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
|
||||
export type IMembershipExit =
|
||||
Queries.MembershipExit.GetMembershipExit.IOutput[typeof Queries.MembershipExit.GetMembershipExit.name];
|
||||
|
||||
// Module-level singleton — состояние gate'а общее для overlay, процесса и страницы.
|
||||
const exitStatus = ref<IMembershipExit | null>(null);
|
||||
const previewTotal = ref<string | null>(null);
|
||||
const loaded = ref(false);
|
||||
|
||||
/**
|
||||
* Глобальный gate выхода: пока у пайщика есть активный процесс выхода, кабинет
|
||||
* блокируется и показывается только статус заявления и сумма возврата.
|
||||
*/
|
||||
export function useExitGate() {
|
||||
const { info } = useSystemStore();
|
||||
const session = useSessionStore();
|
||||
|
||||
// Активный выход → блокируем кабинет.
|
||||
const isExitActive = computed(() => !!exitStatus.value);
|
||||
|
||||
// Off-chain фаза: заявление подписано, ждём перехода по ссылке из письма.
|
||||
const isAwaitingConfirmation = computed(
|
||||
() => exitStatus.value?.status === Zeus.MembershipExitStatus.AWAITING_CONFIRMATION,
|
||||
);
|
||||
|
||||
// Статус исходящего платежа возврата (после одобрения советом) — null, пока
|
||||
// платёж не заведён в реестр кассира.
|
||||
const paymentStatus = computed(() => exitStatus.value?.payment_status ?? null);
|
||||
|
||||
/**
|
||||
* Отмена заявления на выход до подтверждения по email (кнопка на экране ожидания).
|
||||
* После отмены кабинет разблокируется.
|
||||
*/
|
||||
async function cancelExit(): Promise<void> {
|
||||
if (!session.username) return;
|
||||
await client.Mutation(Mutations.MembershipExit.CancelMembershipExit.mutation, {
|
||||
variables: {
|
||||
coopname: info.coopname,
|
||||
username: session.username,
|
||||
},
|
||||
});
|
||||
await loadExitStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает текущий статус выхода пайщика (и предрасчёт суммы — для отображения
|
||||
* планируемого платежа, пока совет не зафиксировал итог).
|
||||
*/
|
||||
async function loadExitStatus(): Promise<void> {
|
||||
if (!session.isAuth || !session.username) {
|
||||
exitStatus.value = null;
|
||||
previewTotal.value = null;
|
||||
loaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
[Queries.MembershipExit.GetMembershipExit.name]: result,
|
||||
} = await client.Query(Queries.MembershipExit.GetMembershipExit.query, {
|
||||
variables: {
|
||||
coopname: info.coopname,
|
||||
username: session.username,
|
||||
},
|
||||
});
|
||||
|
||||
exitStatus.value = result ?? null;
|
||||
|
||||
// Пока совет не зафиксировал сумму (status pending → quantity = 0),
|
||||
// показываем предрасчёт возврата как планируемый платёж.
|
||||
if (exitStatus.value && !hasFixedAmount(exitStatus.value.quantity)) {
|
||||
await loadPreview();
|
||||
} else {
|
||||
previewTotal.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки статуса выхода:', error);
|
||||
} finally {
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview(): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
[Queries.MembershipExit.MembershipExitReturnPreview.name]: preview,
|
||||
} = await client.Query(
|
||||
Queries.MembershipExit.MembershipExitReturnPreview.query,
|
||||
{
|
||||
variables: {
|
||||
coopname: info.coopname,
|
||||
username: session.username,
|
||||
},
|
||||
},
|
||||
);
|
||||
previewTotal.value = preview.total;
|
||||
} catch (error) {
|
||||
console.error('Ошибка предрасчёта суммы возврата:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function hasFixedAmount(quantity?: string): boolean {
|
||||
if (!quantity) return false;
|
||||
const amount = Number(quantity.split(' ')[0]);
|
||||
return Number.isFinite(amount) && amount > 0;
|
||||
}
|
||||
|
||||
// Планируемая сумма платежа: зафиксированная советом либо предрасчёт.
|
||||
const plannedAmount = computed<string | null>(() => {
|
||||
if (exitStatus.value && hasFixedAmount(exitStatus.value.quantity)) {
|
||||
return exitStatus.value.quantity;
|
||||
}
|
||||
return previewTotal.value;
|
||||
});
|
||||
|
||||
return {
|
||||
exitStatus,
|
||||
isExitActive,
|
||||
isAwaitingConfirmation,
|
||||
paymentStatus,
|
||||
plannedAmount,
|
||||
loaded,
|
||||
loadExitStatus,
|
||||
cancelExit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
BaseButton(
|
||||
v-if='walletStore.isWalletAgreementSigned',
|
||||
:variant='variant',
|
||||
:size='micro ? "sm" : "md"',
|
||||
:aria-label='label',
|
||||
@click='open'
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(:name='icon', size='18px')
|
||||
span(v-if='!micro').q-ml-sm {{ label }}
|
||||
q-tooltip(v-if='micro') {{ label }}
|
||||
|
||||
BaseDialog(
|
||||
v-model='showDialog',
|
||||
title='Выход из кооператива',
|
||||
size='lg',
|
||||
@update:model-value='(v) => !v && clear()'
|
||||
)
|
||||
//- Проверка реквизитов
|
||||
div.exit-loading(v-if='checkingRequisites')
|
||||
q-spinner(size='32px', color='primary')
|
||||
span.exit-loading__text Проверяем реквизиты…
|
||||
|
||||
//- Нет реквизитов — выход заблокирован, ведём на страницу реквизитов
|
||||
div(v-else-if='requisitesOk === false')
|
||||
BaseBanner(variant='warn')
|
||||
template(#icon)
|
||||
q-icon(name='warning')
|
||||
div
|
||||
p.q-mb-sm Для выхода из кооператива установите реквизиты для получения возврата паевого взноса.
|
||||
p.q-mb-none Без реквизитов кооператив не сможет вернуть вам паевой взнос.
|
||||
div.q-mt-md.flex.justify-end
|
||||
BaseButton(variant='primary', @click='goToRequisites')
|
||||
template(#icon-left)
|
||||
q-icon(name='account_balance', size='18px')
|
||||
span.q-ml-sm Установить реквизиты
|
||||
|
||||
//- Реквизиты есть — показываем заявление и форму подписи
|
||||
Form(
|
||||
v-else,
|
||||
:handler-submit='handlerSubmit',
|
||||
:is-submitting='isSubmitting',
|
||||
:disabled='!document || loading',
|
||||
:button-cancel-txt='"Отменить"',
|
||||
:button-submit-txt='"Подписать и подать заявление"',
|
||||
@cancel='clear'
|
||||
)
|
||||
BaseBanner(variant='warn')
|
||||
template(#icon)
|
||||
q-icon(name='warning')
|
||||
div
|
||||
p.q-mb-sm Внимательно прочитайте заявление. Выход из кооператива — необратимое действие.
|
||||
p.q-mb-none После подписания и подтверждения по ссылке из письма кабинет блокируется и запускается процесс выхода: получение решения Совета и возврат паевого взноса в срок, установленный Уставом кооператива.
|
||||
|
||||
//- Единый лоадер на подготовку заявления и расчёт суммы (грузим параллельно).
|
||||
div.exit-loading(v-if='loading')
|
||||
q-spinner(size='32px', color='primary')
|
||||
span.exit-loading__text Готовим заявление и сумму к возврату…
|
||||
|
||||
template(v-else)
|
||||
div.exit-doc.q-mt-md(v-if='document')
|
||||
DocumentHtmlReader(:html='document.html')
|
||||
|
||||
//- Итог к возврату — soft-панель под документом, читается как подбивка
|
||||
//- к заявлению (а не «висящий» текст снизу). Сумма авторитетная: собирается
|
||||
//- по сету паевых кошельков (тот же, что обходит контракт при возврате).
|
||||
div.exit-summary(v-if='preview')
|
||||
span.exit-summary__label Сумма к возврату
|
||||
span.exit-summary__value {{ formatAsset2Digits(preview.total) }}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
|
||||
import { BaseBanner } from 'src/shared/ui/base/BaseBanner';
|
||||
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
|
||||
import { Form } from 'src/shared/ui/Form';
|
||||
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
|
||||
import { useWalletStore } from 'src/entities/Wallet';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import {
|
||||
useMembershipExit,
|
||||
useExitDialog,
|
||||
useExitGate,
|
||||
type IMembershipExitReturnPreview,
|
||||
type IGenerateMembershipExitApplicationResult,
|
||||
} from '../model';
|
||||
|
||||
import type { BaseButtonVariant } from 'src/shared/ui/base/BaseButton/BaseButton.types';
|
||||
|
||||
interface Props {
|
||||
micro?: boolean;
|
||||
label?: string;
|
||||
variant?: BaseButtonVariant;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
micro: false,
|
||||
label: 'Выход из кооператива',
|
||||
variant: 'danger',
|
||||
icon: 'logout',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const walletStore = useWalletStore();
|
||||
const { showDialog, open } = useExitDialog();
|
||||
const { generateApplication, submitSignedApplication, getReturnPreview, hasRequisites } = useMembershipExit();
|
||||
const { loadExitStatus } = useExitGate();
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const checkingRequisites = ref(false);
|
||||
const requisitesOk = ref<boolean | null>(null);
|
||||
const loading = ref(false);
|
||||
const document = ref<IGenerateMembershipExitApplicationResult | null>(null);
|
||||
const preview = ref<IMembershipExitReturnPreview | null>(null);
|
||||
|
||||
watch(showDialog, async (opened) => {
|
||||
if (!opened) return;
|
||||
|
||||
// Гейт реквизитов — без них выход не запускаем.
|
||||
checkingRequisites.value = true;
|
||||
try {
|
||||
requisitesOk.value = await hasRequisites();
|
||||
} catch (error) {
|
||||
// Не блокируем проактивно при сбое проверки — бэкенд всё равно отклонит подачу.
|
||||
console.error('Не удалось проверить реквизиты пайщика:', error);
|
||||
requisitesOk.value = true;
|
||||
} finally {
|
||||
checkingRequisites.value = false;
|
||||
}
|
||||
if (!requisitesOk.value) return;
|
||||
|
||||
// Заявление и сумму готовим параллельно под одним лоадером, показываем вместе.
|
||||
loading.value = true;
|
||||
try {
|
||||
const [doc, prev] = await Promise.allSettled([generateApplication(), getReturnPreview()]);
|
||||
if (doc.status === 'fulfilled') {
|
||||
document.value = doc.value;
|
||||
} else {
|
||||
console.error('Ошибка формирования заявления о выходе:', doc.reason);
|
||||
FailAlert('Не удалось сформировать заявление о выходе');
|
||||
}
|
||||
if (prev.status === 'fulfilled') {
|
||||
preview.value = prev.value;
|
||||
} else {
|
||||
// Сумма — некритично: документ можно подписать и без предрасчёта.
|
||||
console.error('Ошибка расчёта суммы возврата:', prev.reason);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const clear = (): void => {
|
||||
showDialog.value = false;
|
||||
isSubmitting.value = false;
|
||||
checkingRequisites.value = false;
|
||||
requisitesOk.value = null;
|
||||
loading.value = false;
|
||||
document.value = null;
|
||||
preview.value = null;
|
||||
};
|
||||
|
||||
const goToRequisites = (): void => {
|
||||
showDialog.value = false;
|
||||
void router.push({ name: 'payment-methods', params: { coopname: route.params.coopname } });
|
||||
};
|
||||
|
||||
const handlerSubmit = async (): Promise<void> => {
|
||||
if (!document.value) return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await submitSignedApplication(document.value);
|
||||
// Подтягиваем статус — overlay заблокирует кабинет и покажет экран ожидания письма.
|
||||
await loadExitStatus();
|
||||
SuccessAlert('Заявление подписано. Перейдите по ссылке из письма, чтобы подтвердить выход.');
|
||||
clear();
|
||||
} catch (e: any) {
|
||||
// Авторитетный гейт реквизитов — на бэкенде. Если он отклонил подачу из-за
|
||||
// отсутствия реквизитов, переключаем диалог на экран-баннер с кнопкой.
|
||||
if (JSON.stringify(e ?? '').includes('установите реквизиты')) {
|
||||
requisitesOk.value = false;
|
||||
} else {
|
||||
FailAlert(e);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.exit-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--p-3);
|
||||
padding: var(--p-8) var(--p-4);
|
||||
}
|
||||
.exit-loading__text {
|
||||
font-size: var(--p-fs-body-sm);
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
|
||||
.exit-doc {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--p-line);
|
||||
border-radius: var(--p-r-md);
|
||||
padding: var(--p-4);
|
||||
}
|
||||
/* Документ приходит из backend с Quasar text-h-классами и inline-стилями
|
||||
(заголовок 3rem, line-height 4.5rem). Прижимаем заголовок и типографику к
|
||||
канону — иначе title распирает диалог. Приём как в ReadStatement.vue
|
||||
(deep + important, чтобы перебить глобальные правила DocumentHtmlReader). */
|
||||
.exit-doc :deep(.statement h1),
|
||||
.exit-doc :deep(.statement .text-h1),
|
||||
.exit-doc :deep(.statement .text-h2) {
|
||||
font-size: var(--p-fs-h2) !important;
|
||||
line-height: var(--p-lh-h2) !important;
|
||||
letter-spacing: var(--p-ls-h2) !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--p-ink) !important;
|
||||
text-align: center !important;
|
||||
margin: var(--p-2) 0 var(--p-4) !important;
|
||||
}
|
||||
.exit-doc :deep(.statement h2),
|
||||
.exit-doc :deep(.statement h3),
|
||||
.exit-doc :deep(.statement h4),
|
||||
.exit-doc :deep(.statement .text-h3),
|
||||
.exit-doc :deep(.statement .text-h4),
|
||||
.exit-doc :deep(.statement .text-h5),
|
||||
.exit-doc :deep(.statement .text-h6) {
|
||||
font-size: var(--p-fs-body) !important;
|
||||
line-height: var(--p-lh-body) !important;
|
||||
letter-spacing: 0 !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--p-ink) !important;
|
||||
margin: var(--p-4) 0 var(--p-1) !important;
|
||||
}
|
||||
.exit-doc :deep(.statement p) {
|
||||
font-size: var(--p-fs-body) !important;
|
||||
line-height: var(--p-lh-body) !important;
|
||||
}
|
||||
|
||||
.exit-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--p-4);
|
||||
margin-top: var(--p-4);
|
||||
padding: var(--p-4) var(--p-5);
|
||||
background: var(--p-surface-2);
|
||||
border-radius: var(--p-r-md);
|
||||
}
|
||||
.exit-summary__label {
|
||||
font-size: var(--p-fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
min-width: 0;
|
||||
}
|
||||
.exit-summary__value {
|
||||
font-family: var(--p-mono);
|
||||
font-size: var(--p-fs-h2);
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
font-feature-settings: 'tnum' 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<template lang="pug">
|
||||
BaseDialog(
|
||||
:model-value='isExitActive',
|
||||
title='Выход из кооператива',
|
||||
:maximized='true',
|
||||
:hide-close-button='true',
|
||||
:close-on-backdrop='false',
|
||||
:close-on-escape='false'
|
||||
)
|
||||
div.exit-overlay
|
||||
AuthCard(v-if='exitStatus', :max-width='440')
|
||||
//- Шапка карточки: иконка статуса в soft-плитке + заголовок + пояснение.
|
||||
template(#head)
|
||||
div.exit-head__icon(:class='`exit-head__icon--${view.tone}`')
|
||||
q-icon(:name='view.icon', size='28px')
|
||||
h2.exit-head__title {{ view.title }}
|
||||
p.exit-head__text {{ view.body }}
|
||||
|
||||
//- Тело: сумма к возврату + сопутствующие подписи.
|
||||
div.exit-amount(v-if='plannedAmount')
|
||||
span.exit-amount__label.t-eyebrow.t-faint {{ view.amountLabel }}
|
||||
span.exit-amount__value {{ formatAsset2Digits(plannedAmount) }}
|
||||
BaseChip.exit-amount__chip(
|
||||
v-if='paymentChip',
|
||||
:variant='paymentChip.variant',
|
||||
size='sm'
|
||||
) {{ paymentChip.label }}
|
||||
p.exit-note.t-sm.t-muted(v-if='view.canCancel') Пока вы не перешли по ссылке, заявление можно отменить.
|
||||
|
||||
//- Футер: действия (отделён бордером внутри AuthCard).
|
||||
template(#footer)
|
||||
div.exit-actions
|
||||
BaseButton(
|
||||
v-if='view.canCancel',
|
||||
variant='secondary',
|
||||
:block='true',
|
||||
:loading='cancelling',
|
||||
@click='onCancel'
|
||||
)
|
||||
| Отменить выход
|
||||
BaseButton(
|
||||
variant='ghost',
|
||||
:block='true',
|
||||
:loading='loggingOut',
|
||||
@click='onLogout'
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(name='logout', size='18px')
|
||||
| Выйти из личного кабинета
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Zeus } from '@coopenomics/sdk';
|
||||
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { BaseChip } from 'src/shared/ui/base/BaseChip';
|
||||
import type { BaseChipVariant } from 'src/shared/ui/base/BaseChip/BaseChip.types';
|
||||
import { AuthCard } from 'src/shared/ui/domain/AuthCard';
|
||||
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
|
||||
import { useLogoutUser } from 'src/features/User/Logout/model';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { useExitGate } from '../model';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { exitStatus, isExitActive, isAwaitingConfirmation, paymentStatus, plannedAmount, cancelExit, loadExitStatus } =
|
||||
useExitGate();
|
||||
const { logout } = useLogoutUser();
|
||||
|
||||
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' },
|
||||
[Zeus.PaymentStatus.PROCESSING]: { label: 'Оплачивается', variant: 'info' },
|
||||
[Zeus.PaymentStatus.PAID]: { label: 'Оплачивается', variant: 'info' },
|
||||
[Zeus.PaymentStatus.COMPLETED]: { label: 'Оплачено', variant: 'pos' },
|
||||
[Zeus.PaymentStatus.FAILED]: { label: 'Ошибка выплаты', variant: 'neg' },
|
||||
[Zeus.PaymentStatus.EXPIRED]: { label: 'Истёк', variant: 'neg' },
|
||||
[Zeus.PaymentStatus.CANCELLED]: { label: 'Отменён', variant: 'neutral' },
|
||||
[Zeus.PaymentStatus.REFUNDED]: { label: 'Отклонён', variant: 'neutral' },
|
||||
[Zeus.PaymentStatus.AWAITING_AUTHORIZATION]: { label: 'Ожидает решения Совета', variant: 'neutral' },
|
||||
};
|
||||
|
||||
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: 'Сумма возврата',
|
||||
canCancel: false,
|
||||
};
|
||||
}
|
||||
if (isAwaitingConfirmation.value) {
|
||||
return {
|
||||
icon: 'mark_email_unread',
|
||||
tone: 'primary',
|
||||
title: 'Подтвердите выход по ссылке из письма',
|
||||
body: 'Мы отправили письмо на вашу электронную почту — перейдите по ссылке в нём, чтобы подтвердить выход и запустить возврат паевого взноса. Письмо могло попасть в папку «Спам».',
|
||||
amountLabel: 'Сумма к возврату',
|
||||
canCancel: true,
|
||||
};
|
||||
}
|
||||
if (isAuthorized.value) {
|
||||
return {
|
||||
icon: 'payments',
|
||||
tone: 'pos',
|
||||
title: 'Совет одобрил выход',
|
||||
body: 'Возврат паевого взноса будет совершён в срок, установленный Уставом кооператива. Аккаунт заблокирован — дождитесь поступления средств.',
|
||||
amountLabel: 'Сумма к возврату',
|
||||
canCancel: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: 'hourglass_top',
|
||||
tone: 'primary',
|
||||
title: 'Заявление на рассмотрении Совета',
|
||||
body: 'Ваше заявление о выходе передано в Совет кооператива. Кабинет заблокирован на время процедуры — дождитесь решения Совета.',
|
||||
amountLabel: 'Сумма к возврату',
|
||||
canCancel: false,
|
||||
};
|
||||
});
|
||||
|
||||
const cancelling = ref(false);
|
||||
const loggingOut = ref(false);
|
||||
|
||||
const onCancel = async (): Promise<void> => {
|
||||
cancelling.value = true;
|
||||
try {
|
||||
await cancelExit();
|
||||
SuccessAlert('Заявление на выход отменено.');
|
||||
} catch (e: any) {
|
||||
FailAlert(e);
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Выход из личного кабинета (logout). При активном выходе аккаунт заблокирован,
|
||||
// иначе из сессии не выйти. После logout gate обнуляется и редирект на вход;
|
||||
// при повторном входе экран выхода снова покажется (статус живёт on-chain).
|
||||
const onLogout = async (): Promise<void> => {
|
||||
loggingOut.value = true;
|
||||
try {
|
||||
await logout();
|
||||
await loadExitStatus();
|
||||
await router.push({ name: 'signin', params: { coopname: route.params.coopname } });
|
||||
} catch (e: any) {
|
||||
FailAlert('Ошибка при выходе: ' + (e?.message ?? e));
|
||||
} finally {
|
||||
loggingOut.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.exit-overlay {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--p-4);
|
||||
}
|
||||
|
||||
.exit-head__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--p-r-lg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto var(--p-3);
|
||||
}
|
||||
.exit-head__icon--primary {
|
||||
background: var(--p-primary-soft);
|
||||
color: var(--p-primary);
|
||||
}
|
||||
.exit-head__icon--pos {
|
||||
background: var(--p-pos-soft);
|
||||
color: var(--p-pos);
|
||||
}
|
||||
|
||||
.exit-head__title {
|
||||
margin: 0;
|
||||
font-size: var(--p-fs-h2);
|
||||
line-height: var(--p-lh-h2);
|
||||
letter-spacing: var(--p-ls-h2);
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
}
|
||||
.exit-head__text {
|
||||
margin: var(--p-2) 0 0;
|
||||
font-size: var(--p-fs-body-sm);
|
||||
line-height: var(--p-lh-body-sm);
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
|
||||
.exit-amount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--p-1);
|
||||
padding: var(--p-4) var(--p-5);
|
||||
background: var(--p-surface-2);
|
||||
border-radius: var(--p-r-md);
|
||||
text-align: center;
|
||||
}
|
||||
.exit-amount__value {
|
||||
font-family: var(--p-mono);
|
||||
font-size: var(--p-fs-h1);
|
||||
line-height: var(--p-lh-h1);
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
font-feature-settings: 'ss01', 'ss02';
|
||||
}
|
||||
.exit-amount__chip {
|
||||
margin-top: var(--p-2);
|
||||
}
|
||||
|
||||
.exit-note {
|
||||
margin: var(--p-3) 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exit-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-2);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ExitButton } from './ExitButton.vue';
|
||||
export { default as ExitOverlay } from './ExitOverlay.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './model';
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { Mutations } from '@coopenomics/sdk';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
|
||||
export type IGenerateMembershipExitDecisionData =
|
||||
Mutations.MembershipExit.GenerateMembershipExitDecision.IInput['data'];
|
||||
export type IGenerateMembershipExitDecisionResult =
|
||||
Mutations.MembershipExit.GenerateMembershipExitDecision.IOutput[typeof Mutations.MembershipExit.GenerateMembershipExitDecision.name];
|
||||
|
||||
/**
|
||||
* Композабл генерации документа решения собрания совета о выходе пайщика (registry 201).
|
||||
*/
|
||||
export function useGenerateMembershipExitDecision() {
|
||||
const { info } = useSystemStore();
|
||||
|
||||
async function generateMembershipExitDecision(
|
||||
data: Omit<IGenerateMembershipExitDecisionData, 'coopname'>,
|
||||
): Promise<IGenerateMembershipExitDecisionResult> {
|
||||
const {
|
||||
[Mutations.MembershipExit.GenerateMembershipExitDecision.name]: result,
|
||||
} = await client.Mutation(
|
||||
Mutations.MembershipExit.GenerateMembershipExitDecision.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
coopname: info.coopname,
|
||||
...data,
|
||||
},
|
||||
options: {
|
||||
lang: 'ru',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
generateMembershipExitDecision,
|
||||
};
|
||||
}
|
||||
@@ -25,9 +25,9 @@ async function loadExpenseFilesByItem(
|
||||
return result;
|
||||
}
|
||||
|
||||
// Механика строки расхода (ADVANCE/DIRECT) нужна, чтобы решить, какие документы
|
||||
// прикладывает кассир: по авансу — только платёжка, по оплате организации —
|
||||
// ещё и закрывающие документы (акт/счёт-фактура/накладная).
|
||||
// Механика строки расхода (ADVANCE/DIRECT) нужна, чтобы решить, показывать ли
|
||||
// закрывающие документы: чек об оплате теперь общий (в ядре), а по оплате
|
||||
// организации (DIRECT) — ещё и закрывающие документы (акт/счёт-фактура/накладная).
|
||||
async function loadItemMechanics(
|
||||
proposal_hash: string,
|
||||
item_hash: string,
|
||||
|
||||
+8
-87
@@ -1,34 +1,9 @@
|
||||
<template lang="pug">
|
||||
.attach-proof
|
||||
.exp-step(v-if='step')
|
||||
.exp-step__num {{ step.number }}
|
||||
.exp-step__title {{ step.title }}
|
||||
//- Секция 1 — платёжка/квитанция об исполненной оплате (для любой механики).
|
||||
//- Закрывающие документы организации (акт, счёт-фактура, накладная) — только
|
||||
//- прямая оплата по счёту (DIRECT). Чек об оплате теперь общий, в ядре
|
||||
//- (AttachPaymentProofPanel по payment_hash) — здесь остаётся expense-специфика.
|
||||
.attach-proof(v-if='isDirect')
|
||||
.attach-proof__section
|
||||
.t-sm.t-muted(v-if='step') Приложите платёжку или квитанцию — это подтверждает, что оплата исполнена.
|
||||
.t-sm.t-muted(v-else) Платёжка об оплате
|
||||
.files(v-if='proofFiles.length')
|
||||
button.file-link(
|
||||
v-for='file in proofFiles',
|
||||
:key='file.id',
|
||||
type='button',
|
||||
:disabled='openingId === file.id',
|
||||
@click='openFile(file)'
|
||||
)
|
||||
q-icon(name='attach_file', size='16px')
|
||||
span {{ fileLabel(file) }}
|
||||
q-spinner(v-if='openingId === file.id', size='14px')
|
||||
FileUploader(
|
||||
v-model='pendingProof',
|
||||
accept='image/jpeg,image/png,image/webp,image/heic,application/pdf',
|
||||
:max-size='20 * 1024 * 1024',
|
||||
title='Приложите платёжку или квитанцию',
|
||||
hint='Изображение или PDF до 20 МБ — отправится сразу',
|
||||
:disabled='uploading'
|
||||
)
|
||||
|
||||
//- Секция 2 — закрывающие документы организации (только прямая оплата по счёту).
|
||||
.attach-proof__section(v-if='isDirect')
|
||||
.t-sm.t-muted Закрывающие документы (акт, счёт-фактура, накладная)
|
||||
.files(v-if='closingFiles.length')
|
||||
button.file-link(
|
||||
@@ -62,28 +37,15 @@ import { api, type IExpenseFile } from '../api';
|
||||
const props = defineProps<{
|
||||
proposalHash: string;
|
||||
itemHash: string;
|
||||
// Опциональный заголовок-этап (номер + название) для последовательной подачи
|
||||
// на столе кассира.
|
||||
step?: { number: number | string; title: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
// Срабатывает только при загрузке ПЛАТЁЖКИ — реестр по ней инкрементит отметку
|
||||
// proof_count (закрывающие документы на эту отметку не влияют).
|
||||
(e: 'uploaded'): void;
|
||||
}>();
|
||||
|
||||
const system = useSystemStore();
|
||||
const files = ref<IExpenseFile[]>([]);
|
||||
const pendingProof = ref<File | null>(null);
|
||||
const pendingClosing = ref<File | null>(null);
|
||||
const uploading = ref(false);
|
||||
const mechanics = ref<Zeus.ExpenseMechanics | null>(null);
|
||||
|
||||
const isDirect = computed(() => mechanics.value === Zeus.ExpenseMechanics.DIRECT);
|
||||
const proofFiles = computed(() =>
|
||||
files.value.filter((f) => f.kind === Zeus.ExpenseFileKind.PAYMENT_PROOF),
|
||||
);
|
||||
const closingFiles = computed(() =>
|
||||
files.value.filter((f) => f.kind === Zeus.ExpenseFileKind.CLOSING_DOC),
|
||||
);
|
||||
@@ -96,7 +58,6 @@ async function refresh(): Promise<void> {
|
||||
props.itemHash,
|
||||
);
|
||||
} catch {
|
||||
// список файлов — вспомогательный; ошибки загрузки списка не прерывают кассира
|
||||
files.value = [];
|
||||
}
|
||||
}
|
||||
@@ -107,8 +68,6 @@ function fileLabel(file: IExpenseFile): string {
|
||||
return `Документ от ${date}`;
|
||||
}
|
||||
|
||||
// Списочные запросы файлов отдают записи без read_url (он короткоживущий) —
|
||||
// свежая ссылка запрашивается по id в момент клика и сразу открывается.
|
||||
const openingId = ref<number | null>(null);
|
||||
async function openFile(file: IExpenseFile): Promise<void> {
|
||||
try {
|
||||
@@ -137,7 +96,7 @@ function toBase64(buffer: ArrayBuffer): string {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function upload(file: File, kind: Zeus.ExpenseFileKind): Promise<void> {
|
||||
async function upload(file: File): Promise<void> {
|
||||
try {
|
||||
uploading.value = true;
|
||||
const buffer = await file.arrayBuffer();
|
||||
@@ -145,21 +104,15 @@ async function upload(file: File, kind: Zeus.ExpenseFileKind): Promise<void> {
|
||||
coopname: system.info.coopname,
|
||||
proposal_hash: props.proposalHash,
|
||||
item_hash: props.itemHash,
|
||||
kind,
|
||||
kind: Zeus.ExpenseFileKind.CLOSING_DOC,
|
||||
mime_type: file.type,
|
||||
size_bytes: file.size,
|
||||
checksum_sha256: await sha256Hex(buffer),
|
||||
content_base64: toBase64(buffer),
|
||||
original_filename: file.name,
|
||||
});
|
||||
SuccessAlert(
|
||||
kind === Zeus.ExpenseFileKind.PAYMENT_PROOF
|
||||
? 'Платёжка приложена'
|
||||
: 'Закрывающий документ приложен',
|
||||
);
|
||||
SuccessAlert('Закрывающий документ приложен');
|
||||
await refresh();
|
||||
// proof_count в реестре считает только платёжки.
|
||||
if (kind === Zeus.ExpenseFileKind.PAYMENT_PROOF) emit('uploaded');
|
||||
} catch (e) {
|
||||
FailAlert(e);
|
||||
} finally {
|
||||
@@ -167,16 +120,9 @@ async function upload(file: File, kind: Zeus.ExpenseFileKind): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор файла = загрузка: отдельная кнопка «Загрузить» не нужна.
|
||||
watch(pendingProof, (file) => {
|
||||
if (!file || uploading.value) return;
|
||||
void upload(file, Zeus.ExpenseFileKind.PAYMENT_PROOF).then(() => {
|
||||
pendingProof.value = null;
|
||||
});
|
||||
});
|
||||
watch(pendingClosing, (file) => {
|
||||
if (!file || uploading.value) return;
|
||||
void upload(file, Zeus.ExpenseFileKind.CLOSING_DOC).then(() => {
|
||||
void upload(file).then(() => {
|
||||
pendingClosing.value = null;
|
||||
});
|
||||
});
|
||||
@@ -198,31 +144,6 @@ onMounted(async () => {
|
||||
gap: var(--p-3);
|
||||
}
|
||||
|
||||
.exp-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--p-2);
|
||||
}
|
||||
|
||||
.exp-step__num {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--p-r-pill);
|
||||
background: var(--p-primary-soft);
|
||||
color: var(--p-primary);
|
||||
font-size: var(--p-fs-body-sm);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.exp-step__title {
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
}
|
||||
|
||||
.attach-proof__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { Mutations, Queries } from '@coopenomics/sdk';
|
||||
|
||||
export type IUploadPaymentProofInput = Mutations.Gateway.UploadPaymentProof.IInput['data'];
|
||||
export type IPaymentFile =
|
||||
Queries.Gateway.PaymentProofs.IOutput[typeof Queries.Gateway.PaymentProofs.name][number];
|
||||
|
||||
async function uploadPaymentProof(data: IUploadPaymentProofInput): Promise<IPaymentFile> {
|
||||
const { [Mutations.Gateway.UploadPaymentProof.name]: result } = await client.Mutation(
|
||||
Mutations.Gateway.UploadPaymentProof.mutation,
|
||||
{ variables: { data } },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadPaymentProofs(coopname: string, payment_hash: string): Promise<IPaymentFile[]> {
|
||||
const { [Queries.Gateway.PaymentProofs.name]: result } = await client.Query(
|
||||
Queries.Gateway.PaymentProofs.query,
|
||||
{ variables: { coopname, payment_hash } },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Списочные запросы отдают файлы без read_url (он короткоживущий) — свежий URL
|
||||
// запрашивается по id в момент клика.
|
||||
async function getPaymentFileReadUrl(id: number): Promise<string | undefined> {
|
||||
const { [Queries.Gateway.PaymentFile.name]: result } = await client.Query(
|
||||
Queries.Gateway.PaymentFile.query,
|
||||
{ variables: { id } },
|
||||
);
|
||||
return result.read_url ?? undefined;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
uploadPaymentProof,
|
||||
loadPaymentProofs,
|
||||
getPaymentFileReadUrl,
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as AttachPaymentProofPanel } from './ui/AttachPaymentProofPanel.vue';
|
||||
export { api as attachPaymentProofApi, type IPaymentFile } from './api';
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
<template lang="pug">
|
||||
.attach-proof
|
||||
.exp-step(v-if='step')
|
||||
.exp-step__num {{ step.number }}
|
||||
.exp-step__title {{ step.title }}
|
||||
.attach-proof__section
|
||||
.t-sm.t-muted(v-if='step') Приложите чек об оплате — подтверждение, что платёж исполнен.
|
||||
.t-sm.t-muted(v-else) Чек об оплате
|
||||
.files(v-if='proofFiles.length')
|
||||
button.file-link(
|
||||
v-for='file in proofFiles',
|
||||
:key='file.id',
|
||||
type='button',
|
||||
:disabled='openingId === file.id',
|
||||
@click='openFile(file)'
|
||||
)
|
||||
q-icon(name='attach_file', size='16px')
|
||||
span {{ fileLabel(file) }}
|
||||
q-spinner(v-if='openingId === file.id', size='14px')
|
||||
FileUploader(
|
||||
v-model='pendingProof',
|
||||
accept='image/jpeg,image/png,image/webp,image/heic,application/pdf',
|
||||
:max-size='20 * 1024 * 1024',
|
||||
title='Приложите чек об оплате',
|
||||
hint='Изображение или PDF до 20 МБ — отправится сразу',
|
||||
:disabled='uploading'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { FileUploader } from 'src/shared/ui/domain/FileUploader';
|
||||
import { api, type IPaymentFile } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
paymentHash: string;
|
||||
// Опциональный заголовок-этап (номер + название) для последовательной подачи
|
||||
// на столе кассира.
|
||||
step?: { number: number | string; title: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
// Срабатывает при загрузке чека — реестр по нему инкрементит отметку proof_count.
|
||||
(e: 'uploaded'): void;
|
||||
}>();
|
||||
|
||||
const system = useSystemStore();
|
||||
const files = ref<IPaymentFile[]>([]);
|
||||
const pendingProof = ref<File | null>(null);
|
||||
const uploading = ref(false);
|
||||
|
||||
const proofFiles = computed(() => files.value);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
files.value = await api.loadPaymentProofs(system.info.coopname, props.paymentHash);
|
||||
} catch {
|
||||
// список файлов — вспомогательный; ошибки загрузки списка не прерывают кассира
|
||||
files.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function fileLabel(file: IPaymentFile): string {
|
||||
if (file.original_filename) return file.original_filename;
|
||||
const date = file.uploaded_at ? new Date(String(file.uploaded_at)).toLocaleString('ru-RU') : '';
|
||||
return `Чек от ${date}`;
|
||||
}
|
||||
|
||||
const openingId = ref<number | null>(null);
|
||||
async function openFile(file: IPaymentFile): Promise<void> {
|
||||
try {
|
||||
openingId.value = file.id;
|
||||
const url = await api.getPaymentFileReadUrl(file.id);
|
||||
if (!url) throw new Error('Не удалось получить ссылку на файл');
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} catch (e) {
|
||||
FailAlert(e);
|
||||
} finally {
|
||||
openingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function upload(file: File): Promise<void> {
|
||||
try {
|
||||
uploading.value = true;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await api.uploadPaymentProof({
|
||||
coopname: system.info.coopname,
|
||||
payment_hash: props.paymentHash,
|
||||
mime_type: file.type,
|
||||
size_bytes: file.size,
|
||||
checksum_sha256: await sha256Hex(buffer),
|
||||
content_base64: toBase64(buffer),
|
||||
original_filename: file.name,
|
||||
});
|
||||
SuccessAlert('Чек об оплате приложен');
|
||||
await refresh();
|
||||
emit('uploaded');
|
||||
} catch (e) {
|
||||
FailAlert(e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор файла = загрузка: отдельная кнопка «Загрузить» не нужна.
|
||||
watch(pendingProof, (file) => {
|
||||
if (!file || uploading.value) return;
|
||||
void upload(file).then(() => {
|
||||
pendingProof.value = null;
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attach-proof {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-3);
|
||||
}
|
||||
|
||||
.exp-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--p-2);
|
||||
}
|
||||
|
||||
.exp-step__num {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--p-r-pill);
|
||||
background: var(--p-primary-soft);
|
||||
color: var(--p-primary);
|
||||
font-size: var(--p-fs-body-sm);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.exp-step__title {
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
}
|
||||
|
||||
.attach-proof__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-2);
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-1);
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--p-1);
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--p-primary);
|
||||
text-decoration: none;
|
||||
font-size: var(--p-fs-body-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+2
-2
@@ -55,11 +55,11 @@ const props = defineProps<{
|
||||
// Хэши исходной СЗ и строки-аванса (item_hash == хэш платежа выдачи аванса).
|
||||
proposalHash: string;
|
||||
itemHash: string;
|
||||
// Сумма этой расчётной платёжки и её направление — чтобы показать кассиру
|
||||
// Сумма этого расчётного платежа и его направление — чтобы показать кассиру
|
||||
// «выдано → заявлено → разница» без поиска исходной строки в реестре.
|
||||
settlementAmount: string;
|
||||
isReturn: boolean;
|
||||
// Описание расхода из самой платёжки (fallback — из позиции СЗ).
|
||||
// Описание расхода из самого платежа (fallback — из позиции СЗ).
|
||||
description?: string;
|
||||
}>();
|
||||
|
||||
|
||||
+6
-6
@@ -98,8 +98,8 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
// settlementHash — хэш заведённой платёжки расчёта (возврат/доплата), чтобы
|
||||
// реестр сразу раскрыл её с реквизитами; пусто при простом закрытии (факт==аванс).
|
||||
// settlementHash — хэш заведённого расчётного платежа (возврат/доплата), чтобы
|
||||
// реестр сразу раскрыл его с реквизитами; пусто при простом закрытии (факт==аванс).
|
||||
(e: 'reported', settlementHash?: string): void;
|
||||
}>();
|
||||
|
||||
@@ -133,8 +133,8 @@ const deltaHint = computed(() => {
|
||||
`${diff.toFixed(advance.value.precision)} ${advance.value.symbol}`,
|
||||
);
|
||||
return diffMinor < 0
|
||||
? `Недорасход ${diffLabel}: будет создана платёжка возврата — её нужно оплатить кооперативу.`
|
||||
: `Перерасход ${diffLabel}: будет создана платёжка доплаты — кооператив выплатит разницу.`;
|
||||
? `Недорасход ${diffLabel}: будет создан платёж возврата — его нужно оплатить кооперативу.`
|
||||
: `Перерасход ${diffLabel}: будет создан платёж доплаты — кооператив выплатит разницу.`;
|
||||
});
|
||||
|
||||
// Панель — только для аванса под отчёт: по счёту (DIRECT) отчётный документ
|
||||
@@ -284,11 +284,11 @@ async function submitReport(): Promise<void> {
|
||||
: '';
|
||||
if (result?.outcome === Zeus.ExpenseReportOutcome.RETURN_PENDING) {
|
||||
SuccessAlert(
|
||||
`Заведена платёжка возврата на ${settlementLabel}. Оплатите её кооперативу — после подтверждения кассой отчёт закроется.`,
|
||||
`Заведён платёж возврата на ${settlementLabel}. Оплатите его кооперативу — после подтверждения кассой отчёт закроется.`,
|
||||
);
|
||||
} else if (result?.outcome === Zeus.ExpenseReportOutcome.OVERSPEND_PENDING) {
|
||||
SuccessAlert(
|
||||
`Заведена платёжка доплаты на ${settlementLabel}. Кооператив выплатит разницу — после подтверждения кассой отчёт закроется.`,
|
||||
`Заведён платёж доплаты на ${settlementLabel}. Кооператив выплатит разницу — после подтверждения кассой отчёт закроется.`,
|
||||
);
|
||||
} else {
|
||||
SuccessAlert('Отчёт по авансу принят');
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<template lang="pug">
|
||||
.membership-exit-confirm.row.justify-center
|
||||
.col-12.col-md-6.text-center.q-pt-xl
|
||||
template(v-if='loading')
|
||||
q-spinner(size='56px', color='primary')
|
||||
div.text-h6.q-mt-md Подтверждаем выход из кооператива…
|
||||
|
||||
template(v-else-if='error')
|
||||
q-icon(name='error_outline', size='56px', color='negative')
|
||||
div.text-h6.q-mt-md Не удалось подтвердить выход
|
||||
p.text-body2.text-grey-7.q-mt-sm {{ error }}
|
||||
BaseButton.q-mt-lg(
|
||||
variant='secondary',
|
||||
:label='isAuth ? "Вернуться в кабинет" : "Войти в кабинет"',
|
||||
@click='goToCabinet'
|
||||
)
|
||||
|
||||
template(v-else)
|
||||
q-icon(name='check_circle', size='56px', color='positive')
|
||||
div.text-h6.q-mt-md Выход подтверждён
|
||||
p.text-body2.text-grey-7.q-mt-sm Заявление отправлено. Процесс выхода запущен — ожидайте решения Совета.
|
||||
p.text-body2.text-grey-7.q-mt-sm(v-if='!isAuth') Войдите в кабинет, чтобы следить за статусом заявления и суммой возврата.
|
||||
BaseButton.q-mt-lg(
|
||||
variant='primary',
|
||||
:label='isAuth ? "Перейти в кабинет" : "Войти в кабинет"',
|
||||
@click='goToCabinet'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { BaseButton } from 'src/shared/ui/base/BaseButton';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
import { useMembershipExit, useExitGate } from 'src/features/Membership/ExitFromCoop';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { info } = useSystemStore();
|
||||
const session = useSessionStore();
|
||||
const { confirmExit } = useMembershipExit();
|
||||
const { loadExitStatus } = useExitGate();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Ссылка-подтверждение приходит на e-mail и часто открывается там, где сессии
|
||||
// кабинета нет (другой браузер/инкогнито/после выхода). Подтверждение работает
|
||||
// по токену и без входа, но кабинет (wallet) защищён — без сессии навигационный
|
||||
// гард молча кинет на логин. Поэтому ведём явно: есть сессия → в кабинет (там
|
||||
// глобальный ExitOverlay сам покажет «на рассмотрении Совета»); нет — на вход.
|
||||
const isAuth = computed(() => session.isAuth);
|
||||
|
||||
const goToCabinet = (): void => {
|
||||
if (session.isAuth) {
|
||||
router.replace({ name: 'wallet', params: { coopname: info.coopname } });
|
||||
} else {
|
||||
router.replace({ name: 'signin', params: { coopname: info.coopname } });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.query.token || '');
|
||||
if (!token) {
|
||||
error.value = 'Ссылка некорректна: отсутствует токен подтверждения.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirmExit(token);
|
||||
await loadExitStatus();
|
||||
} catch (e: any) {
|
||||
error.value =
|
||||
e?.message || 'Ссылка недействительна или срок её действия истёк. Подайте заявление заново.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.membership-exit-confirm {
|
||||
padding: var(--p-6, 24px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MembershipExitConfirmPage } from './MembershipExitConfirmPage.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
@@ -0,0 +1,95 @@
|
||||
<template lang="pug">
|
||||
.membership-exit-page
|
||||
.banner.banner--warn
|
||||
q-icon.banner__icon(name='info', size='18px')
|
||||
.banner__body
|
||||
| Участие в кооперативе добровольное — так же, как вступление, так и выход.
|
||||
| Вы вправе прекратить участие, подав заявление; после одобрения Советом
|
||||
| паевой взнос возвращается в срок, установленный Уставом.
|
||||
|
||||
BaseCard(title='Как проходит выход')
|
||||
ul.exit-points
|
||||
li
|
||||
q-icon(name='description', size='18px')
|
||||
span
|
||||
| Выход оформляется вашим заявлением. Система подготовит его автоматически —
|
||||
| вам нужно внимательно прочитать текст и подписать простой электронной подписью.
|
||||
li
|
||||
q-icon(name='mark_email_unread', size='18px')
|
||||
span
|
||||
| Запуск процедуры подтверждается по ссылке из письма. Совет рассмотрит
|
||||
| заявление; до принятия решения доступ к кабинету будет ограничен.
|
||||
li
|
||||
q-icon(name='payments', size='18px')
|
||||
span
|
||||
| Паевой взнос вернётся на ваши реквизиты
|
||||
| в срок, установленный Уставом кооператива.
|
||||
li.exit-points__note
|
||||
q-icon(name='block', size='18px')
|
||||
span
|
||||
| После выхода вернуться к участию нельзя — для возобновления участия
|
||||
| потребуется пройти регистрацию заново.
|
||||
|
||||
.membership-exit-page__actions
|
||||
ExitButton(
|
||||
label='Написать заявление',
|
||||
variant='primary',
|
||||
icon='edit_note'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BaseCard } from 'src/shared/ui/base/BaseCard';
|
||||
import { ExitButton } from 'src/features/Membership/ExitFromCoop';
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.membership-exit-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-5);
|
||||
padding: var(--p-6);
|
||||
}
|
||||
|
||||
.exit-points {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-3);
|
||||
}
|
||||
|
||||
.exit-points li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--p-2);
|
||||
font-size: var(--p-fs-body-sm);
|
||||
line-height: var(--p-lh-body-sm);
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
|
||||
.exit-points li .q-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--p-ink-3);
|
||||
}
|
||||
|
||||
.exit-points__note {
|
||||
color: var(--p-ink-3);
|
||||
}
|
||||
|
||||
.membership-exit-page__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--p-4);
|
||||
margin-top: var(--p-4);
|
||||
border-top: 1px solid var(--p-line);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.membership-exit-page {
|
||||
padding: var(--p-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MembershipExitPage } from './MembershipExitPage.vue';
|
||||
@@ -4,6 +4,7 @@ import { useUpdateWatch } from 'src/entities/AppVersion/model';
|
||||
import { useInitWalletProcess } from 'src/processes/init-wallet';
|
||||
import type { Router } from 'vue-router';
|
||||
import { useBranchOverlayProcess } from '../watch-branch-overlay';
|
||||
import { useExitOverlayProcess } from '../watch-exit-overlay';
|
||||
import { setupNavigationGuard } from '../navigation-guard-setup';
|
||||
import { useInitExtensionsProcess } from 'src/processes/init-installed-extensions';
|
||||
import { applyThemeFromStorage } from 'src/shared/lib/utils';
|
||||
@@ -108,6 +109,7 @@ export async function useInitAppProcess(router: Router) {
|
||||
}
|
||||
|
||||
useBranchOverlayProcess();
|
||||
useExitOverlayProcess();
|
||||
|
||||
setupNavigationGuard(router);
|
||||
bootrace('navigationGuard installed');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useGenerateFreeDecision } from 'src/features/FreeDecision/GenerateDecis
|
||||
import { useGenerateParticipantApplicationDecision } from 'src/features/Decision/ParticipantApplication';
|
||||
import { useGenerateSovietDecisionOnAnnualMeet } from 'src/features/Meet/GenerateSovietDecision/model';
|
||||
import { useGenerateReturnByMoneyDecision } from 'src/features/Wallet/GenerateReturnByMoneyDecision';
|
||||
import { useGenerateMembershipExitDecision } from 'src/features/Membership/GenerateMembershipExitDecision';
|
||||
|
||||
/**
|
||||
* Регистрация обработчиков базовых решений
|
||||
@@ -100,4 +101,16 @@ export function registerBaseDecisionHandlers() {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Обработчик для DecisionOfParticipantExit (решение о выходе пайщика)
|
||||
decisionFactory.registerHandler('leavecoop', {
|
||||
generateHandler: async ({ decision_id, username }) => {
|
||||
const { generateMembershipExitDecision } =
|
||||
useGenerateMembershipExitDecision();
|
||||
return await generateMembershipExitDecision({
|
||||
username,
|
||||
decision_id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { watch } from 'vue';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
import { useExitGate } from 'src/features/Membership/ExitFromCoop/model';
|
||||
|
||||
const POLL_INTERVAL_MS = 15000;
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Процесс глобального gate выхода: пока пайщик авторизован, периодически
|
||||
* опрашивает статус выхода. Когда выход активен — overlay блокирует кабинет
|
||||
* (см. ExitOverlay в App.vue). Статус выхода живёт на цепи (registrator::exits),
|
||||
* в сторах его нет — поэтому опрашиваем, а не реактивно выводим.
|
||||
*/
|
||||
export function useExitOverlayProcess() {
|
||||
const session = useSessionStore();
|
||||
const { loadExitStatus } = useExitGate();
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(() => {
|
||||
void loadExitStatus();
|
||||
}, POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
if (session.isAuth) {
|
||||
void loadExitStatus();
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
void loadExitStatus();
|
||||
}
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
watch(() => session.isAuth, sync);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function itemStatusVariant(
|
||||
export function fileKindLabel(kind: Zeus.ExpenseFileKind): string {
|
||||
switch (kind) {
|
||||
case Zeus.ExpenseFileKind.PAYMENT_PROOF:
|
||||
return 'Платёжка';
|
||||
return 'Чек об оплате';
|
||||
case Zeus.ExpenseFileKind.REPORT_FILE:
|
||||
return 'Чек';
|
||||
case Zeus.ExpenseFileKind.RETURN_PROOF:
|
||||
|
||||
@@ -25,6 +25,6 @@ export interface RailUserCardProps {
|
||||
collapsed?: boolean;
|
||||
/** Показывать ли блок «Выйти» под карточкой */
|
||||
showSignout?: boolean;
|
||||
/** Подпись для signout (default «Выйти») */
|
||||
/** Подпись для signout (default «Выйти из кабинета») */
|
||||
signoutLabel?: string;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ button.rail__signout(
|
||||
@click="emit('signout')"
|
||||
)
|
||||
q-icon(name='logout')
|
||||
| {{ signoutLabel ?? 'Выйти' }}
|
||||
| {{ signoutLabel ?? 'Выйти из кабинета' }}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
+45
-25
@@ -51,12 +51,12 @@
|
||||
//- Статус отчёта по авансу (личный стол пайщика) — отдельная ось
|
||||
//- рядом со статусом платежа: требуется / подан / принят.
|
||||
BaseBadge(v-if='reportBadge(row)', :variant='reportBadge(row)?.variant') {{ reportBadge(row)?.label }}
|
||||
//- Кассирская отметка «платёжка приложена» — только на столе совета
|
||||
//- Кассирская отметка «чек об оплате приложен» — только на столе совета
|
||||
//- (пайщику чужая бухгалтерия не показывается).
|
||||
q-icon.proof-icon.proof-icon--ok(v-if='!hideActions && proofState(row) === "attached"', name='receipt_long', size='16px')
|
||||
q-tooltip Платёжка приложена
|
||||
q-tooltip Чек об оплате приложен
|
||||
q-icon.proof-icon.proof-icon--missing(v-else-if='!hideActions && proofState(row) === "missing"', name='receipt_long', size='16px')
|
||||
q-tooltip Платёжка не приложена
|
||||
q-tooltip Чек об оплате не приложен
|
||||
td.col-action(v-if='!hideActions', @click.stop)
|
||||
.cell-actions(v-if='["EXPIRED", "PENDING", "FAILED"].includes(row.status)')
|
||||
SetOrderPaidStatusButton(:id='row.id')
|
||||
@@ -68,15 +68,19 @@
|
||||
tr.expand-row(v-if='expanded.get(row.id)')
|
||||
td(:colspan='hideActions ? 7 : 8')
|
||||
PaymentDetails(:payment='row')
|
||||
//- Стол совета: кассир прикладывает платёжку и закрывающие документы;
|
||||
//- при личной передаче чеков — отчитывается за пайщика (панель
|
||||
//- отчёта сама скрывается для прямой оплаты организации, DIRECT).
|
||||
//- Чек об оплате — ядро, для любого исходящего платежа (стол кассира).
|
||||
.q-mt-sm(v-if='!hideActions && paymentProofHash(row)')
|
||||
AttachPaymentProofPanel(
|
||||
:payment-hash='paymentProofHash(row) ?? ""',
|
||||
:step='expenseProofRef(row) ? { number: 1, title: "Подтвердите оплату" } : undefined',
|
||||
@uploaded='onProofUploaded(row)'
|
||||
)
|
||||
//- Расход: закрывающие документы (DIRECT, панель сама скрывается)
|
||||
//- + отчёт пайщика.
|
||||
.expense-flow.q-mt-sm(v-if='!hideActions && expenseProofRef(row)')
|
||||
AttachExpenseProofPanel(
|
||||
:proposal-hash='expenseProofRef(row)?.proposal_hash ?? ""',
|
||||
:item-hash='expenseProofRef(row)?.item_hash ?? ""',
|
||||
:step='{ number: 1, title: "Подтвердите оплату" }',
|
||||
@uploaded='onProofUploaded(row)'
|
||||
:item-hash='expenseProofRef(row)?.item_hash ?? ""'
|
||||
)
|
||||
ReportExpenseAdvancePanel(
|
||||
:proposal-hash='expenseProofRef(row)?.proposal_hash ?? ""',
|
||||
@@ -96,7 +100,7 @@
|
||||
:reported-amount='advanceReportedAmount(row)',
|
||||
@reported='onReported'
|
||||
)
|
||||
//- Расчётная платёжка (возврат/доплата): кассиру/пайщику видно
|
||||
//- Расчётный платёж (возврат/доплата): кассиру/пайщику видно
|
||||
//- основание — исходный аванс, заявленный факт и документы.
|
||||
ExpenseSettlementBasisPanel.q-mt-sm(
|
||||
v-if='settlementRef(row)',
|
||||
@@ -128,9 +132,9 @@
|
||||
BaseBadge(:variant='getStatusVariant(row.status)') {{ row.status_label }}
|
||||
BaseBadge(v-if='reportBadge(row)', :variant='reportBadge(row)?.variant') {{ reportBadge(row)?.label }}
|
||||
q-icon.proof-icon.proof-icon--ok(v-if='!hideActions && proofState(row) === "attached"', name='receipt_long', size='16px')
|
||||
q-tooltip Платёжка приложена
|
||||
q-tooltip Чек об оплате приложен
|
||||
q-icon.proof-icon.proof-icon--missing(v-else-if='!hideActions && proofState(row) === "missing"', name='receipt_long', size='16px')
|
||||
q-tooltip Платёжка не приложена
|
||||
q-tooltip Чек об оплате не приложен
|
||||
.pay-card__row
|
||||
span.pay-card__amount
|
||||
q-icon.q-mr-xs(
|
||||
@@ -151,12 +155,17 @@
|
||||
SetOrderRefundedStatusButton(v-if='!isRefundType(row.type)', :id='row.id')
|
||||
template(v-if='expanded.get(row.id)')
|
||||
PaymentDetails.pay-card__details(:payment='row')
|
||||
//- Чек об оплате — ядро, для любого исходящего платежа (стол кассира).
|
||||
.q-mt-sm(v-if='!hideActions && paymentProofHash(row)')
|
||||
AttachPaymentProofPanel(
|
||||
:payment-hash='paymentProofHash(row) ?? ""',
|
||||
:step='expenseProofRef(row) ? { number: 1, title: "Подтвердите оплату" } : undefined',
|
||||
@uploaded='onProofUploaded(row)'
|
||||
)
|
||||
.expense-flow.q-mt-sm(v-if='!hideActions && expenseProofRef(row)')
|
||||
AttachExpenseProofPanel(
|
||||
:proposal-hash='expenseProofRef(row)?.proposal_hash ?? ""',
|
||||
:item-hash='expenseProofRef(row)?.item_hash ?? ""',
|
||||
:step='{ number: 1, title: "Подтвердите оплату" }',
|
||||
@uploaded='onProofUploaded(row)'
|
||||
:item-hash='expenseProofRef(row)?.item_hash ?? ""'
|
||||
)
|
||||
ReportExpenseAdvancePanel(
|
||||
:proposal-hash='expenseProofRef(row)?.proposal_hash ?? ""',
|
||||
@@ -212,6 +221,7 @@ import { usePaymentStore } from 'src/entities/Payment/model';
|
||||
import { SetOrderPaidStatusButton } from 'src/features/Payment/SetStatus/ui/SetOrderPaidStatusButton';
|
||||
import { SetOrderRefundedStatusButton } from 'src/features/Payment/SetStatus/ui/SetOrderRefundedStatusButton';
|
||||
import { AttachExpenseProofPanel } from 'src/features/Payment/AttachExpenseProof';
|
||||
import { AttachPaymentProofPanel } from 'src/features/Payment/AttachPaymentProof';
|
||||
import { ReportExpenseAdvancePanel } from 'src/features/Payment/ReportExpenseAdvance';
|
||||
import { ExpenseSettlementBasisPanel } from 'src/features/Payment/ExpenseSettlementBasis';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
@@ -308,7 +318,7 @@ const getDirectionIcon = (direction?: string | null) => {
|
||||
};
|
||||
|
||||
// Ссылка на позицию СЗ для блока «Подтверждение оплаты»: только у платежей
|
||||
// расхода и только после подтверждения кассиром (платёжка появляется по факту).
|
||||
// расхода и только после подтверждения кассиром (чек появляется по факту).
|
||||
const expenseProofRef = (
|
||||
row: IPaymentRow,
|
||||
): { proposal_hash: string; item_hash: string } | null => {
|
||||
@@ -319,6 +329,15 @@ const expenseProofRef = (
|
||||
return { proposal_hash: data.proposal_hash, item_hash: data.item_hash };
|
||||
};
|
||||
|
||||
// Ядровый «чек об оплате»: любой ИСХОДЯЩИЙ платёж после оплаты (PAID/COMPLETED)
|
||||
// сопровождается чеком, привязка по hash платежа. Единый механизм на все
|
||||
// исходящие (возврат паевого/withdrawal/registration-refund/аванс расхода).
|
||||
const paymentProofHash = (row: IPaymentRow): string | null => {
|
||||
if (row.direction !== Zeus.PaymentDirection.OUTGOING) return null;
|
||||
if (![Zeus.PaymentStatus.PAID, Zeus.PaymentStatus.COMPLETED].includes(row.status)) return null;
|
||||
return row.hash ?? null;
|
||||
};
|
||||
|
||||
// Личный реестр пайщика (hideActions): получатель аванса под отчёт прикладывает
|
||||
// чек и подаёт отчёт по своей позиции. Механику (ADVANCE/DIRECT) панель
|
||||
// проверяет сама по данным СЗ и для DIRECT не отображается.
|
||||
@@ -338,7 +357,7 @@ const advanceReportState = (row: IPaymentRow): string =>
|
||||
const advanceReportedAmount = (row: IPaymentRow): string =>
|
||||
(row.blockchain_data as { reported_amount?: string } | null)?.reported_amount ?? '';
|
||||
|
||||
// Расчётная платёжка (возврат недорасхода / доплата перерасхода) — ссылка на
|
||||
// Расчётный платёж (возврат недорасхода / доплата перерасхода) — ссылка на
|
||||
// исходную позицию-аванс, чтобы кассир видел основание (аванс/факт/документы),
|
||||
// не выискивая исходный платёж среди сотен строк реестра.
|
||||
const settlementRef = (
|
||||
@@ -361,7 +380,7 @@ const settlementRef = (
|
||||
|
||||
// Статус отчёта по авансу — только на личном столе пайщика и только у платежа
|
||||
// выдачи аванса (EXPENSE). Источник — зеркало blockchain_data.report_state;
|
||||
// дефолт «Требуется отчёт», пока пайщик не отчитался. Расчётные платёжки
|
||||
// дефолт «Требуется отчёт», пока пайщик не отчитался. Расчётные платежи
|
||||
// (EXPENSE_RETURN/EXPENSE_OVERSPEND) свой отчёт-бейдж не показывают.
|
||||
const reportBadge = (
|
||||
row: IPaymentRow,
|
||||
@@ -376,15 +395,16 @@ const reportBadge = (
|
||||
return { label: reportStateLabel(state), variant: reportStateVariant(state) };
|
||||
};
|
||||
|
||||
// Отметка отчитанности оплаченного расхода: бэкенд зеркалит число платёжек
|
||||
// в blockchain_data.proof_count при каждой загрузке/удалении файла.
|
||||
// Отметка «чек об оплате приложен»: бэкенд зеркалит число чеков в
|
||||
// blockchain_data.proof_count при каждой загрузке/удалении файла. Для любого
|
||||
// исходящего платежа после оплаты.
|
||||
const proofState = (row: IPaymentRow): 'attached' | 'missing' | 'none' => {
|
||||
if (!expenseProofRef(row)) return 'none';
|
||||
if (!paymentProofHash(row)) return 'none';
|
||||
const count = (row.blockchain_data as { proof_count?: number } | null)?.proof_count ?? 0;
|
||||
return count > 0 ? 'attached' : 'missing';
|
||||
};
|
||||
|
||||
// Локально отражаем загруженную платёжку, не перезагружая реестр.
|
||||
// Локально отражаем загруженный чек, не перезагружая реестр.
|
||||
const onProofUploaded = (row: IPaymentRow): void => {
|
||||
const data = (row.blockchain_data ?? {}) as { proof_count?: number };
|
||||
row.blockchain_data = { ...data, proof_count: (data.proof_count ?? 0) + 1 };
|
||||
@@ -473,7 +493,7 @@ const toggleExpand = (id: string | number): void => {
|
||||
expanded.set(id, !expanded.get(id));
|
||||
};
|
||||
|
||||
// После отчёта по авансу с недо-/перерасходом заводится отдельная платёжка
|
||||
// После отчёта по авансу с недо-/перерасходом заводится отдельный платёж
|
||||
// расчёта — перезагружаем реестр и сразу раскрываем её (по хэшу), чтобы пайщик
|
||||
// увидел реквизиты для оплаты, не догадываясь нажать «развернуть».
|
||||
const onReported = async (settlementHash?: string): Promise<void> => {
|
||||
@@ -485,8 +505,8 @@ const onReported = async (settlementHash?: string): Promise<void> => {
|
||||
if (row) expanded.set(row.id, true);
|
||||
};
|
||||
|
||||
// Кассир жмёт «Основание» в расчётной платёжке (возврат/доплата) → раскрываем
|
||||
// платёж выдачи аванса (его hash == item_hash расчётной платёжки) и прокручиваем
|
||||
// Кассир жмёт «Основание» в расчётном платеже (возврат/доплата) → раскрываем
|
||||
// платёж выдачи аванса (его hash == item_hash расчётного платежа) и прокручиваем
|
||||
// к нему. Так видно, сколько выдавалось и что в чеке, без поиска строки вручную.
|
||||
const openSourcePayment = async (itemHash: string): Promise<void> => {
|
||||
const target = items.value.find(
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:balance-route='{ name: "wallet", params: { coopname: info.coopname } }',
|
||||
primary-action-label='Пополнить',
|
||||
show-signout,
|
||||
signout-label='Выйти',
|
||||
signout-label='Выйти из кабинета',
|
||||
@primary-action='onDeposit',
|
||||
@signout='onLogout'
|
||||
)
|
||||
@@ -294,7 +294,7 @@ async function onLogout(): Promise<void> {
|
||||
.left-drawer-menu__hidden-dialogs {
|
||||
display: none;
|
||||
}
|
||||
/* Кнопка «Выйти» из RailUserCard — урезаем нижний padding, чтобы версия
|
||||
/* Кнопка «Выйти из кабинета» из RailUserCard — урезаем нижний padding, чтобы версия
|
||||
шла сразу под ней без лишнего вертикального зазора. */
|
||||
:deep(.rail__signout) {
|
||||
padding-bottom: var(--p-1, 4px);
|
||||
|
||||
@@ -31,17 +31,7 @@
|
||||
.participant-card__status
|
||||
BaseBadge(:variant='getAccountStatusBadge(participant).variant') {{ getAccountStatusBadge(participant).label }}
|
||||
.participant-card__meta-right
|
||||
span.participant-card__date {{ formatDate(String(participant.participant_account?.created_at || '')) }}
|
||||
BaseButton(
|
||||
v-if='deletable',
|
||||
variant='danger',
|
||||
size='sm',
|
||||
icon-only,
|
||||
aria-label='Удалить пайщика',
|
||||
@click.stop='$emit("delete", participant)'
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(name='delete_outline', size='18px')
|
||||
span.participant-card__date {{ joinDate(participant) }}
|
||||
|
||||
q-slide-transition
|
||||
.participant-card__body(v-show='expanded')
|
||||
@@ -67,12 +57,10 @@ import {
|
||||
const props = defineProps<{
|
||||
participant: IAccount;
|
||||
expanded?: boolean;
|
||||
deletable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'toggle-expand': [];
|
||||
delete: [participant: IAccount];
|
||||
update: [
|
||||
participant: IAccount,
|
||||
newData: IIndividualData | IOrganizationData | IEntrepreneurData,
|
||||
@@ -96,6 +84,13 @@ const formatDate = (date?: string) => {
|
||||
return formatted === 'Invalid date' ? 'Дата не указана' : formatted;
|
||||
};
|
||||
|
||||
// Дата вступления: приём советом (participant_account), у вышедших запись стёрта —
|
||||
// фолбэк на дату регистрации аккаунта on-chain (user_account.registered_at).
|
||||
const joinDate = (row: IAccount): string => {
|
||||
const raw = row.participant_account?.created_at || row.user_account?.registered_at;
|
||||
return formatDate(raw ? String(raw) : undefined);
|
||||
};
|
||||
|
||||
const onUpdate = (
|
||||
newData: IIndividualData | IOrganizationData | IEntrepreneurData,
|
||||
) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user