diff --git a/components/contracts/cpp/lib/consts.hpp b/components/contracts/cpp/lib/consts.hpp index e3aa8edb4b1..454e0bf4969 100644 --- a/components/contracts/cpp/lib/consts.hpp +++ b/components/contracts/cpp/lib/consts.hpp @@ -85,7 +85,8 @@ static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелё static const std::set soviet_actions = { "joincoop"_n, //регистрация пайщика - + "leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса) + //MEET "creategm"_n,//предложение повестки планового общего собрание "completegm"_n, //решение общего собрания пайщиков diff --git a/components/contracts/cpp/lib/core/ledger2/operations.hpp b/components/contracts/cpp/lib/core/ledger2/operations.hpp index b6534ab779e..e1684a531f8 100644 --- a/components/contracts/cpp/lib/core/ledger2/operations.hpp +++ b/components/contracts/cpp/lib/core/ledger2/operations.hpp @@ -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, diff --git a/components/contracts/cpp/lib/core/ledger2/wallets.hpp b/components/contracts/cpp/lib/core/ledger2/wallets.hpp index 094edf86dbd..b443f96677f 100644 --- a/components/contracts/cpp/lib/core/ledger2/wallets.hpp +++ b/components/contracts/cpp/lib/core/ledger2/wallets.hpp @@ -252,6 +252,37 @@ inline constexpr std::array 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 LEDGER2_EXIT_REFUND_WALLETS = {{ + ledger2_wallets::MIN_SHARE_FUND, + ledger2_wallets::SHARE_FUND_PAY, + ledger2_wallets::BLAGOROST_FUND, +}}; + /** * @brief Возвращает required program_id для USER_SHARED-кошелька. * diff --git a/components/contracts/cpp/lib/domain/index.hpp b/components/contracts/cpp/lib/domain/index.hpp index 020654ef016..7466cc125f7 100644 --- a/components/contracts/cpp/lib/domain/index.hpp +++ b/components/contracts/cpp/lib/domain/index.hpp @@ -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" diff --git a/components/contracts/cpp/lib/domain/table_registrator_exits.hpp b/components/contracts/cpp/lib/domain/table_registrator_exits.hpp new file mode 100644 index 00000000000..32f0437674d --- /dev/null +++ b/components/contracts/cpp/lib/domain/table_registrator_exits.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include + +#include +#include +#include + +#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>> + exits_index; + +inline std::optional 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 diff --git a/components/contracts/cpp/registrator/registrator.cpp b/components/contracts/cpp/registrator/registrator.cpp index 25b51aa1564..9bb9777b645 100755 --- a/components/contracts/cpp/registrator/registrator.cpp +++ b/components/contracts/cpp/registrator/registrator.cpp @@ -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" diff --git a/components/contracts/cpp/registrator/registrator.hpp b/components/contracts/cpp/registrator/registrator.hpp index 0caf8ba9ec0..eb6596bc852 100755 --- a/components/contracts/cpp/registrator/registrator.hpp +++ b/components/contracts/cpp/registrator/registrator.hpp @@ -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); diff --git a/components/contracts/cpp/registrator/src/exit/completexit.cpp b/components/contracts/cpp/registrator/src/exit/completexit.cpp new file mode 100644 index 00000000000..5c951670755 --- /dev/null +++ b/components/contracts/cpp/registrator/src/exit/completexit.cpp @@ -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); +} diff --git a/components/contracts/cpp/registrator/src/exit/confirmexit.cpp b/components/contracts/cpp/registrator/src/exit/confirmexit.cpp new file mode 100644 index 00000000000..aeb3dae66af --- /dev/null +++ b/components/contracts/cpp/registrator/src/exit/confirmexit.cpp @@ -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( + _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); + } +} diff --git a/components/contracts/cpp/registrator/src/exit/declinexit.cpp b/components/contracts/cpp/registrator/src/exit/declinexit.cpp new file mode 100644 index 00000000000..26576f97cc5 --- /dev/null +++ b/components/contracts/cpp/registrator/src/exit/declinexit.cpp @@ -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); +} diff --git a/components/contracts/cpp/registrator/src/exit/exit_helpers.hpp b/components/contracts/cpp/registrator/src/exit/exit_helpers.hpp new file mode 100644 index 00000000000..76bd2b69ce5 --- /dev/null +++ b/components/contracts/cpp/registrator/src/exit/exit_helpers.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include + +#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 diff --git a/components/contracts/cpp/registrator/src/exit/exitcoop.cpp b/components/contracts/cpp/registrator/src/exit/exitcoop.cpp new file mode 100644 index 00000000000..b5da77c5684 --- /dev/null +++ b/components/contracts/cpp/registrator/src/exit/exitcoop.cpp @@ -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("") + ); +} diff --git a/components/contracts/cpp/soviet/soviet.cpp b/components/contracts/cpp/soviet/soviet.cpp index 9f4646adadb..083dec095c6 100755 --- a/components/contracts/cpp/soviet/soviet.cpp +++ b/components/contracts/cpp/soviet/soviet.cpp @@ -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" diff --git a/components/contracts/cpp/soviet/soviet.hpp b/components/contracts/cpp/soviet/soviet.hpp index 51d51cbe656..cb3f2d36775 100755 --- a/components/contracts/cpp/soviet/soviet.hpp +++ b/components/contracts/cpp/soviet/soviet.hpp @@ -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); diff --git a/components/contracts/cpp/soviet/src/participant/delparticipant.cpp b/components/contracts/cpp/soviet/src/participant/delparticipant.cpp new file mode 100644 index 00000000000..cead789c2ad --- /dev/null +++ b/components/contracts/cpp/soviet/src/participant/delparticipant.cpp @@ -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); +} diff --git a/components/controller/src/app.module.ts b/components/controller/src/app.module.ts index e92d1cb5f4c..5b223c30ca0 100644 --- a/components/controller/src/app.module.ts +++ b/components/controller/src/app.module.ts @@ -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, diff --git a/components/controller/src/application/document/documents-dto/membership-exit-application-document.dto.ts b/components/controller/src/application/document/documents-dto/membership-exit-application-document.dto.ts new file mode 100644 index 00000000000..1d945dae389 --- /dev/null +++ b/components/controller/src/application/document/documents-dto/membership-exit-application-document.dto.ts @@ -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 { + @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; +} diff --git a/components/controller/src/application/document/documents-dto/membership-exit-decision-document.dto.ts b/components/controller/src/application/document/documents-dto/membership-exit-decision-document.dto.ts new file mode 100644 index 00000000000..e64afabf1a5 --- /dev/null +++ b/components/controller/src/application/document/documents-dto/membership-exit-decision-document.dto.ts @@ -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 { + @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; +} diff --git a/components/controller/src/application/gateway/dto/payment-file.output.ts b/components/controller/src/application/gateway/dto/payment-file.output.ts new file mode 100644 index 00000000000..428cb68b85c --- /dev/null +++ b/components/controller/src/application/gateway/dto/payment-file.output.ts @@ -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; + } +} diff --git a/components/controller/src/application/gateway/dto/upload-payment-proof.input.ts b/components/controller/src/application/gateway/dto/upload-payment-proof.input.ts new file mode 100644 index 00000000000..7dba04f6058 --- /dev/null +++ b/components/controller/src/application/gateway/dto/upload-payment-proof.input.ts @@ -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; +} diff --git a/components/controller/src/application/gateway/gateway.module.ts b/components/controller/src/application/gateway/gateway.module.ts index abb923c48b8..0fe706c7ea5 100644 --- a/components/controller/src/application/gateway/gateway.module.ts +++ b/components/controller/src/application/gateway/gateway.module.ts @@ -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 {} diff --git a/components/controller/src/application/gateway/resolvers/payment-files.resolver.ts b/components/controller/src/application/gateway/resolvers/payment-files.resolver.ts new file mode 100644 index 00000000000..81b301ea3d4 --- /dev/null +++ b/components/controller/src/application/gateway/resolvers/payment-files.resolver.ts @@ -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 { + 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 { + 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 { + const items = await this.paymentFiles.listByPayment(coopname, paymentHash); + return items.map((d) => PaymentFileOutputDTO.fromDomain(d)); + } +} diff --git a/components/controller/src/application/gateway/services/payment-files.service.ts b/components/controller/src/application/gateway/services/payment-files.service.ts new file mode 100644 index 00000000000..ed8250f8cf0 --- /dev/null +++ b/components/controller/src/application/gateway/services/payment-files.service.ts @@ -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 = { + '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 { + return this.files.findByPayment(coopname, paymentHash.toLowerCase()); + } + + async deleteFile(fileId: number): Promise { + 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 { + 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}`; + } +} diff --git a/components/controller/src/application/membership-exit/dto/create-membership-exit-input.dto.ts b/components/controller/src/application/membership-exit/dto/create-membership-exit-input.dto.ts new file mode 100644 index 00000000000..21967ca1eef --- /dev/null +++ b/components/controller/src/application/membership-exit/dto/create-membership-exit-input.dto.ts @@ -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; +} diff --git a/components/controller/src/application/membership-exit/dto/membership-exit-result.dto.ts b/components/controller/src/application/membership-exit/dto/membership-exit-result.dto.ts new file mode 100644 index 00000000000..5ea52e34df7 --- /dev/null +++ b/components/controller/src/application/membership-exit/dto/membership-exit-result.dto.ts @@ -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; +} diff --git a/components/controller/src/application/membership-exit/dto/membership-exit-return-preview.dto.ts b/components/controller/src/application/membership-exit/dto/membership-exit-return-preview.dto.ts new file mode 100644 index 00000000000..3da9dee6593 --- /dev/null +++ b/components/controller/src/application/membership-exit/dto/membership-exit-return-preview.dto.ts @@ -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; +} diff --git a/components/controller/src/application/membership-exit/dto/membership-exit.dto.ts b/components/controller/src/application/membership-exit/dto/membership-exit.dto.ts new file mode 100644 index 00000000000..b9767cf1c5e --- /dev/null +++ b/components/controller/src/application/membership-exit/dto/membership-exit.dto.ts @@ -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; +} diff --git a/components/controller/src/application/membership-exit/enums/membership-exit-status.enum.ts b/components/controller/src/application/membership-exit/enums/membership-exit-status.enum.ts new file mode 100644 index 00000000000..2aa98439263 --- /dev/null +++ b/components/controller/src/application/membership-exit/enums/membership-exit-status.enum.ts @@ -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: 'Статус процесса выхода пайщика из кооператива', +}); diff --git a/components/controller/src/application/membership-exit/membership-exit.module.ts b/components/controller/src/application/membership-exit/membership-exit.module.ts new file mode 100644 index 00000000000..c6905496376 --- /dev/null +++ b/components/controller/src/application/membership-exit/membership-exit.module.ts @@ -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 {} diff --git a/components/controller/src/application/membership-exit/resolvers/membership-exit.resolver.ts b/components/controller/src/application/membership-exit/resolvers/membership-exit.resolver.ts new file mode 100644 index 00000000000..3d01701950e --- /dev/null +++ b/components/controller/src/application/membership-exit/resolvers/membership-exit.resolver.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member'; + if (!isOperator && username !== currentUser.username) { + throw new ForbiddenException('Доступен только собственный расчёт возврата'); + } + return this.membershipExitService.getReturnPreview(coopname, username); + } +} diff --git a/components/controller/src/application/membership-exit/services/membership-exit-authorization.listener.ts b/components/controller/src/application/membership-exit/services/membership-exit-authorization.listener.ts new file mode 100644 index 00000000000..e2b7da43af1 --- /dev/null +++ b/components/controller/src/application/membership-exit/services/membership-exit-authorization.listener.ts @@ -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 { + 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})` + ); + } +} diff --git a/components/controller/src/application/membership-exit/services/membership-exit.service.ts b/components/controller/src/application/membership-exit/services/membership-exit.service.ts new file mode 100644 index 00000000000..2a9dd9e56a2 --- /dev/null +++ b/components/controller/src/application/membership-exit/services/membership-exit.service.ts @@ -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 + ) {} + + async generateMembershipExitApplication( + data: Cooperative.Registry.ParticipantExitApplication.Action, + options: Cooperative.Document.IGenerationOptions + ): Promise { + 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 { + 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 { + 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, + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`; + } +} diff --git a/components/controller/src/application/participant/interactors/participant.interactor.ts b/components/controller/src/application/participant/interactors/participant.interactor.ts index 7bc77d4dc48..7d379190b28 100644 --- a/components/controller/src/application/participant/interactors/participant.interactor.ts +++ b/components/controller/src/application/participant/interactors/participant.interactor.ts @@ -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 { + 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 { + data.registry_id = Cooperative.Registry.DecisionOfParticipantExit.registry_id; + return await this.documentDomainService.generateDocument({ data, options }); + } + /** * Проверяет подпись документа * @private diff --git a/components/controller/src/application/token/services/token-application.service.ts b/components/controller/src/application/token/services/token-application.service.ts index 6d8f17067d5..a95f36c6057 100644 --- a/components/controller/src/application/token/services/token-application.service.ts +++ b/components/controller/src/application/token/services/token-application.service.ts @@ -61,6 +61,15 @@ export class TokenApplicationService { return this.tokenDomainService.generateVerifyEmailToken(userId); } + /** + * Генерирует токен подтверждения выхода из кооператива + * @param userId - ID пользователя + * @returns Токен подтверждения выхода + */ + async generateConfirmExitToken(userId: string): Promise { + return this.tokenDomainService.generateConfirmExitToken(userId); + } + /** * Верифицирует токен * @param input - данные для верификации diff --git a/components/controller/src/domain/account/interfaces/account-blockchain.port.ts b/components/controller/src/domain/account/interfaces/account-blockchain.port.ts index 038aaf09bbd..a7be36198e5 100644 --- a/components/controller/src/domain/account/interfaces/account-blockchain.port.ts +++ b/components/controller/src/domain/account/interfaces/account-blockchain.port.ts @@ -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; @@ -12,6 +23,13 @@ export interface AccountBlockchainPort { getUserAccount(username: string): Promise; addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise; registerBlockchainAccount(candidate: CandidateDomainInterface): Promise; + // Подача заявления на выход пайщика из кооператива (registrator::exitcoop) + exitCoop(data: ExitCoopDomainInterface): Promise; + // Текущий процесс выхода пайщика (registrator::exits), либо null + getExit(coopname: string, username: string): Promise; + // Процесс выхода по exit_hash (registrator::exits): on-chain confirmexit отдаёт + // только coopname+exit_hash, username и сумму возврата берём из таблицы по хэшу. + getExitByHash(coopname: string, exit_hash: string): Promise; } export const ACCOUNT_BLOCKCHAIN_PORT = Symbol('AccountBlockchainPort'); diff --git a/components/controller/src/domain/account/interfaces/create-membership-exit-input.interface.ts b/components/controller/src/domain/account/interfaces/create-membership-exit-input.interface.ts new file mode 100644 index 00000000000..01bc56fe3bd --- /dev/null +++ b/components/controller/src/domain/account/interfaces/create-membership-exit-input.interface.ts @@ -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; +} diff --git a/components/controller/src/domain/gateway/constants/gateway-bucket.ts b/components/controller/src/domain/gateway/constants/gateway-bucket.ts new file mode 100644 index 00000000000..0e9bd664886 --- /dev/null +++ b/components/controller/src/domain/gateway/constants/gateway-bucket.ts @@ -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]; diff --git a/components/controller/src/domain/gateway/entities/payment-domain.entity.ts b/components/controller/src/domain/gateway/entities/payment-domain.entity.ts index 95764c955d7..89b23790bca 100644 --- a/components/controller/src/domain/gateway/entities/payment-domain.entity.ts +++ b/components/controller/src/domain/gateway/entities/payment-domain.entity.ts @@ -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]: 'Доплата по перерасходу аванса', diff --git a/components/controller/src/domain/gateway/enums/payment-file-kind.enum.ts b/components/controller/src/domain/gateway/enums/payment-file-kind.enum.ts new file mode 100644 index 00000000000..b110c0dd2d9 --- /dev/null +++ b/components/controller/src/domain/gateway/enums/payment-file-kind.enum.ts @@ -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: 'Чек об оплате — подтверждающий документ по исполненному платежу.' }, + }, +}); diff --git a/components/controller/src/domain/gateway/enums/payment-type.enum.ts b/components/controller/src/domain/gateway/enums/payment-type.enum.ts index 3845be4a75d..2055b0be300 100644 --- a/components/controller/src/domain/gateway/enums/payment-type.enum.ts +++ b/components/controller/src/domain/gateway/enums/payment-type.enum.ts @@ -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.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, ]; diff --git a/components/controller/src/domain/gateway/interfaces/payment-file-database.interface.ts b/components/controller/src/domain/gateway/interfaces/payment-file-database.interface.ts new file mode 100644 index 00000000000..c1393689b00 --- /dev/null +++ b/components/controller/src/domain/gateway/interfaces/payment-file-database.interface.ts @@ -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; +} diff --git a/components/controller/src/domain/gateway/repositories/payment-file.repository.ts b/components/controller/src/domain/gateway/repositories/payment-file.repository.ts new file mode 100644 index 00000000000..90e3da18c7e --- /dev/null +++ b/components/controller/src/domain/gateway/repositories/payment-file.repository.ts @@ -0,0 +1,16 @@ +import type { IPaymentFileDatabaseData } from '../interfaces/payment-file-database.interface'; + +/** + * Реестр файлов, приложенных к платежам (чеки об оплате). Файл — чистая БД-сущность + * (MinIO + метаданные), блокчейна за ним нет, поэтому интерфейс не наследует + * sync-репозиторий. + */ +export interface PaymentFileRepository { + create(data: IPaymentFileDatabaseData): Promise; + findById(id: number): Promise; + findByChecksum(coopname: string, checksum: string): Promise; + findByPayment(coopname: string, paymentHash: string): Promise; + delete(id: number): Promise; +} + +export const PAYMENT_FILE_REPOSITORY = Symbol('PaymentFileRepository'); diff --git a/components/controller/src/domain/token/services/token-domain.service.ts b/components/controller/src/domain/token/services/token-domain.service.ts index 6314cdc0c4b..11a2c05bb19 100644 --- a/components/controller/src/domain/token/services/token-domain.service.ts +++ b/components/controller/src/domain/token/services/token-domain.service.ts @@ -190,6 +190,22 @@ export class TokenDomainService { return verifyEmailToken; } + /** + * Генерирует токен подтверждения выхода из кооператива (ссылка в письме). + * Срок жизни — как у верификации email (это тоже подтверждение по почте). + */ + async generateConfirmExitToken(userId: string): Promise { + 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; + } + /** * Удаляет токены по критериям */ diff --git a/components/controller/src/infrastructure/blockchain/adapters/account.adapter.ts b/components/controller/src/infrastructure/blockchain/adapters/account.adapter.ts index 8eb90e7580f..bc9903257ce 100644 --- a/components/controller/src/infrastructure/blockchain/adapters/account.adapter.ts +++ b/components/controller/src/infrastructure/blockchain/adapters/account.adapter.ts @@ -211,6 +211,27 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort { }; } + async exitCoop(data: import('~/domain/account/interfaces/account-blockchain.port').ExitCoopDomainInterface): Promise { + 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 { 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 { + return this.blockchainService.getSingleRow( + RegistratorContract.contractName.production, + coopname, + RegistratorContract.Tables.Exits.tableName, + Name.from(username) + ); + } + + async getExitByHash( + coopname: string, + exit_hash: string + ): Promise { + // Таблица exits в scope=coopname мала (одна запись на выходящего пайщика, + // удаляется при завершении), поэтому проще выбрать все строки и найти по + // exit_hash, чем ходить во вторичный индекс byhash. + const rows = await this.blockchainService.getAllRows( + 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 diff --git a/components/controller/src/infrastructure/database/typeorm/entities/membership-exit-request.entity.ts b/components/controller/src/infrastructure/database/typeorm/entities/membership-exit-request.entity.ts new file mode 100644 index 00000000000..c2c24aa4f73 --- /dev/null +++ b/components/controller/src/infrastructure/database/typeorm/entities/membership-exit-request.entity.ts @@ -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; + + @Column({ name: 'token', length: 1024 }) + token!: string; + + @CreateDateColumn({ name: 'created_at' }) + created_at!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updated_at!: Date; +} diff --git a/components/controller/src/infrastructure/database/typeorm/entities/payment-file.entity.ts b/components/controller/src/infrastructure/database/typeorm/entities/payment-file.entity.ts new file mode 100644 index 00000000000..aa03dceab3c --- /dev/null +++ b/components/controller/src/infrastructure/database/typeorm/entities/payment-file.entity.ts @@ -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; +} diff --git a/components/controller/src/infrastructure/database/typeorm/repositories/typeorm-payment-file.repository.ts b/components/controller/src/infrastructure/database/typeorm/repositories/typeorm-payment-file.repository.ts new file mode 100644 index 00000000000..b74669f87f4 --- /dev/null +++ b/components/controller/src/infrastructure/database/typeorm/repositories/typeorm-payment-file.repository.ts @@ -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 + ) {} + + async create(data: IPaymentFileDatabaseData): Promise { + 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 { + const entity = await this.repository.findOne({ where: { id } }); + return entity ? this.toDomain(entity) : null; + } + + async findByChecksum(coopname: string, checksum: string): Promise { + const entity = await this.repository.findOne({ where: { coopname, checksum_sha256: checksum } }); + return entity ? this.toDomain(entity) : null; + } + + async findByPayment(coopname: string, paymentHash: string): Promise { + 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 { + 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, + }; + } +} diff --git a/components/controller/src/infrastructure/database/typeorm/typeorm.module.ts b/components/controller/src/infrastructure/database/typeorm/typeorm.module.ts index 4db5672d114..63361f64872 100644 --- a/components/controller/src/infrastructure/database/typeorm/typeorm.module.ts +++ b/components/controller/src/infrastructure/database/typeorm/typeorm.module.ts @@ -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, diff --git a/components/controller/src/types/token.types.ts b/components/controller/src/types/token.types.ts index ff8090e47a4..fbf5b6c838b 100644 --- a/components/controller/src/types/token.types.ts +++ b/components/controller/src/types/token.types.ts @@ -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]; diff --git a/components/controller/zeus/const.ts b/components/controller/zeus/const.ts index 80823dce561..cbd179966bf 100644 --- a/components/controller/zeus/const.ts +++ b/components/controller/zeus/const.ts @@ -326,6 +326,9 @@ export const AllTypesProps: Record = { }, CreateMatrixAccountInputDTO:{ + }, + CreateMembershipExitInput:{ + statement:"MembershipExitApplicationSignedDocumentInput" }, CreateOrganizationDataInput:{ bank_account:"BankAccountInput", @@ -694,6 +697,20 @@ export const AllTypesProps: Record = { 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 = { }, authorizeDecision:{ data:"AuthorizeDecisionInput" + }, + cancelMembershipExit:{ + }, cancelRequest:{ data:"CancelRequestInput" @@ -1013,6 +1033,9 @@ export const AllTypesProps: Record = { }, confirmAgreement:{ data:"ConfirmAgreementInput" + }, + confirmMembershipExit:{ + }, confirmReceiveOnRequest:{ data:"ConfirmReceiveOnRequestInput" @@ -1038,6 +1061,9 @@ export const AllTypesProps: Record = { createInitialPayment:{ data:"CreateInitialPaymentInput" }, + createMembershipExit:{ + data:"CreateMembershipExitInput" + }, createParentOffer:{ data:"CreateParentOfferInput" }, @@ -1133,6 +1159,14 @@ export const AllTypesProps: Record = { 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 = { uploadExpenseFile:{ data:"UploadExpenseFileInput" }, + uploadPaymentProof:{ + data:"UploadPaymentProofInput" + }, verifyEmail:{ data:"VerifyEmailInputDTO" }, @@ -1393,6 +1430,7 @@ export const AllTypesProps: Record = { }, PaymentDirection: "enum" as const, + PaymentFileKind: "enum" as const, PaymentFiltersInput:{ direction:"PaymentDirection", status:"PaymentStatus", @@ -1778,9 +1816,21 @@ export const AllTypesProps: Record = { }, listReportDrafts:{ filter:"ListReportDraftsFilterInput" + }, + membershipExit:{ + + }, + membershipExitReturnPreview:{ + }, onecoopGetDocuments:{ data:"GetOneCoopDocumentsInput" + }, + paymentFile:{ + + }, + paymentProofs:{ + }, process:{ @@ -2080,6 +2130,9 @@ export const AllTypesProps: Record = { }, UploadExpenseFileInput:{ kind:"ExpenseFileKind" + }, + UploadPaymentProofInput:{ + }, UserStatus: "enum" as const, VarsInput:{ @@ -3778,6 +3831,22 @@ export const ReturnTypes: Record = { 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 = { addPaymentMethod:"PaymentMethod", addTrustedAccount:"Branch", authorizeDecision:"Transaction", + cancelMembershipExit:"Boolean", cancelRequest:"Transaction", capitalAddAuthor:"CapitalProject", capitalApproveCommit:"CapitalCommit", @@ -3899,6 +3969,7 @@ export const ReturnTypes: Record = { completeExtensionOnboardingStep:"ExtensionOnboardingState", completeRequest:"Transaction", confirmAgreement:"Transaction", + confirmMembershipExit:"MembershipExitResult", confirmReceiveOnRequest:"Transaction", confirmSupplyOnRequest:"Transaction", createAnnualGeneralMeet:"MeetAggregate", @@ -3907,6 +3978,7 @@ export const ReturnTypes: Record = { createDepositPayment:"GatewayPayment", createExpenseProposal:"Transaction", createInitialPayment:"GatewayPayment", + createMembershipExit:"MembershipExitResult", createParentOffer:"Transaction", createProjectOfFreeDecision:"CreatedProjectFreeDecision", createWebPushSubscription:"CreateSubscriptionResponse", @@ -3935,6 +4007,8 @@ export const ReturnTypes: Record = { generateExpenseProposalDecisionDocument:"GeneratedDocument", generateExpenseProposalStatementDocument:"GeneratedDocument", generateFreeDecision:"GeneratedDocument", + generateMembershipExitApplication:"GeneratedDocument", + generateMembershipExitDecision:"GeneratedDocument", generateParticipantApplication:"GeneratedDocument", generateParticipantApplicationDecision:"GeneratedDocument", generatePrivacyAgreement:"GeneratedDocument", @@ -3999,6 +4073,7 @@ export const ReturnTypes: Record = { updateSettings:"Settings", updateSystem:"SystemInfo", uploadExpenseFile:"ExpenseFile", + uploadPaymentProof:"PaymentFile", verifyEmail:"Boolean", voteOnAnnualGeneralMeet:"MeetAggregate", walmoveWallets:"Ledger2AdjustmentResult" @@ -4280,6 +4355,20 @@ export const ReturnTypes: Record = { 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 = { getUserWebPushSubscriptions:"WebPushSubscriptionDto", getWebPushSubscriptionStats:"SubscriptionStatsDto", listReportDrafts:"ReportDraft", + membershipExit:"MembershipExit", + membershipExitReturnPreview:"MembershipExitReturnPreview", onecoopGetDocuments:"OneCoopDocumentsResponse", + paymentFile:"PaymentFile", + paymentProofs:"PaymentFile", process:"ProcessView", processes:"ProcessSummaryPaginationResult", searchDocuments:"SearchResult", diff --git a/components/controller/zeus/index.ts b/components/controller/zeus/index.ts index 99a7dbe823f..2e5d04463f5 100644 --- a/components/controller/zeus/index.ts +++ b/components/controller/zeus/index.ts @@ -4595,6 +4595,16 @@ export type ValueTypes = { ["CreateMatrixAccountInputDTO"]: { password: string | Variable, username: string | Variable +}; + ["CreateMembershipExitInput"]: { + /** Имя аккаунта кооператива */ + coopname: string | Variable, + /** Хеш процесса выхода (генерируется на клиенте) */ + exit_hash: string | Variable, + /** Подписанное пайщиком заявление о выходе из кооператива */ + statement: ValueTypes["MembershipExitApplicationSignedDocumentInput"] | Variable, + /** Имя пайщика, выходящего из кооператива */ + username: string | Variable }; ["CreateOrganizationDataInput"]: { /** Банковский счет организации */ @@ -6832,6 +6842,127 @@ export type ValueTypes = { __typename?: boolean | `@${string}`, ['...on MeetQuestionResult']?: Omit }>; + ["MembershipExit"]: AliasType<{ + /** Дата подачи заявления на выход */ + created_at?:boolean | `@${string}`, + /** Хеш процесса выхода */ + exit_hash?:boolean | `@${string}`, + /** Статус исходящего платежа возврата паевого взноса в реестре кассира. Создаётся при одобрении советом; null — платёж ещё не заведён. */ + payment_status?:boolean | `@${string}`, + /** Сумма к возврату (фиксируется советом при одобрении; до одобрения — 0) */ + quantity?:boolean | `@${string}`, + /** Статус процесса выхода */ + status?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on MembershipExit']?: Omit +}>; + ["MembershipExitApplicationGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null | Variable, + /** Название кооператива, связанное с документом */ + coopname: string | Variable, + /** Дата и время создания документа */ + created_at?: string | undefined | null | Variable, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null | Variable, + /** Язык документа */ + lang?: string | undefined | null | Variable, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null | Variable, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean | Variable, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null | Variable, + /** Название документа */ + title?: string | undefined | null | Variable, + /** Имя пользователя, создавшего документ */ + username: string | Variable, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null | Variable +}; + ["MembershipExitApplicationSignedDocumentInput"]: { + /** Хэш содержимого документа */ + doc_hash: string | Variable, + /** Общий хэш (doc_hash + meta_hash) */ + hash: string | Variable, + meta: ValueTypes["MembershipExitApplicationSignedMetaDocumentInput"] | Variable, + /** Хэш мета-данных */ + meta_hash: string | Variable, + /** Вектор подписей */ + signatures: Array | Variable, + /** Версия стандарта документа */ + version: string | Variable +}; + ["MembershipExitApplicationSignedMetaDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num: number | Variable, + /** Название кооператива, связанное с документом */ + coopname: string | Variable, + /** Дата и время создания документа */ + created_at: string | Variable, + /** Имя генератора, использованного для создания документа */ + generator: string | Variable, + /** Язык документа */ + lang: string | Variable, + /** Ссылки, связанные с документом */ + links: Array | Variable, + /** ID документа в реестре */ + registry_id: number | Variable, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean | Variable, + /** Часовой пояс, в котором был создан документ */ + timezone: string | Variable, + /** Название документа */ + title: string | Variable, + /** Имя пользователя, создавшего документ */ + username: string | Variable, + /** Версия генератора, использованного для создания документа */ + version: string | Variable +}; + ["MembershipExitDecisionGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null | Variable, + /** Название кооператива, связанное с документом */ + coopname: string | Variable, + /** Дата и время создания документа */ + created_at?: string | undefined | null | Variable, + /** Идентификатор протокола решения собрания совета */ + decision_id: number | Variable, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null | Variable, + /** Язык документа */ + lang?: string | undefined | null | Variable, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null | Variable, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null | Variable, + /** Название документа */ + title?: string | undefined | null | Variable, + /** Имя пользователя, создавшего документ */ + username: string | Variable, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null | Variable +}; + ["MembershipExitResult"]: AliasType<{ + /** Хеш созданного процесса выхода */ + exit_hash?:boolean | `@${string}`, + /** Статус процесса выхода после подачи (ожидает подтверждения по ссылке из письма) */ + status?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on MembershipExitResult']?: Omit +}>; + ["MembershipExitReturnPreview"]: AliasType<{ + /** Минимальный паевой взнос пайщика */ + minimum_contribution?:boolean | `@${string}`, + /** Целевой паевой взнос пайщика */ + share_contribution?:boolean | `@${string}`, + /** Итоговая сумма к возврату (минимальный + целевой паевой) */ + total?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on MembershipExitReturnPreview']?: Omit +}>; + /** Статус процесса выхода пайщика из кооператива */ +["MembershipExitStatus"]:MembershipExitStatus; ["MissingRequisiteField"]: AliasType<{ key?:boolean | `@${string}`, label?:boolean | `@${string}`, @@ -6894,6 +7025,7 @@ addParticipant?: [{ data: ValueTypes["AddParticipantInput"] | Variable},ValueTypes["PaymentMethod"]], addTrustedAccount?: [{ data: ValueTypes["AddTrustedAccountInput"] | Variable},ValueTypes["Branch"]], authorizeDecision?: [{ data: ValueTypes["AuthorizeDecisionInput"] | Variable},ValueTypes["Transaction"]], +cancelMembershipExit?: [{ coopname: string | Variable, username: string | Variable},boolean | `@${string}`], cancelRequest?: [{ data: ValueTypes["CancelRequestInput"] | Variable},ValueTypes["Transaction"]], capitalAddAuthor?: [{ data: ValueTypes["AddAuthorInput"] | Variable},ValueTypes["CapitalProject"]], capitalApproveCommit?: [{ data: ValueTypes["CommitApproveInput"] | Variable},ValueTypes["CapitalCommit"]], @@ -6990,6 +7122,7 @@ completeChairmanGeneralMeetStep?: [{ data: ValueTypes["ChairmanOnboardingGeneral completeExtensionOnboardingStep?: [{ data: ValueTypes["CompleteExtensionOnboardingStepInput"] | Variable},ValueTypes["ExtensionOnboardingState"]], completeRequest?: [{ data: ValueTypes["CompleteRequestInput"] | Variable},ValueTypes["Transaction"]], confirmAgreement?: [{ data: ValueTypes["ConfirmAgreementInput"] | Variable},ValueTypes["Transaction"]], +confirmMembershipExit?: [{ token: string | Variable},ValueTypes["MembershipExitResult"]], confirmReceiveOnRequest?: [{ data: ValueTypes["ConfirmReceiveOnRequestInput"] | Variable},ValueTypes["Transaction"]], confirmSupplyOnRequest?: [{ data: ValueTypes["ConfirmSupplyOnRequestInput"] | Variable},ValueTypes["Transaction"]], createAnnualGeneralMeet?: [{ data: ValueTypes["CreateAnnualGeneralMeetInput"] | Variable},ValueTypes["MeetAggregate"]], @@ -6998,6 +7131,7 @@ createChildOrder?: [{ data: ValueTypes["CreateChildOrderInput"] | Variable},ValueTypes["GatewayPayment"]], createExpenseProposal?: [{ data: ValueTypes["CreateExpenseProposalInput"] | Variable},ValueTypes["Transaction"]], createInitialPayment?: [{ data: ValueTypes["CreateInitialPaymentInput"] | Variable},ValueTypes["GatewayPayment"]], +createMembershipExit?: [{ data: ValueTypes["CreateMembershipExitInput"] | Variable},ValueTypes["MembershipExitResult"]], createParentOffer?: [{ data: ValueTypes["CreateParentOfferInput"] | Variable},ValueTypes["Transaction"]], createProjectOfFreeDecision?: [{ data: ValueTypes["CreateProjectFreeDecisionInput"] | Variable},ValueTypes["CreatedProjectFreeDecision"]], createWebPushSubscription?: [{ data: ValueTypes["CreateSubscriptionInput"] | Variable},ValueTypes["CreateSubscriptionResponse"]], @@ -7026,6 +7160,8 @@ generateDocument?: [{ input: ValueTypes["GenerateAnyDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], generateExpenseProposalStatementDocument?: [{ data: ValueTypes["ExpenseProposalStatementGenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], generateFreeDecision?: [{ data: ValueTypes["FreeDecisionGenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], +generateMembershipExitApplication?: [{ data: ValueTypes["MembershipExitApplicationGenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], +generateMembershipExitDecision?: [{ data: ValueTypes["MembershipExitDecisionGenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], generateParticipantApplication?: [{ data: ValueTypes["ParticipantApplicationGenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], generateParticipantApplicationDecision?: [{ data: ValueTypes["ParticipantApplicationDecisionGenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], generatePrivacyAgreement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable},ValueTypes["GeneratedDocument"]], @@ -7091,6 +7227,7 @@ updateRequest?: [{ data: ValueTypes["UpdateRequestInput"] | Variable},ValueTypes["Settings"]], updateSystem?: [{ data: ValueTypes["Update"] | Variable},ValueTypes["SystemInfo"]], uploadExpenseFile?: [{ data: ValueTypes["UploadExpenseFileInput"] | Variable},ValueTypes["ExpenseFile"]], +uploadPaymentProof?: [{ data: ValueTypes["UploadPaymentProofInput"] | Variable},ValueTypes["PaymentFile"]], verifyEmail?: [{ data: ValueTypes["VerifyEmailInputDTO"] | Variable},boolean | `@${string}`], voteOnAnnualGeneralMeet?: [{ data: ValueTypes["VoteOnAnnualGeneralMeetInput"] | Variable},ValueTypes["MeetAggregate"]], walmoveWallets?: [{ input: ValueTypes["WalmoveInput"] | Variable},ValueTypes["Ledger2AdjustmentResult"]], @@ -7828,6 +7965,37 @@ walmoveWallets?: [{ input: ValueTypes["WalmoveInput"] | Variable},V }>; /** Направление платежа */ ["PaymentDirection"]:PaymentDirection; + /** Запись о файле, приложенном к платежу (чек об оплате). */ +["PaymentFile"]: AliasType<{ + /** SHA-256 содержимого, hex-lowercase. */ + checksum_sha256?:boolean | `@${string}`, + /** Имя кооператива (scope). */ + coopname?:boolean | `@${string}`, + /** Внутренний ID записи. */ + id?:boolean | `@${string}`, + /** Назначение файла. */ + kind?:boolean | `@${string}`, + /** MIME-тип содержимого. */ + mime_type?:boolean | `@${string}`, + /** Оригинальное имя загруженного файла. */ + original_filename?:boolean | `@${string}`, + /** Хеш платежа. */ + payment_hash?:boolean | `@${string}`, + /** Короткоживущий URL на скачивание (HMAC-signed). */ + read_url?:boolean | `@${string}`, + /** Размер файла в байтах. */ + size_bytes?:boolean | `@${string}`, + /** MinIO-ключ внутри бакета. */ + storage_key?:boolean | `@${string}`, + /** Когда загружено. */ + uploaded_at?:boolean | `@${string}`, + /** Кто загрузил (username). */ + uploaded_by_username?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on PaymentFile']?: Omit +}>; + /** Тип файла, приложенного к платежу. */ +["PaymentFileKind"]:PaymentFileKind; ["PaymentFiltersInput"]: { /** Название кооператива */ coopname?: string | undefined | null | Variable, @@ -8550,7 +8718,11 @@ getUserWebPushSubscriptions?: [{ data: ValueTypes["GetUserSubscriptionsInput"] | Требуемые роли: chairman. */ getWebPushSubscriptionStats?:ValueTypes["SubscriptionStatsDto"], listReportDrafts?: [{ filter?: ValueTypes["ListReportDraftsFilterInput"] | undefined | null | Variable},ValueTypes["ReportDraft"]], +membershipExit?: [{ coopname: string | Variable, username: string | Variable},ValueTypes["MembershipExit"]], +membershipExitReturnPreview?: [{ coopname: string | Variable, username: string | Variable},ValueTypes["MembershipExitReturnPreview"]], onecoopGetDocuments?: [{ data: ValueTypes["GetOneCoopDocumentsInput"] | Variable},ValueTypes["OneCoopDocumentsResponse"]], +paymentFile?: [{ id: number | Variable},ValueTypes["PaymentFile"]], +paymentProofs?: [{ coopname: string | Variable, payment_hash: string | Variable},ValueTypes["PaymentFile"]], process?: [{ coopname: string | Variable, hash: string | Variable},ValueTypes["ProcessView"]], processes?: [{ filter: ValueTypes["ProcessesFilter"] | Variable, pagination: ValueTypes["PaginationInput"] | Variable},ValueTypes["ProcessSummaryPaginationResult"]], searchDocuments?: [{ data: ValueTypes["SearchDocumentsInput"] | Variable},ValueTypes["SearchResult"]], @@ -10076,6 +10248,22 @@ validateReportEdits?: [{ editsJson: string | Variable, reportType: proposal_hash: string | Variable, /** Размер файла в байтах (для серверной валидации). */ size_bytes: number | Variable +}; + ["UploadPaymentProofInput"]: { + /** SHA-256 содержимого, hex-lowercase (64 hex-символа). */ + checksum_sha256: string | Variable, + /** Содержимое файла, base64 без префикса data:. */ + content_base64: string | Variable, + /** Имя кооператива. */ + coopname: string | Variable, + /** MIME-тип содержимого. */ + mime_type: string | Variable, + /** Оригинальное имя файла — для отображения и поиска. */ + original_filename?: string | undefined | null | Variable, + /** Хеш платежа, к которому прикладывается чек. */ + payment_hash: string | Variable, + /** Размер файла в байтах (для серверной валидации). */ + size_bytes: number | Variable }; ["UserAccount"]: AliasType<{ /** Метаинформация */ @@ -13704,6 +13892,16 @@ export type ResolverInputTypes = { ["CreateMatrixAccountInputDTO"]: { password: string, username: string +}; + ["CreateMembershipExitInput"]: { + /** Имя аккаунта кооператива */ + coopname: string, + /** Хеш процесса выхода (генерируется на клиенте) */ + exit_hash: string, + /** Подписанное пайщиком заявление о выходе из кооператива */ + statement: ResolverInputTypes["MembershipExitApplicationSignedDocumentInput"], + /** Имя пайщика, выходящего из кооператива */ + username: string }; ["CreateOrganizationDataInput"]: { /** Банковский счет организации */ @@ -15883,6 +16081,124 @@ export type ResolverInputTypes = { votes_for?:boolean | `@${string}`, __typename?: boolean | `@${string}` }>; + ["MembershipExit"]: AliasType<{ + /** Дата подачи заявления на выход */ + created_at?:boolean | `@${string}`, + /** Хеш процесса выхода */ + exit_hash?:boolean | `@${string}`, + /** Статус исходящего платежа возврата паевого взноса в реестре кассира. Создаётся при одобрении советом; null — платёж ещё не заведён. */ + payment_status?:boolean | `@${string}`, + /** Сумма к возврату (фиксируется советом при одобрении; до одобрения — 0) */ + quantity?:boolean | `@${string}`, + /** Статус процесса выхода */ + status?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; + ["MembershipExitApplicationGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at?: string | undefined | null, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null, + /** Язык документа */ + lang?: string | undefined | null, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null, + /** Название документа */ + title?: string | undefined | null, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null +}; + ["MembershipExitApplicationSignedDocumentInput"]: { + /** Хэш содержимого документа */ + doc_hash: string, + /** Общий хэш (doc_hash + meta_hash) */ + hash: string, + meta: ResolverInputTypes["MembershipExitApplicationSignedMetaDocumentInput"], + /** Хэш мета-данных */ + meta_hash: string, + /** Вектор подписей */ + signatures: Array, + /** Версия стандарта документа */ + version: string +}; + ["MembershipExitApplicationSignedMetaDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num: number, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at: string, + /** Имя генератора, использованного для создания документа */ + generator: string, + /** Язык документа */ + lang: string, + /** Ссылки, связанные с документом */ + links: Array, + /** ID документа в реестре */ + registry_id: number, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean, + /** Часовой пояс, в котором был создан документ */ + timezone: string, + /** Название документа */ + title: string, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version: string +}; + ["MembershipExitDecisionGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at?: string | undefined | null, + /** Идентификатор протокола решения собрания совета */ + decision_id: number, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null, + /** Язык документа */ + lang?: string | undefined | null, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null, + /** Название документа */ + title?: string | undefined | null, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null +}; + ["MembershipExitResult"]: AliasType<{ + /** Хеш созданного процесса выхода */ + exit_hash?:boolean | `@${string}`, + /** Статус процесса выхода после подачи (ожидает подтверждения по ссылке из письма) */ + status?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; + ["MembershipExitReturnPreview"]: AliasType<{ + /** Минимальный паевой взнос пайщика */ + minimum_contribution?:boolean | `@${string}`, + /** Целевой паевой взнос пайщика */ + share_contribution?:boolean | `@${string}`, + /** Итоговая сумма к возврату (минимальный + целевой паевой) */ + total?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; + /** Статус процесса выхода пайщика из кооператива */ +["MembershipExitStatus"]:MembershipExitStatus; ["MissingRequisiteField"]: AliasType<{ key?:boolean | `@${string}`, label?:boolean | `@${string}`, @@ -15943,6 +16259,7 @@ addParticipant?: [{ data: ResolverInputTypes["AddParticipantInput"]},ResolverInp addPaymentMethod?: [{ data: ResolverInputTypes["AddPaymentMethodInput"]},ResolverInputTypes["PaymentMethod"]], addTrustedAccount?: [{ data: ResolverInputTypes["AddTrustedAccountInput"]},ResolverInputTypes["Branch"]], authorizeDecision?: [{ data: ResolverInputTypes["AuthorizeDecisionInput"]},ResolverInputTypes["Transaction"]], +cancelMembershipExit?: [{ coopname: string, username: string},boolean | `@${string}`], cancelRequest?: [{ data: ResolverInputTypes["CancelRequestInput"]},ResolverInputTypes["Transaction"]], capitalAddAuthor?: [{ data: ResolverInputTypes["AddAuthorInput"]},ResolverInputTypes["CapitalProject"]], capitalApproveCommit?: [{ data: ResolverInputTypes["CommitApproveInput"]},ResolverInputTypes["CapitalCommit"]], @@ -16039,6 +16356,7 @@ completeChairmanGeneralMeetStep?: [{ data: ResolverInputTypes["ChairmanOnboardin completeExtensionOnboardingStep?: [{ data: ResolverInputTypes["CompleteExtensionOnboardingStepInput"]},ResolverInputTypes["ExtensionOnboardingState"]], completeRequest?: [{ data: ResolverInputTypes["CompleteRequestInput"]},ResolverInputTypes["Transaction"]], confirmAgreement?: [{ data: ResolverInputTypes["ConfirmAgreementInput"]},ResolverInputTypes["Transaction"]], +confirmMembershipExit?: [{ token: string},ResolverInputTypes["MembershipExitResult"]], confirmReceiveOnRequest?: [{ data: ResolverInputTypes["ConfirmReceiveOnRequestInput"]},ResolverInputTypes["Transaction"]], confirmSupplyOnRequest?: [{ data: ResolverInputTypes["ConfirmSupplyOnRequestInput"]},ResolverInputTypes["Transaction"]], createAnnualGeneralMeet?: [{ data: ResolverInputTypes["CreateAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]], @@ -16047,6 +16365,7 @@ createChildOrder?: [{ data: ResolverInputTypes["CreateChildOrderInput"]},Resolve createDepositPayment?: [{ data: ResolverInputTypes["CreateDepositPaymentInput"]},ResolverInputTypes["GatewayPayment"]], createExpenseProposal?: [{ data: ResolverInputTypes["CreateExpenseProposalInput"]},ResolverInputTypes["Transaction"]], createInitialPayment?: [{ data: ResolverInputTypes["CreateInitialPaymentInput"]},ResolverInputTypes["GatewayPayment"]], +createMembershipExit?: [{ data: ResolverInputTypes["CreateMembershipExitInput"]},ResolverInputTypes["MembershipExitResult"]], createParentOffer?: [{ data: ResolverInputTypes["CreateParentOfferInput"]},ResolverInputTypes["Transaction"]], createProjectOfFreeDecision?: [{ data: ResolverInputTypes["CreateProjectFreeDecisionInput"]},ResolverInputTypes["CreatedProjectFreeDecision"]], createWebPushSubscription?: [{ data: ResolverInputTypes["CreateSubscriptionInput"]},ResolverInputTypes["CreateSubscriptionResponse"]], @@ -16075,6 +16394,8 @@ generateDocument?: [{ input: ResolverInputTypes["GenerateAnyDocumentInput"]},Res generateExpenseProposalDecisionDocument?: [{ data: ResolverInputTypes["ExpenseProposalDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], generateExpenseProposalStatementDocument?: [{ data: ResolverInputTypes["ExpenseProposalStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], generateFreeDecision?: [{ data: ResolverInputTypes["FreeDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], +generateMembershipExitApplication?: [{ data: ResolverInputTypes["MembershipExitApplicationGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], +generateMembershipExitDecision?: [{ data: ResolverInputTypes["MembershipExitDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], generateParticipantApplication?: [{ data: ResolverInputTypes["ParticipantApplicationGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], generateParticipantApplicationDecision?: [{ data: ResolverInputTypes["ParticipantApplicationDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], generatePrivacyAgreement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]], @@ -16140,6 +16461,7 @@ updateRequest?: [{ data: ResolverInputTypes["UpdateRequestInput"]},ResolverInput updateSettings?: [{ data: ResolverInputTypes["UpdateSettingsInput"]},ResolverInputTypes["Settings"]], updateSystem?: [{ data: ResolverInputTypes["Update"]},ResolverInputTypes["SystemInfo"]], uploadExpenseFile?: [{ data: ResolverInputTypes["UploadExpenseFileInput"]},ResolverInputTypes["ExpenseFile"]], +uploadPaymentProof?: [{ data: ResolverInputTypes["UploadPaymentProofInput"]},ResolverInputTypes["PaymentFile"]], verifyEmail?: [{ data: ResolverInputTypes["VerifyEmailInputDTO"]},boolean | `@${string}`], voteOnAnnualGeneralMeet?: [{ data: ResolverInputTypes["VoteOnAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]], walmoveWallets?: [{ input: ResolverInputTypes["WalmoveInput"]},ResolverInputTypes["Ledger2AdjustmentResult"]], @@ -16838,6 +17160,36 @@ walmoveWallets?: [{ input: ResolverInputTypes["WalmoveInput"]},ResolverInputType }>; /** Направление платежа */ ["PaymentDirection"]:PaymentDirection; + /** Запись о файле, приложенном к платежу (чек об оплате). */ +["PaymentFile"]: AliasType<{ + /** SHA-256 содержимого, hex-lowercase. */ + checksum_sha256?:boolean | `@${string}`, + /** Имя кооператива (scope). */ + coopname?:boolean | `@${string}`, + /** Внутренний ID записи. */ + id?:boolean | `@${string}`, + /** Назначение файла. */ + kind?:boolean | `@${string}`, + /** MIME-тип содержимого. */ + mime_type?:boolean | `@${string}`, + /** Оригинальное имя загруженного файла. */ + original_filename?:boolean | `@${string}`, + /** Хеш платежа. */ + payment_hash?:boolean | `@${string}`, + /** Короткоживущий URL на скачивание (HMAC-signed). */ + read_url?:boolean | `@${string}`, + /** Размер файла в байтах. */ + size_bytes?:boolean | `@${string}`, + /** MinIO-ключ внутри бакета. */ + storage_key?:boolean | `@${string}`, + /** Когда загружено. */ + uploaded_at?:boolean | `@${string}`, + /** Кто загрузил (username). */ + uploaded_by_username?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; + /** Тип файла, приложенного к платежу. */ +["PaymentFileKind"]:PaymentFileKind; ["PaymentFiltersInput"]: { /** Название кооператива */ coopname?: string | undefined | null, @@ -17538,7 +17890,11 @@ getUserWebPushSubscriptions?: [{ data: ResolverInputTypes["GetUserSubscriptionsI Требуемые роли: chairman. */ getWebPushSubscriptionStats?:ResolverInputTypes["SubscriptionStatsDto"], listReportDrafts?: [{ filter?: ResolverInputTypes["ListReportDraftsFilterInput"] | undefined | null},ResolverInputTypes["ReportDraft"]], +membershipExit?: [{ coopname: string, username: string},ResolverInputTypes["MembershipExit"]], +membershipExitReturnPreview?: [{ coopname: string, username: string},ResolverInputTypes["MembershipExitReturnPreview"]], onecoopGetDocuments?: [{ data: ResolverInputTypes["GetOneCoopDocumentsInput"]},ResolverInputTypes["OneCoopDocumentsResponse"]], +paymentFile?: [{ id: number},ResolverInputTypes["PaymentFile"]], +paymentProofs?: [{ coopname: string, payment_hash: string},ResolverInputTypes["PaymentFile"]], process?: [{ coopname: string, hash: string},ResolverInputTypes["ProcessView"]], processes?: [{ filter: ResolverInputTypes["ProcessesFilter"], pagination: ResolverInputTypes["PaginationInput"]},ResolverInputTypes["ProcessSummaryPaginationResult"]], searchDocuments?: [{ data: ResolverInputTypes["SearchDocumentsInput"]},ResolverInputTypes["SearchResult"]], @@ -19025,6 +19381,22 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo proposal_hash: string, /** Размер файла в байтах (для серверной валидации). */ size_bytes: number +}; + ["UploadPaymentProofInput"]: { + /** SHA-256 содержимого, hex-lowercase (64 hex-символа). */ + checksum_sha256: string, + /** Содержимое файла, base64 без префикса data:. */ + content_base64: string, + /** Имя кооператива. */ + coopname: string, + /** MIME-тип содержимого. */ + mime_type: string, + /** Оригинальное имя файла — для отображения и поиска. */ + original_filename?: string | undefined | null, + /** Хеш платежа, к которому прикладывается чек. */ + payment_hash: string, + /** Размер файла в байтах (для серверной валидации). */ + size_bytes: number }; ["UserAccount"]: AliasType<{ /** Метаинформация */ @@ -22556,6 +22928,16 @@ export type ModelTypes = { ["CreateMatrixAccountInputDTO"]: { password: string, username: string +}; + ["CreateMembershipExitInput"]: { + /** Имя аккаунта кооператива */ + coopname: string, + /** Хеш процесса выхода (генерируется на клиенте) */ + exit_hash: string, + /** Подписанное пайщиком заявление о выходе из кооператива */ + statement: ModelTypes["MembershipExitApplicationSignedDocumentInput"], + /** Имя пайщика, выходящего из кооператива */ + username: string }; ["CreateOrganizationDataInput"]: { /** Банковский счет организации */ @@ -24659,6 +25041,120 @@ export type ModelTypes = { /** Количество голосов за */ votes_for: number }; + ["MembershipExit"]: { + /** Дата подачи заявления на выход */ + created_at: string, + /** Хеш процесса выхода */ + exit_hash: string, + /** Статус исходящего платежа возврата паевого взноса в реестре кассира. Создаётся при одобрении советом; null — платёж ещё не заведён. */ + payment_status?: ModelTypes["PaymentStatus"] | undefined | null, + /** Сумма к возврату (фиксируется советом при одобрении; до одобрения — 0) */ + quantity: string, + /** Статус процесса выхода */ + status: ModelTypes["MembershipExitStatus"] +}; + ["MembershipExitApplicationGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at?: string | undefined | null, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null, + /** Язык документа */ + lang?: string | undefined | null, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null, + /** Название документа */ + title?: string | undefined | null, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null +}; + ["MembershipExitApplicationSignedDocumentInput"]: { + /** Хэш содержимого документа */ + doc_hash: string, + /** Общий хэш (doc_hash + meta_hash) */ + hash: string, + meta: ModelTypes["MembershipExitApplicationSignedMetaDocumentInput"], + /** Хэш мета-данных */ + meta_hash: string, + /** Вектор подписей */ + signatures: Array, + /** Версия стандарта документа */ + version: string +}; + ["MembershipExitApplicationSignedMetaDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num: number, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at: string, + /** Имя генератора, использованного для создания документа */ + generator: string, + /** Язык документа */ + lang: string, + /** Ссылки, связанные с документом */ + links: Array, + /** ID документа в реестре */ + registry_id: number, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean, + /** Часовой пояс, в котором был создан документ */ + timezone: string, + /** Название документа */ + title: string, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version: string +}; + ["MembershipExitDecisionGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at?: string | undefined | null, + /** Идентификатор протокола решения собрания совета */ + decision_id: number, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null, + /** Язык документа */ + lang?: string | undefined | null, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null, + /** Название документа */ + title?: string | undefined | null, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null +}; + ["MembershipExitResult"]: { + /** Хеш созданного процесса выхода */ + exit_hash: string, + /** Статус процесса выхода после подачи (ожидает подтверждения по ссылке из письма) */ + status: ModelTypes["MembershipExitStatus"] +}; + ["MembershipExitReturnPreview"]: { + /** Минимальный паевой взнос пайщика */ + minimum_contribution: string, + /** Целевой паевой взнос пайщика */ + share_contribution: string, + /** Итоговая сумма к возврату (минимальный + целевой паевой) */ + total: string +}; + ["MembershipExitStatus"]:MembershipExitStatus; ["MissingRequisiteField"]: { key: string, label: string, @@ -24728,6 +25224,8 @@ export type ModelTypes = { Требуемые роли: chairman. */ authorizeDecision: ModelTypes["Transaction"], + /** Отменить заявление на выход до подтверждения по email. */ + cancelMembershipExit: boolean, /** Отменить заявку */ cancelRequest: ModelTypes["Transaction"], /** Добавление автора проекта в CAPITAL контракте @@ -25092,6 +25590,8 @@ export type ModelTypes = { Требуемые роли: chairman, member. */ confirmAgreement: ModelTypes["Transaction"], + /** Подтвердить выход из кооператива по ссылке из письма. Проверяет токен и отправляет ранее подписанное заявление в блокчейн. */ + confirmMembershipExit: ModelTypes["MembershipExitResult"], /** Подтвердить получение имущества Уполномоченным лицом от Заказчика по новации и акту приёмки-передачи */ confirmReceiveOnRequest: ModelTypes["Transaction"], /** Подтвердить поставку имущества Поставщиком по заявке Заказчика и акту приёма-передачи */ @@ -25118,6 +25618,8 @@ export type ModelTypes = { Требуемые роли: chairman, member. */ createInitialPayment: ModelTypes["GatewayPayment"], + /** Подать подписанное заявление на выход из кооператива. Запускает рассмотрение советом и последующий возврат паевого взноса. */ + createMembershipExit: ModelTypes["MembershipExitResult"], /** Создать предложение на поставку имущества */ createParentOffer: ModelTypes["Transaction"], /** Создать повестку дня и проект решения, и сохранить в хранилище для дальнейшей генерации документа и его публикации @@ -25214,10 +25716,16 @@ export type ModelTypes = { Требуемые роли: chairman, member. */ generateExpenseProposalStatementDocument: ModelTypes["GeneratedDocument"], - /** Сгенерировать протокол решения по предложенной повестке + /** Сгенерировать протокол решения по предложенно�� повестке Требуемые роли: chairman, member. */ generateFreeDecision: ModelTypes["GeneratedDocument"], + /** Сгенерировать документ заявления о выходе из кооператива. */ + generateMembershipExitApplication: ModelTypes["GeneratedDocument"], + /** Сгенерировать документ решения собрания совета о выходе пайщика. + +Требуемые роли: chairman, member. */ + generateMembershipExitDecision: ModelTypes["GeneratedDocument"], /** Сгенерировать документ заявления о вступлении в кооператив. Требуемые роли: chairman, member. */ @@ -25434,6 +25942,10 @@ export type ModelTypes = { Требуемые роли: chairman, member, user. */ uploadExpenseFile: ModelTypes["ExpenseFile"], + /** Приложить чек об оплате к платежу (бакет gateway:files). + +Требуемые роли: chairman, member. */ + uploadPaymentProof: ModelTypes["PaymentFile"], /** Подтвердить email адрес пользователя */ verifyEmail: boolean, /** Голосование на общем собрании пайщиков @@ -26094,6 +26606,34 @@ export type ModelTypes = { tolerance_percent: number }; ["PaymentDirection"]:PaymentDirection; + /** Запись о файле, приложенном к платежу (чек об оплате). */ +["PaymentFile"]: { + /** SHA-256 содержимого, hex-lowercase. */ + checksum_sha256: string, + /** Имя кооператива (scope). */ + coopname: string, + /** Внутренний ID записи. */ + id: number, + /** Назначение файла. */ + kind: ModelTypes["PaymentFileKind"], + /** MIME-тип содержимого. */ + mime_type: string, + /** Оригинальное имя загруженного файла. */ + original_filename?: string | undefined | null, + /** Хеш платежа. */ + payment_hash: string, + /** Короткоживущий URL на скачивание (HMAC-signed). */ + read_url?: string | undefined | null, + /** Размер файла в байтах. */ + size_bytes: number, + /** MinIO-ключ внутри бакета. */ + storage_key: string, + /** Когда загружено. */ + uploaded_at: ModelTypes["DateTime"], + /** Кто загрузил (username). */ + uploaded_by_username: string +}; + ["PaymentFileKind"]:PaymentFileKind; ["PaymentFiltersInput"]: { /** Название кооператива */ coopname?: string | undefined | null, @@ -26979,8 +27519,20 @@ export type ModelTypes = { Требуемые роли: chairman. */ listReportDrafts: Array, + /** Текущий процесс выхода пайщика из кооператива (статус заявления и планируемая сумма возврата). null — активного выхода нет. */ + membershipExit?: ModelTypes["MembershipExit"] | undefined | null, + /** Предварительный расчёт суммы возврата паевого взноса при выходе пайщика (минимальный + целевой паевой). Ориентир для пайщика; итог фиксирует совет. */ + membershipExitReturnPreview: ModelTypes["MembershipExitReturnPreview"], /** Получение документов кооператива для синхронизации с 1С. Требует секретный ключ в заголовке x-onecoop-secret-key. */ onecoopGetDocuments: ModelTypes["OneCoopDocumentsResponse"], + /** Получить запись о файле платежа + свежий короткоживущий read-URL. + +Требуемые роли: chairman, member, user. */ + paymentFile: ModelTypes["PaymentFile"], + /** Список чеков об оплате платежа (без read-URL — запрос отдельно по id). + +Требуемые роли: chairman, member, user. */ + paymentProofs: Array, /** Получить полную картину процесса ledger2 по process_hash Требуемые роли: chairman, member. */ @@ -28434,6 +28986,22 @@ export type ModelTypes = { proposal_hash: string, /** Размер файла в байтах (для серверной валидации). */ size_bytes: number +}; + ["UploadPaymentProofInput"]: { + /** SHA-256 содержимого, hex-lowercase (64 hex-символа). */ + checksum_sha256: string, + /** Содержимое файла, base64 без префикса data:. */ + content_base64: string, + /** Имя кооператива. */ + coopname: string, + /** MIME-тип содержимого. */ + mime_type: string, + /** Оригинальное имя файла — для отображения и поиска. */ + original_filename?: string | undefined | null, + /** Хеш платежа, к которому прикладывается чек. */ + payment_hash: string, + /** Размер файла в байтах (для серверной валидации). */ + size_bytes: number }; ["UserAccount"]: { /** Метаинформация */ @@ -28642,10 +29210,7 @@ export type ModelTypes = { } export type GraphQLTypes = { - // ------------------------------------------------------; - // THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY); - // ------------------------------------------------------; - ["AcceptChildOrderInput"]: { + ["AcceptChildOrderInput"]: { /** Имя аккаунта кооператива */ coopname: string, /** Подписанное заявление на имущественный паевый взнос */ @@ -32133,6 +32698,16 @@ export type GraphQLTypes = { ["CreateMatrixAccountInputDTO"]: { password: string, username: string +}; + ["CreateMembershipExitInput"]: { + /** Имя аккаунта кооператива */ + coopname: string, + /** Хеш процесса выхода (генерируется на клиенте) */ + exit_hash: string, + /** Подписанное пайщиком заявление о выходе из кооператива */ + statement: GraphQLTypes["MembershipExitApplicationSignedDocumentInput"], + /** Имя пайщика, выходящего из кооператива */ + username: string }; ["CreateOrganizationDataInput"]: { /** Банковский счет организации */ @@ -34370,6 +34945,127 @@ export type GraphQLTypes = { votes_for: number, ['...on MeetQuestionResult']: Omit }; + ["MembershipExit"]: { + __typename: "MembershipExit", + /** Дата подачи заявления на выход */ + created_at: string, + /** Хеш процесса выхода */ + exit_hash: string, + /** Статус исходящего платежа возврата паевого взноса в реестре кассира. Создаётся при одобрении советом; null — платёж ещё не заведён. */ + payment_status?: GraphQLTypes["PaymentStatus"] | undefined | null, + /** Сумма к возврату (фиксируется советом при одобрении; до одобрения — 0) */ + quantity: string, + /** Статус процесса выхода */ + status: GraphQLTypes["MembershipExitStatus"], + ['...on MembershipExit']: Omit +}; + ["MembershipExitApplicationGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at?: string | undefined | null, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null, + /** Язык документа */ + lang?: string | undefined | null, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null, + /** Название документа */ + title?: string | undefined | null, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null +}; + ["MembershipExitApplicationSignedDocumentInput"]: { + /** Хэш содержимого документа */ + doc_hash: string, + /** Общий хэш (doc_hash + meta_hash) */ + hash: string, + meta: GraphQLTypes["MembershipExitApplicationSignedMetaDocumentInput"], + /** Хэш мета-данных */ + meta_hash: string, + /** Вектор подписей */ + signatures: Array, + /** Версия стандарта документа */ + version: string +}; + ["MembershipExitApplicationSignedMetaDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num: number, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at: string, + /** Имя генератора, использованного для создания документа */ + generator: string, + /** Язык документа */ + lang: string, + /** Ссылки, связанные с документом */ + links: Array, + /** ID документа в реестре */ + registry_id: number, + /** Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю) */ + skip_save: boolean, + /** Часовой пояс, в котором был создан документ */ + timezone: string, + /** Название документа */ + title: string, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version: string +}; + ["MembershipExitDecisionGenerateDocumentInput"]: { + /** Номер блока, на котором был создан документ */ + block_num?: number | undefined | null, + /** Название кооператива, связанное с документом */ + coopname: string, + /** Дата и время создания документа */ + created_at?: string | undefined | null, + /** Идентификатор протокола решения собрания совета */ + decision_id: number, + /** Имя генератора, использованного для создания документа */ + generator?: string | undefined | null, + /** Язык документа */ + lang?: string | undefined | null, + /** Ссылки, связанные с документом */ + links?: Array | undefined | null, + /** Часовой пояс, в котором был создан документ */ + timezone?: string | undefined | null, + /** Название документа */ + title?: string | undefined | null, + /** Имя пользователя, создавшего документ */ + username: string, + /** Версия генератора, использованного для создания документа */ + version?: string | undefined | null +}; + ["MembershipExitResult"]: { + __typename: "MembershipExitResult", + /** Хеш созданного процесса выхода */ + exit_hash: string, + /** Статус процесса выхода после подачи (ожидает подтверждения по ссылке из письма) */ + status: GraphQLTypes["MembershipExitStatus"], + ['...on MembershipExitResult']: Omit +}; + ["MembershipExitReturnPreview"]: { + __typename: "MembershipExitReturnPreview", + /** Минимальный паевой взнос пайщика */ + minimum_contribution: string, + /** Целевой паевой взнос пайщика */ + share_contribution: string, + /** Итоговая сумма к возврату (минимальный + целевой паевой) */ + total: string, + ['...on MembershipExitReturnPreview']: Omit +}; + /** Статус процесса выхода пайщика из кооператива */ +["MembershipExitStatus"]: MembershipExitStatus; ["MissingRequisiteField"]: { __typename: "MissingRequisiteField", key: string, @@ -34444,6 +35140,8 @@ export type GraphQLTypes = { Требуемые роли: chairman. */ authorizeDecision: GraphQLTypes["Transaction"], + /** Отменить заявление на выход до подтверждения по email. */ + cancelMembershipExit: boolean, /** Отменить заявку */ cancelRequest: GraphQLTypes["Transaction"], /** Добавление автора проекта в CAPITAL контракте @@ -34808,6 +35506,8 @@ export type GraphQLTypes = { Требуемые роли: chairman, member. */ confirmAgreement: GraphQLTypes["Transaction"], + /** Подтвердить выход из кооператива по ссылке из письма. Проверяет токен и отправляет ранее подписанное заявление в блокчейн. */ + confirmMembershipExit: GraphQLTypes["MembershipExitResult"], /** Подтвердить получение имущества Уполномоченным лицом от Заказчика по новации и акту приёмки-передачи */ confirmReceiveOnRequest: GraphQLTypes["Transaction"], /** Подтвердить поставку имущества Поставщиком по заявке Заказчика и акту приёма-передачи */ @@ -34834,6 +35534,8 @@ export type GraphQLTypes = { Требуемые роли: chairman, member. */ createInitialPayment: GraphQLTypes["GatewayPayment"], + /** Подать подписанное заявление на выход из кооператива. Запускает рассмотрение советом и последующий возврат паевого взноса. */ + createMembershipExit: GraphQLTypes["MembershipExitResult"], /** Создать предложение на поставку имущества */ createParentOffer: GraphQLTypes["Transaction"], /** Создать повестку дня и проект решения, и сохранить в хранилище для дальнейшей генерации документа и его публикации @@ -34930,10 +35632,16 @@ export type GraphQLTypes = { Требуемые роли: chairman, member. */ generateExpenseProposalStatementDocument: GraphQLTypes["GeneratedDocument"], - /** Сгенерировать протокол решения по предложенной повестке + /** Сгенерировать протокол решения по предложенно�� повестке Требуемые роли: chairman, member. */ generateFreeDecision: GraphQLTypes["GeneratedDocument"], + /** Сгенерировать документ заявления о выходе из кооператива. */ + generateMembershipExitApplication: GraphQLTypes["GeneratedDocument"], + /** Сгенерировать документ решения собрания совета о выходе пайщика. + +Требуемые роли: chairman, member. */ + generateMembershipExitDecision: GraphQLTypes["GeneratedDocument"], /** Сгенерировать документ заявления о вступлении в кооператив. Требуемые роли: chairman, member. */ @@ -35150,6 +35858,10 @@ export type GraphQLTypes = { Требуемые роли: chairman, member, user. */ uploadExpenseFile: GraphQLTypes["ExpenseFile"], + /** Приложить чек об оплате к платежу (бакет gateway:files). + +Требуемые роли: chairman, member. */ + uploadPaymentProof: GraphQLTypes["PaymentFile"], /** Подтвердить email адрес пользователя */ verifyEmail: boolean, /** Голосование на общем собрании пайщиков @@ -35893,6 +36605,37 @@ export type GraphQLTypes = { }; /** Направление платежа */ ["PaymentDirection"]: PaymentDirection; + /** Запись о файле, приложенном к платежу (чек об оплате). */ +["PaymentFile"]: { + __typename: "PaymentFile", + /** SHA-256 содержимого, hex-lowercase. */ + checksum_sha256: string, + /** Имя кооператива (scope). */ + coopname: string, + /** Внутренний ID записи. */ + id: number, + /** Назначение файла. */ + kind: GraphQLTypes["PaymentFileKind"], + /** MIME-тип содержимого. */ + mime_type: string, + /** Оригинальное имя загруженного файла. */ + original_filename?: string | undefined | null, + /** Хеш платежа. */ + payment_hash: string, + /** Короткоживущий URL на скачивание (HMAC-signed). */ + read_url?: string | undefined | null, + /** Размер файла в байтах. */ + size_bytes: number, + /** MinIO-ключ внутри бакета. */ + storage_key: string, + /** Когда загружено. */ + uploaded_at: GraphQLTypes["DateTime"], + /** Кто загрузил (username). */ + uploaded_by_username: string, + ['...on PaymentFile']: Omit +}; + /** Тип файла, приложенного к платежу. */ +["PaymentFileKind"]: PaymentFileKind; ["PaymentFiltersInput"]: { /** Название кооператива */ coopname?: string | undefined | null, @@ -36841,8 +37584,20 @@ export type GraphQLTypes = { Требуемые роли: chairman. */ listReportDrafts: Array, + /** Текущий процесс выхода пайщика из кооператива (статус заявления и планируемая сумма возврата). null — активного выхода нет. */ + membershipExit?: GraphQLTypes["MembershipExit"] | undefined | null, + /** Предварительный расчёт суммы возврата паевого взноса при выходе пайщика (минимальный + целевой паевой). Ориентир для пайщика; итог фиксирует совет. */ + membershipExitReturnPreview: GraphQLTypes["MembershipExitReturnPreview"], /** Получение документов кооператива для синхронизации с 1С. Требует секретный ключ в заголовке x-onecoop-secret-key. */ onecoopGetDocuments: GraphQLTypes["OneCoopDocumentsResponse"], + /** Получить запись о файле платежа + свежий короткоживущий read-URL. + +Требуемые роли: chairman, member, user. */ + paymentFile: GraphQLTypes["PaymentFile"], + /** Список чеков об оплате платежа (без read-URL — запрос отдельно по id). + +Требуемые роли: chairman, member, user. */ + paymentProofs: Array, /** Получить полную картину процесса ledger2 по process_hash Требуемые роли: chairman, member. */ @@ -38380,6 +39135,22 @@ export type GraphQLTypes = { proposal_hash: string, /** Размер файла в байтах (для серверной валидации). */ size_bytes: number +}; + ["UploadPaymentProofInput"]: { + /** SHA-256 содержимого, hex-lowercase (64 hex-символа). */ + checksum_sha256: string, + /** Содержимое файла, base64 без префикса data:. */ + content_base64: string, + /** Имя кооператива. */ + coopname: string, + /** MIME-тип содержимого. */ + mime_type: string, + /** Оригинальное имя файла — для отображения и поиска. */ + original_filename?: string | undefined | null, + /** Хеш платежа, к которому прикладывается чек. */ + payment_hash: string, + /** Размер файла в байтах (для серверной валидации). */ + size_bytes: number }; ["UserAccount"]: { __typename: "UserAccount", @@ -38872,6 +39643,13 @@ export enum ManagedRoomKind { MEMBERS = "MEMBERS", SECRETARY = "SECRETARY" } +/** Статус процесса выхода пайщика из кооператива */ +export enum MembershipExitStatus { + AUTHORIZED = "AUTHORIZED", + AWAITING_CONFIRMATION = "AWAITING_CONFIRMATION", + COMPLETED = "COMPLETED", + PENDING = "PENDING" +} /** Тип комнаты вне проекта: пайщики, совет, комната секретаря */ export enum NonProjectRoomKind { COUNCIL = "COUNCIL", @@ -38912,6 +39690,10 @@ export enum PaymentDirection { INCOMING = "INCOMING", OUTGOING = "OUTGOING" } +/** Тип файла, приложенного к платежу. */ +export enum PaymentFileKind { + PAYMENT_PROOF = "PAYMENT_PROOF" +} /** Статус платежа */ export enum PaymentStatus { AWAITING_AUTHORIZATION = "AWAITING_AUTHORIZATION", @@ -38930,6 +39712,7 @@ export enum PaymentType { EXPENSE = "EXPENSE", EXPENSE_OVERSPEND = "EXPENSE_OVERSPEND", EXPENSE_RETURN = "EXPENSE_RETURN", + MEMBERSHIP_EXIT = "MEMBERSHIP_EXIT", REGISTRATION = "REGISTRATION", REGISTRATION_REFUND = "REGISTRATION_REFUND", WITHDRAWAL = "WITHDRAWAL" @@ -39164,6 +39947,7 @@ type ZEUS_VARIABLES = { ["CreateInitialPaymentInput"]: ValueTypes["CreateInitialPaymentInput"]; ["CreateIssueInput"]: ValueTypes["CreateIssueInput"]; ["CreateMatrixAccountInputDTO"]: ValueTypes["CreateMatrixAccountInputDTO"]; + ["CreateMembershipExitInput"]: ValueTypes["CreateMembershipExitInput"]; ["CreateOrganizationDataInput"]: ValueTypes["CreateOrganizationDataInput"]; ["CreateParentOfferInput"]: ValueTypes["CreateParentOfferInput"]; ["CreateProcessTemplateInput"]: ValueTypes["CreateProcessTemplateInput"]; @@ -39293,6 +40077,11 @@ type ZEUS_VARIABLES = { ["MakeClearanceInput"]: ValueTypes["MakeClearanceInput"]; ["ManagedRoomKind"]: ValueTypes["ManagedRoomKind"]; ["MarkReportPeriodInput"]: ValueTypes["MarkReportPeriodInput"]; + ["MembershipExitApplicationGenerateDocumentInput"]: ValueTypes["MembershipExitApplicationGenerateDocumentInput"]; + ["MembershipExitApplicationSignedDocumentInput"]: ValueTypes["MembershipExitApplicationSignedDocumentInput"]; + ["MembershipExitApplicationSignedMetaDocumentInput"]: ValueTypes["MembershipExitApplicationSignedMetaDocumentInput"]; + ["MembershipExitDecisionGenerateDocumentInput"]: ValueTypes["MembershipExitDecisionGenerateDocumentInput"]; + ["MembershipExitStatus"]: ValueTypes["MembershipExitStatus"]; ["ModerateRequestInput"]: ValueTypes["ModerateRequestInput"]; ["MoveCapitalIssueToComponentInput"]: ValueTypes["MoveCapitalIssueToComponentInput"]; ["NonProjectRoomKind"]: ValueTypes["NonProjectRoomKind"]; @@ -39314,6 +40103,7 @@ type ZEUS_VARIABLES = { ["PassportInput"]: ValueTypes["PassportInput"]; ["PayExpenseItemInput"]: ValueTypes["PayExpenseItemInput"]; ["PaymentDirection"]: ValueTypes["PaymentDirection"]; + ["PaymentFileKind"]: ValueTypes["PaymentFileKind"]; ["PaymentFiltersInput"]: ValueTypes["PaymentFiltersInput"]; ["PaymentStatus"]: ValueTypes["PaymentStatus"]; ["PaymentType"]: ValueTypes["PaymentType"]; @@ -39429,6 +40219,7 @@ type ZEUS_VARIABLES = { ["UpdateSettingsInput"]: ValueTypes["UpdateSettingsInput"]; ["UpdateStoryInput"]: ValueTypes["UpdateStoryInput"]; ["UploadExpenseFileInput"]: ValueTypes["UploadExpenseFileInput"]; + ["UploadPaymentProofInput"]: ValueTypes["UploadPaymentProofInput"]; ["UserStatus"]: ValueTypes["UserStatus"]; ["VarsInput"]: ValueTypes["VarsInput"]; ["VerifyEmailInputDTO"]: ValueTypes["VerifyEmailInputDTO"]; diff --git a/components/cooptypes/scripts/gen-from-cpp.ts b/components/cooptypes/scripts/gen-from-cpp.ts index 05f1939cabc..fdf525fcc4c 100644 --- a/components/cooptypes/scripts/gen-from-cpp.ts +++ b/components/cooptypes/scripts/gen-from-cpp.ts @@ -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\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})`) diff --git a/components/cooptypes/src/contracts/registrator/actions/exitCoop.ts b/components/cooptypes/src/contracts/registrator/actions/exitCoop.ts new file mode 100644 index 00000000000..fe5531d2298 --- /dev/null +++ b/components/cooptypes/src/contracts/registrator/actions/exitCoop.ts @@ -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 diff --git a/components/cooptypes/src/contracts/registrator/actions/index.ts b/components/cooptypes/src/contracts/registrator/actions/index.ts index cd02d897db2..299e76f1ff7 100644 --- a/components/cooptypes/src/contracts/registrator/actions/index.ts +++ b/components/cooptypes/src/contracts/registrator/actions/index.ts @@ -28,6 +28,11 @@ export * as CreateAccount from './createAccount' */ export * as RegisterUser from './registerUser' +/** + * Действие подачи заявления на выход пайщика из кооператива (возврат паевого взноса) + */ +export * as ExitCoop from './exitCoop' + /** * Действие регистрации карточки организации в кооперативе */ diff --git a/components/cooptypes/src/contracts/registrator/tables/exits.ts b/components/cooptypes/src/contracts/registrator/tables/exits.ts new file mode 100644 index 00000000000..0178e28e1d1 --- /dev/null +++ b/components/cooptypes/src/contracts/registrator/tables/exits.ts @@ -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 diff --git a/components/cooptypes/src/contracts/registrator/tables/index.ts b/components/cooptypes/src/contracts/registrator/tables/index.ts index 756f08c7e18..4a14405ea2b 100644 --- a/components/cooptypes/src/contracts/registrator/tables/index.ts +++ b/components/cooptypes/src/contracts/registrator/tables/index.ts @@ -8,3 +8,8 @@ export * as Accounts from './accounts' * Таблица содержит реестр кооперативов системы. */ export * as Cooperatives from './cooperatives' + +/** + * Таблица содержит реестр заявлений пайщиков на выход из кооператива. + */ +export * as Exits from './exits' diff --git a/components/cooptypes/src/cooperative/registry/200.ParticipantExitApplication/index.ts b/components/cooptypes/src/cooperative/registry/200.ParticipantExitApplication/index.ts new file mode 100644 index 00000000000..b7f5d11cbb6 --- /dev/null +++ b/components/cooptypes/src/cooperative/registry/200.ParticipantExitApplication/index.ts @@ -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 = ` +
+ +{% if type == 'individual' %} +

{% trans 'application_exit_individual' %}

+

{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}

+

{{ meta.created_at }}, {{ coop.city }}

+

{% 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 %}

+

{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}

+

{% trans 'obligation_to_settle_individual' %}

+
+

{% trans 'signed_electronically' %}

+

{{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}

+
+ +{% elif type == 'entrepreneur' %} +

{% trans 'application_exit_entrepreneur' %}

+

{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}

+

{{ meta.created_at }}, {{ coop.city }}

+

{% 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 %}

+

{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}

+

{% trans 'obligation_to_settle_individual' %}

+
+

{% trans 'signed_electronically' %}

+

{{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}

+
+ +{% elif type == 'organization' %} +

{% trans 'application_exit_legal_entity' %}

+

{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}

+

{{ meta.created_at }}, {{ coop.city }}

+

{% 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 %}

+

{% 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 %}

+

{% trans 'obligation_to_settle_legal_entity' %}

+
+

{% trans 'signed_electronically' %}

+

{{ organization.represented_by.last_name }} {{ organization.represented_by.first_name }} {{ organization.represented_by.middle_name }}

+
+ +{% endif %} +
` + +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', + }, +} diff --git a/components/cooptypes/src/cooperative/registry/201.DecisionOfParticipantExit/index.ts b/components/cooptypes/src/cooperative/registry/201.DecisionOfParticipantExit/index.ts new file mode 100644 index 00000000000..68a48f8d8e5 --- /dev/null +++ b/components/cooptypes/src/cooperative/registry/201.DecisionOfParticipantExit/index.ts @@ -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 = '\n\n
\n

{% trans \'protocol_number\', decision.id %}

\n

{% trans \'council_meeting_name\' %} {{vars.full_abbr_genitive}} "{{vars.name}}"

\n

{{ meta.created_at }}, {{ coop.city }}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
{% trans \'meeting_format\' %}{% trans \'meeting_format_value\' %}
{% trans \'meeting_place\' %}{{ coop.full_address }}
{% trans \'meeting_date\' %}{{ decision.date }}
{% trans \'opening_time\' %}{{ decision.time }}
\n

{% trans \'council_members\' %}

\n \n \n {% for member in coop.members %}\n \n \n \n\n {% endfor %}\n \n
{% if member.is_chairman %}{% trans \'chairman_of_the_council\' %}{% else %}{% trans \'member_of_the_council\' %}{% endif %}{{ member.last_name }} {{ member.first_name }} {{ member.middle_name }}
\n

{% trans \'meeting_legality\' %}

\n

{% 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 %}.

\n

{% trans \'agenda\' %}

\n \n \n \n \n \n \n \n \n \n \n \n
{% trans \'question\' %}
{% trans \'agenda_item\' %}{% 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
\n

{% trans \'voting\' %}

\n

{% trans \'vote_results\' %}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
{% trans \'votes_for\' %}{{ decision.votes_for }}
{% trans \'votes_against\' %}{{ decision.votes_against }}
{% trans \'votes_abstained\' %}{{ decision.votes_abstained }}
\n

{% trans \'decision_made\' %}

\n \n \n \n \n \n \n \n\n \n \n \n \n
{% trans \'question\' %}
{% trans \'decision\' %}{% 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
\n
\n

{% trans \'closing_time\', decision.time %}

\n

{% trans \'signature\' %}

{% trans \'chairman\' %} {{ coop.chairman.last_name }} {{ coop.chairman.first_name }} {{ coop.chairman.middle_name }}

\n
\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: 'ВОСХОД', + }, +} diff --git a/components/cooptypes/src/cooperative/registry/index.ts b/components/cooptypes/src/cooperative/registry/index.ts index 1536788db6d..dcabb437a4c 100644 --- a/components/cooptypes/src/cooperative/registry/index.ts +++ b/components/cooptypes/src/cooperative/registry/index.ts @@ -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' diff --git a/components/cooptypes/src/interfaces/registrator.ts b/components/cooptypes/src/interfaces/registrator.ts index 45726307259..23f8461ec5a 100644 --- a/components/cooptypes/src/interfaces/registrator.ts +++ b/components/cooptypes/src/interfaces/registrator.ts @@ -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 { } diff --git a/components/cooptypes/src/interfaces/soviet.ts b/components/cooptypes/src/interfaces/soviet.ts index 6b988c2703c..a9218bd22b9 100644 --- a/components/cooptypes/src/interfaces/soviet.ts +++ b/components/cooptypes/src/interfaces/soviet.ts @@ -343,6 +343,11 @@ export interface IDeletebranch { braname: IName } +export interface IDelpartcpnt { + coopname: IName + username: IName +} + export interface IDisableprog { coopname: IName program_id: IUint64 diff --git a/components/cooptypes/src/ledger2/operations.ts b/components/cooptypes/src/ledger2/operations.ts index 308df56e06c..eaa9cf68939 100644 --- a/components/cooptypes/src/ledger2/operations.ts +++ b/components/cooptypes/src/ledger2/operations.ts @@ -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( - 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) } diff --git a/components/cooptypes/src/ledger2/wallets.generated.ts b/components/cooptypes/src/ledger2/wallets.generated.ts index 332fbc56443..ab57eb4928e 100644 --- a/components/cooptypes/src/ledger2/wallets.generated.ts +++ b/components/cooptypes/src/ledger2/wallets.generated.ts @@ -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 diff --git a/components/cooptypes/src/ledger2/wallets.ts b/components/cooptypes/src/ledger2/wallets.ts index 5e63034cf29..303783e2169 100644 --- a/components/cooptypes/src/ledger2/wallets.ts +++ b/components/cooptypes/src/ledger2/wallets.ts @@ -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`). diff --git a/components/cooptypes/test/__snapshots__/wallets-registry.snapshot.test.ts.snap b/components/cooptypes/test/__snapshots__/wallets-registry.snapshot.test.ts.snap index 4902aa3a9b0..8212afb04b6 100644 --- a/components/cooptypes/test/__snapshots__/wallets-registry.snapshot.test.ts.snap +++ b/components/cooptypes/test/__snapshots__/wallets-registry.snapshot.test.ts.snap @@ -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", + }, ] `; diff --git a/components/cooptypes/test/wallets-registry.snapshot.test.ts b/components/cooptypes/test/wallets-registry.snapshot.test.ts index 9ef3e1490d6..86a98a132ef 100644 --- a/components/cooptypes/test/wallets-registry.snapshot.test.ts +++ b/components/cooptypes/test/wallets-registry.snapshot.test.ts @@ -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) + }) }) diff --git a/components/desktop/extensions/participant/install.ts b/components/desktop/extensions/participant/install.ts index deabe3c5ab1..644cb309e90 100644 --- a/components/desktop/extensions/participant/install.ts +++ b/components/desktop/extensions/participant/install.ts @@ -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 { 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 { { 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 { { meta: { title: 'Реквизиты', - icon: 'fas fa-link', + icon: 'account_balance', roles: ['user', 'member', 'chairman'], requiresAuth: true, }, @@ -161,6 +179,20 @@ export default async function (): Promise { 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: 'Поддержка', diff --git a/components/desktop/src/app/App.vue b/components/desktop/src/app/App.vue index f6ca38df18e..c8dd0d8c789 100644 --- a/components/desktop/src/app/App.vue +++ b/components/desktop/src/app/App.vue @@ -4,6 +4,7 @@ RequireAgreements SelectBranchOverlay + ExitOverlay NotificationPermissionDialog @@ -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, diff --git a/components/desktop/src/entities/Account/lib/accountStatus.ts b/components/desktop/src/entities/Account/lib/accountStatus.ts index ba89f74c20a..10c17a7f471 100644 --- a/components/desktop/src/entities/Account/lib/accountStatus.ts +++ b/components/desktop/src/entities/Account/lib/accountStatus.ts @@ -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 — diff --git a/components/desktop/src/features/Membership/ExitFromCoop/index.ts b/components/desktop/src/features/Membership/ExitFromCoop/index.ts new file mode 100644 index 00000000000..c51f0c574ac --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/index.ts @@ -0,0 +1,2 @@ +export * from './ui'; +export * from './model'; diff --git a/components/desktop/src/features/Membership/ExitFromCoop/model/index.ts b/components/desktop/src/features/Membership/ExitFromCoop/model/index.ts new file mode 100644 index 00000000000..03ad7dcccef --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/model/index.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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 { + 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 { + 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 { + return generateMembershipExitApplication({ + username: session.username, + skip_save: false, + }); + } + + /** + * Шаг 2: подписывает показанный документ приватным ключом пайщика и подаёт + * заявление. На бэкенде заявление принимается и уходит письмо с подтверждением — + * в блокчейн отправится только после перехода по ссылке (confirmExit). + */ + async function submitSignedApplication( + document: IGenerateMembershipExitApplicationResult, + ): Promise { + const exit_hash = await generateUniqueHash(); + + const digitalDocument = new DigitalDocument(document); + const signedDocument = + await digitalDocument.sign( + session.username, + ); + + return createMembershipExit({ + exit_hash, + statement: signedDocument, + }); + } + + /** + * Подтверждение выхода по токену из письма — отправляет ранее подписанное + * заявление в блокчейн. + */ + async function confirmExit(token: string): Promise { + 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, + }; +} diff --git a/components/desktop/src/features/Membership/ExitFromCoop/model/useExitDialog.ts b/components/desktop/src/features/Membership/ExitFromCoop/model/useExitDialog.ts new file mode 100644 index 00000000000..6cf65a3e5db --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/model/useExitDialog.ts @@ -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, + }; +} diff --git a/components/desktop/src/features/Membership/ExitFromCoop/model/useExitGate.ts b/components/desktop/src/features/Membership/ExitFromCoop/model/useExitGate.ts new file mode 100644 index 00000000000..cfcc50d9716 --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/model/useExitGate.ts @@ -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(null); +const previewTotal = ref(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 { + if (!session.username) return; + await client.Mutation(Mutations.MembershipExit.CancelMembershipExit.mutation, { + variables: { + coopname: info.coopname, + username: session.username, + }, + }); + await loadExitStatus(); + } + + /** + * Загружает текущий статус выхода пайщика (и предрасчёт суммы — для отображения + * планируемого платежа, пока совет не зафиксировал итог). + */ + async function loadExitStatus(): Promise { + 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 { + 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(() => { + if (exitStatus.value && hasFixedAmount(exitStatus.value.quantity)) { + return exitStatus.value.quantity; + } + return previewTotal.value; + }); + + return { + exitStatus, + isExitActive, + isAwaitingConfirmation, + paymentStatus, + plannedAmount, + loaded, + loadExitStatus, + cancelExit, + }; +} diff --git a/components/desktop/src/features/Membership/ExitFromCoop/ui/ExitButton.vue b/components/desktop/src/features/Membership/ExitFromCoop/ui/ExitButton.vue new file mode 100644 index 00000000000..617a98bbdce --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/ui/ExitButton.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/components/desktop/src/features/Membership/ExitFromCoop/ui/ExitOverlay.vue b/components/desktop/src/features/Membership/ExitFromCoop/ui/ExitOverlay.vue new file mode 100644 index 00000000000..65d282abf49 --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/ui/ExitOverlay.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/components/desktop/src/features/Membership/ExitFromCoop/ui/index.ts b/components/desktop/src/features/Membership/ExitFromCoop/ui/index.ts new file mode 100644 index 00000000000..10201c092f1 --- /dev/null +++ b/components/desktop/src/features/Membership/ExitFromCoop/ui/index.ts @@ -0,0 +1,2 @@ +export { default as ExitButton } from './ExitButton.vue'; +export { default as ExitOverlay } from './ExitOverlay.vue'; diff --git a/components/desktop/src/features/Membership/GenerateMembershipExitDecision/index.ts b/components/desktop/src/features/Membership/GenerateMembershipExitDecision/index.ts new file mode 100644 index 00000000000..9f8ccaddf53 --- /dev/null +++ b/components/desktop/src/features/Membership/GenerateMembershipExitDecision/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/components/desktop/src/features/Membership/GenerateMembershipExitDecision/model/index.ts b/components/desktop/src/features/Membership/GenerateMembershipExitDecision/model/index.ts new file mode 100644 index 00000000000..e63f8ae0999 --- /dev/null +++ b/components/desktop/src/features/Membership/GenerateMembershipExitDecision/model/index.ts @@ -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, + ): Promise { + 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, + }; +} diff --git a/components/desktop/src/features/Payment/AttachExpenseProof/api/index.ts b/components/desktop/src/features/Payment/AttachExpenseProof/api/index.ts index dc43e24c1e5..ab279f0169e 100644 --- a/components/desktop/src/features/Payment/AttachExpenseProof/api/index.ts +++ b/components/desktop/src/features/Payment/AttachExpenseProof/api/index.ts @@ -25,9 +25,9 @@ async function loadExpenseFilesByItem( return result; } -// Механика строки расхода (ADVANCE/DIRECT) нужна, чтобы решить, какие документы -// прикладывает кассир: по авансу — только платёжка, по оплате организации — -// ещё и закрывающие документы (акт/счёт-фактура/накладная). +// Механика строки расхода (ADVANCE/DIRECT) нужна, чтобы решить, показывать ли +// закрывающие документы: чек об оплате теперь общий (в ядре), а по оплате +// организации (DIRECT) — ещё и закрывающие документы (акт/счёт-фактура/накладная). async function loadItemMechanics( proposal_hash: string, item_hash: string, diff --git a/components/desktop/src/features/Payment/AttachExpenseProof/ui/AttachExpenseProofPanel.vue b/components/desktop/src/features/Payment/AttachExpenseProof/ui/AttachExpenseProofPanel.vue index f607cc3f6be..bbfc09d4d5f 100644 --- a/components/desktop/src/features/Payment/AttachExpenseProof/ui/AttachExpenseProofPanel.vue +++ b/components/desktop/src/features/Payment/AttachExpenseProof/ui/AttachExpenseProofPanel.vue @@ -1,34 +1,9 @@