Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74dce675b9 | |||
| 45feef2676 | |||
| 02ce9400a2 | |||
| bfa2830650 | |||
| c7824545cf | |||
| b9107e9ee9 | |||
| 4d151befe0 | |||
| eb16d85abc | |||
| ee5f7e1850 | |||
| 10bacaad13 | |||
| 1f68169b94 | |||
| 95891b2319 | |||
| f41f1444b1 | |||
| da450ec605 | |||
| 7fdfd8385a | |||
| 39433753c2 | |||
| 560321853c | |||
| f42b703098 | |||
| d7e08785eb | |||
| 7efd19f1bd | |||
| cce099748a | |||
| 3d04f76446 | |||
| 532e34c53b | |||
| a0e0c9310c | |||
| 0d51973a7b | |||
| 9c2729d243 | |||
| 00e15be65e | |||
| a349cfcf6a | |||
| 07ec6d9a5b | |||
| 102eeb319e | |||
| 2f1e078fe2 | |||
| 554798b74b | |||
| 0ae8702ade | |||
| 2d71706840 | |||
| b8568eff4c | |||
| 448eb1de80 | |||
| 8fae69d979 | |||
| 1b1b8b3246 | |||
| 72a8a3d0c3 | |||
| 56cd422440 | |||
| 949b5139af | |||
| 50d5d71c8d | |||
| 8d9bc5f592 | |||
| 82ea0a4c03 | |||
| b728bc6295 | |||
| 59585dc4ea | |||
| 6bba3c7b76 | |||
| 9ee57d1e05 | |||
| 0c995b1bca | |||
| 7ab0c31878 | |||
| cabc589ce8 | |||
| c3086277f4 | |||
| 5aea77bdcd | |||
| 3ac1b168d7 | |||
| 2e42cac14c | |||
| c1a940d1cd | |||
| 90f3a892a6 | |||
| 941030f58f | |||
| dbda7ca5ba | |||
| 93e13d6a14 | |||
| afeb728bec | |||
| ee60ece571 |
@@ -33,7 +33,22 @@ on:
|
||||
branches: [testnet, main]
|
||||
paths:
|
||||
- 'lerna.json'
|
||||
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
|
||||
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
|
||||
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Куда деплоить'
|
||||
type: choice
|
||||
options:
|
||||
- testnet
|
||||
- main
|
||||
required: true
|
||||
ref:
|
||||
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -50,13 +65,22 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
|
||||
# (пусто = верхушка ветки окружения, на которой запущен workflow).
|
||||
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
|
||||
|
||||
- name: Resolve branch, build mode and version
|
||||
id: resolve
|
||||
run: |
|
||||
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
# Окружение: при ручном workflow_dispatch — из inputs.environment;
|
||||
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
|
||||
# из реально вычекнутого дерева, не из ветки-триггера.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
BRANCH="${{ github.event.inputs.environment }}"
|
||||
else
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
fi
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
|
||||
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/binary_extension.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
@@ -39,11 +41,19 @@ namespace Capital {
|
||||
asset program_membership_distributed = asset(0, _root_govern_symbol); ///< Распределенная сумма членских взносов по программе
|
||||
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
|
||||
|
||||
asset program_expense_pool = asset(0, _root_govern_symbol); ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
asset program_expense_reserved = asset(0, _root_govern_symbol); ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
|
||||
config config; ///< Управляемая конфигурация контракта
|
||||
|
||||
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
|
||||
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
|
||||
// struct и только через binary_extension — иначе ломается десериализация
|
||||
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
|
||||
// перед config напрямую (без extension) и задеплоены на прод — таблица state
|
||||
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
|
||||
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
|
||||
// пустые), чтобы порядок хвостовых extension оставался консистентным.
|
||||
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
|
||||
};
|
||||
|
||||
@@ -53,6 +63,16 @@ namespace Capital {
|
||||
|
||||
|
||||
namespace State {
|
||||
|
||||
/**
|
||||
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
|
||||
* ещё не материализовано (старая запись, созданная до добавления
|
||||
* program_expense_* через binary_extension).
|
||||
*/
|
||||
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
|
||||
return v.has_value() ? v.value() : asset(0, sym);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Обновляет глобальное состояние новыми значениями.
|
||||
*
|
||||
@@ -103,8 +123,12 @@ inline void topup_program_expense_pool(eosio::name coopname, const asset &amount
|
||||
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
s.global_available_invest_pool -= amount;
|
||||
s.program_expense_pool += amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,12 +141,16 @@ inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
|
||||
eosio::check(itr->program_expense_pool >= amount,
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_pool -= amount;
|
||||
s.program_expense_reserved += amount;
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
reserved += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,12 +163,16 @@ inline void release_program_expense(eosio::name coopname, const asset &amount) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
|
||||
eosio::check(itr->program_expense_reserved >= amount,
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_reserved -= amount;
|
||||
s.program_expense_pool += amount;
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,11 +186,15 @@ inline void consume_program_expense(eosio::name coopname, const asset &amount) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(itr->program_expense_reserved >= amount,
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_reserved -= amount;
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,11 +208,15 @@ inline void spend_program_expense_pool(eosio::name coopname, const asset &amount
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(itr->program_expense_pool >= amount,
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_pool -= amount;
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
}// namespace State
|
||||
|
||||
@@ -85,7 +85,8 @@ static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелё
|
||||
|
||||
static const std::set<eosio::name> soviet_actions = {
|
||||
"joincoop"_n, //регистрация пайщика
|
||||
|
||||
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
|
||||
|
||||
//MEET
|
||||
"creategm"_n,//предложение повестки планового общего собрание
|
||||
"completegm"_n, //решение общего собрания пайщиков
|
||||
|
||||
@@ -61,6 +61,7 @@ namespace operations {
|
||||
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
|
||||
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
|
||||
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
|
||||
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
|
||||
}
|
||||
|
||||
// wallet
|
||||
@@ -242,6 +243,15 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат регистрационного взноса при отказе совета" },
|
||||
|
||||
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
|
||||
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
|
||||
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
|
||||
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
|
||||
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
|
||||
0, 0,
|
||||
"Перенос минимального паевого на главный при выходе из кооператива" },
|
||||
|
||||
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
|
||||
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
|
||||
@@ -252,6 +252,37 @@ inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
|
||||
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
|
||||
*
|
||||
* Единый источник истины для суммы возврата. При одобрении выхода советом
|
||||
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
|
||||
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
|
||||
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
|
||||
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
|
||||
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
|
||||
* тем, что реально вернёт контракт.
|
||||
*
|
||||
* Состав — только паевые/возвратные USER_SHARED-кошельки:
|
||||
* w.reg.minshr — минимальный паевой взнос;
|
||||
* w.wal.share — целевой паевой взнос (ЦК);
|
||||
* w.cap.blago — паевой взнос в ЦПП «Благорост».
|
||||
*
|
||||
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
|
||||
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
|
||||
* w.cap.preimp (пред-импорт-учёт РИД).
|
||||
*
|
||||
* Каждому не-главному кошельку сета должна соответствовать операция переноса
|
||||
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
|
||||
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
|
||||
*/
|
||||
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
|
||||
ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_wallets::BLAGOROST_FUND,
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Возвращает required program_id для USER_SHARED-кошелька.
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "table_registrator_verification.hpp"
|
||||
#include "table_registrator_candidates.hpp"
|
||||
#include "table_registrator_candidates_legacy.hpp"
|
||||
#include "table_registrator_exits.hpp"
|
||||
|
||||
// coops (soviet)
|
||||
#include "coops_access_helpers.hpp"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "document_core.hpp"
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @ingroup public_tables
|
||||
* @ingroup public_registrator_tables
|
||||
* @par table: exits (registrator)
|
||||
*
|
||||
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
|
||||
* процесс возврата паевого взноса.
|
||||
*
|
||||
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
|
||||
* wallet::createwthd→authwthd→completewthd):
|
||||
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
|
||||
* - confirmexit → совет одобрил: статус `authorized`, консолидация
|
||||
* минимального паевого на главный (o.reg.mvmin), резерв
|
||||
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
|
||||
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
|
||||
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
|
||||
* запись стирается;
|
||||
* - declinexit → отказ совета или отклонение платежа: снятие резерва
|
||||
* (o.wal.wthdec, если он был сделан) и стирание записи.
|
||||
*
|
||||
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
|
||||
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
|
||||
* ledger2), а не доверяется клиенту.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
|
||||
name username;
|
||||
name coopname;
|
||||
name status; ///< pending | authorized
|
||||
time_point_sec created_at;
|
||||
document2 statement; ///< заявление о выходе (registry 200)
|
||||
document2 approved_statement; ///< решение совета о выходе (авторизация)
|
||||
checksum256 exit_hash;
|
||||
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
|
||||
|
||||
uint64_t primary_key() const { return username.value; }
|
||||
checksum256 by_hash() const { return exit_hash; }
|
||||
};
|
||||
|
||||
typedef multi_index<
|
||||
"exits"_n, exit,
|
||||
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
|
||||
exits_index;
|
||||
|
||||
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
|
||||
exits_index primary_index(_registrator, coopname.value);
|
||||
auto secondary_index = primary_index.get_index<"byhash"_n>();
|
||||
|
||||
auto itr = secondary_index.find(hash);
|
||||
if (itr == secondary_index.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return *itr;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -12,6 +12,12 @@
|
||||
#include "src/user/declinerfnd.cpp"
|
||||
#include "src/user/reguser.cpp"
|
||||
|
||||
#include "src/exit/exit_helpers.hpp"
|
||||
#include "src/exit/exitcoop.cpp"
|
||||
#include "src/exit/confirmexit.cpp"
|
||||
#include "src/exit/completexit.cpp"
|
||||
#include "src/exit/declinexit.cpp"
|
||||
|
||||
#include "src/account/createbranch.cpp"
|
||||
#include "src/account/newaccount.cpp"
|
||||
#include "src/account/updateaccnt.cpp"
|
||||
|
||||
@@ -85,6 +85,12 @@ public:
|
||||
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
|
||||
|
||||
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
|
||||
|
||||
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
|
||||
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
|
||||
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
|
||||
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
|
||||
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
|
||||
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @brief Завершение возврата паевого взноса при выходе из кооператива.
|
||||
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
|
||||
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
|
||||
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
|
||||
* Возврат назад невозможен — повторное участие только через новую регистрацию.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
|
||||
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
|
||||
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @brief Одобрение советом выхода пайщика из кооператива.
|
||||
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
|
||||
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
|
||||
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
|
||||
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
|
||||
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
|
||||
* без платежа.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param authorization Документ-решение совета о выходе
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
|
||||
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
|
||||
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
|
||||
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
|
||||
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
|
||||
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
|
||||
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
|
||||
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
|
||||
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
|
||||
if (balance.amount <= 0) continue;
|
||||
total_return += balance;
|
||||
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
|
||||
}
|
||||
|
||||
exits.modify(e, _soviet, [&](auto &row) {
|
||||
row.status = "authorized"_n;
|
||||
row.approved_statement = authorization;
|
||||
row.quantity = total_return;
|
||||
});
|
||||
|
||||
if (total_return.amount > 0) {
|
||||
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
|
||||
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
|
||||
total_return, username, exit_hash, memo_req);
|
||||
|
||||
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
|
||||
Action::send<createoutpay_interface>(
|
||||
_gateway,
|
||||
"createoutpay"_n,
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
exit_hash,
|
||||
total_return,
|
||||
_registrator,
|
||||
"completexit"_n,
|
||||
"declinexit"_n
|
||||
);
|
||||
} else {
|
||||
// Возвращать нечего — финализируем выход без платежа.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
exits.erase(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @brief Отказ в выходе из кооператива.
|
||||
* Вызывается либо советом при отклонении заявления (до резервирования средств),
|
||||
* либо gateway при отклонении исходящего платежа (после резервирования). Если
|
||||
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
|
||||
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
|
||||
*/
|
||||
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
|
||||
check_auth_and_get_payer_or_fail({_soviet, _gateway});
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
|
||||
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
|
||||
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
|
||||
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
|
||||
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
|
||||
if (e->status == "authorized"_n && e->quantity.amount > 0) {
|
||||
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../../../lib/index.hpp"
|
||||
|
||||
/**
|
||||
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
|
||||
*
|
||||
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
|
||||
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
|
||||
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
|
||||
* весь lib/index.hpp уже доступен.
|
||||
*/
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
|
||||
*
|
||||
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
|
||||
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
|
||||
* валюте управления.
|
||||
*/
|
||||
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
|
||||
userwallets_index userwallets(_ledger2, coopname.value);
|
||||
auto idx = userwallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(wallet_name.value, username.value));
|
||||
if (it == idx.end()) {
|
||||
return asset(0, _root_govern_symbol);
|
||||
}
|
||||
return it->available;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Консолидация доступного паевого кошелька пайщика на главный
|
||||
* (`w.wal.share`) перед резервом возврата при выходе.
|
||||
*
|
||||
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
|
||||
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
|
||||
* главный:
|
||||
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
|
||||
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
|
||||
*
|
||||
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
|
||||
* валит транзакцию с явным сообщением — защита от тихой потери средств.
|
||||
*/
|
||||
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
|
||||
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
|
||||
|
||||
eosio::name op;
|
||||
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
|
||||
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
|
||||
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
|
||||
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
|
||||
} else {
|
||||
eosio::check(false,
|
||||
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
|
||||
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
|
||||
}
|
||||
|
||||
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
|
||||
wallet_name.to_string() + ", username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
|
||||
* аккаунта в registrator.
|
||||
*
|
||||
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
|
||||
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
|
||||
* для пайщика начинает падать — он лишён права подавать заявления.
|
||||
*/
|
||||
inline void finalize_member_exit(name coopname, name username) {
|
||||
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
|
||||
action(
|
||||
permission_level{_registrator, "active"_n},
|
||||
_soviet,
|
||||
"delpartcpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
|
||||
// блокируем аккаунт в картотеке registrator
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account = accounts.find(username.value);
|
||||
eosio::check(account != accounts.end(), "Аккаунт не найден");
|
||||
accounts.modify(account, _registrator, [&](auto &a) {
|
||||
a.status = "blocked"_n;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @brief Подача заявления на выход из кооператива.
|
||||
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
|
||||
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
|
||||
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Имя пайщика, выходящего из кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param statement Подписанное заявление о выходе (registry 200)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
// выйти может только действующий пайщик (не заблокированный)
|
||||
get_participant_or_fail(coopname, username);
|
||||
|
||||
// повторная подача запрещена — у пайщика может быть только один процесс выхода
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto existing = exits.find(username.value);
|
||||
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
|
||||
|
||||
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
|
||||
|
||||
// проверяем подпись заявления о выходе
|
||||
verify_document_or_fail(statement);
|
||||
|
||||
exits.emplace(coopname, [&](auto &e) {
|
||||
e.username = username;
|
||||
e.coopname = coopname;
|
||||
e.status = "pending"_n;
|
||||
e.created_at = current_time_point();
|
||||
e.statement = statement;
|
||||
e.exit_hash = exit_hash;
|
||||
e.quantity = asset(0, _root_govern_symbol);
|
||||
});
|
||||
|
||||
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
|
||||
::Soviet::create_agenda(
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("leavecoop"_n),
|
||||
exit_hash,
|
||||
_registrator, // callback_contract
|
||||
"confirmexit"_n, // callback при одобрении
|
||||
"declinexit"_n, // callback при отказе
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
#include "src/fund/fundwithdraw.cpp"
|
||||
|
||||
#include "src/participant/addparticipant.cpp"
|
||||
#include "src/participant/delparticipant.cpp"
|
||||
#include "src/participant/setminamt.cpp"
|
||||
#include "src/participant/block.cpp"
|
||||
#include "src/participant/cancelreg.cpp"
|
||||
|
||||
@@ -139,6 +139,9 @@ public:
|
||||
//regaccount.cpp
|
||||
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
|
||||
|
||||
//delparticipant.cpp
|
||||
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
|
||||
|
||||
//setminamt.cpp
|
||||
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @brief Удаление пайщика из кооператива при выходе
|
||||
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
|
||||
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
|
||||
* пайщик был активным и имел право голоса — уменьшает счётчик активных
|
||||
* пайщиков в registrator (как это делает `block`).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Наименование выбывающего пайщика
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта в белом списке контрактов
|
||||
*/
|
||||
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
|
||||
check_auth_and_get_payer_or_fail(contracts_whitelist);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
participants_index participants(_soviet, coopname.value);
|
||||
auto participant = participants.find(username.value);
|
||||
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
|
||||
|
||||
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
|
||||
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
|
||||
bool had_vote = participant->has_vote;
|
||||
bool was_active = participant->status == "accepted"_n;
|
||||
|
||||
if (was_active && had_vote) {
|
||||
action(
|
||||
permission_level{_soviet, "active"_n},
|
||||
_registrator,
|
||||
"decparticpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
}
|
||||
|
||||
participants.erase(participant);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import mongoose from 'mongoose';
|
||||
import config from '~/config/config';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
const CAPITAL_DOC_REGISTRY_ID = 994;
|
||||
const TARGET_COOPNAME = 'voskhod';
|
||||
const COLLECTION_NAME = 'doc_private_data';
|
||||
|
||||
const capitalProgramPrivateData = {
|
||||
approval_protocol_number: '1',
|
||||
approval_protocol_day: '15',
|
||||
approval_protocol_month: 'января',
|
||||
approval_protocol_year: '26',
|
||||
cooperative_name: 'ВОСХОД',
|
||||
cooperative_short_name: 'ПК',
|
||||
cooperative_quoted_name: '«ВОСХОД»',
|
||||
website: 'https://цифровой-кооператив.рф',
|
||||
chairman_full_name: 'Муравьев Алексей Николаевич',
|
||||
generator_program_purpose:
|
||||
'развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) под названием “Кооперативная Экономика”',
|
||||
eoap_definition:
|
||||
'информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”',
|
||||
generator_task_goal:
|
||||
'центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений',
|
||||
idea_unit_cost: '50',
|
||||
idea_unit_cost_words: 'пятьдесят',
|
||||
blagorost_goal_expansion:
|
||||
'вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате',
|
||||
blagorost_task_expansion:
|
||||
'расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате',
|
||||
blagorost_task_development:
|
||||
'развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений',
|
||||
return_source_description:
|
||||
'аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕОАП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий',
|
||||
return_additional_source:
|
||||
'взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП',
|
||||
offer_template_number: '______',
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
|
||||
const entries = Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const v = (value as Record<string, unknown>)[key];
|
||||
return `${JSON.stringify(key)}:${stableStringify(v)}`;
|
||||
});
|
||||
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
function calculateSha256(value: unknown): string {
|
||||
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Backfill capital_program_doc_data_hash for voskhod',
|
||||
validUntil: new Date('2027-05-01T00:00:00Z'),
|
||||
|
||||
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
if (config.coopname !== TARGET_COOPNAME) {
|
||||
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const hash = calculateSha256(capitalProgramPrivateData);
|
||||
let mongo: mongoose.Connection | null = null;
|
||||
|
||||
try {
|
||||
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
|
||||
const db = mongo.db;
|
||||
const collection = db.collection(COLLECTION_NAME);
|
||||
await collection.createIndex({ hash: 1 }, { unique: true });
|
||||
await collection.updateOne(
|
||||
{ hash },
|
||||
{
|
||||
$setOnInsert: {
|
||||
hash,
|
||||
registry_id: CAPITAL_DOC_REGISTRY_ID,
|
||||
payload: capitalProgramPrivateData,
|
||||
_created_at: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
const result = await dataSource.query(
|
||||
`UPDATE extensions
|
||||
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
|
||||
updated_at = NOW()
|
||||
WHERE name = 'capital'`,
|
||||
[hash]
|
||||
);
|
||||
|
||||
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
|
||||
return false;
|
||||
} finally {
|
||||
await mongo?.close();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
if (config.coopname !== TARGET_COOPNAME) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await dataSource.query(
|
||||
`UPDATE extensions
|
||||
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
|
||||
updated_at = NOW()
|
||||
WHERE name = 'capital'`
|
||||
);
|
||||
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
|
||||
@@ -76,6 +76,7 @@ import { UserModule } from './application/user/user.module';
|
||||
import { TokenApplicationModule } from './application/token/token-application.module';
|
||||
import { SettingsApplicationModule } from './application/settings/settings.module';
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { MembershipExitModule } from './application/membership-exit/membership-exit.module';
|
||||
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
|
||||
import { SearchModule } from './application/search/search.module';
|
||||
import { SignedDocumentsModule } from './application/signed-documents/signed-documents.module';
|
||||
@@ -169,6 +170,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
TokenApplicationModule,
|
||||
SettingsApplicationModule,
|
||||
RegistrationModule,
|
||||
MembershipExitModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
SignedDocumentsModule,
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
import type { ExcludeCommonProps } from '~/application/document/types';
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.ParticipantExitApplication.Action;
|
||||
|
||||
@InputType(`BaseMembershipExitApplicationMetaDocumentInput`)
|
||||
class BaseMembershipExitApplicationMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({
|
||||
description:
|
||||
'Флаг пропуска сохранения документа (используется для предварительной генерации и демонстрации пользователю)',
|
||||
})
|
||||
@IsBoolean()
|
||||
skip_save!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitApplicationGenerateDocumentInput`)
|
||||
export class MembershipExitApplicationGenerateDocumentInputDTO
|
||||
extends IntersectionType(
|
||||
BaseMembershipExitApplicationMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
)
|
||||
implements action
|
||||
{
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitApplicationSignedMetaDocumentInput`)
|
||||
export class MembershipExitApplicationSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseMembershipExitApplicationMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`MembershipExitApplicationSignedDocumentInput`)
|
||||
export class MembershipExitApplicationSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => MembershipExitApplicationSignedMetaDocumentInputDTO)
|
||||
public readonly meta!: MembershipExitApplicationSignedMetaDocumentInputDTO;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsNumber } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
import type { ExcludeCommonProps } from '~/application/document/types';
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.DecisionOfParticipantExit.Action;
|
||||
|
||||
@InputType(`BaseMembershipExitDecisionMetaDocumentInput`)
|
||||
class BaseMembershipExitDecisionMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Идентификатор протокола решения собрания совета' })
|
||||
@IsNumber()
|
||||
decision_id!: number;
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitDecisionGenerateDocumentInput`)
|
||||
export class MembershipExitDecisionGenerateDocumentInputDTO
|
||||
extends IntersectionType(
|
||||
BaseMembershipExitDecisionMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
)
|
||||
implements action
|
||||
{
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`MembershipExitDecisionSignedMetaDocumentInput`)
|
||||
export class MembershipExitDecisionSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseMembershipExitDecisionMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`MembershipExitDecisionSignedDocumentInput`)
|
||||
export class MembershipExitDecisionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => MembershipExitDecisionSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация решения собрания совета о выходе пайщика',
|
||||
})
|
||||
public readonly meta!: MembershipExitDecisionSignedMetaDocumentInputDTO;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
|
||||
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
|
||||
|
||||
/**
|
||||
* Output DTO записи о файле платежа (чеке об оплате) в MinIO-бакете.
|
||||
*
|
||||
* `read_url` — короткоживущий HMAC-signed URL (TTL = `defaultUrlTtlSeconds` бакета),
|
||||
* выдаётся после проверки прав; держатель URL может скачать файл до истечения TTL.
|
||||
*/
|
||||
@ObjectType('PaymentFile', { description: 'Запись о файле, приложенном к платежу (чек об оплате).' })
|
||||
export class PaymentFileOutputDTO {
|
||||
@Field(() => Int, { description: 'Внутренний ID записи.' })
|
||||
id!: number;
|
||||
|
||||
@Field(() => String, { description: 'Имя кооператива (scope).' })
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Хеш платежа.' })
|
||||
payment_hash!: string;
|
||||
|
||||
@Field(() => PaymentFileKind, { description: 'Назначение файла.' })
|
||||
kind!: PaymentFileKind;
|
||||
|
||||
@Field(() => String, { description: 'SHA-256 содержимого, hex-lowercase.' })
|
||||
checksum_sha256!: string;
|
||||
|
||||
@Field(() => String, { description: 'MIME-тип содержимого.' })
|
||||
mime_type!: string;
|
||||
|
||||
@Field(() => Int, { description: 'Размер файла в байтах.' })
|
||||
size_bytes!: number;
|
||||
|
||||
@Field(() => String, { description: 'MinIO-ключ внутри бакета.' })
|
||||
storage_key!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Оригинальное имя загруженного файла.' })
|
||||
original_filename?: string;
|
||||
|
||||
@Field(() => String, { description: 'Кто загрузил (username).' })
|
||||
uploaded_by_username!: string;
|
||||
|
||||
@Field(() => Date, { description: 'Когда загружено.' })
|
||||
uploaded_at!: Date;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Короткоживущий URL на скачивание (HMAC-signed).' })
|
||||
read_url?: string;
|
||||
|
||||
static fromDomain(data: IPaymentFileDatabaseData, readUrl?: string): PaymentFileOutputDTO {
|
||||
const dto = new PaymentFileOutputDTO();
|
||||
dto.id = data.id ?? 0;
|
||||
dto.coopname = data.coopname;
|
||||
dto.payment_hash = data.payment_hash;
|
||||
dto.kind = data.kind;
|
||||
dto.checksum_sha256 = data.checksum_sha256;
|
||||
dto.mime_type = data.mime_type;
|
||||
dto.size_bytes = data.size_bytes;
|
||||
dto.storage_key = data.storage_key;
|
||||
dto.original_filename = data.original_filename ?? undefined;
|
||||
dto.uploaded_by_username = data.uploaded_by_username;
|
||||
dto.uploaded_at = data.uploaded_at;
|
||||
dto.read_url = readUrl;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsBase64, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'application/pdf'] as const;
|
||||
|
||||
/**
|
||||
* Input для `uploadPaymentProof` — приложить чек об оплате к платежу.
|
||||
*
|
||||
* Привязка по `payment_hash`. MVP-передача: base64-содержимое внутри mutation
|
||||
* (до 20 МБ — хватает для фото/PDF чека). Вид файла всегда PAYMENT_PROOF
|
||||
* (чек об оплате) — поэтому в input не выносится.
|
||||
*/
|
||||
@InputType('UploadPaymentProofInput')
|
||||
export class UploadPaymentProofInputDTO {
|
||||
@Field(() => String, { description: 'Имя кооператива.' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Хеш платежа, к которому прикладывается чек.' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
payment_hash!: string;
|
||||
|
||||
@Field(() => String, { description: 'MIME-тип содержимого.' })
|
||||
@IsIn([...ALLOWED_MIME], { message: 'mime_type должен быть одним из allowed.' })
|
||||
mime_type!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Оригинальное имя файла — для отображения и поиска.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
original_filename?: string;
|
||||
|
||||
@Field(() => Number, { description: 'Размер файла в байтах (для серверной валидации).' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20 * 1024 * 1024)
|
||||
size_bytes!: number;
|
||||
|
||||
@Field(() => String, { description: 'SHA-256 содержимого, hex-lowercase (64 hex-символа).' })
|
||||
@Matches(/^[a-f0-9]{64}$/, { message: 'checksum_sha256 должен быть 64-символьным lowercase hex.' })
|
||||
checksum_sha256!: string;
|
||||
|
||||
@Field(() => String, { description: 'Содержимое файла, base64 без префикса data:.' })
|
||||
@IsBase64()
|
||||
content_base64!: string;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { GatewayResolver } from './resolvers/gateway.resolver';
|
||||
import { PaymentFilesResolver } from './resolvers/payment-files.resolver';
|
||||
import { GatewayService } from './services/gateway.service';
|
||||
import { PaymentFilesService } from './services/payment-files.service';
|
||||
import { PaymentNotificationService } from './services/payment-notification.service';
|
||||
import { WithdrawAuthorizationListener } from './services/withdraw-authorization.listener';
|
||||
import { PaymentController } from './controllers/payment.controller';
|
||||
@@ -13,6 +15,7 @@ import { SystemModule } from '~/application/system/system.module';
|
||||
import { GatewayInfrastructureModule } from '~/infrastructure/gateway/gateway-infrastructure.module';
|
||||
import { UserInfrastructureModule } from '~/infrastructure/user/user-infrastructure.module';
|
||||
import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,16 +27,20 @@ import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
AccountInfrastructureModule,
|
||||
SystemModule,
|
||||
RedisModule,
|
||||
// Чек об оплате (gateway:files) — ядровый механизм, бакет по @UseBucket.
|
||||
FileStorageInfrastructureModule.forFeature([PaymentFilesService]),
|
||||
],
|
||||
controllers: [PaymentController],
|
||||
providers: [
|
||||
GatewayResolver,
|
||||
PaymentFilesResolver,
|
||||
GatewayService,
|
||||
PaymentFilesService,
|
||||
PaymentNotificationService,
|
||||
GatewayInteractor,
|
||||
GatewayNotificationHandler,
|
||||
WithdrawAuthorizationListener,
|
||||
],
|
||||
exports: [GatewayService, PaymentNotificationService, GatewayInteractor],
|
||||
exports: [GatewayService, PaymentNotificationService, GatewayInteractor, PaymentFilesService],
|
||||
})
|
||||
export class GatewayModule {}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { PaymentFilesService } from '../services/payment-files.service';
|
||||
import { UploadPaymentProofInputDTO } from '../dto/upload-payment-proof.input';
|
||||
import { PaymentFileOutputDTO } from '../dto/payment-file.output';
|
||||
|
||||
/**
|
||||
* GraphQL для чеков об оплате платежей (ядро gateway).
|
||||
*
|
||||
* - Прикладывает чек кассир (chairman/member) — подтверждение исполненной оплаты.
|
||||
* - Списки и read-URL видят те же роли и пайщик (свой чек). ACL уточнится после E2E.
|
||||
*/
|
||||
@Resolver(() => PaymentFileOutputDTO)
|
||||
export class PaymentFilesResolver {
|
||||
constructor(private readonly paymentFiles: PaymentFilesService) {}
|
||||
|
||||
@Mutation(() => PaymentFileOutputDTO, {
|
||||
name: 'uploadPaymentProof',
|
||||
description: 'Приложить чек об оплате к платежу (бакет gateway:files).',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async uploadPaymentProof(
|
||||
@Args('data', { type: () => UploadPaymentProofInputDTO }) data: UploadPaymentProofInputDTO,
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<PaymentFileOutputDTO> {
|
||||
const { data: saved, readUrl } = await this.paymentFiles.uploadProof(data, user.username);
|
||||
return PaymentFileOutputDTO.fromDomain(saved, readUrl);
|
||||
}
|
||||
|
||||
@Query(() => PaymentFileOutputDTO, {
|
||||
name: 'paymentFile',
|
||||
description: 'Получить запись о файле платежа + свежий короткоживущий read-URL.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async getPaymentFile(@Args('id', { type: () => Int }) id: number): Promise<PaymentFileOutputDTO> {
|
||||
const { data, readUrl } = await this.paymentFiles.getReadUrl(id);
|
||||
return PaymentFileOutputDTO.fromDomain(data, readUrl);
|
||||
}
|
||||
|
||||
@Query(() => [PaymentFileOutputDTO], {
|
||||
name: 'paymentProofs',
|
||||
description: 'Список чеков об оплате платежа (без read-URL — запрос отдельно по id).',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async listByPayment(
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('payment_hash', { type: () => String }) paymentHash: string
|
||||
): Promise<PaymentFileOutputDTO[]> {
|
||||
const items = await this.paymentFiles.listByPayment(coopname, paymentHash);
|
||||
return items.map((d) => PaymentFileOutputDTO.fromDomain(d));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import type { InterFileStorageBucket } from '@coopenomics/inter';
|
||||
import { InjectBucket, UseBucket } from '~/infrastructure/file-storage';
|
||||
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import {
|
||||
PAYMENT_FILE_REPOSITORY,
|
||||
type PaymentFileRepository,
|
||||
} from '~/domain/gateway/repositories/payment-file.repository';
|
||||
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
|
||||
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
|
||||
import { GATEWAY_BUCKET } from '~/domain/gateway/constants/gateway-bucket';
|
||||
import { UploadPaymentProofInputDTO } from '../dto/upload-payment-proof.input';
|
||||
|
||||
const EXTENSION_BY_MIME: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/heic': 'heic',
|
||||
'application/pdf': 'pdf',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ядровый сервис файлов платежей: чек об оплате исходящего платежа.
|
||||
*
|
||||
* «Культура денег»: входящие подтверждаем, исходящие сопровождаем чеком.
|
||||
* Привязка по `payment_hash` — единый ключ любого платежа, поэтому механизм
|
||||
* один на все исходящие (возврат паевого/withdrawal/registration-refund/аванс
|
||||
* расхода) и переиспользуется расширениями. Контроль мягкий: статус платежа не
|
||||
* трогаем (деньги уже ушли on-chain), но зеркалим число чеков в
|
||||
* `blockchain_data.proof_count` — реестр по нему рисует «чек приложен / нет».
|
||||
*
|
||||
* Ключ MinIO детерминирован: `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
|
||||
* Повторная загрузка того же содержимого идемпотентна на бакете, в БД защищаемся
|
||||
* явным `findByChecksum` (409 при дубле).
|
||||
*/
|
||||
@UseBucket(GATEWAY_BUCKET)
|
||||
@Injectable()
|
||||
export class PaymentFilesService {
|
||||
constructor(
|
||||
@InjectBucket() private readonly bucket: InterFileStorageBucket,
|
||||
@Inject(PAYMENT_FILE_REPOSITORY) private readonly files: PaymentFileRepository,
|
||||
@Inject(PAYMENT_REPOSITORY) private readonly payments: PaymentRepository
|
||||
) {}
|
||||
|
||||
async uploadProof(
|
||||
input: UploadPaymentProofInputDTO,
|
||||
uploadedByUsername: string
|
||||
): Promise<{ data: IPaymentFileDatabaseData; readUrl: string }> {
|
||||
const body = Buffer.from(input.content_base64, 'base64');
|
||||
if (body.byteLength !== input.size_bytes) {
|
||||
throw new BadRequestException(
|
||||
`size_bytes (${input.size_bytes}) не совпадает с фактическим размером base64-контента (${body.byteLength}).`
|
||||
);
|
||||
}
|
||||
const actualChecksum = createHash('sha256').update(body).digest('hex');
|
||||
if (actualChecksum !== input.checksum_sha256.toLowerCase()) {
|
||||
throw new BadRequestException(`checksum_sha256 не совпадает с реальным SHA-256 содержимого.`);
|
||||
}
|
||||
|
||||
const payment = await this.payments.findByHash(input.payment_hash);
|
||||
if (!payment) {
|
||||
throw new NotFoundException(`Платёж ${input.payment_hash} не найден.`);
|
||||
}
|
||||
|
||||
const existing = await this.files.findByChecksum(input.coopname, actualChecksum);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Файл с таким SHA-256 уже зарегистрирован в этом кооперативе (id=${existing.id}).`
|
||||
);
|
||||
}
|
||||
|
||||
const storageKey = this.buildKey({
|
||||
coopname: input.coopname,
|
||||
paymentHash: input.payment_hash,
|
||||
kind: PaymentFileKind.PAYMENT_PROOF,
|
||||
checksum: actualChecksum,
|
||||
mimeType: input.mime_type,
|
||||
});
|
||||
|
||||
await this.bucket.put(storageKey, new Uint8Array(body), { contentType: input.mime_type });
|
||||
|
||||
const saved = await this.files.create({
|
||||
coopname: input.coopname,
|
||||
payment_hash: input.payment_hash.toLowerCase(),
|
||||
kind: PaymentFileKind.PAYMENT_PROOF,
|
||||
checksum_sha256: actualChecksum,
|
||||
mime_type: input.mime_type,
|
||||
size_bytes: body.byteLength,
|
||||
storage_key: storageKey,
|
||||
original_filename: input.original_filename ?? null,
|
||||
uploaded_by_username: uploadedByUsername,
|
||||
uploaded_at: new Date(),
|
||||
});
|
||||
|
||||
await this.syncProofMark(saved.coopname, saved.payment_hash);
|
||||
|
||||
const readUrl = await this.bucket.getReadUrl(storageKey);
|
||||
return { data: saved, readUrl };
|
||||
}
|
||||
|
||||
async getReadUrl(fileId: number): Promise<{ data: IPaymentFileDatabaseData; readUrl: string }> {
|
||||
const file = await this.files.findById(fileId);
|
||||
if (!file) throw new NotFoundException(`Файл платежа #${fileId} не найден.`);
|
||||
const readUrl = await this.bucket.getReadUrl(file.storage_key);
|
||||
return { data: file, readUrl };
|
||||
}
|
||||
|
||||
async listByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]> {
|
||||
return this.files.findByPayment(coopname, paymentHash.toLowerCase());
|
||||
}
|
||||
|
||||
async deleteFile(fileId: number): Promise<void> {
|
||||
const file = await this.files.findById(fileId);
|
||||
if (!file) throw new NotFoundException(`Файл платежа #${fileId} не найден.`);
|
||||
await this.bucket.delete(file.storage_key);
|
||||
await this.files.delete(fileId);
|
||||
await this.syncProofMark(file.coopname, file.payment_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Зеркалит число приложенных чеков в платёж (`blockchain_data.proof_count`) —
|
||||
* реестр показывает по нему «чек приложен / не приложен», не дёргая хранилище
|
||||
* файлов на каждую строку. Статус платежа не трогаем (им управляет gateway).
|
||||
*/
|
||||
private async syncProofMark(coopname: string, paymentHash: string): Promise<void> {
|
||||
const payment = await this.payments.findByHash(paymentHash);
|
||||
if (!payment?.id) return;
|
||||
const proofs = await this.files.findByPayment(coopname, paymentHash);
|
||||
await this.payments.update(payment.id, {
|
||||
blockchain_data: { ...(payment.blockchain_data ?? {}), proof_count: proofs.length },
|
||||
});
|
||||
}
|
||||
|
||||
private buildKey(params: {
|
||||
coopname: string;
|
||||
paymentHash: string;
|
||||
kind: PaymentFileKind;
|
||||
checksum: string;
|
||||
mimeType: string;
|
||||
}): string {
|
||||
const ext = EXTENSION_BY_MIME[params.mimeType] ?? 'bin';
|
||||
return `${params.coopname}/gateway/${params.paymentHash.toLowerCase()}/${params.kind}/${params.checksum}.${ext}`;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsString, ValidateNested, IsNotEmpty } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import type { CreateMembershipExitInputDomainInterface } from '~/domain/account/interfaces/create-membership-exit-input.interface';
|
||||
import { MembershipExitApplicationSignedDocumentInputDTO } from '~/application/document/documents-dto/membership-exit-application-document.dto';
|
||||
|
||||
/**
|
||||
* DTO подачи заявления на выход пайщика из кооператива.
|
||||
*/
|
||||
@InputType('CreateMembershipExitInput')
|
||||
export class CreateMembershipExitInputDTO implements CreateMembershipExitInputDomainInterface {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя пайщика, выходящего из кооператива' })
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@Field(() => String, { description: 'Хеш процесса выхода (генерируется на клиенте)' })
|
||||
@IsString()
|
||||
exit_hash!: string;
|
||||
|
||||
@Field(() => MembershipExitApplicationSignedDocumentInputDTO, {
|
||||
description: 'Подписанное пайщиком заявление о выходе из кооператива',
|
||||
})
|
||||
@ValidateNested()
|
||||
@IsNotEmpty({ message: 'Поле "statement" обязательно для заполнения.' })
|
||||
@Type(() => MembershipExitApplicationSignedDocumentInputDTO)
|
||||
statement!: MembershipExitApplicationSignedDocumentInputDTO;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { MembershipExitStatus } from '../enums/membership-exit-status.enum';
|
||||
|
||||
/**
|
||||
* Результат подачи заявления на выход из кооператива.
|
||||
*/
|
||||
@ObjectType('MembershipExitResult')
|
||||
export class MembershipExitResultDTO {
|
||||
@Field(() => String, { description: 'Хеш созданного процесса выхода' })
|
||||
exit_hash!: string;
|
||||
|
||||
@Field(() => MembershipExitStatus, {
|
||||
description: 'Статус процесса выхода после подачи (ожидает подтверждения по ссылке из письма)',
|
||||
})
|
||||
status!: MembershipExitStatus;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Предварительный расчёт суммы возврата паевого взноса при выходе.
|
||||
*
|
||||
* Считается по L3-балансам ledger2 на момент запроса. Итоговую сумму при
|
||||
* одобрении совет фиксирует контракт (registrator::confirmexit), поэтому это
|
||||
* ориентир для пайщика, а не обязательство.
|
||||
*/
|
||||
@ObjectType('MembershipExitReturnPreview')
|
||||
export class MembershipExitReturnPreviewDTO {
|
||||
@Field(() => String, { description: 'Итоговая сумма к возврату (минимальный + целевой паевой)' })
|
||||
total!: string;
|
||||
|
||||
@Field(() => String, { description: 'Целевой паевой взнос пайщика' })
|
||||
share_contribution!: string;
|
||||
|
||||
@Field(() => String, { description: 'Минимальный паевой взнос пайщика' })
|
||||
minimum_contribution!: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import { MembershipExitStatus } from '../enums/membership-exit-status.enum';
|
||||
|
||||
/**
|
||||
* Текущий процесс выхода пайщика из кооператива (если активен).
|
||||
*/
|
||||
@ObjectType('MembershipExit')
|
||||
export class MembershipExitDTO {
|
||||
@Field(() => String, { description: 'Хеш процесса выхода' })
|
||||
exit_hash!: string;
|
||||
|
||||
@Field(() => MembershipExitStatus, { description: 'Статус процесса выхода' })
|
||||
status!: MembershipExitStatus;
|
||||
|
||||
@Field(() => String, { description: 'Сумма к возврату (фиксируется советом при одобрении; до одобрения — 0)' })
|
||||
quantity!: string;
|
||||
|
||||
@Field(() => PaymentStatusEnum, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Статус исходящего платежа возврата паевого взноса в реестре кассира. Создаётся при одобрении советом; null — платёж ещё не заведён.',
|
||||
})
|
||||
payment_status?: PaymentStatusEnum;
|
||||
|
||||
@Field(() => String, { description: 'Дата подачи заявления на выход' })
|
||||
created_at!: string;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Статус процесса выхода пайщика из кооператива (registrator::exits.status).
|
||||
*/
|
||||
export enum MembershipExitStatus {
|
||||
// Заявление подписано и принято, но в блокчейн ещё не отправлено —
|
||||
// ожидает подтверждения пайщиком по ссылке из письма (off-chain фаза).
|
||||
AWAITING_CONFIRMATION = 'awaiting_confirmation',
|
||||
// Заявление подано в блокчейн, ожидает решения совета.
|
||||
PENDING = 'pending',
|
||||
// Совет одобрил, идёт возврат паевого взноса (зарезервирован, исходящий платёж).
|
||||
AUTHORIZED = 'authorized',
|
||||
// Выход завершён: возврат оплачен, аккаунт заблокирован, пайщик вышел.
|
||||
// exits-строка на цепи уже стёрта (терминал=erase) — статус восстанавливаем
|
||||
// по blocked-аккаунту и завершённому MEMBERSHIP_EXIT-платежу.
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
registerEnumType(MembershipExitStatus, {
|
||||
name: 'MembershipExitStatus',
|
||||
description: 'Статус процесса выхода пайщика из кооператива',
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MembershipExitResolver } from './resolvers/membership-exit.resolver';
|
||||
import { MembershipExitService } from './services/membership-exit.service';
|
||||
import { MembershipExitAuthorizationListener } from './services/membership-exit-authorization.listener';
|
||||
import { ParticipantModule } from '../participant/participant.module';
|
||||
import { TokenApplicationModule } from '../token/token-application.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { SystemModule } from '../system/system.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { EventsInfrastructureModule } from '~/infrastructure/events/events.module';
|
||||
import { MembershipExitRequestEntity } from '~/infrastructure/database/typeorm/entities/membership-exit-request.entity';
|
||||
|
||||
/**
|
||||
* Модуль выхода пайщика из кооператива: генерация документов выхода (200/201),
|
||||
* подача заявления с подтверждением по email (off-chain черновик → токен →
|
||||
* письмо → confirm → registrator::exitcoop) и предрасчёт суммы возврата паевого.
|
||||
*
|
||||
* Порты ACCOUNT_BLOCKCHAIN_PORT / BLOCKCHAIN_PORT / USER_WALLET_REPOSITORY
|
||||
* предоставляются глобальными модулями (blockchain.module, typeorm.module).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ParticipantModule,
|
||||
TypeOrmModule.forFeature([MembershipExitRequestEntity]),
|
||||
forwardRef(() => TokenApplicationModule),
|
||||
forwardRef(() => NotificationModule),
|
||||
SystemModule,
|
||||
UserDomainModule,
|
||||
EventsInfrastructureModule,
|
||||
],
|
||||
providers: [MembershipExitResolver, MembershipExitService, MembershipExitAuthorizationListener],
|
||||
exports: [MembershipExitService],
|
||||
})
|
||||
export class MembershipExitModule {}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
import { MembershipExitApplicationGenerateDocumentInputDTO } from '~/application/document/documents-dto/membership-exit-application-document.dto';
|
||||
import { MembershipExitDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/membership-exit-decision-document.dto';
|
||||
import { MembershipExitService } from '../services/membership-exit.service';
|
||||
import { CreateMembershipExitInputDTO } from '../dto/create-membership-exit-input.dto';
|
||||
import { MembershipExitResultDTO } from '../dto/membership-exit-result.dto';
|
||||
import { MembershipExitReturnPreviewDTO } from '../dto/membership-exit-return-preview.dto';
|
||||
import { MembershipExitDTO } from '../dto/membership-exit.dto';
|
||||
|
||||
/**
|
||||
* GraphQL резолвер выхода пайщика из кооператива.
|
||||
*/
|
||||
@Resolver()
|
||||
export class MembershipExitResolver {
|
||||
constructor(private readonly membershipExitService: MembershipExitService) {}
|
||||
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'generateMembershipExitApplication',
|
||||
description: 'Сгенерировать документ заявления о выходе из кооператива.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async generateMembershipExitApplication(
|
||||
@Args('data', { type: () => MembershipExitApplicationGenerateDocumentInputDTO })
|
||||
data: MembershipExitApplicationGenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.membershipExitService.generateMembershipExitApplication(data, options);
|
||||
}
|
||||
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'generateMembershipExitDecision',
|
||||
description: 'Сгенерировать документ решения собрания совета о выходе пайщика.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async generateMembershipExitDecision(
|
||||
@Args('data', { type: () => MembershipExitDecisionGenerateDocumentInputDTO })
|
||||
data: MembershipExitDecisionGenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.membershipExitService.generateMembershipExitDecision(data, options);
|
||||
}
|
||||
|
||||
@Mutation(() => MembershipExitResultDTO, {
|
||||
name: 'createMembershipExit',
|
||||
description:
|
||||
'Подать подписанное заявление на выход из кооператива. Запускает рассмотрение советом и последующий возврат паевого взноса.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async createMembershipExit(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('data', { type: () => CreateMembershipExitInputDTO }) data: CreateMembershipExitInputDTO
|
||||
): Promise<MembershipExitResultDTO> {
|
||||
return this.membershipExitService.createMembershipExit(data, currentUser);
|
||||
}
|
||||
|
||||
@Mutation(() => MembershipExitResultDTO, {
|
||||
name: 'confirmMembershipExit',
|
||||
description:
|
||||
'Подтвердить выход из кооператива по ссылке из письма. Проверяет токен и отправляет ранее подписанное заявление в блокчейн.',
|
||||
})
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async confirmMembershipExit(
|
||||
@Args('token', { type: () => String }) token: string
|
||||
): Promise<MembershipExitResultDTO> {
|
||||
return this.membershipExitService.confirmMembershipExit(token);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'cancelMembershipExit',
|
||||
description: 'Отменить заявление на выход до подтверждения по email.',
|
||||
})
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async cancelMembershipExit(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('username', { type: () => String }) username: string
|
||||
): Promise<boolean> {
|
||||
return this.membershipExitService.cancelMembershipExit(coopname, username, currentUser);
|
||||
}
|
||||
|
||||
@Query(() => MembershipExitDTO, {
|
||||
name: 'membershipExit',
|
||||
nullable: true,
|
||||
description:
|
||||
'Текущий процесс выхода пайщика из кооператива (статус заявления и планируемая сумма возврата). null — активного выхода нет.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async membershipExit(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('username', { type: () => String }) username: string
|
||||
): Promise<MembershipExitDTO | null> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && username !== currentUser.username) {
|
||||
throw new ForbiddenException('Доступен только собственный статус выхода');
|
||||
}
|
||||
return this.membershipExitService.getMembershipExit(coopname, username);
|
||||
}
|
||||
|
||||
@Query(() => MembershipExitReturnPreviewDTO, {
|
||||
name: 'membershipExitReturnPreview',
|
||||
description:
|
||||
'Предварительный расчёт суммы возврата паевого взноса при выходе пайщика (минимальный + целевой паевой). Ориентир для пайщика; итог фиксирует совет.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async membershipExitReturnPreview(
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface,
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('username', { type: () => String }) username: string
|
||||
): Promise<MembershipExitReturnPreviewDTO> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && username !== currentUser.username) {
|
||||
throw new ForbiddenException('Доступен только собственный расчёт возврата');
|
||||
}
|
||||
return this.membershipExitService.getReturnPreview(coopname, username);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { RegistratorContract } from 'cooptypes';
|
||||
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { PAYMENT_METHOD_REPOSITORY, type PaymentMethodRepository } from '~/domain/common/repositories/payment-method.repository';
|
||||
import { ACCOUNT_BLOCKCHAIN_PORT, type AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
|
||||
import { SYSTEM_DOMAIN_PORT, type SystemDomainPort } from '~/domain/system/interfaces/system-domain.port';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import { PaymentTypeEnum, PaymentDirectionEnum, VAT_EXEMPT_NOTE } from '~/domain/gateway/enums/payment-type.enum';
|
||||
import type {
|
||||
PaymentDomainInterface,
|
||||
PaymentDetailsDomainInterface,
|
||||
} from '~/domain/gateway/interfaces/payment-domain.interface';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
import { generateUniqueHash } from '~/utils/generate-hash.util';
|
||||
|
||||
// confirmexit — callback-экшен совета (одобрение повестки leavecoop). В cooptypes
|
||||
// SDK-обёртки нет (не подаётся клиентом), поэтому имя экшена — как в контракте.
|
||||
const REGISTRATOR = RegistratorContract.contractName.production;
|
||||
const CONFIRM_EXIT_EVENT = `action::${REGISTRATOR}::confirmexit`;
|
||||
|
||||
/**
|
||||
* Заводит исходящий платёж-возврат паевого взноса при одобрении советом выхода
|
||||
* пайщика из кооператива.
|
||||
*
|
||||
* On-chain registrator::confirmexit (одобрение повестки leavecoop) сам считает
|
||||
* сумму возврата по L3-балансам (минимальный + целевой паевой), резервирует её
|
||||
* (o.wal.wthreq) и создаёт исходящий объект в gateway через createoutpay с
|
||||
* confirm-callback completexit. Но платёж в реестре gateway, который УВИДИТ
|
||||
* кассир, on-chain не создаётся — заводим его здесь по реквизитам пайщика.
|
||||
*
|
||||
* Подтверждение кассиром проводит on-chain завершение: default-ветка
|
||||
* gateway.interactor.processOutgoingPayment вызывает completeOutcome →
|
||||
* gateway::outcomplete → registrator::completexit (списание Дт80/Кт51 +
|
||||
* блокировка аккаунта). Поэтому hash платежа = exit_hash (= outcome_hash
|
||||
* on-chain объекта), иначе completeOutcome не найдёт объект.
|
||||
*
|
||||
* Реквизиты обязательны на момент подачи заявления (гейт в
|
||||
* MembershipExitService.createMembershipExit); здесь берём метод по умолчанию.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MembershipExitAuthorizationListener {
|
||||
private readonly logger = new Logger(MembershipExitAuthorizationListener.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository,
|
||||
@Inject(PAYMENT_METHOD_REPOSITORY) private readonly paymentMethodRepository: PaymentMethodRepository,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(SYSTEM_DOMAIN_PORT) private readonly systemDomainPort: SystemDomainPort
|
||||
) {}
|
||||
|
||||
@OnEvent(CONFIRM_EXIT_EVENT)
|
||||
async onConfirmExit(action: ActionDomainInterface): Promise<void> {
|
||||
const coopname = action?.data?.coopname as string | undefined;
|
||||
const exitHashFromAction = action?.data?.exit_hash as string | undefined;
|
||||
if (!coopname || !exitHashFromAction) return;
|
||||
|
||||
// exit_hash на входе confirmexit есть, но username и итоговую сумму берём из
|
||||
// таблицы (контракт сам посчитал quantity и перевёл статус в authorized).
|
||||
const exit = await this.accountBlockchainPort.getExitByHash(coopname, exitHashFromAction);
|
||||
if (!exit) {
|
||||
// Запись могла быть стёрта контрактом — это штатно при total_return == 0
|
||||
// (возвращать нечего, выход финализирован без платежа).
|
||||
this.logger.debug(`confirmexit: запись выхода по exit_hash=${exitHashFromAction} не найдена — пропуск`);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = String(exit.status);
|
||||
if (status !== 'authorized') {
|
||||
this.logger.debug(`confirmexit: выход ${exit.exit_hash} в статусе "${status}" (не authorized) — пропуск`);
|
||||
return;
|
||||
}
|
||||
|
||||
const exitHash = String(exit.exit_hash);
|
||||
const username = String(exit.username);
|
||||
|
||||
// Идемпотентность: повторный приход события (replay/форк) не плодит платежи.
|
||||
const existing = await this.paymentRepository.findByHash(exitHash);
|
||||
if (existing) {
|
||||
this.logger.debug(`confirmexit: платёж возврата по выходу ${exitHash} уже создан (${existing.id}) — пропуск`);
|
||||
return;
|
||||
}
|
||||
|
||||
// quantity на чейне — asset-строка вида "300.0000 RUB".
|
||||
const [amountStr, symbol] = String(exit.quantity).split(' ');
|
||||
const quantity = Number(amountStr);
|
||||
if (!symbol || !(quantity > 0)) {
|
||||
this.logger.warn(`confirmexit: нулевая/некорректная сумма возврата (${exit.quantity}) по выходу ${exitHash} — платёж не создаём`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Метод по умолчанию (или первый) — на момент подачи заявления реквизиты были
|
||||
// обязательны (гейт createMembershipExit). Если пайщик удалил их после подачи
|
||||
// — платёж создать не из чего, логируем ошибку (вырожденный случай).
|
||||
const methods = await this.paymentMethodRepository.list({
|
||||
username,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
sortOrder: 'DESC',
|
||||
});
|
||||
const method = methods.items.find((m) => m.is_default) ?? methods.items[0];
|
||||
if (!method) {
|
||||
this.logger.error(
|
||||
`confirmexit: у пайщика ${username} нет реквизитов — невозможно создать возврат паевого по выходу ${exitHash}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await this.systemDomainPort.getSettings();
|
||||
const now = new Date();
|
||||
|
||||
const paymentDetails: PaymentDetailsDomainInterface = {
|
||||
data: method.data,
|
||||
// Для исходящих платежей комиссия не применяется.
|
||||
amount_plus_fee: quantity.toString(),
|
||||
amount_without_fee: quantity.toString(),
|
||||
fee_amount: '0',
|
||||
fee_percent: 0,
|
||||
fact_fee_percent: 0,
|
||||
tolerance_percent: 0,
|
||||
};
|
||||
|
||||
const payment: PaymentDomainInterface = {
|
||||
id: '',
|
||||
coopname,
|
||||
username,
|
||||
quantity,
|
||||
symbol,
|
||||
type: PaymentTypeEnum.MEMBERSHIP_EXIT,
|
||||
direction: PaymentDirectionEnum.OUTGOING,
|
||||
// Совет уже одобрил (confirmexit) — повторная авторизация не нужна, платёж
|
||||
// сразу готов к выплате кассиром (PENDING виден кассиру с кнопкой «Подтвердить»).
|
||||
status: PaymentStatusEnum.PENDING,
|
||||
provider: settings.provider_name,
|
||||
payment_method_id: method.method_id,
|
||||
payment_details: paymentDetails,
|
||||
memo: `Возврат паевого взноса при выходе из кооператива №${exitHash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
|
||||
secret: generateUniqueHash(),
|
||||
// hash = exit_hash = outcome_hash on-chain объекта (createoutpay в confirmexit),
|
||||
// чтобы completeOutcome при подтверждении кассой вызвал completexit.
|
||||
hash: exitHash,
|
||||
expired_at: undefined, // бессрочный — как и прочие исходящие платежи
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const created = await this.paymentRepository.create(payment);
|
||||
this.logger.log(
|
||||
`confirmexit: создан возврат паевого взноса ${created.id} для ${username} на ${quantity} ${symbol} (выход ${exitHash})`
|
||||
);
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Cooperative, Ledger2 } from 'cooptypes';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
import { config } from '~/config';
|
||||
import { ParticipantInteractor } from '~/application/participant/interactors/participant.interactor';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { NotificationSenderService } from '~/application/notification/services/notification-sender.service';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { ACCOUNT_BLOCKCHAIN_PORT, type AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
|
||||
import { USER_WALLET_REPOSITORY, type UserWalletRepository } from '~/domain/wallet/repositories/user-wallet.repository';
|
||||
import { BLOCKCHAIN_PORT, type BlockchainPort } from '~/domain/common/ports/blockchain.port';
|
||||
import { USER_DOMAIN_SERVICE, type UserDomainService } from '~/domain/user/services/user-domain.service';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { PAYMENT_METHOD_REPOSITORY, type PaymentMethodRepository } from '~/domain/common/repositories/payment-method.repository';
|
||||
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
|
||||
import { MembershipExitRequestEntity } from '~/infrastructure/database/typeorm/entities/membership-exit-request.entity';
|
||||
import { tokenTypes } from '~/types/token.types';
|
||||
import { CreateMembershipExitInputDTO } from '../dto/create-membership-exit-input.dto';
|
||||
import { MembershipExitResultDTO } from '../dto/membership-exit-result.dto';
|
||||
import { MembershipExitReturnPreviewDTO } from '../dto/membership-exit-return-preview.dto';
|
||||
import { MembershipExitDTO } from '../dto/membership-exit.dto';
|
||||
import { MembershipExitStatus } from '../enums/membership-exit-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class MembershipExitService {
|
||||
private readonly logger = new Logger(MembershipExitService.name);
|
||||
|
||||
constructor(
|
||||
private readonly participantInteractor: ParticipantInteractor,
|
||||
private readonly tokenApplicationService: TokenApplicationService,
|
||||
private readonly notificationSenderService: NotificationSenderService,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT)
|
||||
private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(USER_WALLET_REPOSITORY)
|
||||
private readonly userWalletRepository: UserWalletRepository,
|
||||
@Inject(BLOCKCHAIN_PORT)
|
||||
private readonly blockchainPort: BlockchainPort,
|
||||
@Inject(USER_DOMAIN_SERVICE)
|
||||
private readonly userDomainService: UserDomainService,
|
||||
@Inject(PAYMENT_METHOD_REPOSITORY)
|
||||
private readonly paymentMethodRepository: PaymentMethodRepository,
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepository: PaymentRepository,
|
||||
@InjectRepository(MembershipExitRequestEntity)
|
||||
private readonly exitRequestRepository: Repository<MembershipExitRequestEntity>
|
||||
) {}
|
||||
|
||||
async generateMembershipExitApplication(
|
||||
data: Cooperative.Registry.ParticipantExitApplication.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const document = await this.participantInteractor.generateMembershipExitApplication(data, options);
|
||||
return document as unknown as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
async generateMembershipExitDecision(
|
||||
data: Cooperative.Registry.DecisionOfParticipantExit.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const document = await this.participantInteractor.generateMembershipExitDecision(data, options);
|
||||
return document as unknown as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подача заявления на выход. Заявление подписывается пайщиком на клиенте и
|
||||
* сразу принимается: сохраняется off-chain и требует подтверждения по ссылке
|
||||
* из письма. В блокчейн (registrator::exitcoop) отправляется только после
|
||||
* перехода по ссылке — см. confirmMembershipExit. Это двойное подтверждение
|
||||
* необратимого действия (закрытие аккаунта). Пайщик подаёт выход за себя;
|
||||
* председатель/член совета — за любого пайщика (операторская подача).
|
||||
*/
|
||||
async createMembershipExit(
|
||||
data: CreateMembershipExitInputDTO,
|
||||
currentUser: MonoAccountDomainInterface
|
||||
): Promise<MembershipExitResultDTO> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && data.username !== currentUser.username) {
|
||||
throw new ForbiddenException('Подать заявление на выход можно только за себя');
|
||||
}
|
||||
|
||||
// Гард повторного выхода: вышедший пайщик заблокирован on-chain (completexit →
|
||||
// status=blocked + удалён из участников). Контракт exitcoop его уже не пропустит
|
||||
// (get_participant_or_fail), но отсекаем раньше — чтобы не плодить off-chain
|
||||
// черновик и не доводить пайщика до провала на шаге подтверждения по ссылке.
|
||||
const account = await this.accountBlockchainPort.getUserAccount(data.username);
|
||||
if (String(account?.status) === 'blocked') {
|
||||
throw new BadRequestException('Вы уже вышли из кооператива — повторный выход невозможен.');
|
||||
}
|
||||
|
||||
// Гейт реквизитов: выход нельзя запускать, пока у пайщика нет реквизитов для
|
||||
// получения возврата паевого взноса — иначе при одобрении совета исходящий
|
||||
// платёж возврата некуда будет создать (см. MembershipExitAuthorizationListener).
|
||||
const methods = await this.paymentMethodRepository.list({
|
||||
username: data.username,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
sortOrder: 'DESC',
|
||||
});
|
||||
if (!methods || methods.items.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'Для выхода из кооператива установите реквизиты для получения возврата паевого взноса.'
|
||||
);
|
||||
}
|
||||
|
||||
// Уже идёт выход on-chain?
|
||||
const onchain = await this.accountBlockchainPort.getExit(data.coopname, data.username);
|
||||
if (onchain) {
|
||||
throw new BadRequestException('Процесс выхода уже запущен и отправлен в блокчейн');
|
||||
}
|
||||
|
||||
// Уже есть заявление, ожидающее подтверждения по email?
|
||||
const existing = await this.exitRequestRepository.findOne({
|
||||
where: { coopname: data.coopname, username: data.username },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
'Заявление на выход уже подано и ожидает подтверждения по ссылке из письма'
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userDomainService.getUserByUsername(data.username);
|
||||
const confirmToken = await this.tokenApplicationService.generateConfirmExitToken(user.id);
|
||||
|
||||
await this.exitRequestRepository.save(
|
||||
this.exitRequestRepository.create({
|
||||
coopname: data.coopname,
|
||||
username: data.username,
|
||||
exit_hash: data.exit_hash,
|
||||
statement: data.statement as unknown as Record<string, any>,
|
||||
token: confirmToken,
|
||||
})
|
||||
);
|
||||
|
||||
const confirmationUrl = `${config.frontend_url}/${config.coopname}/user/membership-exit/confirm?token=${confirmToken}`;
|
||||
this.logger.debug(`Ссылка подтверждения выхода (${data.username}): ${confirmationUrl}`);
|
||||
|
||||
// Письмо — не критично для приёма заявления: черновик уже сохранён, и если
|
||||
// провайдер писем недоступен, пайщик не теряет заявление (можно отменить).
|
||||
try {
|
||||
await this.notificationSenderService.sendNotificationToUser(
|
||||
data.username,
|
||||
Workflows.MembershipExitConfirmation.id,
|
||||
{ confirmationUrl }
|
||||
);
|
||||
} catch (e: any) {
|
||||
this.logger.error(
|
||||
`Не удалось отправить письмо с подтверждением выхода (${data.username}): ${e?.message ?? e}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Принято заявление на выход (ожидает email-подтверждения): ${data.exit_hash} (username=${data.username})`
|
||||
);
|
||||
|
||||
return { exit_hash: data.exit_hash, status: MembershipExitStatus.AWAITING_CONFIRMATION };
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение выхода по ссылке из письма. Проверяет токен, берёт сохранённое
|
||||
* подписанное заявление и только теперь отправляет его в блокчейн
|
||||
* (registrator::exitcoop ключом кооператива). Запись-черновик удаляется —
|
||||
* далее источником истины служит on-chain registrator::exits.
|
||||
*/
|
||||
async confirmMembershipExit(token: string): Promise<MembershipExitResultDTO> {
|
||||
await this.tokenApplicationService.verifyToken({ token, types: [tokenTypes.CONFIRM_EXIT] });
|
||||
|
||||
const request = await this.exitRequestRepository.findOne({ where: { token } });
|
||||
if (!request) {
|
||||
throw new NotFoundException('Заявление на выход не найдено или уже подтверждено');
|
||||
}
|
||||
|
||||
await this.accountBlockchainPort.exitCoop({
|
||||
coopname: request.coopname,
|
||||
username: request.username,
|
||||
exit_hash: request.exit_hash,
|
||||
statement: request.statement as any,
|
||||
});
|
||||
|
||||
await this.exitRequestRepository.delete({ id: request.id });
|
||||
await this.tokenApplicationService.findOneAndDelete(token, tokenTypes.CONFIRM_EXIT);
|
||||
|
||||
this.logger.log(
|
||||
`Выход подтверждён и отправлен в блокчейн: ${request.exit_hash} (username=${request.username})`
|
||||
);
|
||||
|
||||
return { exit_hash: request.exit_hash, status: MembershipExitStatus.PENDING };
|
||||
}
|
||||
|
||||
/**
|
||||
* Отмена заявления на выход до подтверждения по email. Удаляет черновик и
|
||||
* аннулирует токен подтверждения. Доступно пайщику (за себя) и оператору.
|
||||
*/
|
||||
async cancelMembershipExit(
|
||||
coopname: string,
|
||||
username: string,
|
||||
currentUser: MonoAccountDomainInterface
|
||||
): Promise<boolean> {
|
||||
const isOperator = currentUser.role === 'chairman' || currentUser.role === 'member';
|
||||
if (!isOperator && username !== currentUser.username) {
|
||||
throw new ForbiddenException('Отменить выход можно только за себя');
|
||||
}
|
||||
|
||||
const request = await this.exitRequestRepository.findOne({ where: { coopname, username } });
|
||||
if (!request) {
|
||||
throw new BadRequestException('Нет заявления на выход, ожидающего подтверждения');
|
||||
}
|
||||
|
||||
await this.exitRequestRepository.delete({ id: request.id });
|
||||
await this.tokenApplicationService.findOneAndDelete(request.token, tokenTypes.CONFIRM_EXIT);
|
||||
|
||||
this.logger.log(`Заявление на выход отменено до подтверждения (username=${username})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий процесс выхода пайщика (если активен) — для блокировки кабинета и
|
||||
* отображения статуса заявления и планируемой суммы возврата. null — выхода нет.
|
||||
*/
|
||||
async getMembershipExit(coopname: string, username: string): Promise<MembershipExitDTO | null> {
|
||||
const exit = await this.accountBlockchainPort.getExit(coopname, username);
|
||||
if (exit) {
|
||||
// Статус исходящего платежа возврата (заводится при одобрении советом —
|
||||
// см. MembershipExitAuthorizationListener). hash платежа = exit_hash.
|
||||
const payment = await this.paymentRepository.findByHash(exit.exit_hash);
|
||||
return {
|
||||
exit_hash: exit.exit_hash,
|
||||
status: exit.status as MembershipExitStatus,
|
||||
quantity: exit.quantity,
|
||||
payment_status: payment?.status,
|
||||
created_at: exit.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Off-chain фаза: заявление подписано, но ещё не подтверждено по email.
|
||||
const pending = await this.exitRequestRepository.findOne({ where: { coopname, username } });
|
||||
if (pending) {
|
||||
return {
|
||||
exit_hash: pending.exit_hash,
|
||||
status: MembershipExitStatus.AWAITING_CONFIRMATION,
|
||||
quantity: '',
|
||||
created_at: pending.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Терминальная фаза: выход завершён on-chain. После completexit строка
|
||||
// registrator::exits СТЁРТА (терминал=erase), аккаунт переведён в blocked.
|
||||
// exits-строки уже нет — источник терминального состояния: blocked-аккаунт +
|
||||
// персистентный MEMBERSHIP_EXIT-платёж в gateway (сумма возврата + «оплачено»).
|
||||
// Так кабинет остаётся заблокированным экраном «вы вышли» и после стирания
|
||||
// exits, и при следующих входах (blocked-статус живёт on-chain).
|
||||
const account = await this.accountBlockchainPort.getUserAccount(username);
|
||||
if (String(account?.status) === 'blocked') {
|
||||
const payment = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
username,
|
||||
PaymentTypeEnum.MEMBERSHIP_EXIT
|
||||
);
|
||||
if (payment) {
|
||||
const precision = config.blockchain.root_govern_precision ?? 4;
|
||||
return {
|
||||
exit_hash: payment.hash,
|
||||
status: MembershipExitStatus.COMPLETED,
|
||||
quantity: `${payment.quantity.toFixed(precision)} ${payment.symbol}`,
|
||||
payment_status: payment.status,
|
||||
created_at: (payment.completed_at ?? payment.created_at).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Предварительный расчёт суммы возврата паевого взноса при выходе пайщика.
|
||||
*
|
||||
* Считается обходом сета паевых кошельков Ledger2.EXIT_REFUND_WALLET_NAMES
|
||||
* (w.reg.minshr + w.wal.share + w.cap.blago) по L3-балансам — тот же сет, что
|
||||
* обходит контракт `confirmexit` при одобрении выхода советом, поэтому preview
|
||||
* совпадает с суммой, которую реально вернёт контракт.
|
||||
*/
|
||||
async getReturnPreview(coopname: string, username: string): Promise<MembershipExitReturnPreviewDTO> {
|
||||
const wallets = Ledger2.EXIT_REFUND_WALLET_NAMES;
|
||||
const rows = await Promise.all(
|
||||
wallets.map((wallet) => this.userWalletRepository.findByWalletAndUsername(coopname, wallet, username)),
|
||||
);
|
||||
|
||||
// available учитываем только для существующих (present) кошельков
|
||||
const balances = rows.map((row) => (row?.present !== false ? row?.available : undefined));
|
||||
|
||||
const template = await this.resolveAssetTemplate(coopname, balances.find((b) => !!b));
|
||||
const zero = this.zeroAssetLike(template);
|
||||
|
||||
const assets = balances.map((b) => b ?? zero);
|
||||
const total = assets.reduce((acc, a) => this.sumAssets(acc, a), zero);
|
||||
|
||||
// индивидуальные паевые отдаём для информации; фронт показывает total
|
||||
const balanceOf = (wallet: string): string => {
|
||||
const idx = wallets.indexOf(wallet);
|
||||
return idx >= 0 ? assets[idx] : zero;
|
||||
};
|
||||
|
||||
return {
|
||||
total,
|
||||
share_contribution: balanceOf(Ledger2.SHARE_WALLET_NAME),
|
||||
minimum_contribution: balanceOf(Ledger2.MIN_SHARE_WALLET_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
/** Сумма двух asset-строк одного символа («100.0000 RUB»). */
|
||||
private sumAssets(a: string, b: string): string {
|
||||
const [amountA, sym] = a.split(' ');
|
||||
const [amountB] = b.split(' ');
|
||||
const precision = (amountA.split('.')[1] || '').length;
|
||||
const total = Number(amountA) + Number(amountB);
|
||||
return `${total.toFixed(precision)} ${sym}`;
|
||||
}
|
||||
|
||||
/** Нулевой asset в символе/точности образца. */
|
||||
private zeroAssetLike(template: string): string {
|
||||
const [amount, sym] = template.split(' ');
|
||||
const precision = (amount.split('.')[1] || '').length;
|
||||
return `${(0).toFixed(precision)} ${sym}`;
|
||||
}
|
||||
|
||||
/** Образец asset для нулей — из имеющегося баланса либо из настроек кооператива. */
|
||||
private async resolveAssetTemplate(coopname: string, sample?: string): Promise<string> {
|
||||
if (sample) return sample;
|
||||
const coop = await this.blockchainPort.getCooperative(coopname);
|
||||
const precision = config.blockchain.root_govern_precision ?? 4;
|
||||
const symbol = config.blockchain.root_govern_symbol ?? 'RUB';
|
||||
return (coop?.initial as string | undefined) ?? `${(0).toFixed(precision)} ${symbol}`;
|
||||
}
|
||||
}
|
||||
+16
@@ -73,6 +73,22 @@ export class ParticipantInteractor {
|
||||
return await this.documentDomainService.generateDocument({ data, options });
|
||||
}
|
||||
|
||||
async generateMembershipExitApplication(
|
||||
data: Cooperative.Registry.ParticipantExitApplication.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<DocumentDomainEntity> {
|
||||
data.registry_id = Cooperative.Registry.ParticipantExitApplication.registry_id;
|
||||
return await this.documentDomainService.generateDocument({ data, options });
|
||||
}
|
||||
|
||||
async generateMembershipExitDecision(
|
||||
data: Cooperative.Registry.DecisionOfParticipantExit.Action,
|
||||
options: Cooperative.Document.IGenerationOptions
|
||||
): Promise<DocumentDomainEntity> {
|
||||
data.registry_id = Cooperative.Registry.DecisionOfParticipantExit.registry_id;
|
||||
return await this.documentDomainService.generateDocument({ data, options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет подпись документа
|
||||
* @private
|
||||
|
||||
@@ -25,6 +25,42 @@ import { Workflows } from '@coopenomics/notifications';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { normalizeUserEmail } from '~/utils/normalize-user-email';
|
||||
|
||||
/** Минимум членов совета — как MIN_SOVIET_MEMBERS_COUNT в контракте soviet (3 prod / 1 dev). */
|
||||
const MIN_SOVIET_MEMBERS_COUNT = config.min_soviet_members_count;
|
||||
|
||||
function councilRoleLabel(index: number, role?: string): string {
|
||||
if (role === 'chairman' || index === 0) return 'председателя совета';
|
||||
return `члена совета №${index + 1}`;
|
||||
}
|
||||
|
||||
function assertSovietMemberComplete(
|
||||
member: InstallInputDomainInterface['soviet'][number],
|
||||
index: number,
|
||||
): void {
|
||||
const data = member?.individual_data;
|
||||
const label = councilRoleLabel(index, member?.role);
|
||||
const email = typeof data?.email === 'string' ? data.email.trim() : '';
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException(`Не указан email для ${label}`);
|
||||
}
|
||||
|
||||
const requiredStringFields: Array<[keyof Cooperative.Users.IIndividualData, string]> = [
|
||||
['last_name', 'фамилия'],
|
||||
['first_name', 'имя'],
|
||||
['full_address', 'адрес регистрации'],
|
||||
['birthdate', 'дата рождения'],
|
||||
['phone', 'телефон'],
|
||||
];
|
||||
|
||||
for (const [field, humanName] of requiredStringFields) {
|
||||
const value = data?.[field];
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new BadRequestException(`Не заполнено поле «${humanName}» для ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InstallInteractor {
|
||||
constructor(
|
||||
@@ -118,14 +154,32 @@ export class InstallInteractor {
|
||||
|
||||
async install(data: InstallInputDomainInterface): Promise<void> {
|
||||
const status = await this.monoStatusRepository.getStatus();
|
||||
const savedVars = await this.varsRepository.get();
|
||||
const isIncompleteInstallMaintenance =
|
||||
status === SystemStatus.maintenance && !savedVars?.name;
|
||||
|
||||
// Разрешаем установку ТОЛЬКО если система в статусе 'initialized' (после initSystem)
|
||||
if (status !== SystemStatus.initialized) {
|
||||
// Разрешаем установку если:
|
||||
// - initialized (нормальный прогон)
|
||||
// - maintenance без сохранённых vars (прерванная установка, можно повторить)
|
||||
if (status !== SystemStatus.initialized && !isIncompleteInstallMaintenance) {
|
||||
throw new BadRequestException(
|
||||
`Установка невозможна. Текущий статус: ${status}. Требуется статус: ${SystemStatus.initialized}`
|
||||
);
|
||||
}
|
||||
|
||||
if (data.soviet.length < MIN_SOVIET_MEMBERS_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`Количество членов совета должно быть не менее ${MIN_SOVIET_MEMBERS_COUNT}`
|
||||
);
|
||||
}
|
||||
|
||||
data.soviet.forEach((member, index) => assertSovietMemberComplete(member, index));
|
||||
|
||||
// Повтор после прерванной установки: снимаем maintenance, дальше — обычный прогон.
|
||||
if (isIncompleteInstallMaintenance) {
|
||||
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
|
||||
}
|
||||
|
||||
const info = await this.blockchainPort.getInfo();
|
||||
const coop = await this.blockchainPort.getCooperative(config.coopname);
|
||||
|
||||
|
||||
@@ -61,6 +61,15 @@ export class TokenApplicationService {
|
||||
return this.tokenDomainService.generateVerifyEmailToken(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует токен подтверждения выхода из кооператива
|
||||
* @param userId - ID пользователя
|
||||
* @returns Токен подтверждения выхода
|
||||
*/
|
||||
async generateConfirmExitToken(userId: string): Promise<string> {
|
||||
return this.tokenDomainService.generateConfirmExitToken(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует токен
|
||||
* @param input - данные для верификации
|
||||
|
||||
@@ -189,6 +189,10 @@ const envVarsSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
|
||||
MIN_SOVIET_MEMBERS_COUNT: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Минимум членов совета при install; пусто — 3 на production, 1 на development/test'),
|
||||
});
|
||||
|
||||
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
|
||||
@@ -260,6 +264,11 @@ export default {
|
||||
},
|
||||
},
|
||||
coopname: envVars.data.COOPNAME,
|
||||
min_soviet_members_count: envVars.data.MIN_SOVIET_MEMBERS_COUNT
|
||||
? parseInt(envVars.data.MIN_SOVIET_MEMBERS_COUNT, 10)
|
||||
: envVars.data.NODE_ENV === 'production'
|
||||
? 3
|
||||
: 1,
|
||||
graphql_service: envVars.data.GRAPHQL_SERVICE,
|
||||
provider_base_url: envVars.data.PROVIDER_BASE_URL,
|
||||
union: {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { RegistratorContract, SovietContract } from 'cooptypes';
|
||||
import type { BlockchainAccountInterface } from '~/types/shared';
|
||||
import type { CandidateDomainInterface } from '../interfaces/candidate-domain.interface';
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
|
||||
/**
|
||||
* Входные данные подачи заявления на выход пайщика из кооператива (registrator::exitcoop).
|
||||
*/
|
||||
export interface ExitCoopDomainInterface {
|
||||
coopname: string;
|
||||
username: string;
|
||||
exit_hash: string;
|
||||
statement: ISignedDocumentDomainInterface;
|
||||
}
|
||||
|
||||
export interface AccountBlockchainPort {
|
||||
getBlockchainAccount(username: string): Promise<BlockchainAccountInterface | null>;
|
||||
@@ -12,6 +23,13 @@ export interface AccountBlockchainPort {
|
||||
getUserAccount(username: string): Promise<RegistratorContract.Tables.Accounts.IAccount | null>;
|
||||
addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void>;
|
||||
registerBlockchainAccount(candidate: CandidateDomainInterface): Promise<void>;
|
||||
// Подача заявления на выход пайщика из кооператива (registrator::exitcoop)
|
||||
exitCoop(data: ExitCoopDomainInterface): Promise<void>;
|
||||
// Текущий процесс выхода пайщика (registrator::exits), либо null
|
||||
getExit(coopname: string, username: string): Promise<RegistratorContract.Tables.Exits.IExit | null>;
|
||||
// Процесс выхода по exit_hash (registrator::exits): on-chain confirmexit отдаёт
|
||||
// только coopname+exit_hash, username и сумму возврата берём из таблицы по хэшу.
|
||||
getExitByHash(coopname: string, exit_hash: string): Promise<RegistratorContract.Tables.Exits.IExit | null>;
|
||||
}
|
||||
|
||||
export const ACCOUNT_BLOCKCHAIN_PORT = Symbol('AccountBlockchainPort');
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
|
||||
/**
|
||||
* Входные данные подачи заявления на выход пайщика из кооператива.
|
||||
*/
|
||||
export interface CreateMembershipExitInputDomainInterface {
|
||||
coopname: string;
|
||||
username: string;
|
||||
exit_hash: string;
|
||||
statement: ISignedDocumentDomainInterface;
|
||||
}
|
||||
@@ -22,6 +22,26 @@ export class DocumentDomainService {
|
||||
) {}
|
||||
|
||||
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
|
||||
const documentData = data.data as Cooperative.Document.IGenerate & {
|
||||
doc_data?: Record<string, unknown>;
|
||||
doc_data_hash?: string;
|
||||
};
|
||||
|
||||
if (documentData.doc_data) {
|
||||
const { doc_data, ...publicDocumentData } = documentData;
|
||||
const { hash } = documentData.doc_data_hash
|
||||
? { hash: documentData.doc_data_hash }
|
||||
: await this.saveDocData(doc_data, documentData.registry_id);
|
||||
|
||||
return await this.generatorInfrastructureService.generateDocument({
|
||||
...data,
|
||||
data: {
|
||||
...publicDocumentData,
|
||||
doc_data_hash: hash,
|
||||
} as Cooperative.Document.IGenerate,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.generatorInfrastructureService.generateDocument(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Spec MinIO-бакета `gateway:files` — ядровое хранилище файлов платежей.
|
||||
*
|
||||
* Содержимое: чеки об оплате (PAYMENT_PROOF) по исходящим платежам — единая
|
||||
* «культура денег» (входящие подтверждаем, исходящие сопровождаем чеком).
|
||||
* Привязка по `payment_hash`; переиспользуется всеми расширениями.
|
||||
*
|
||||
* Ключ объекта: `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
|
||||
*/
|
||||
export const GATEWAY_BUCKET = {
|
||||
name: 'gateway:files',
|
||||
maxBytes: 20 * 1024 * 1024,
|
||||
allowedMime: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/heic',
|
||||
'application/pdf',
|
||||
] as const,
|
||||
defaultUrlTtlSeconds: 600,
|
||||
} as const;
|
||||
|
||||
export type GatewayBucketAllowedMime = (typeof GATEWAY_BUCKET.allowedMime)[number];
|
||||
@@ -113,6 +113,7 @@ export class PaymentDomainEntity implements PaymentDomainInterface {
|
||||
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
|
||||
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат взноса',
|
||||
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
|
||||
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
|
||||
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
|
||||
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
|
||||
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Назначение файла, приложенного к платежу (бакет `gateway:files`).
|
||||
*
|
||||
* Ядровая «культура денег»: каждый ИСХОДЯЩИЙ платёж сопровождается чеком об
|
||||
* оплате (подтверждающий документ — для кассира «чем оплатил», для пайщика «на
|
||||
* что»). Сейчас единственный вид — PAYMENT_PROOF; enum оставлен расширяемым.
|
||||
*/
|
||||
export enum PaymentFileKind {
|
||||
PAYMENT_PROOF = 'PAYMENT_PROOF',
|
||||
}
|
||||
|
||||
registerEnumType(PaymentFileKind, {
|
||||
name: 'PaymentFileKind',
|
||||
description: 'Тип файла, приложенного к платежу.',
|
||||
valuesMap: {
|
||||
PAYMENT_PROOF: { description: 'Чек об оплате — подтверждающий документ по исполненному платежу.' },
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,10 @@ export enum PaymentTypeEnum {
|
||||
// Исходящий возврат вступительного и мин. паевого взносов при отказе совета
|
||||
// в приёме. Отдельный тип, не WITHDRAWAL — чтобы не путать с возвратом паевого.
|
||||
REGISTRATION_REFUND = 'registration_refund',
|
||||
// Исходящий возврат всего паевого взноса при выходе пайщика из кооператива.
|
||||
// Отдельный тип, не WITHDRAWAL — это полный выход с блокировкой аккаунта,
|
||||
// а не частичный возврат паевого действующему пайщику.
|
||||
MEMBERSHIP_EXIT = 'membership_exit',
|
||||
// Исходящая оплата позиции служебной записки-сметы (шасси expense). Создаётся
|
||||
// автоматически после авторизации СЗ советом; подтверждение кассиром проводит
|
||||
// on-chain оплату expense::payexp по позиции.
|
||||
@@ -41,6 +45,7 @@ export const PAYMENT_TYPE_LABELS: Record<PaymentTypeEnum, string> = {
|
||||
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
|
||||
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат паевого взноса',
|
||||
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
|
||||
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
|
||||
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
|
||||
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
|
||||
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
|
||||
@@ -84,6 +89,7 @@ export const INCOMING_PAYMENT_TYPES = [
|
||||
export const OUTGOING_PAYMENT_TYPES = [
|
||||
PaymentTypeEnum.WITHDRAWAL,
|
||||
PaymentTypeEnum.REGISTRATION_REFUND,
|
||||
PaymentTypeEnum.MEMBERSHIP_EXIT,
|
||||
PaymentTypeEnum.EXPENSE,
|
||||
PaymentTypeEnum.EXPENSE_OVERSPEND,
|
||||
];
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { PaymentFileKind } from '../enums/payment-file-kind.enum';
|
||||
|
||||
/**
|
||||
* Запись о файле, приложенном к платежу (чек об оплате). Сам бинарь лежит в
|
||||
* MinIO-бакете `gateway:files`; в блокчейне файла нет — это чистая БД-сущность,
|
||||
* для проверки целостности используется `checksum_sha256`.
|
||||
*
|
||||
* Привязка — по `payment_hash` (единый ключ любого платежа). Ключ MinIO:
|
||||
* `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
|
||||
*/
|
||||
export interface IPaymentFileDatabaseData {
|
||||
id?: number;
|
||||
coopname: string;
|
||||
payment_hash: string;
|
||||
kind: PaymentFileKind;
|
||||
checksum_sha256: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_key: string;
|
||||
/** Оригинальное имя загруженного файла — для отображения и поиска */
|
||||
original_filename?: string | null;
|
||||
uploaded_by_username: string;
|
||||
uploaded_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IPaymentFileDatabaseData } from '../interfaces/payment-file-database.interface';
|
||||
|
||||
/**
|
||||
* Реестр файлов, приложенных к платежам (чеки об оплате). Файл — чистая БД-сущность
|
||||
* (MinIO + метаданные), блокчейна за ним нет, поэтому интерфейс не наследует
|
||||
* sync-репозиторий.
|
||||
*/
|
||||
export interface PaymentFileRepository {
|
||||
create(data: IPaymentFileDatabaseData): Promise<IPaymentFileDatabaseData>;
|
||||
findById(id: number): Promise<IPaymentFileDatabaseData | null>;
|
||||
findByChecksum(coopname: string, checksum: string): Promise<IPaymentFileDatabaseData | null>;
|
||||
findByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]>;
|
||||
delete(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
export const PAYMENT_FILE_REPOSITORY = Symbol('PaymentFileRepository');
|
||||
@@ -190,6 +190,22 @@ export class TokenDomainService {
|
||||
return verifyEmailToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует токен подтверждения выхода из кооператива (ссылка в письме).
|
||||
* Срок жизни — как у верификации email (это тоже подтверждение по почте).
|
||||
*/
|
||||
async generateConfirmExitToken(userId: string): Promise<string> {
|
||||
const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes');
|
||||
const confirmExitToken = this.generateToken({
|
||||
userId,
|
||||
expires: expires.toDate(),
|
||||
type: tokenTypes.CONFIRM_EXIT,
|
||||
});
|
||||
|
||||
await this.saveToken(confirmExitToken, userId, expires.toDate(), tokenTypes.CONFIRM_EXIT);
|
||||
return confirmExitToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет токены по критериям
|
||||
*/
|
||||
|
||||
@@ -68,6 +68,9 @@ export class CapitalOnboardingStateDTO {
|
||||
@Field(() => String, { nullable: true })
|
||||
onboarding_blagorost_offer_template_hash?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
capital_program_doc_data_hash?: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
onboarding_init_at!: string;
|
||||
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { CapitalContract } from 'cooptypes';
|
||||
import config from '~/config/config';
|
||||
import type { IDelta } from '~/types/common';
|
||||
import { ProjectStatus } from '../../domain/enums/project-status.enum';
|
||||
import { ProgramShareRegistrationService } from '../services/program-share-registration.service';
|
||||
|
||||
/**
|
||||
* Слушатель дельт `capital::projects`. Как только проект (в т.ч. компонент)
|
||||
* оказывается в статусе active — СРАЗУ регистрирует доли всех активных пайщиков
|
||||
* Благороста в нём, не дожидаясь периодического scheduler'а. В pending не
|
||||
* заводим: заход только в активные проекты (решение пользователя 2026-06-16);
|
||||
* при переходе pending→active придёт новая дельта со status=active.
|
||||
*
|
||||
* Why: окно между созданием проекта и его переводом в `result` может быть
|
||||
* минутами. Контракт `regshare` принимает только pending|active, а отката
|
||||
* `result → active` нет — пайщики, не успевшие попасть до закрытия окна, теряют
|
||||
* долю в компоненте безвозвратно (инцидент voskhod, компонент 011bcd92…,
|
||||
* 2026-06-16). Событие закрывает это окно. Зеркалит
|
||||
* `ProgramShareRegistrationOnUserWalletDeltaListener` (тот реагирует на
|
||||
* изменение баланса, этот — на появление проекта).
|
||||
*
|
||||
* Неблокирующий: обработчик получает событие и неспешно обходит пайщиков;
|
||||
* на dispatch-pipeline/парсер не влияет (EventEmitter2.emit — fire-and-forget).
|
||||
* Промахи (downtime/потеря события) подбирает периодический scheduler-бэкстоп.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProgramShareRegistrationOnProjectDeltaListener {
|
||||
private readonly logger = new Logger(ProgramShareRegistrationOnProjectDeltaListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly programShareRegistrationService: ProgramShareRegistrationService
|
||||
) {}
|
||||
|
||||
@OnEvent(`delta::${CapitalContract.contractName.production}::${CapitalContract.Tables.Projects.tableName}`)
|
||||
async handleProjectDelta(delta: IDelta): Promise<void> {
|
||||
if (!delta.present) return;
|
||||
if (delta.scope !== config.coopname) return;
|
||||
|
||||
const value = delta.value as CapitalContract.Tables.Projects.IProject | undefined;
|
||||
if (!value?.project_hash) return;
|
||||
|
||||
// Заводим доли только в active-проектах: pending ещё не готов к заходу
|
||||
// (решение пользователя 2026-06-16). При переходе pending→active придёт
|
||||
// новая дельта со status=active — на ней и зайдём.
|
||||
const status = String(value.status);
|
||||
if (status !== ProjectStatus.ACTIVE) return;
|
||||
|
||||
const project_hash = String(value.project_hash);
|
||||
try {
|
||||
await this.programShareRegistrationService.syncProgramSharesForProject(
|
||||
delta.scope,
|
||||
project_hash
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.warn(
|
||||
`regshare event-driven (project) sync не выполнен: coop=${delta.scope} project=${project_hash}: ${message}`,
|
||||
stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-8
@@ -29,6 +29,10 @@ type OnboardingHashKey =
|
||||
| 'onboarding_blagorost_provision_hash'
|
||||
| 'onboarding_blagorost_offer_template_hash';
|
||||
|
||||
type CapitalOnboardingConfig = IConfig &
|
||||
Partial<Record<OnboardingFlagKey, boolean>> &
|
||||
Partial<Record<OnboardingHashKey | 'onboarding_init_at' | 'onboarding_expire_at' | 'capital_program_doc_data_hash', string>>;
|
||||
|
||||
@Injectable()
|
||||
export class CapitalOnboardingService {
|
||||
constructor(
|
||||
@@ -88,10 +92,40 @@ export class CapitalOnboardingService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPlugin(): Promise<ExtensionDomainEntity<IConfig & Record<string, any>>> {
|
||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
private getMetaString(meta: unknown, key: string): string | undefined {
|
||||
if (!this.isRecord(meta)) return undefined;
|
||||
const value = meta[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private isSignatureInfo(value: unknown): value is ISignedDocumentDomainInterface['signatures'][number] {
|
||||
if (!this.isRecord(value)) return false;
|
||||
|
||||
return (
|
||||
typeof value.id === 'number' &&
|
||||
typeof value.signed_hash === 'string' &&
|
||||
typeof value.signer === 'string' &&
|
||||
typeof value.public_key === 'string' &&
|
||||
typeof value.signature === 'string' &&
|
||||
typeof value.signed_at === 'string' &&
|
||||
typeof value.meta === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
private getMetaSignatures(meta: unknown): ISignedDocumentDomainInterface['signatures'] {
|
||||
if (!this.isRecord(meta)) return [];
|
||||
const value = meta.signatures;
|
||||
return Array.isArray(value) ? value.filter((item) => this.isSignatureInfo(item)) : [];
|
||||
}
|
||||
|
||||
private async loadPlugin(): Promise<ExtensionDomainEntity<CapitalOnboardingConfig>> {
|
||||
const plugin = await this.extensionRepository.findByName('capital');
|
||||
if (!plugin) throw new Error('Конфигурация расширения capital не найдена');
|
||||
const pluginConfig = { ...plugin.config } as IConfig & Record<string, any>;
|
||||
const pluginConfig: CapitalOnboardingConfig = { ...plugin.config };
|
||||
|
||||
let needUpdate = false;
|
||||
if (!pluginConfig.onboarding_init_at) {
|
||||
@@ -113,7 +147,7 @@ export class CapitalOnboardingService {
|
||||
return { ...plugin, config: pluginConfig };
|
||||
}
|
||||
|
||||
private buildState(pluginConfig: IConfig & Record<string, any>): CapitalOnboardingStateDTO {
|
||||
private buildState(pluginConfig: CapitalOnboardingConfig): CapitalOnboardingStateDTO {
|
||||
return {
|
||||
generator_program_template_done: !!pluginConfig.onboarding_generator_program_template_done,
|
||||
onboarding_generator_program_template_hash: pluginConfig.onboarding_generator_program_template_hash || null,
|
||||
@@ -125,6 +159,7 @@ export class CapitalOnboardingService {
|
||||
onboarding_blagorost_provision_hash: pluginConfig.onboarding_blagorost_provision_hash || null,
|
||||
blagorost_offer_template_done: !!pluginConfig.onboarding_blagorost_offer_template_done,
|
||||
onboarding_blagorost_offer_template_hash: pluginConfig.onboarding_blagorost_offer_template_hash || null,
|
||||
capital_program_doc_data_hash: pluginConfig.capital_program_doc_data_hash || null,
|
||||
onboarding_init_at: pluginConfig.onboarding_init_at || '',
|
||||
onboarding_expire_at: pluginConfig.onboarding_expire_at || '',
|
||||
};
|
||||
@@ -141,7 +176,7 @@ export class CapitalOnboardingService {
|
||||
const hashKey = this.mapStepToHash(data.step);
|
||||
const normalizedTitle = data.title?.trim().substring(0, 200) || undefined;
|
||||
|
||||
if ((plugin.config as any)[flagKey]) {
|
||||
if (plugin.config[flagKey]) {
|
||||
return this.buildState(plugin.config);
|
||||
}
|
||||
const project_id = uuid();
|
||||
@@ -168,12 +203,12 @@ export class CapitalOnboardingService {
|
||||
|
||||
// Публикуем проект решения в блокчейн сразу после генерации
|
||||
const documentForPublish: ISignedDocumentDomainInterface = {
|
||||
version: (generatedDoc.meta as any)?.version || '1.0',
|
||||
version: this.getMetaString(generatedDoc.meta, 'version') || '1.0',
|
||||
hash: generatedDoc.hash,
|
||||
doc_hash: (generatedDoc.meta as any)?.doc_hash || generatedDoc.hash,
|
||||
meta_hash: (generatedDoc.meta as any)?.meta_hash || generatedDoc.hash,
|
||||
doc_hash: this.getMetaString(generatedDoc.meta, 'doc_hash') || generatedDoc.hash,
|
||||
meta_hash: this.getMetaString(generatedDoc.meta, 'meta_hash') || generatedDoc.hash,
|
||||
meta: generatedDoc.meta,
|
||||
signatures: (generatedDoc.meta as any)?.signatures || [],
|
||||
signatures: this.getMetaSignatures(generatedDoc.meta),
|
||||
};
|
||||
|
||||
await this.freeDecisionPort.publishProjectOfFreeDecision({
|
||||
|
||||
+43
-14
@@ -47,12 +47,12 @@ export class ProgramShareRegistrationService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Обход участников в статусах active/import, проектов pending/active; при изменении user_shares относительно capital_contributor_shares — regshare.
|
||||
* Обход участников в статусах active/import по active-проектам; при изменении user_shares относительно capital_contributor_shares — regshare.
|
||||
*/
|
||||
async syncProgramSharesForCoop(coopname: string): Promise<void> {
|
||||
const projects = await this.findActiveProjects(coopname);
|
||||
if (projects.length === 0) {
|
||||
this.logger.debug(`Синхронизация regshare: нет проектов в статусах pending/active для ${coopname}`);
|
||||
this.logger.debug(`Синхронизация regshare: нет active-проектов для ${coopname}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,15 +62,16 @@ export class ProgramShareRegistrationService {
|
||||
(c.status === ContributorStatus.ACTIVE || c.status === ContributorStatus.IMPORT)
|
||||
);
|
||||
|
||||
const projectHashes = projects.map((p) => p.project_hash);
|
||||
for (const contributor of contributors) {
|
||||
await this.syncContributor(coopname, contributor, projects);
|
||||
await this.syncContributor(coopname, contributor, projectHashes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Точечная синхронизация regshare для одного пайщика — вызывается из listener'а
|
||||
* на дельты `ledger2::userwallets[w.cap.blago]`. Не пишет лог, если у пайщика
|
||||
* нет ни одного pending/active проекта.
|
||||
* нет ни одного active-проекта.
|
||||
*/
|
||||
async syncProgramSharesForUser(coopname: string, username: string): Promise<void> {
|
||||
const projects = await this.findActiveProjects(coopname);
|
||||
@@ -84,21 +85,49 @@ export class ProgramShareRegistrationService {
|
||||
);
|
||||
if (!contributor) return;
|
||||
|
||||
await this.syncContributor(coopname, contributor, projects);
|
||||
await this.syncContributor(coopname, contributor, projects.map((p) => p.project_hash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Точечная регистрация долей всех активных пайщиков в один проект — вызывается
|
||||
* из listener'а на дельты `capital::projects` сразу при появлении проекта.
|
||||
*
|
||||
* Why: между созданием проекта и его переводом в `result` может пройти меньше
|
||||
* минуты; контракт `regshare` принимает только статусы pending|active, а откат
|
||||
* `result → active` не предусмотрен — значит пайщики, не успевшие попасть в
|
||||
* проект до закрытия окна, теряют долю в нём безвозвратно (инцидент voskhod,
|
||||
* компонент 011bcd92…, 2026-06-16). Реакция на событие закрывает окно, не
|
||||
* дожидаясь периодического scheduler'а.
|
||||
*
|
||||
* Переиспользует тот же `syncContributor`, что и обход по расписанию.
|
||||
*/
|
||||
async syncProgramSharesForProject(coopname: string, project_hash: string): Promise<void> {
|
||||
const contributors = (await this.contributorRepository.findAll()).filter(
|
||||
(c) =>
|
||||
c.coopname === coopname &&
|
||||
(c.status === ContributorStatus.ACTIVE || c.status === ContributorStatus.IMPORT)
|
||||
);
|
||||
if (contributors.length === 0) return;
|
||||
|
||||
for (const contributor of contributors) {
|
||||
await this.syncContributor(coopname, contributor, [project_hash]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Только active-проекты: заход долей в pending отключён (решение пользователя
|
||||
* 2026-06-16) — заводим/сверяем доли лишь в активных проектах.
|
||||
*/
|
||||
private async findActiveProjects(coopname: string): Promise<ProjectDomainEntity[]> {
|
||||
return (await this.projectRepository.findAll()).filter(
|
||||
(p) =>
|
||||
p.coopname === coopname &&
|
||||
(p.status === ProjectStatus.PENDING || p.status === ProjectStatus.ACTIVE)
|
||||
(p) => p.coopname === coopname && p.status === ProjectStatus.ACTIVE
|
||||
);
|
||||
}
|
||||
|
||||
private async syncContributor(
|
||||
coopname: string,
|
||||
contributor: ContributorDomainEntity,
|
||||
projects: ProjectDomainEntity[]
|
||||
projectHashes: string[]
|
||||
): Promise<void> {
|
||||
const programId = getProgramId(ProgramType.BLAGOROST);
|
||||
|
||||
@@ -124,10 +153,10 @@ export class ProgramShareRegistrationService {
|
||||
const targetParsed = AssetUtils.parseAsset(targetShares);
|
||||
if (!targetParsed.symbol) return;
|
||||
|
||||
for (const project of projects) {
|
||||
for (const projectHash of projectHashes) {
|
||||
const segment = await this.capitalBlockchainPort.getSegmentByProjectUser(
|
||||
coopname,
|
||||
project.project_hash,
|
||||
projectHash,
|
||||
contributor.username
|
||||
);
|
||||
|
||||
@@ -139,19 +168,19 @@ export class ProgramShareRegistrationService {
|
||||
try {
|
||||
await this.capitalBlockchainPort.registerShare({
|
||||
coopname,
|
||||
project_hash: project.project_hash,
|
||||
project_hash: projectHash,
|
||||
username: contributor.username,
|
||||
user_shares: targetShares,
|
||||
});
|
||||
this.logger.log(
|
||||
`regshare: ${contributor.username} → проект ${project.project_hash}, user_shares=${targetShares} (было ${registeredStr})`
|
||||
`regshare: ${contributor.username} → проект ${projectHash}, user_shares=${targetShares} (было ${registeredStr})`
|
||||
);
|
||||
await delay(REGSHARE_TX_GAP_MS);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof HttpApiError ? error.message : error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.warn(
|
||||
`regshare не выполнен: coop=${coopname} project=${project.project_hash} user=${contributor.username}: ${message}`,
|
||||
`regshare не выполнен: coop=${coopname} project=${projectHash} user=${contributor.username}: ${message}`,
|
||||
stack
|
||||
);
|
||||
}
|
||||
|
||||
+27
-1
@@ -51,6 +51,11 @@ import { ProgramKey } from '~/domain/registration/enum';
|
||||
import type { GenerateCapitalRegistrationDocumentsDomainInput } from '../../domain/actions/generate-capital-registration-documents-domain-input.interface';
|
||||
import type { GenerateCapitalRegistrationDocumentsDomainOutput } from '../../domain/actions/generate-capital-registration-documents-domain-output.interface';
|
||||
import type { CompleteCapitalRegistrationDomainInput } from '../../domain/actions/complete-capital-registration-domain-input.interface';
|
||||
import {
|
||||
EXTENSION_REPOSITORY,
|
||||
type ExtensionDomainRepository,
|
||||
} from '~/domain/extension/repositories/extension-domain.repository';
|
||||
import type { IConfig } from '../../capital-extension.module';
|
||||
|
||||
/**
|
||||
* Интерактор домена для управления участием в CAPITAL контракте
|
||||
@@ -76,8 +81,24 @@ export class ParticipationManagementInteractor {
|
||||
private readonly projectManagementInteractor: ProjectManagementInteractor,
|
||||
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
|
||||
private readonly documentInteractor: DocumentInteractor,
|
||||
@Inject(EXTENSION_REPOSITORY)
|
||||
private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
|
||||
) { }
|
||||
|
||||
private async getCapitalProgramDocDataHash(): Promise<string> {
|
||||
const extension = await this.extensionRepository.findByName('capital');
|
||||
const hash = (extension?.config as IConfig | undefined)?.capital_program_doc_data_hash?.trim();
|
||||
|
||||
if (!hash) {
|
||||
throw new HttpApiError(
|
||||
httpStatus.BAD_REQUEST,
|
||||
'Параметры документов ЦПП не заполнены: отсутствует capital_program_doc_data_hash в конфигурации capital'
|
||||
);
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение отображаемого имени из аккаунта через порт расширения
|
||||
*/
|
||||
@@ -313,7 +334,8 @@ export class ParticipationManagementInteractor {
|
||||
}
|
||||
|
||||
// Извлекаем appendix_hash из метаданных документа
|
||||
const appendix_hash = (document.meta as any).appendix_hash;
|
||||
const documentMeta: Record<string, unknown> = document.meta;
|
||||
const appendix_hash = typeof documentMeta.appendix_hash === 'string' ? documentMeta.appendix_hash : undefined;
|
||||
|
||||
//TODO: адаптировать или документ или код ниже к parent_appendix_hash
|
||||
if (!appendix_hash) {
|
||||
@@ -778,6 +800,7 @@ export class ParticipationManagementInteractor {
|
||||
|
||||
// Генерируем параметры для соглашения Благорост
|
||||
await this.udataDocumentParametersService.generateBlagorostAgreementParametersIfNotExist(data.coopname, data.username);
|
||||
const capitalProgramDocDataHash = await this.getCapitalProgramDocDataHash();
|
||||
|
||||
blagorostAgreement = await this.documentInteractor.generateDocument({
|
||||
data: {
|
||||
@@ -785,6 +808,7 @@ export class ParticipationManagementInteractor {
|
||||
username: data.username,
|
||||
lang,
|
||||
registry_id: Cooperative.Registry.BlagorostAgreement.registry_id,
|
||||
doc_data_hash: capitalProgramDocDataHash,
|
||||
},
|
||||
options: { skip_save: false },
|
||||
});
|
||||
@@ -809,6 +833,7 @@ export class ParticipationManagementInteractor {
|
||||
|
||||
// Генерируем параметры для оферты Генератор
|
||||
await this.udataDocumentParametersService.generateGeneratorOfferParametersIfNotExist(data.coopname, data.username);
|
||||
const capitalProgramDocDataHash = await this.getCapitalProgramDocDataHash();
|
||||
|
||||
generatorOffer = await this.documentInteractor.generateDocument({
|
||||
data: {
|
||||
@@ -816,6 +841,7 @@ export class ParticipationManagementInteractor {
|
||||
username: data.username,
|
||||
lang,
|
||||
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
|
||||
doc_data_hash: capitalProgramDocDataHash,
|
||||
},
|
||||
options: { skip_save: false },
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export const defaultConfig = {
|
||||
onboarding_generator_offer_template_done: false,
|
||||
onboarding_blagorost_provision_done: false,
|
||||
onboarding_blagorost_offer_template_done: false,
|
||||
capital_program_doc_data_hash: '',
|
||||
/** Ветка для выборки коммитов (FR3 / PRD); URL репозитория — на проекте/компоненте (PRD §6.2.1). */
|
||||
github_sync_branch: 'dev',
|
||||
/** Интервал polling GitHub API в минутах (FR2); 0 — периодический опрос отключён. */
|
||||
@@ -253,6 +254,10 @@ export const Schema = z.object({
|
||||
.boolean()
|
||||
.default(defaultConfig.onboarding_blagorost_offer_template_done)
|
||||
.describe(describeField({ label: 'Шаг предложения Благорост выполнен', visible: false })),
|
||||
capital_program_doc_data_hash: z
|
||||
.string()
|
||||
.default(defaultConfig.capital_program_doc_data_hash)
|
||||
.describe(describeField({ label: 'PrivateData документов ЦПП', visible: false })),
|
||||
|
||||
});
|
||||
|
||||
@@ -311,6 +316,7 @@ import { GitHubSyncSchedulerService } from './infrastructure/services/github-syn
|
||||
import { ProgramShareRegistrationSchedulerService } from './infrastructure/services/program-share-registration-scheduler.service';
|
||||
import { ProgramShareRegistrationService } from './application/services/program-share-registration.service';
|
||||
import { ProgramShareRegistrationOnUserWalletDeltaListener } from './application/listeners/program-share-registration-on-user-wallet-delta.listener';
|
||||
import { ProgramShareRegistrationOnProjectDeltaListener } from './application/listeners/program-share-registration-on-project-delta.listener';
|
||||
import { CapitalDevelopmentRepositoryGitSyncService } from './application/services/capital-development-repository-git-sync.service';
|
||||
import { CapitalGithubExtensionLifecycleListener } from './application/listeners/capital-github-extension-lifecycle.listener';
|
||||
import { GitCommitMarkersSyncService } from './application/services/git-commit-markers-sync.service';
|
||||
@@ -576,8 +582,10 @@ export class CapitalPlugin extends BaseExtModule {
|
||||
} else {
|
||||
this.logger.log('Конфигурация контракта CAPITAL уже актуальна');
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Не удалось синхронизировать конфигурацию с контрактом CAPITAL: ${error.message}`, error.stack);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Не удалось синхронизировать конфигурацию с контрактом CAPITAL: ${message}`, stack);
|
||||
// Не бросаем ошибку, чтобы не блокировать запуск модуля
|
||||
}
|
||||
|
||||
@@ -585,10 +593,12 @@ export class CapitalPlugin extends BaseExtModule {
|
||||
try {
|
||||
await this.syncInteractor.initializeSync();
|
||||
this.logger.log('Синхронизация благороста с блокчейном инициализирована');
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(
|
||||
`Не удалось инициализировать синхронизацию благороста с блокчейном: ${error.message}`,
|
||||
error.stack
|
||||
`Не удалось инициализировать синхронизацию благороста с блокчейном: ${message}`,
|
||||
stack
|
||||
);
|
||||
// Не бросаем ошибку, чтобы не блокировать запуск модуля
|
||||
}
|
||||
@@ -875,6 +885,7 @@ IssueIdGenerationService,
|
||||
ProgramShareRegistrationService,
|
||||
ProgramShareRegistrationSchedulerService,
|
||||
ProgramShareRegistrationOnUserWalletDeltaListener,
|
||||
ProgramShareRegistrationOnProjectDeltaListener,
|
||||
GitCommitMarkersSyncService,
|
||||
{
|
||||
provide: ISSUE_LINKED_GIT_COMMIT_REPOSITORY,
|
||||
|
||||
+8
-1
@@ -3,8 +3,15 @@ import { config } from '~/config';
|
||||
import { ProgramShareRegistrationService } from '../../application/services/program-share-registration.service';
|
||||
|
||||
/**
|
||||
* Периодическая синхронизация долей участников (regshare) по балансу программы Благорост.
|
||||
* Периодическая сверка долей участников (regshare) с балансом программы Благорост.
|
||||
* Интервал задаётся в конфигурации расширения Capital (минуты); 0 — отключено.
|
||||
*
|
||||
* Роль — reconciliation-бэкстоп, не основной путь. Горячие сценарии покрыты
|
||||
* событиями: появление проекта — `ProgramShareRegistrationOnProjectDeltaListener`,
|
||||
* изменение баланса — `ProgramShareRegistrationOnUserWalletDeltaListener`. Крон
|
||||
* оставлен, потому что он ещё и ДОобновляет уже зарегистрированные доли при
|
||||
* дрейфе баланса (контракт `upsert_contributor_segment` это допускает) и
|
||||
* подбирает события, потерянные при downtime контроллера.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProgramShareRegistrationSchedulerService implements OnModuleDestroy {
|
||||
|
||||
@@ -211,6 +211,27 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort {
|
||||
};
|
||||
}
|
||||
|
||||
async exitCoop(data: import('~/domain/account/interfaces/account-blockchain.port').ExitCoopDomainInterface): Promise<void> {
|
||||
const wif = await this.vaultDomainService.getWif(data.coopname);
|
||||
if (!wif) throw new BadGatewayException('Не найден приватный ключ для совершения операции');
|
||||
|
||||
await this.blockchainService.initialize(data.coopname, wif);
|
||||
|
||||
const exitData: RegistratorContract.Actions.ExitCoop.IExitCoop = {
|
||||
coopname: data.coopname,
|
||||
username: data.username,
|
||||
exit_hash: data.exit_hash,
|
||||
statement: Classes.Document.finalize(data.statement),
|
||||
};
|
||||
|
||||
await this.blockchainService.transact({
|
||||
account: RegistratorContract.contractName.production,
|
||||
name: RegistratorContract.Actions.ExitCoop.actionName,
|
||||
authorization: [{ actor: data.coopname, permission: 'active' }],
|
||||
data: exitData,
|
||||
});
|
||||
}
|
||||
|
||||
async addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void> {
|
||||
const wif = await this.vaultDomainService.getWif(data.coopname);
|
||||
|
||||
@@ -230,6 +251,34 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort {
|
||||
return this.blockchainService.getAccount(username);
|
||||
}
|
||||
|
||||
getExit(
|
||||
coopname: string,
|
||||
username: string
|
||||
): Promise<RegistratorContract.Tables.Exits.IExit | null> {
|
||||
return this.blockchainService.getSingleRow(
|
||||
RegistratorContract.contractName.production,
|
||||
coopname,
|
||||
RegistratorContract.Tables.Exits.tableName,
|
||||
Name.from(username)
|
||||
);
|
||||
}
|
||||
|
||||
async getExitByHash(
|
||||
coopname: string,
|
||||
exit_hash: string
|
||||
): Promise<RegistratorContract.Tables.Exits.IExit | null> {
|
||||
// Таблица exits в scope=coopname мала (одна запись на выходящего пайщика,
|
||||
// удаляется при завершении), поэтому проще выбрать все строки и найти по
|
||||
// exit_hash, чем ходить во вторичный индекс byhash.
|
||||
const rows = await this.blockchainService.getAllRows<RegistratorContract.Tables.Exits.IExit>(
|
||||
RegistratorContract.contractName.production,
|
||||
coopname,
|
||||
RegistratorContract.Tables.Exits.tableName
|
||||
);
|
||||
const target = exit_hash.toLowerCase();
|
||||
return rows.find((r) => String(r.exit_hash).toLowerCase() === target) ?? null;
|
||||
}
|
||||
|
||||
getParticipantAccount(
|
||||
coopname: string,
|
||||
username: string
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Заявление пайщика на выход, принятое и подписанное, но ещё НЕ отправленное в
|
||||
* блокчейн — ожидает подтверждения по ссылке из письма. После подтверждения
|
||||
* запись удаляется (источником истины становится on-chain registrator::exits),
|
||||
* при отмене — тоже удаляется. На один (coopname, username) — один активный
|
||||
* процесс выхода.
|
||||
*
|
||||
* statement хранит подписанный документ заявления (как пришёл с клиента),
|
||||
* чтобы при подтверждении отправить его в registrator::exitcoop без повторной
|
||||
* подписи пайщиком.
|
||||
*/
|
||||
@Entity('membership_exit_requests')
|
||||
@Index(['coopname', 'username'], { unique: true })
|
||||
export class MembershipExitRequestEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column()
|
||||
coopname!: string;
|
||||
|
||||
@Column()
|
||||
username!: string;
|
||||
|
||||
@Column({ name: 'exit_hash', length: 64 })
|
||||
exit_hash!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
statement!: Record<string, any>;
|
||||
|
||||
@Column({ name: 'token', length: 1024 })
|
||||
token!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
created_at!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updated_at!: Date;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
|
||||
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
|
||||
|
||||
export const EntityName = 'payment_files';
|
||||
|
||||
/**
|
||||
* Файл, приложенный к платежу (чек об оплате). Бинарь — в MinIO `gateway:files`,
|
||||
* здесь только метаданные. Привязка — по `payment_hash` (единый ключ платежа).
|
||||
*/
|
||||
@Entity(EntityName)
|
||||
@Index(`uq_${EntityName}_checksum`, ['coopname', 'checksum_sha256'], { unique: true })
|
||||
@Index(`idx_${EntityName}_payment`, ['coopname', 'payment_hash'])
|
||||
export class PaymentFileEntity {
|
||||
static getTableName(): string {
|
||||
return EntityName;
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
coopname!: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
payment_hash!: string;
|
||||
|
||||
@Column({ type: 'enum', enum: PaymentFileKind })
|
||||
kind!: PaymentFileKind;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
checksum_sha256!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
mime_type!: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
size_bytes!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 512 })
|
||||
storage_key!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true, comment: 'Оригинальное имя загруженного файла — для отображения и поиска' })
|
||||
original_filename!: string | null;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
uploaded_by_username!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
uploaded_at!: Date;
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PaymentFileEntity } from '../entities/payment-file.entity';
|
||||
import type { PaymentFileRepository } from '~/domain/gateway/repositories/payment-file.repository';
|
||||
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
|
||||
|
||||
/**
|
||||
* TypeORM-репозиторий файлов платежей (чеков об оплате). БД-only сущность —
|
||||
* маппинг симметричный, без доменной логики.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TypeormPaymentFileRepository implements PaymentFileRepository {
|
||||
constructor(
|
||||
@InjectRepository(PaymentFileEntity)
|
||||
private readonly repository: Repository<PaymentFileEntity>
|
||||
) {}
|
||||
|
||||
async create(data: IPaymentFileDatabaseData): Promise<IPaymentFileDatabaseData> {
|
||||
const entity = this.repository.create({
|
||||
coopname: data.coopname,
|
||||
payment_hash: data.payment_hash,
|
||||
kind: data.kind,
|
||||
checksum_sha256: data.checksum_sha256,
|
||||
mime_type: data.mime_type,
|
||||
size_bytes: data.size_bytes,
|
||||
storage_key: data.storage_key,
|
||||
original_filename: data.original_filename ?? null,
|
||||
uploaded_by_username: data.uploaded_by_username,
|
||||
uploaded_at: data.uploaded_at,
|
||||
});
|
||||
const saved = await this.repository.save(entity);
|
||||
return this.toDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<IPaymentFileDatabaseData | null> {
|
||||
const entity = await this.repository.findOne({ where: { id } });
|
||||
return entity ? this.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByChecksum(coopname: string, checksum: string): Promise<IPaymentFileDatabaseData | null> {
|
||||
const entity = await this.repository.findOne({ where: { coopname, checksum_sha256: checksum } });
|
||||
return entity ? this.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]> {
|
||||
const entities = await this.repository.find({
|
||||
where: { coopname, payment_hash: paymentHash.toLowerCase() },
|
||||
order: { uploaded_at: 'DESC' },
|
||||
});
|
||||
return entities.map((e) => this.toDomain(e));
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.repository.delete({ id });
|
||||
}
|
||||
|
||||
private toDomain(entity: PaymentFileEntity): IPaymentFileDatabaseData {
|
||||
return {
|
||||
id: entity.id,
|
||||
coopname: entity.coopname,
|
||||
payment_hash: entity.payment_hash,
|
||||
kind: entity.kind,
|
||||
checksum_sha256: entity.checksum_sha256,
|
||||
mime_type: entity.mime_type,
|
||||
size_bytes: entity.size_bytes,
|
||||
storage_key: entity.storage_key,
|
||||
original_filename: entity.original_filename,
|
||||
uploaded_by_username: entity.uploaded_by_username,
|
||||
uploaded_at: entity.uploaded_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
|
||||
import { PaymentEntity } from './entities/payment.entity';
|
||||
import { PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.repository';
|
||||
import { PaymentFileEntity } from './entities/payment-file.entity';
|
||||
import { PAYMENT_FILE_REPOSITORY } from '~/domain/gateway/repositories/payment-file.repository';
|
||||
import { TypeormPaymentFileRepository } from './repositories/typeorm-payment-file.repository';
|
||||
import { WebPushSubscriptionEntity } from './entities/web-push-subscription.entity';
|
||||
import { NOTIFICATION_SUBSCRIPTION_PORT } from '~/domain/notification/interfaces/web-push-subscription.port';
|
||||
import { TypeOrmWebPushSubscriptionRepository } from './repositories/typeorm-web-push-subscription.repository';
|
||||
@@ -121,6 +124,7 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
|
||||
MigrationEntity,
|
||||
CandidateEntity,
|
||||
PaymentEntity,
|
||||
PaymentFileEntity,
|
||||
WebPushSubscriptionEntity,
|
||||
LedgerOperationEntity,
|
||||
AgreementTypeormEntity,
|
||||
@@ -175,6 +179,10 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
|
||||
provide: PAYMENT_REPOSITORY,
|
||||
useClass: TypeOrmPaymentRepository,
|
||||
},
|
||||
{
|
||||
provide: PAYMENT_FILE_REPOSITORY,
|
||||
useClass: TypeormPaymentFileRepository,
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_SUBSCRIPTION_PORT,
|
||||
useClass: TypeOrmWebPushSubscriptionRepository,
|
||||
@@ -276,6 +284,7 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
|
||||
MIGRATION_REPOSITORY,
|
||||
CANDIDATE_REPOSITORY,
|
||||
PAYMENT_REPOSITORY,
|
||||
PAYMENT_FILE_REPOSITORY,
|
||||
NOTIFICATION_SUBSCRIPTION_PORT,
|
||||
LEDGER_OPERATION_REPOSITORY,
|
||||
AGREEMENT_REPOSITORY,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const tokenTypes = {
|
||||
RESET_KEY: 'resetPassword',
|
||||
VERIFY_EMAIL: 'verifyEmail',
|
||||
INVITE: 'invite',
|
||||
CONFIRM_EXIT: 'confirmExit',
|
||||
} as const;
|
||||
|
||||
export type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes];
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Unit-тесты ProgramShareRegistrationOnProjectDeltaListener.
|
||||
*
|
||||
* Фокус — гейтинг: доли регистрируются СРАЗУ при появлении проекта в статусе
|
||||
* active, и только в своём кооперативе. На pending (заводим только в active),
|
||||
* present=false, чужой scope и прочие статусы реакции нет.
|
||||
*/
|
||||
|
||||
import config from '~/config/config';
|
||||
import { ProgramShareRegistrationOnProjectDeltaListener } from '~/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener';
|
||||
import { ProjectStatus } from '~/extensions/capital/domain/enums/project-status.enum';
|
||||
import type { IDelta } from '~/types/common';
|
||||
|
||||
const PROJECT_HASH = '011bcd92cc6fc6c3fbac1aa31e5ab302590993fedb666642a5bd2f88e96e6a0e';
|
||||
|
||||
function makeServiceStub() {
|
||||
return { syncProgramSharesForProject: jest.fn(async () => undefined) } as any;
|
||||
}
|
||||
|
||||
function makeListener(service: any) {
|
||||
return new ProgramShareRegistrationOnProjectDeltaListener(service);
|
||||
}
|
||||
|
||||
function makeDelta(overrides: Partial<IDelta> = {}, value: Record<string, any> = {}): IDelta {
|
||||
return {
|
||||
present: true,
|
||||
scope: config.coopname,
|
||||
value: { project_hash: PROJECT_HASH, status: ProjectStatus.ACTIVE, ...value },
|
||||
...overrides,
|
||||
} as IDelta;
|
||||
}
|
||||
|
||||
describe('ProgramShareRegistrationOnProjectDeltaListener', () => {
|
||||
it('active в своём кооперативе → регистрирует доли по project_hash', async () => {
|
||||
const service = makeServiceStub();
|
||||
await makeListener(service).handleProjectDelta(makeDelta());
|
||||
expect(service.syncProgramSharesForProject).toHaveBeenCalledWith(config.coopname, PROJECT_HASH);
|
||||
});
|
||||
|
||||
it('pending → не реагирует (заводим только в active, при переходе в active придёт новая дельта)', async () => {
|
||||
const service = makeServiceStub();
|
||||
await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.PENDING }));
|
||||
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('result → не реагирует (окно закрыто, откат статуса контракт не даёт)', async () => {
|
||||
const service = makeServiceStub();
|
||||
await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.RESULT }));
|
||||
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('present=false → не реагирует', async () => {
|
||||
const service = makeServiceStub();
|
||||
await makeListener(service).handleProjectDelta(makeDelta({ present: false }));
|
||||
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('чужой кооператив (scope) → не реагирует', async () => {
|
||||
const service = makeServiceStub();
|
||||
await makeListener(service).handleProjectDelta(makeDelta({ scope: 'othercoop' }));
|
||||
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ошибка сервиса не пробрасывается (best-effort, бэкстоп — scheduler)', async () => {
|
||||
const service = { syncProgramSharesForProject: jest.fn(async () => { throw new Error('boom'); }) } as any;
|
||||
await expect(makeListener(service).handleProjectDelta(makeDelta())).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -326,6 +326,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
CreateMatrixAccountInputDTO:{
|
||||
|
||||
},
|
||||
CreateMembershipExitInput:{
|
||||
statement:"MembershipExitApplicationSignedDocumentInput"
|
||||
},
|
||||
CreateOrganizationDataInput:{
|
||||
bank_account:"BankAccountInput",
|
||||
@@ -694,6 +697,20 @@ export const AllTypesProps: Record<string,any> = {
|
||||
mark:"ReportSubmissionMark",
|
||||
reportType:"ReportType"
|
||||
},
|
||||
MembershipExitApplicationGenerateDocumentInput:{
|
||||
|
||||
},
|
||||
MembershipExitApplicationSignedDocumentInput:{
|
||||
meta:"MembershipExitApplicationSignedMetaDocumentInput",
|
||||
signatures:"SignatureInfoInput"
|
||||
},
|
||||
MembershipExitApplicationSignedMetaDocumentInput:{
|
||||
|
||||
},
|
||||
MembershipExitDecisionGenerateDocumentInput:{
|
||||
|
||||
},
|
||||
MembershipExitStatus: "enum" as const,
|
||||
ModerateRequestInput:{
|
||||
|
||||
},
|
||||
@@ -715,6 +732,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
authorizeDecision:{
|
||||
data:"AuthorizeDecisionInput"
|
||||
},
|
||||
cancelMembershipExit:{
|
||||
|
||||
},
|
||||
cancelRequest:{
|
||||
data:"CancelRequestInput"
|
||||
@@ -1013,6 +1033,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
confirmAgreement:{
|
||||
data:"ConfirmAgreementInput"
|
||||
},
|
||||
confirmMembershipExit:{
|
||||
|
||||
},
|
||||
confirmReceiveOnRequest:{
|
||||
data:"ConfirmReceiveOnRequestInput"
|
||||
@@ -1038,6 +1061,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
createInitialPayment:{
|
||||
data:"CreateInitialPaymentInput"
|
||||
},
|
||||
createMembershipExit:{
|
||||
data:"CreateMembershipExitInput"
|
||||
},
|
||||
createParentOffer:{
|
||||
data:"CreateParentOfferInput"
|
||||
},
|
||||
@@ -1133,6 +1159,14 @@ export const AllTypesProps: Record<string,any> = {
|
||||
data:"FreeDecisionGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
generateMembershipExitApplication:{
|
||||
data:"MembershipExitApplicationGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
generateMembershipExitDecision:{
|
||||
data:"MembershipExitDecisionGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
generateParticipantApplication:{
|
||||
data:"ParticipantApplicationGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
@@ -1336,6 +1370,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
uploadExpenseFile:{
|
||||
data:"UploadExpenseFileInput"
|
||||
},
|
||||
uploadPaymentProof:{
|
||||
data:"UploadPaymentProofInput"
|
||||
},
|
||||
verifyEmail:{
|
||||
data:"VerifyEmailInputDTO"
|
||||
},
|
||||
@@ -1393,6 +1430,7 @@ export const AllTypesProps: Record<string,any> = {
|
||||
|
||||
},
|
||||
PaymentDirection: "enum" as const,
|
||||
PaymentFileKind: "enum" as const,
|
||||
PaymentFiltersInput:{
|
||||
direction:"PaymentDirection",
|
||||
status:"PaymentStatus",
|
||||
@@ -1778,9 +1816,21 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
listReportDrafts:{
|
||||
filter:"ListReportDraftsFilterInput"
|
||||
},
|
||||
membershipExit:{
|
||||
|
||||
},
|
||||
membershipExitReturnPreview:{
|
||||
|
||||
},
|
||||
onecoopGetDocuments:{
|
||||
data:"GetOneCoopDocumentsInput"
|
||||
},
|
||||
paymentFile:{
|
||||
|
||||
},
|
||||
paymentProofs:{
|
||||
|
||||
},
|
||||
process:{
|
||||
|
||||
@@ -2080,6 +2130,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
UploadExpenseFileInput:{
|
||||
kind:"ExpenseFileKind"
|
||||
},
|
||||
UploadPaymentProofInput:{
|
||||
|
||||
},
|
||||
UserStatus: "enum" as const,
|
||||
VarsInput:{
|
||||
@@ -3778,6 +3831,22 @@ export const ReturnTypes: Record<string,any> = {
|
||||
votes_against:"Int",
|
||||
votes_for:"Int"
|
||||
},
|
||||
MembershipExit:{
|
||||
created_at:"String",
|
||||
exit_hash:"String",
|
||||
payment_status:"PaymentStatus",
|
||||
quantity:"String",
|
||||
status:"MembershipExitStatus"
|
||||
},
|
||||
MembershipExitResult:{
|
||||
exit_hash:"String",
|
||||
status:"MembershipExitStatus"
|
||||
},
|
||||
MembershipExitReturnPreview:{
|
||||
minimum_contribution:"String",
|
||||
share_contribution:"String",
|
||||
total:"String"
|
||||
},
|
||||
MissingRequisiteField:{
|
||||
key:"String",
|
||||
label:"String",
|
||||
@@ -3806,6 +3875,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
addPaymentMethod:"PaymentMethod",
|
||||
addTrustedAccount:"Branch",
|
||||
authorizeDecision:"Transaction",
|
||||
cancelMembershipExit:"Boolean",
|
||||
cancelRequest:"Transaction",
|
||||
capitalAddAuthor:"CapitalProject",
|
||||
capitalApproveCommit:"CapitalCommit",
|
||||
@@ -3899,6 +3969,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
completeExtensionOnboardingStep:"ExtensionOnboardingState",
|
||||
completeRequest:"Transaction",
|
||||
confirmAgreement:"Transaction",
|
||||
confirmMembershipExit:"MembershipExitResult",
|
||||
confirmReceiveOnRequest:"Transaction",
|
||||
confirmSupplyOnRequest:"Transaction",
|
||||
createAnnualGeneralMeet:"MeetAggregate",
|
||||
@@ -3907,6 +3978,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
createDepositPayment:"GatewayPayment",
|
||||
createExpenseProposal:"Transaction",
|
||||
createInitialPayment:"GatewayPayment",
|
||||
createMembershipExit:"MembershipExitResult",
|
||||
createParentOffer:"Transaction",
|
||||
createProjectOfFreeDecision:"CreatedProjectFreeDecision",
|
||||
createWebPushSubscription:"CreateSubscriptionResponse",
|
||||
@@ -3935,6 +4007,8 @@ export const ReturnTypes: Record<string,any> = {
|
||||
generateExpenseProposalDecisionDocument:"GeneratedDocument",
|
||||
generateExpenseProposalStatementDocument:"GeneratedDocument",
|
||||
generateFreeDecision:"GeneratedDocument",
|
||||
generateMembershipExitApplication:"GeneratedDocument",
|
||||
generateMembershipExitDecision:"GeneratedDocument",
|
||||
generateParticipantApplication:"GeneratedDocument",
|
||||
generateParticipantApplicationDecision:"GeneratedDocument",
|
||||
generatePrivacyAgreement:"GeneratedDocument",
|
||||
@@ -3999,6 +4073,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
updateSettings:"Settings",
|
||||
updateSystem:"SystemInfo",
|
||||
uploadExpenseFile:"ExpenseFile",
|
||||
uploadPaymentProof:"PaymentFile",
|
||||
verifyEmail:"Boolean",
|
||||
voteOnAnnualGeneralMeet:"MeetAggregate",
|
||||
walmoveWallets:"Ledger2AdjustmentResult"
|
||||
@@ -4280,6 +4355,20 @@ export const ReturnTypes: Record<string,any> = {
|
||||
fee_percent:"Float",
|
||||
tolerance_percent:"Float"
|
||||
},
|
||||
PaymentFile:{
|
||||
checksum_sha256:"String",
|
||||
coopname:"String",
|
||||
id:"Int",
|
||||
kind:"PaymentFileKind",
|
||||
mime_type:"String",
|
||||
original_filename:"String",
|
||||
payment_hash:"String",
|
||||
read_url:"String",
|
||||
size_bytes:"Int",
|
||||
storage_key:"String",
|
||||
uploaded_at:"DateTime",
|
||||
uploaded_by_username:"String"
|
||||
},
|
||||
PaymentMethod:{
|
||||
created_at:"DateTime",
|
||||
data:"PaymentMethodData",
|
||||
@@ -4592,7 +4681,11 @@ export const ReturnTypes: Record<string,any> = {
|
||||
getUserWebPushSubscriptions:"WebPushSubscriptionDto",
|
||||
getWebPushSubscriptionStats:"SubscriptionStatsDto",
|
||||
listReportDrafts:"ReportDraft",
|
||||
membershipExit:"MembershipExit",
|
||||
membershipExitReturnPreview:"MembershipExitReturnPreview",
|
||||
onecoopGetDocuments:"OneCoopDocumentsResponse",
|
||||
paymentFile:"PaymentFile",
|
||||
paymentProofs:"PaymentFile",
|
||||
process:"ProcessView",
|
||||
processes:"ProcessSummaryPaginationResult",
|
||||
searchDocuments:"SearchResult",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cooptypes",
|
||||
"type": "module",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -106,6 +106,29 @@ const programMapping: ProgramMappingEntry[] = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5b. LEDGER2_EXIT_REFUND_WALLETS ──────────────────────────────────────
|
||||
// Сет паевых кошельков, возвращаемых пайщику при выходе. Простой массив имён
|
||||
// `ledger2_wallets::NAME` — единый источник для контракта (confirmexit) и
|
||||
// backend-preview.
|
||||
const exitRefundWallets: string[] = []
|
||||
{
|
||||
const body = extractArrayBody('LEDGER2_EXIT_REFUND_WALLETS')
|
||||
const re = /ledger2_wallets::(\w+)/g
|
||||
for (const m of body.matchAll(re)) {
|
||||
const wallet_name = nameMap.get(m[1]!)
|
||||
if (!wallet_name) throw new Error(`gen-from-cpp: ledger2_wallets::${m[1]} не найден среди имён`)
|
||||
exitRefundWallets.push(wallet_name)
|
||||
}
|
||||
if (exitRefundWallets.length === 0) throw new Error('gen-from-cpp: LEDGER2_EXIT_REFUND_WALLETS пуст')
|
||||
const sizeMatch = /std::array<eosio::name,\s*(\d+)>\s+LEDGER2_EXIT_REFUND_WALLETS/.exec(hpp)
|
||||
if (sizeMatch && Number(sizeMatch[1]) !== exitRefundWallets.length)
|
||||
throw new Error(`gen-from-cpp: распарсено ${exitRefundWallets.length} exit-refund-кошельков, объявлено ${sizeMatch[1]}`)
|
||||
const known = new Set(walletRegistry.map(w => w.name))
|
||||
for (const w of exitRefundWallets) {
|
||||
if (!known.has(w)) throw new Error(`gen-from-cpp: exit-refund ссылается на отсутствующий в реестре кошелёк "${w}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. emit TS ───────────────────────────────────────────────────────────
|
||||
const lines: string[] = []
|
||||
lines.push('// AUTO-GENERATED by cooptypes/scripts/gen-from-cpp.ts — DO NOT EDIT.')
|
||||
@@ -153,6 +176,18 @@ for (const m of programMapping) {
|
||||
}
|
||||
lines.push('] as const')
|
||||
lines.push('')
|
||||
lines.push('/**')
|
||||
lines.push(' * Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.')
|
||||
lines.push(' * Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит')
|
||||
lines.push(' * этот сет, собирает доступные балансы и ставит их на возврат; backend-preview')
|
||||
lines.push(' * считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.')
|
||||
lines.push(' */')
|
||||
lines.push('export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [')
|
||||
for (const w of exitRefundWallets) {
|
||||
lines.push(` ${JSON.stringify(w)},`)
|
||||
}
|
||||
lines.push('] as const')
|
||||
lines.push('')
|
||||
|
||||
writeFileSync(OUT_PATH, lines.join('\n'), 'utf8')
|
||||
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length})`)
|
||||
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length}, exitRefund=${exitRefundWallets.length})`)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Registrator from '../../../interfaces/registrator'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active, Permissions.special], actor: Actors._admin }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'exitcoop'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IExitCoop = Registrator.IExitcoop
|
||||
@@ -28,6 +28,11 @@ export * as CreateAccount from './createAccount'
|
||||
*/
|
||||
export * as RegisterUser from './registerUser'
|
||||
|
||||
/**
|
||||
* Действие подачи заявления на выход пайщика из кооператива (возврат паевого взноса)
|
||||
*/
|
||||
export * as ExitCoop from './exitCoop'
|
||||
|
||||
/**
|
||||
* Действие регистрации карточки организации в кооперативе
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type * as Registrator from '../../../interfaces/registrator'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
/**
|
||||
* Имя таблицы
|
||||
*/
|
||||
export const tableName = 'exits'
|
||||
|
||||
/**
|
||||
* Таблица хранится в области памяти {@link Actors._coopname | кооператива}.
|
||||
*/
|
||||
export const scope = Actors._coopname
|
||||
|
||||
/**
|
||||
* @interface
|
||||
* Реестр заявлений пайщиков на выход из кооператива и сопровождающих их
|
||||
* процессов возврата паевого взноса.
|
||||
*/
|
||||
export type IExit = Registrator.IExit
|
||||
@@ -8,3 +8,8 @@ export * as Accounts from './accounts'
|
||||
* Таблица содержит реестр кооперативов системы.
|
||||
*/
|
||||
export * as Cooperatives from './cooperatives'
|
||||
|
||||
/**
|
||||
* Таблица содержит реестр заявлений пайщиков на выход из кооператива.
|
||||
*/
|
||||
export * as Exits from './exits'
|
||||
|
||||
File diff suppressed because one or more lines are too long
+178
@@ -0,0 +1,178 @@
|
||||
import type { IGenerate, IMetaDocument } from '../../document'
|
||||
import type { ICooperativeData, IVars } from '../../model'
|
||||
import type { IEntrepreneurData, IIndividualData, IOrganizationData } from '../../users'
|
||||
|
||||
export const registry_id = 200
|
||||
|
||||
/**
|
||||
* Интерфейс генерации заявления на выход из состава пайщиков кооператива
|
||||
*/
|
||||
export interface Action extends IGenerate {
|
||||
skip_save: boolean
|
||||
}
|
||||
|
||||
export type Meta = IMetaDocument & Action
|
||||
|
||||
// Модель данных
|
||||
export interface Model {
|
||||
type: string
|
||||
individual?: IIndividualData
|
||||
organization?: IOrganizationData
|
||||
entrepreneur?: IEntrepreneurData
|
||||
coop: ICooperativeData
|
||||
meta: IMetaDocument
|
||||
vars: IVars
|
||||
}
|
||||
|
||||
export const title = 'Заявление на выход из кооператива'
|
||||
export const description = 'Форма заявления физического лица, индивидуального предпринимателя или юридического лица о выходе из состава пайщиков потребительского кооператива'
|
||||
export const context = `<style>
|
||||
.digital-document h1 {
|
||||
margin: 0px;
|
||||
text-align:center;
|
||||
}
|
||||
.digital-document {padding: 20px;}
|
||||
.subheader {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.signature {
|
||||
padding-top: 30px;
|
||||
}
|
||||
</style>
|
||||
<div class="digital-document">
|
||||
|
||||
{% if type == 'individual' %}
|
||||
<h1 class="header">{% trans 'application_exit_individual' %}</h1>
|
||||
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
|
||||
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
|
||||
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from' %} {{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}, {% trans 'birthdate' %} {{ individual.birthdate }}, {% trans 'registration_address' %} {{ individual.full_address }}, {% trans 'phone_and_email_notice', individual.phone, individual.email %}{% if vars.passport_request == 'yes' %} {% trans 'passport' %} № {{ individual.passport.series }} {{ individual.passport.number }}, {% trans 'passport_code' %} {{ individual.passport.code }}, {% trans 'passport_issued' %} {{ individual.passport.issued_by }} {% trans 'passport_from' %} {{ individual.passport.issued_at }}.{% endif %}</p>
|
||||
<p>{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}</p>
|
||||
<p>{% trans 'obligation_to_settle_individual' %}</p>
|
||||
<div class="signature">
|
||||
<p>{% trans 'signed_electronically' %}</p>
|
||||
<p style="text-align: right;">{{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}</p>
|
||||
</div>
|
||||
|
||||
{% elif type == 'entrepreneur' %}
|
||||
<h1 class="header">{% trans 'application_exit_entrepreneur' %}</h1>
|
||||
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
|
||||
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
|
||||
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from_entrepreneur' %} {{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}, {% trans 'birthdate' %} {{ entrepreneur.birthdate }}, {% trans 'registration_address' %} {{ entrepreneur.full_address }}, {% trans 'entrepreneur_details', entrepreneur.details.inn, entrepreneur.details.ogrn %}, {% trans 'phone_and_email_notice', entrepreneur.phone, entrepreneur.email %}</p>
|
||||
<p>{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}</p>
|
||||
<p>{% trans 'obligation_to_settle_individual' %}</p>
|
||||
<div class="signature">
|
||||
<p>{% trans 'signed_electronically' %}</p>
|
||||
<p style="text-align: right;">{{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}</p>
|
||||
</div>
|
||||
|
||||
{% elif type == 'organization' %}
|
||||
<h1 class="header">{% trans 'application_exit_legal_entity' %}</h1>
|
||||
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
|
||||
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
|
||||
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from_legal_entity' %} {{ organization.full_name }}, {% trans 'legal_address' %}: {{ organization.full_address }}, {% trans 'fact_address' %}: {{ organization.fact_address }}, {% trans 'legal_entity_details', organization.details.inn, organization.details.ogrn, organization.details.kpp %}, {% trans 'phone_and_email_notice', organization.phone, organization.email %}</p>
|
||||
<p>{% trans 'request_to_exit_legal_entity', organization.represented_by.position, organization.represented_by.last_name, organization.represented_by.first_name, organization.represented_by.middle_name, organization.represented_by.based_on, organization.full_name, coop.full_name %}</p>
|
||||
<p>{% trans 'obligation_to_settle_legal_entity' %}</p>
|
||||
<div class="signature">
|
||||
<p>{% trans 'signed_electronically' %}</p>
|
||||
<p style="text-align: right;">{{ organization.represented_by.last_name }} {{ organization.represented_by.first_name }} {{ organization.represented_by.middle_name }}</p>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>`
|
||||
|
||||
export const translations = {
|
||||
ru: {
|
||||
from: 'от',
|
||||
of_consumer_cooperative: 'Потребительского кооператива',
|
||||
to_council_of: 'В Совет',
|
||||
birthdate: 'дата рождения',
|
||||
registration_address: 'адрес регистрации (как в паспорте): ',
|
||||
phone_and_email_notice: 'номер телефона: {0}, адрес электронной почты: {1}.',
|
||||
application_exit_individual: 'Заявление физического лица о выходе из состава пайщиков',
|
||||
application_exit_entrepreneur: 'Заявление индивидуального предпринимателя о выходе из состава пайщиков',
|
||||
application_exit_legal_entity: 'Заявление юридического лица о выходе из состава пайщиков',
|
||||
from_entrepreneur: 'от индивидуального предпринимателя',
|
||||
from_legal_entity: 'от юридического лица',
|
||||
legal_address: 'юридический адрес',
|
||||
fact_address: 'фактический адрес',
|
||||
entrepreneur_details: 'ИНН {0}, ОГРНИП {1}',
|
||||
legal_entity_details: 'ИНН {0}, ОГРН {1}, КПП {2}',
|
||||
request_to_exit: 'В соответствии с Уставом {0} (ОГРН {1}, ИНН {2}, КПП {3}) (далее по тексту Общество) прошу вывести меня из состава пайщиков Общества.',
|
||||
request_to_exit_legal_entity: 'Заявитель, в лице представителя юридического лица — {0} {1} {2} {3}, действующего на основании {4}, в соответствии с Уставом Общества просит вывести {5} из состава пайщиков {6}.',
|
||||
obligation_to_settle_individual: 'Обязуюсь погасить все свои обязательства перед Обществом, при наличии таковых, а также получить возврат своих паевых взносов в связи с моим участием в хозяйственной деятельности и целевых потребительских программах Общества, предусмотренном Уставом Общества и его действующих Положений и в соответствии с действующим законодательством РФ.',
|
||||
obligation_to_settle_legal_entity: 'Заявитель обязуется погасить все свои обязательства перед Обществом, при наличии таковых, а также получить возврат своих паевых взносов в связи с участием в хозяйственной деятельности и целевых потребительских программах Общества, предусмотренном Уставом Общества и его действующих Положений и в соответствии с действующим законодательством РФ.',
|
||||
signed_electronically: 'Документ подписан электронной подписью.',
|
||||
passport: 'Паспорт',
|
||||
passport_code: 'код подразделения',
|
||||
passport_issued: 'выдан',
|
||||
passport_from: 'от',
|
||||
},
|
||||
}
|
||||
|
||||
export const exampleData = {
|
||||
coop: {
|
||||
short_name: 'ПК "ВОСХОД"',
|
||||
full_name: 'потребительский кооператив "Восход"',
|
||||
city: 'Москва',
|
||||
details: {
|
||||
inn: '9728130611',
|
||||
ogrn: '1247700283346',
|
||||
kpp: '772801001',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
created_at: '04.03.2024 10:54',
|
||||
},
|
||||
individual: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
birthdate: '04.03.1990',
|
||||
phone: '790343432423',
|
||||
email: 'email@gmail.com',
|
||||
full_address: 'Советов 3-84',
|
||||
passport: {
|
||||
series: '7712',
|
||||
number: '122112',
|
||||
issued_by: 'отделом УФМС г. Москва',
|
||||
issued_at: '10.05.2010',
|
||||
code: '220-220',
|
||||
},
|
||||
},
|
||||
entrepreneur: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
birthdate: '04.03.1990',
|
||||
full_address: 'Советов 3-84',
|
||||
details: {
|
||||
inn: '34534534534',
|
||||
ogrn: '345345345',
|
||||
},
|
||||
phone: '+734534534534',
|
||||
email: 'email@gmail.com',
|
||||
},
|
||||
organization: {
|
||||
full_name: 'ООО РОМАШКА',
|
||||
full_address: 'Советов 3-84',
|
||||
fact_address: 'Советов 3-84',
|
||||
represented_by: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
position: 'директор',
|
||||
based_on: 'решения учредителей №1 от 05.03.2024',
|
||||
},
|
||||
details: {
|
||||
inn: '234234234',
|
||||
ogrn: '234234234234',
|
||||
kpp: '772801001',
|
||||
},
|
||||
phone: '+35345345345',
|
||||
email: 'an.mddf@gmail.com',
|
||||
},
|
||||
type: 'individual',
|
||||
vars: {
|
||||
passport_request: 'yes',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { IDecisionData, IGenerate, IMetaDocument } from '../../document'
|
||||
import type { ICooperativeData, IVars } from '../../model'
|
||||
import type { IEntrepreneurData, IIndividualData, IOrganizationData } from '../../users'
|
||||
|
||||
export const registry_id = 201
|
||||
|
||||
/**
|
||||
* Интерфейс генерации решения совета о выходе пайщика из кооператива
|
||||
*/
|
||||
export interface Action extends IGenerate {
|
||||
decision_id: number
|
||||
}
|
||||
|
||||
// Модель данных
|
||||
export interface Model {
|
||||
type: string
|
||||
|
||||
individual?: IIndividualData
|
||||
organization?: IOrganizationData
|
||||
entrepreneur?: IEntrepreneurData
|
||||
coop: ICooperativeData
|
||||
meta: IMetaDocument
|
||||
decision: IDecisionData
|
||||
vars: IVars
|
||||
}
|
||||
|
||||
export const title = 'Решение совета о выходе пайщика из кооператива'
|
||||
export const description = 'Форма решения совета о выходе пайщика из состава потребительского кооператива'
|
||||
export const context = '<style> \nh1 {\nmargin: 0px; \ntext-align:center;\n}\nh3{\nmargin: 0px;\npadding-top: 15px;\n}\n.about {\npadding: 20px;\n}\n.digital-document {\npadding: 20px;\nwhite-space: pre-wrap;\n}\n.subheader {\npadding-bottom: 20px; \n}\ntable {\n width: 100%;\n border-collapse: collapse;\n}\nth, td {\n border: 1px solid #ccc;\n padding: 8px;\n text-align: left;\n word-wrap: break-word; \n overflow-wrap: break-word; \n}\nth {\n background-color: #f4f4f4;\n width: 30%;\n}\n</style>\n\n<div class="digital-document">\n <h1 class="header">{% trans \'protocol_number\', decision.id %}</h1>\n <p style="text-align:center" class="subheader">{% trans \'council_meeting_name\' %} {{vars.full_abbr_genitive}} "{{vars.name}}"</p>\n <p style="text-align: right"> {{ meta.created_at }}, {{ coop.city }}</p>\n <table class="about">\n <tbody>\n <tr>\n <th>{% trans \'meeting_format\' %}</th>\n <td>{% trans \'meeting_format_value\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'meeting_place\' %}</th>\n <td>{{ coop.full_address }}</td>\n </tr>\n <tr>\n <th>{% trans \'meeting_date\' %}</th>\n <td>{{ decision.date }}</td>\n </tr>\n <tr>\n <th>{% trans \'opening_time\' %}</th>\n <td>{{ decision.time }}</td>\n </tr>\n </tbody>\n </table>\n <h3>{% trans \'council_members\' %}</h3>\n <table>\n <tbody>\n {% for member in coop.members %}\n <tr>\n <th>{% if member.is_chairman %}{% trans \'chairman_of_the_council\' %}{% else %}{% trans \'member_of_the_council\' %}{% endif %}</th>\n <td>{{ member.last_name }} {{ member.first_name }} {{ member.middle_name }}</td>\n</tr>\n {% endfor %}\n </tbody>\n </table>\n <h3>{% trans \'meeting_legality\' %}</h3>\n <p>{% trans \'voting_results\', decision.voters_percent %} {% trans \'quorum\' %} {% trans \'chairman_of_the_meeting\', coop.chairman.last_name, coop.chairman.first_name, coop.chairman.middle_name %}.</p>\n <h3>{% trans \'agenda\' %}</h3>\n <table>\n <tbody>\n <tr>\n <th>№</th>\n <td>{% trans \'question\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'agenda_item\' %}</th>\n <td>{% trans \'decision_leavecoop1\' %} {% if type == \'individual\' %}{{individual.last_name}} {{individual.first_name}} {{individual.middle_name}} ({{individual.birthdate}} {% trans \'birthdate\' %}){% endif %}{% if type == \'entrepreneur\' %}{% trans \'entrepreneur\' %} {{entrepreneur.last_name}} {{entrepreneur.first_name}} {{entrepreneur.middle_name}} ({% trans \'ogrnip\' %} {{entrepreneur.details.ogrn}}){% endif %}{% if type == \'organization\' %} {{organization.short_name}} ({% trans \'ogrn\' %} {{organization.details.ogrn}}){% endif %}{% trans \'in_participants\' %} {{ coop.short_name }}.\n </td>\n </tr>\n </tbody>\n </table>\n<h3>{% trans \'voting\' %}</h3>\n<p>{% trans \'vote_results\' %}</p><table>\n <tbody>\n <tr>\n <th>{% trans \'votes_for\' %}</th>\n <td>{{ decision.votes_for }}</td>\n </tr>\n <tr>\n <th>{% trans \'votes_against\' %}</th>\n <td>{{ decision.votes_against }}</td>\n </tr>\n <tr>\n <th>{% trans \'votes_abstained\' %}</th>\n <td>{{ decision.votes_abstained }}</td>\n </tr>\n </tbody>\n </table>\n <h3>{% trans \'decision_made\' %}</h3>\n <table>\n <tbody>\n <tr>\n <tr>\n <th>№</th>\n <td>{% trans \'question\' %}</td>\n </tr>\n\n <th>{% trans \'decision\' %}</th>\n <td>{% trans \'decision_leavecoop2\' %}\n{% if type == \'individual\' %}{{individual.last_name}} {{individual.first_name}} {{individual.middle_name}} {{individual.birthdate}} {% trans \'birthdate\' %} {% endif %}{% if type == \'entrepreneur\' %}{% trans \'entrepreneur\' %} {{entrepreneur.last_name}} {{entrepreneur.first_name}} {{entrepreneur.middle_name}}, {% trans \'ogrnip\' %} {{entrepreneur.details.ogrn}}{% endif %}{% if type == \'organization\' %}{{organization.short_name}}, {% trans \'ogrn\' %} {{organization.details.ogrn}}{% endif %}\n </td>\n </tr>\n </tbody>\n </table>\n <hr>\n<p>{% trans \'closing_time\', decision.time %}</p>\n<div class="signature"><p>{% trans \'signature\' %}</p><p>{% trans \'chairman\' %} {{ coop.chairman.last_name }} {{ coop.chairman.first_name }} {{ coop.chairman.middle_name }}</p></div>\n</div>\n'
|
||||
|
||||
export const translations = {
|
||||
ru: {
|
||||
meeting_format: 'Форма',
|
||||
meeting_date: 'Дата',
|
||||
meeting_place: 'Место',
|
||||
opening_time: 'Время открытия',
|
||||
council_members: 'ЧЛЕНЫ СОВЕТА',
|
||||
voting_results: 'Количество голосов составляет {0}% от общего числа членов Совета.',
|
||||
meeting_legality: 'СОБРАНИЕ ПРАВОМОЧНО',
|
||||
chairman_of_the_meeting: 'Председатель собрания совета: {0} {1} {2}',
|
||||
agenda: 'ПОВЕСТКА ДНЯ',
|
||||
decision_made: 'РЕШИЛИ',
|
||||
closing_time: 'Время закрытия собрания совета: {0}.',
|
||||
protocol_number: 'ПРОТОКОЛ № {0}',
|
||||
council_meeting_name: 'Собрания Совета',
|
||||
chairman_of_the_council: 'Председатель совета',
|
||||
signature: 'Документ подписан электронной подписью.',
|
||||
chairman: 'Председатель',
|
||||
birthdate: 'г.р.',
|
||||
ogrnip: 'ОГРНИП',
|
||||
entrepreneur: 'ИП',
|
||||
ogrn: 'ОГРН',
|
||||
quorum: 'Кворум для решения поставленных на повестку дня вопросов имеется.',
|
||||
voting: 'ГОЛОСОВАНИЕ',
|
||||
in_participants: ' о выходе из состава пайщиков',
|
||||
decision_leavecoop1: 'Рассмотреть заявление о выходе',
|
||||
decision_leavecoop2: 'Вывести из состава пайщиков',
|
||||
meeting_format_value: 'заочная',
|
||||
agenda_item: '1',
|
||||
votes_for: 'ЗА',
|
||||
votes_against: 'ПРОТИВ',
|
||||
votes_abstained: 'ВОЗДЕРЖАЛСЯ',
|
||||
decision: '1',
|
||||
question: 'Вопрос',
|
||||
vote_results: 'По первому вопросу повестки дня проголосовали:',
|
||||
member_of_the_council: 'Член совета',
|
||||
},
|
||||
}
|
||||
|
||||
export const exampleData = {
|
||||
meta: {
|
||||
created_at: '12.02.2024 00:01',
|
||||
},
|
||||
coop: {
|
||||
city: 'Москва',
|
||||
full_address: 'Смольная 3-84',
|
||||
chairman: {
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
},
|
||||
short_name: 'ПК "Восход"',
|
||||
},
|
||||
decision: {
|
||||
time: '00:01',
|
||||
date: '12.02.2024',
|
||||
votes_for: '3',
|
||||
votes_against: '0',
|
||||
votes_abstained: '0',
|
||||
voters_percent: '100',
|
||||
id: '1',
|
||||
},
|
||||
member: {
|
||||
is_chairman: true,
|
||||
last_name: 'Муравьев',
|
||||
first_name: 'Алексей',
|
||||
middle_name: 'Николаевич',
|
||||
},
|
||||
organization: {
|
||||
details: {
|
||||
ogrn: '2222222222',
|
||||
},
|
||||
short_name: 'ООО "Ромашка"',
|
||||
},
|
||||
type: 'organization',
|
||||
individual: {
|
||||
last_name: 'Мартин',
|
||||
first_name: 'Роберт',
|
||||
middle_name: 'Иванович',
|
||||
birthdate: '04.04.2000',
|
||||
},
|
||||
entrepreneur: {
|
||||
last_name: 'Мартин',
|
||||
first_name: 'Роберт',
|
||||
middle_name: 'Иванович',
|
||||
details: {
|
||||
ogrn: '11111111111',
|
||||
},
|
||||
},
|
||||
vars: {
|
||||
full_abbr_genitive: 'Потребительского Кооператива',
|
||||
name: 'ВОСХОД',
|
||||
},
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,64 @@
|
||||
export interface CapitalProgramPrivateData {
|
||||
approval_protocol_number: string
|
||||
approval_protocol_day: string
|
||||
approval_protocol_month: string
|
||||
approval_protocol_year: string
|
||||
cooperative_name: string
|
||||
cooperative_short_name: string
|
||||
cooperative_quoted_name: string
|
||||
website: string
|
||||
chairman_full_name: string
|
||||
generator_program_purpose: string
|
||||
eoap_definition: string
|
||||
generator_task_goal: string
|
||||
idea_unit_cost: string
|
||||
idea_unit_cost_words: string
|
||||
blagorost_goal_expansion: string
|
||||
blagorost_task_expansion: string
|
||||
blagorost_task_development: string
|
||||
return_source_description: string
|
||||
return_additional_source: string
|
||||
offer_template_number: string
|
||||
}
|
||||
|
||||
export const capitalProgramPrivateDataRequiredFields = [
|
||||
'approval_protocol_number',
|
||||
'approval_protocol_day',
|
||||
'approval_protocol_month',
|
||||
'approval_protocol_year',
|
||||
'cooperative_name',
|
||||
'cooperative_short_name',
|
||||
'cooperative_quoted_name',
|
||||
'website',
|
||||
'chairman_full_name',
|
||||
'generator_program_purpose',
|
||||
'eoap_definition',
|
||||
'generator_task_goal',
|
||||
'idea_unit_cost',
|
||||
'idea_unit_cost_words',
|
||||
'blagorost_goal_expansion',
|
||||
'blagorost_task_expansion',
|
||||
'blagorost_task_development',
|
||||
'return_source_description',
|
||||
'return_additional_source',
|
||||
'offer_template_number',
|
||||
] as const
|
||||
|
||||
export function requireCapitalProgramPrivateData(
|
||||
payload: CapitalProgramPrivateData | null | undefined,
|
||||
registry_id: number,
|
||||
): CapitalProgramPrivateData {
|
||||
if (!payload)
|
||||
throw new Error(`PrivateData для документа #${registry_id} не найдены: передайте doc_data_hash`)
|
||||
|
||||
const missing = capitalProgramPrivateDataRequiredFields.filter((field) => {
|
||||
const value = payload[field]
|
||||
return typeof value !== 'string' || value.trim() === ''
|
||||
})
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`PrivateData для документа #${registry_id} заполнены не полностью: ${missing.join(', ')}`)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -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'
|
||||
@@ -77,3 +80,5 @@ export * as AnnualGeneralMeetingSovietDecision from './301.AnnualGeneralMeetingS
|
||||
export * as AnnualGeneralMeetingNotification from './302.AnnualGeneralMeetingNotification'
|
||||
export * as AnnualGeneralMeetingVotingBallot from './303.AnnualGeneralMeetingVotingBallot'
|
||||
export * as AnnualGeneralMeetingDecision from './304.AnnualGeneralMeetingDecision'
|
||||
|
||||
export * from './capitalProgramPrivateData'
|
||||
|
||||
@@ -53,6 +53,17 @@ export interface IChangekey {
|
||||
public_key: IPublicKey
|
||||
}
|
||||
|
||||
export interface ICompletexit {
|
||||
coopname: IName
|
||||
exit_hash: IChecksum256
|
||||
}
|
||||
|
||||
export interface IConfirmexit {
|
||||
coopname: IName
|
||||
exit_hash: IChecksum256
|
||||
authorization: IDocument2
|
||||
}
|
||||
|
||||
export interface IConfirmpay {
|
||||
coopname: IName
|
||||
registration_hash: IChecksum256
|
||||
@@ -122,6 +133,12 @@ export interface IDeclinereg {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface IDeclinexit {
|
||||
coopname: IName
|
||||
exit_hash: IChecksum256
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface IDelcoop {
|
||||
registrator: IName
|
||||
coopname: IName
|
||||
@@ -151,6 +168,24 @@ export interface IEnabranches {
|
||||
coopname: IName
|
||||
}
|
||||
|
||||
export interface IExit {
|
||||
username: IName
|
||||
coopname: IName
|
||||
status: IName
|
||||
created_at: ITimePointSec
|
||||
statement: IDocument2
|
||||
approved_statement: IDocument2
|
||||
exit_hash: IChecksum256
|
||||
quantity: IAsset
|
||||
}
|
||||
|
||||
export interface IExitcoop {
|
||||
coopname: IName
|
||||
username: IName
|
||||
exit_hash: IChecksum256
|
||||
statement: IDocument2
|
||||
}
|
||||
|
||||
export interface IInit {
|
||||
}
|
||||
|
||||
|
||||
@@ -343,6 +343,11 @@ export interface IDeletebranch {
|
||||
braname: IName
|
||||
}
|
||||
|
||||
export interface IDelpartcpnt {
|
||||
coopname: IName
|
||||
username: IName
|
||||
}
|
||||
|
||||
export interface IDisableprog {
|
||||
coopname: IName
|
||||
program_id: IUint64
|
||||
|
||||
@@ -57,120 +57,56 @@ export interface OperationMeta {
|
||||
|
||||
export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
|
||||
// registrator
|
||||
{ code: 'o.reg.payent', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'PAY_ENTRANCE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry',
|
||||
debit: 51, credit: 86,
|
||||
human_name: 'Вступительный взнос пайщика' },
|
||||
{ code: 'o.reg.payent', process_type: 'p.reg.accept', contract: 'registrator', name: 'PAY_ENTRANCE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry', debit: 51, credit: 86, human_name: 'Вступительный взнос пайщика' },
|
||||
|
||||
{ code: 'o.reg.putmin', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'PUT_MINSHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Минимальный паевой взнос пайщика при регистрации' },
|
||||
{ code: 'o.reg.putmin', process_type: 'p.reg.accept', contract: 'registrator', name: 'PUT_MINSHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr', debit: 51, credit: 80, human_name: 'Минимальный паевой взнос пайщика при регистрации' },
|
||||
|
||||
// Двухфазный путь через совет (reguser → confirmpay → confirmreg/declinereg)
|
||||
{ code: 'o.reg.inpay', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'RECEIVE_PAYMENT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.pend',
|
||||
debit: 51, credit: 76,
|
||||
human_name: 'Приём регистрационного взноса в ожидание решения совета' },
|
||||
{ code: 'o.reg.inpay', process_type: 'p.reg.accept', contract: 'registrator', name: 'RECEIVE_PAYMENT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.pend', debit: 51, credit: 76, human_name: 'Приём регистрационного взноса в ожидание решения совета' },
|
||||
|
||||
{ code: 'o.reg.setmin', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'SETTLE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.minshr',
|
||||
debit: 76, credit: 80,
|
||||
human_name: 'Зачисление минимального паевого взноса по решению совета' },
|
||||
{ code: 'o.reg.setmin', process_type: 'p.reg.accept', contract: 'registrator', name: 'SETTLE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.minshr', debit: 76, credit: 80, human_name: 'Зачисление минимального паевого взноса по решению совета' },
|
||||
|
||||
{ code: 'o.reg.setent', process_type: 'p.reg.accept', contract: 'registrator',
|
||||
name: 'SETTLE_ENTRANCE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.entry',
|
||||
debit: 76, credit: 86,
|
||||
human_name: 'Зачисление вступительного взноса по решению совета' },
|
||||
{ code: 'o.reg.setent', process_type: 'p.reg.accept', contract: 'registrator', name: 'SETTLE_ENTRANCE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.entry', debit: 76, credit: 86, human_name: 'Зачисление вступительного взноса по решению совета' },
|
||||
|
||||
{ code: 'o.reg.refund', process_type: 'p.reg.refund', contract: 'registrator',
|
||||
name: 'REFUND', wallet_op: 'BURN', wallet_from: 'w.reg.pend', wallet_to: null,
|
||||
debit: 76, credit: 51,
|
||||
human_name: 'Возврат регистрационного взноса при отказе совета' },
|
||||
{ code: 'o.reg.refund', process_type: 'p.reg.refund', contract: 'registrator', name: 'REFUND', wallet_op: 'BURN', wallet_from: 'w.reg.pend', wallet_to: null, debit: 76, credit: 51, human_name: 'Возврат регистрационного взноса при отказе совета' },
|
||||
|
||||
{ code: 'o.reg.mvmin', process_type: 'p.wal.wthdrw', contract: 'registrator', name: 'MOVE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.minshr', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Перенос минимального паевого на главный при выходе из кооператива' },
|
||||
|
||||
// wallet
|
||||
{ code: 'o.wal.depcpl', process_type: 'p.wal.depo', contract: 'wallet',
|
||||
name: 'COMPLETE_DEPOSIT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Внесение пайщиком паевого взноса' },
|
||||
{ code: 'o.wal.depcpl', process_type: 'p.wal.depo', contract: 'wallet', name: 'COMPLETE_DEPOSIT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Внесение пайщиком паевого взноса' },
|
||||
|
||||
{ code: 'o.wal.wthreq', process_type: 'p.wal.wthdrw', contract: 'wallet',
|
||||
name: 'REQUEST_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.wal.wpend',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Резервирование паевого под запрос на возврат' },
|
||||
{ code: 'o.wal.wthreq', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'REQUEST_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.wal.wpend', debit: null, credit: null, human_name: 'Резервирование паевого под запрос на возврат' },
|
||||
|
||||
{ code: 'o.wal.wthdec', process_type: 'p.wal.wthdrw', contract: 'wallet',
|
||||
name: 'DECLINE_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.wpend', wallet_to: 'w.wal.share',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Снятие резерва паевого после отклонения запроса на возврат' },
|
||||
{ code: 'o.wal.wthdec', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'DECLINE_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.wpend', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Снятие резерва паевого после отклонения запроса на возврат' },
|
||||
|
||||
{ code: 'o.wal.wthcpl', process_type: 'p.wal.wthdrw', contract: 'wallet',
|
||||
name: 'COMPLETE_WITHDRAW', wallet_op: 'BURN', wallet_from: 'w.wal.wpend', wallet_to: null,
|
||||
debit: 80, credit: 51,
|
||||
human_name: 'Возврат паевого взноса пайщику' },
|
||||
{ code: 'o.wal.wthcpl', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'COMPLETE_WITHDRAW', wallet_op: 'BURN', wallet_from: 'w.wal.wpend', wallet_to: null, debit: 80, credit: 51, human_name: 'Возврат паевого взноса пайщику' },
|
||||
|
||||
// capital (ADR-009: единые программные кошельки `w.cap.blago`/`w.cap.gen`)
|
||||
// IMPORT и ACCEPT_PROPERTY — Dr 04 (НМА), не Dr 51: импорт/акт-2 фиксируют
|
||||
// имущественный вклад как РИД, не деньги. Денежные взносы в Благорост — INVEST.
|
||||
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital',
|
||||
name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
|
||||
debit: 4, credit: 80,
|
||||
human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
|
||||
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital', name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago', debit: 4, credit: 80, human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
|
||||
|
||||
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital',
|
||||
name: 'INVEST', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.cap.blago',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Инвестиция в ЦПП «Благорост»' },
|
||||
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital', name: 'INVEST', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.cap.blago', debit: null, credit: null, human_name: 'Инвестиция в ЦПП «Благорост»' },
|
||||
|
||||
{ code: 'o.cap.commit', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'COMMIT_RID', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.gen',
|
||||
debit: 8, credit: 80,
|
||||
human_name: 'Коммит РИД по программе «Генератор»' },
|
||||
{ code: 'o.cap.commit', process_type: 'p.cap.rid', contract: 'capital', name: 'COMMIT_RID', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.gen', debit: 8, credit: 80, human_name: 'Коммит РИД по программе «Генератор»' },
|
||||
|
||||
{ code: 'o.cap.accept', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'ACCEPT_RID', wallet_op: 'NONE', wallet_from: null, wallet_to: null,
|
||||
debit: 4, credit: 8,
|
||||
human_name: 'Приём РИД в паевой фонд' },
|
||||
{ code: 'o.cap.accept', process_type: 'p.cap.rid', contract: 'capital', name: 'ACCEPT_RID', wallet_op: 'NONE', wallet_from: null, wallet_to: null, debit: 4, credit: 8, human_name: 'Приём РИД в паевой фонд' },
|
||||
|
||||
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital',
|
||||
name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
|
||||
debit: 4, credit: 80,
|
||||
human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
|
||||
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital', name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago', debit: 4, credit: 80, human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
|
||||
|
||||
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital',
|
||||
name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp',
|
||||
debit: 4, credit: 80,
|
||||
human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
|
||||
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital', name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp', debit: 4, credit: 80, human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
|
||||
|
||||
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital',
|
||||
name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null,
|
||||
debit: 80, credit: 4,
|
||||
human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
|
||||
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital', name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null, debit: 80, credit: 4, human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
|
||||
|
||||
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital',
|
||||
name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan',
|
||||
debit: 58, credit: 51,
|
||||
human_name: 'Выдача пайщику беспроцентного займа' },
|
||||
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital', name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan', debit: 58, credit: 51, human_name: 'Выдача пайщику беспроцентного займа' },
|
||||
|
||||
{ code: 'o.cap.repay', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'REPAY', wallet_op: 'TRANSFER', wallet_from: 'w.cap.loan', wallet_to: 'w.wal.share',
|
||||
debit: 80, credit: 58,
|
||||
human_name: 'Возврат беспроцентного займа пайщика по акту-2' },
|
||||
{ code: 'o.cap.repay', process_type: 'p.cap.rid', contract: 'capital', name: 'REPAY', wallet_op: 'TRANSFER', wallet_from: 'w.cap.loan', wallet_to: 'w.wal.share', debit: 80, credit: 58, human_name: 'Возврат беспроцентного займа пайщика по акту-2' },
|
||||
|
||||
{ code: 'o.cap.wthcap', process_type: 'p.cap.wthcap', contract: 'capital',
|
||||
name: 'WITHDRAW_FROM_CAPITAL', wallet_op: 'TRANSFER', wallet_from: 'w.cap.blago', wallet_to: 'w.wal.share',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Возврат паевого из ЦПП «Благорост» в Цифровой Кошелёк' },
|
||||
{ code: 'o.cap.wthcap', process_type: 'p.cap.wthcap', contract: 'capital', name: 'WITHDRAW_FROM_CAPITAL', wallet_op: 'TRANSFER', wallet_from: 'w.cap.blago', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Возврат паевого из ЦПП «Благорост» в Цифровой Кошелёк' },
|
||||
|
||||
{ code: 'o.cap.cnvshr', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'CONVERT_TO_SHARE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.wal.share',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Конвертация сегмента: РИД → главный кошелёк' },
|
||||
{ code: 'o.cap.cnvshr', process_type: 'p.cap.rid', contract: 'capital', name: 'CONVERT_TO_SHARE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Конвертация сегмента: РИД → главный кошелёк' },
|
||||
|
||||
{ code: 'o.cap.cnvbl', process_type: 'p.cap.rid', contract: 'capital',
|
||||
name: 'CONVERT_TO_BLAGO', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.cap.blago',
|
||||
debit: null, credit: null,
|
||||
human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
|
||||
{ code: 'o.cap.cnvbl', process_type: 'p.cap.rid', contract: 'capital', name: 'CONVERT_TO_BLAGO', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.cap.blago', debit: null, credit: null, human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
|
||||
|
||||
{ code: 'o.cap.pgtop', process_type: 'p.cap.pgexp', contract: 'capital',
|
||||
name: 'PROGRAM_EXPENSE_TOPUP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.pgexp',
|
||||
@@ -204,59 +140,34 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
|
||||
human_name: 'Доплата сверх подотчёта (перерасход)' },
|
||||
|
||||
// marketplace
|
||||
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace',
|
||||
name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Подтверждение поставки' },
|
||||
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace', name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Подтверждение поставки' },
|
||||
|
||||
{ code: 'o.mkt.recv', process_type: 'p.mkt.reqst', contract: 'marketplace',
|
||||
name: 'CONFIRM_RECEIPT', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.payout',
|
||||
debit: 80, credit: 51,
|
||||
human_name: 'Подтверждение получения — выплата поставщику' },
|
||||
{ code: 'o.mkt.recv', process_type: 'p.mkt.reqst', contract: 'marketplace', name: 'CONFIRM_RECEIPT', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.payout', debit: 80, credit: 51, human_name: 'Подтверждение получения — выплата поставщику' },
|
||||
|
||||
// soviet
|
||||
{ code: 'o.sov.axncnv', process_type: 'p.sov.axncnv', contract: 'soviet',
|
||||
name: 'CONVERT_AXN', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.sov.delgte',
|
||||
debit: 80, credit: 86,
|
||||
human_name: 'Трансляция паевого в членский взнос инфраструктуры' },
|
||||
{ code: 'o.sov.axncnv', process_type: 'p.sov.axncnv', contract: 'soviet', name: 'CONVERT_AXN', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.sov.delgte', debit: 80, credit: 86, human_name: 'Трансляция паевого в членский взнос инфраструктуры' },
|
||||
|
||||
// migration
|
||||
{ code: 'o.mig.minshr', process_type: 'p.mig.trans', contract: 'migration',
|
||||
name: 'MIN_SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Транзит: минимальные паевые взносы' },
|
||||
{ code: 'o.mig.minshr', process_type: 'p.mig.trans', contract: 'migration', name: 'MIN_SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr', debit: 51, credit: 80, human_name: 'Транзит: минимальные паевые взносы' },
|
||||
|
||||
{ code: 'o.mig.share', process_type: 'p.mig.trans', contract: 'migration',
|
||||
name: 'SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
|
||||
debit: 51, credit: 80,
|
||||
human_name: 'Транзит: остаток паевых взносов деньгами' },
|
||||
{ code: 'o.mig.share', process_type: 'p.mig.trans', contract: 'migration', name: 'SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Транзит: остаток паевых взносов деньгами' },
|
||||
|
||||
{ code: 'o.mig.entry', process_type: 'p.mig.trans', contract: 'migration',
|
||||
name: 'ENTRY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry',
|
||||
debit: 51, credit: 86,
|
||||
human_name: 'Транзит: вступительные взносы' },
|
||||
{ code: 'o.mig.entry', process_type: 'p.mig.trans', contract: 'migration', name: 'ENTRY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry', debit: 51, credit: 86, human_name: 'Транзит: вступительные взносы' },
|
||||
|
||||
// adjustment (ручные корректировки председателя — динамические параметры,
|
||||
// не идут через ledger2::apply; см. operations.hpp `OPERATION_ADJUSTMENT_REGISTRY`).
|
||||
{ code: 'o.adj.walmove', process_type: 'p.adj.fix', contract: 'ledger2',
|
||||
name: 'WALMOVE', wallet_op: null, wallet_from: null, wallet_to: null,
|
||||
debit: null, credit: null,
|
||||
human_name: 'Перевод между кошельками',
|
||||
kind: 'adjustment' },
|
||||
{ code: 'o.adj.walmove', process_type: 'p.adj.fix', contract: 'ledger2', name: 'WALMOVE', wallet_op: null, wallet_from: null, wallet_to: null, debit: null, credit: null, human_name: 'Перевод между кошельками', kind: 'adjustment' },
|
||||
|
||||
{ code: 'o.adj.rev', process_type: 'p.adj.fix', contract: 'ledger2',
|
||||
name: 'REVERSAL', wallet_op: null, wallet_from: null, wallet_to: null,
|
||||
debit: null, credit: null,
|
||||
human_name: 'Откат операции',
|
||||
kind: 'adjustment' },
|
||||
{ code: 'o.adj.rev', process_type: 'p.adj.fix', contract: 'ledger2', name: 'REVERSAL', wallet_op: null, wallet_from: null, wallet_to: null, debit: null, credit: null, human_name: 'Откат операции', kind: 'adjustment' },
|
||||
] as const
|
||||
|
||||
const opByCode = new Map<string, OperationMeta>(
|
||||
LEDGER2_OPERATION_REGISTRY.map((o) => [o.code, o]),
|
||||
LEDGER2_OPERATION_REGISTRY.map(o => [o.code, o]),
|
||||
)
|
||||
|
||||
export function getOperationMeta(code: string | null | undefined): OperationMeta | undefined {
|
||||
if (!code) return undefined
|
||||
if (!code)
|
||||
return undefined
|
||||
return opByCode.get(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,3 +62,15 @@ export const LEDGER2_USER_SHARED_PROGRAM_MAPPING: readonly ProgramWalletMapping[
|
||||
{ wallet_name: "w.exp.adv", required_program_id: 0, program_label: null },
|
||||
{ wallet_name: "w.reg.pend", required_program_id: 0, program_label: null },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.
|
||||
* Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит
|
||||
* этот сет, собирает доступные балансы и ставит их на возврат; backend-preview
|
||||
* считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.
|
||||
*/
|
||||
export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [
|
||||
"w.reg.minshr",
|
||||
"w.wal.share",
|
||||
"w.cap.blago",
|
||||
] as const
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { IName } from '../interfaces/ledger2'
|
||||
|
||||
export {
|
||||
LEDGER2_EXIT_REFUND_WALLETS,
|
||||
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
|
||||
LEDGER2_WALLET_REGISTRY,
|
||||
} from './wallets.generated'
|
||||
@@ -28,6 +29,7 @@ export type {
|
||||
} from './wallets.generated'
|
||||
|
||||
import {
|
||||
LEDGER2_EXIT_REFUND_WALLETS,
|
||||
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
|
||||
LEDGER2_WALLET_REGISTRY,
|
||||
} from './wallets.generated'
|
||||
@@ -64,6 +66,26 @@ const walletNamesByProgramId = (() => {
|
||||
*/
|
||||
export const MEMBERSHIP_WALLET_NAME = 'w.wal.member'
|
||||
|
||||
/**
|
||||
* wallet_name целевого паевого взноса пайщика (program_id=1, ЦК).
|
||||
*/
|
||||
export const SHARE_WALLET_NAME = 'w.wal.share'
|
||||
|
||||
/**
|
||||
* wallet_name минимального паевого взноса пайщика (required_program_id=0 —
|
||||
* вне программ, заполняется при регистрации). Возвращается пайщику при выходе.
|
||||
*/
|
||||
export const MIN_SHARE_WALLET_NAME = 'w.reg.minshr'
|
||||
|
||||
/**
|
||||
* Сет паевых («боевых») кошельков, возвращаемых пайщику при выходе из кооператива
|
||||
* (`w.reg.minshr` + `w.wal.share` + `w.cap.blago`). Источник истины — контракт
|
||||
* (`LEDGER2_EXIT_REFUND_WALLETS` в wallets.hpp), сгенерирован в wallets.generated.
|
||||
* Контракт `confirmexit` пылесосит эти кошельки на возврат; backend-preview
|
||||
* суммирует их же — расчёт всегда совпадает с тем, что вернёт контракт.
|
||||
*/
|
||||
export const EXIT_REFUND_WALLET_NAMES: readonly string[] = LEDGER2_EXIT_REFUND_WALLETS
|
||||
|
||||
/**
|
||||
* Все wallet_name'ы, привязанные к какой-либо программе (program_id > 0).
|
||||
* Исключает кошельки с required_program_id == 0 (например, `w.reg.minshr`).
|
||||
|
||||
@@ -32,6 +32,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_USER_SHARED_PRO
|
||||
"required_program_id": 0,
|
||||
"wallet_name": "w.cap.preimp",
|
||||
},
|
||||
{
|
||||
"program_label": null,
|
||||
"required_program_id": 0,
|
||||
"wallet_name": "w.exp.adv",
|
||||
},
|
||||
{
|
||||
"program_label": null,
|
||||
"required_program_id": 0,
|
||||
@@ -67,6 +72,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
|
||||
"kind": "USER_SHARED",
|
||||
"name": "w.cap.preimp",
|
||||
},
|
||||
{
|
||||
"human_name": "Подотчётные средства пайщика",
|
||||
"kind": "USER_SHARED",
|
||||
"name": "w.exp.adv",
|
||||
},
|
||||
{
|
||||
"human_name": "Регистрационный взнос в ожидании решения совета",
|
||||
"kind": "USER_SHARED",
|
||||
@@ -122,5 +132,10 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.mkt.payout",
|
||||
},
|
||||
{
|
||||
"human_name": "Пул программных расходов ЦПП «Благорост»",
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.cap.pgexp",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EXIT_REFUND_WALLET_NAMES,
|
||||
LEDGER2_EXIT_REFUND_WALLETS,
|
||||
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
|
||||
LEDGER2_WALLET_REGISTRY,
|
||||
programIdForWallet,
|
||||
@@ -35,4 +37,13 @@ describe('ledger2 wallets registry (generated from C++)', () => {
|
||||
it('ЦК split: program_id=1 → share + member', () => {
|
||||
expect(walletNamesForProgram(1).sort()).toEqual(['w.wal.member', 'w.wal.share'])
|
||||
})
|
||||
|
||||
it('exit-refund сет: ровно три паевых кошелька (minshr + share + blago)', () => {
|
||||
expect(LEDGER2_EXIT_REFUND_WALLETS).toEqual(['w.reg.minshr', 'w.wal.share', 'w.cap.blago'])
|
||||
// алиас-обёртка ссылается на тот же сет
|
||||
expect(EXIT_REFUND_WALLET_NAMES).toEqual(LEDGER2_EXIT_REFUND_WALLETS)
|
||||
// каждый кошелёк сета зарегистрирован в реестре
|
||||
const known = new Set(LEDGER2_WALLET_REGISTRY.map(w => w.name))
|
||||
for (const w of LEDGER2_EXIT_REFUND_WALLETS) expect(known.has(w)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './ui';
|
||||
export { useCapitalOnboarding } from './model';
|
||||
export type { CapitalOnboardingState } from './api';
|
||||
|
||||
@@ -13,6 +13,8 @@ interface GeneratedDocument {
|
||||
full_title: string;
|
||||
}
|
||||
|
||||
type CapitalOnboardingStepId = Mutations.Capital.CompleteOnboardingStep.IInput['data']['step'];
|
||||
|
||||
export const useCapitalOnboarding = () => {
|
||||
const systemStore = useSystemStore();
|
||||
const sessionStore = useSessionStore();
|
||||
@@ -24,30 +26,40 @@ export const useCapitalOnboarding = () => {
|
||||
const currentGeneratedDoc = ref<GeneratedDocument | null>(null);
|
||||
|
||||
// Маппинг шагов на registry_id
|
||||
const stepToRegistryId: Record<string, number> = {
|
||||
const stepToRegistryId: Record<CapitalOnboardingStepId, number> = {
|
||||
'generator_program_template': 994,
|
||||
'generation_contract_template': 997,
|
||||
'generator_offer_template': 995,
|
||||
'blagorost_program': 998,
|
||||
'blagorost_offer_template': 999,
|
||||
};
|
||||
const capitalProgramDocDataRegistryIds = new Set([994, 995, 998, 999]);
|
||||
|
||||
const isCapitalOnboardingStepId = (stepId: string): stepId is CapitalOnboardingStepId => {
|
||||
return stepId in stepToRegistryId;
|
||||
};
|
||||
|
||||
// Генерация документа для шага
|
||||
const generateDocument = async (step: ICouncilOnboardingStep): Promise<GeneratedDocument> => {
|
||||
try {
|
||||
generatingDocument.value = true;
|
||||
const registry_id = stepToRegistryId[step.id];
|
||||
|
||||
if (!registry_id) {
|
||||
if (!isCapitalOnboardingStepId(step.id)) {
|
||||
throw new Error(`Неизвестный шаг онбординга: ${step.id}`);
|
||||
}
|
||||
|
||||
const registry_id = stepToRegistryId[step.id];
|
||||
const docDataHash = onboardingState.value?.capital_program_doc_data_hash;
|
||||
if (capitalProgramDocDataRegistryIds.has(registry_id) && !docDataHash) {
|
||||
throw new Error('Параметры документов ЦПП не заполнены: переустановите или обновите настройки расширения capital');
|
||||
}
|
||||
|
||||
const generateDocInput: Mutations.Documents.GenerateDocument.IInput = {
|
||||
input: {
|
||||
data: {
|
||||
coopname: systemStore.info?.coopname || '',
|
||||
username: sessionStore.username,
|
||||
registry_id,
|
||||
...(docDataHash && capitalProgramDocDataRegistryIds.has(registry_id) ? { doc_data_hash: docDataHash } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -189,9 +201,13 @@ export const useCapitalOnboarding = () => {
|
||||
await handleStepClick(step);
|
||||
}
|
||||
|
||||
if (!isCapitalOnboardingStepId(step.id)) {
|
||||
throw new Error(`Неизвестный шаг онбординга: ${step.id}`);
|
||||
}
|
||||
|
||||
// Подготавливаем данные для отправки
|
||||
const stepData: Mutations.Capital.CompleteOnboardingStep.IInput['data'] = {
|
||||
step: step.id as any,
|
||||
step: step.id,
|
||||
title: step.title,
|
||||
question: step.question,
|
||||
decision: currentGeneratedDoc.value?.html || step.decisionPrefix || step.decision,
|
||||
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
<template lang="pug">
|
||||
BaseCard(title='Параметры документов ЦПП')
|
||||
p.capital-doc-params__intro.t-body2
|
||||
| Заполните поля, которые будут подставлены в положения и оферты программ «ГЕНЕРАТОР» и «БЛАГОРОСТ».
|
||||
| Перед установкой расширения сформируйте предпросмотр документов: так параметры сохранятся в PrivateData, а в config попадёт только hash.
|
||||
|
||||
BaseInput(
|
||||
:model-value='config?.capital_program_doc_data_hash || ""'
|
||||
label='Hash PrivateData документов'
|
||||
hint='Появится после формирования предпросмотра'
|
||||
readonly
|
||||
mono
|
||||
)
|
||||
|
||||
BaseBanner(v-if='config?.capital_program_doc_data_hash' variant='pos')
|
||||
| Параметры сохранены. Hash: {{ config.capital_program_doc_data_hash }}
|
||||
|
||||
q-expansion-item(default-opened label='Протокол и кооператив' icon='description')
|
||||
.row.q-col-gutter-md.q-mt-sm
|
||||
.col-12.col-md-3(v-for='field in protocolFields' :key='field.key')
|
||||
BaseInput(
|
||||
v-model='form[field.key]'
|
||||
:label='field.label'
|
||||
:error='fieldErrors[field.key]'
|
||||
)
|
||||
.col-12.col-md-6(v-for='field in cooperativeFields' :key='field.key')
|
||||
BaseInput(
|
||||
v-model='form[field.key]'
|
||||
:label='field.label'
|
||||
:error='fieldErrors[field.key]'
|
||||
)
|
||||
|
||||
q-expansion-item(label='Программа ГЕНЕРАТОР' icon='bolt')
|
||||
.capital-doc-params__section
|
||||
BaseInput(
|
||||
v-for='field in generatorFields'
|
||||
:key='field.key'
|
||||
v-model='form[field.key]'
|
||||
:label='field.label'
|
||||
:type='field.type || "textarea"'
|
||||
:autogrow='field.type !== "text"'
|
||||
:error='fieldErrors[field.key]'
|
||||
)
|
||||
|
||||
q-expansion-item(label='Программа БЛАГОРОСТ' icon='eco')
|
||||
.capital-doc-params__section
|
||||
BaseInput(
|
||||
v-for='field in blagorostFields'
|
||||
:key='field.key'
|
||||
v-model='form[field.key]'
|
||||
:label='field.label'
|
||||
type='textarea'
|
||||
autogrow
|
||||
:error='fieldErrors[field.key]'
|
||||
)
|
||||
|
||||
q-expansion-item(label='Возврат, источники и оферты' icon='article')
|
||||
.capital-doc-params__section
|
||||
BaseInput(
|
||||
v-for='field in returnFields'
|
||||
:key='field.key'
|
||||
v-model='form[field.key]'
|
||||
:label='field.label'
|
||||
:type='field.type || "textarea"'
|
||||
:autogrow='field.type !== "text"'
|
||||
:error='fieldErrors[field.key]'
|
||||
)
|
||||
|
||||
.capital-doc-params__footer
|
||||
p.capital-doc-params__hint.t-caption
|
||||
| Предпросмотр генерирует документы без публикации и сохраняет PrivateData по hash.
|
||||
BaseButton(:loading='isGenerating' @click='generatePreview')
|
||||
| Сформировать предпросмотр
|
||||
|
||||
BaseDialog(v-model='previewOpen' title='Предпросмотр положений ЦПП' size='xl')
|
||||
.capital-doc-params__preview(v-if='previewDocuments.length')
|
||||
q-tabs(v-model='activePreviewTab' dense align='left' class='capital-doc-params__tabs')
|
||||
q-tab(
|
||||
v-for='doc in previewDocuments'
|
||||
:key='doc.registry_id'
|
||||
:name='String(doc.registry_id)'
|
||||
:label='doc.title'
|
||||
)
|
||||
q-separator
|
||||
q-tab-panels(v-model='activePreviewTab' animated)
|
||||
q-tab-panel(
|
||||
v-for='doc in previewDocuments'
|
||||
:key='doc.registry_id'
|
||||
:name='String(doc.registry_id)'
|
||||
)
|
||||
.doc-preview
|
||||
DocumentHtmlReader(:html='doc.html' :sanitize='false')
|
||||
template(#footer)
|
||||
BaseButton(variant='ghost' @click='previewOpen = false')
|
||||
| Закрыть
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Mutations } from '@coopenomics/sdk';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useSessionStore } from 'src/entities/Session';
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { BaseBanner, BaseButton, BaseCard, BaseDialog, BaseInput } from 'src/shared/ui/base';
|
||||
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
|
||||
|
||||
type CapitalProgramPrivateData = {
|
||||
approval_protocol_number: string;
|
||||
approval_protocol_day: string;
|
||||
approval_protocol_month: string;
|
||||
approval_protocol_year: string;
|
||||
cooperative_name: string;
|
||||
cooperative_short_name: string;
|
||||
cooperative_quoted_name: string;
|
||||
website: string;
|
||||
chairman_full_name: string;
|
||||
generator_program_purpose: string;
|
||||
eoap_definition: string;
|
||||
generator_task_goal: string;
|
||||
idea_unit_cost: string;
|
||||
idea_unit_cost_words: string;
|
||||
blagorost_goal_expansion: string;
|
||||
blagorost_task_expansion: string;
|
||||
blagorost_task_development: string;
|
||||
return_source_description: string;
|
||||
return_additional_source: string;
|
||||
offer_template_number: string;
|
||||
};
|
||||
|
||||
type FieldDefinition = {
|
||||
key: keyof CapitalProgramPrivateData;
|
||||
label: string;
|
||||
type?: 'text' | 'textarea';
|
||||
};
|
||||
|
||||
type CapitalProgramConfig = Record<string, unknown> & {
|
||||
capital_program_doc_data_hash?: string | null;
|
||||
};
|
||||
|
||||
type GeneratedPreviewDocument = NonNullable<
|
||||
Mutations.Documents.GenerateDocument.IOutput[typeof Mutations.Documents.GenerateDocument.name]
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
config: CapitalProgramConfig;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:config', value: CapitalProgramConfig): void;
|
||||
}>();
|
||||
|
||||
const system = useSystemStore();
|
||||
const session = useSessionStore();
|
||||
const isGenerating = ref(false);
|
||||
const previewOpen = ref(false);
|
||||
const activePreviewTab = ref('994');
|
||||
const previewDocuments = ref<Array<{ registry_id: number; title: string; html: string; hash: string }>>([]);
|
||||
const fieldErrors = reactive<Partial<Record<keyof CapitalProgramPrivateData, string>>>({});
|
||||
|
||||
const form = reactive<Omit<CapitalProgramPrivateData, 'cooperative_quoted_name'>>({
|
||||
approval_protocol_number: '',
|
||||
approval_protocol_day: '',
|
||||
approval_protocol_month: '',
|
||||
approval_protocol_year: '',
|
||||
cooperative_name: '',
|
||||
cooperative_short_name: '',
|
||||
website: '',
|
||||
chairman_full_name: '',
|
||||
generator_program_purpose: '',
|
||||
eoap_definition: '',
|
||||
generator_task_goal: '',
|
||||
idea_unit_cost: '',
|
||||
idea_unit_cost_words: '',
|
||||
blagorost_goal_expansion: '',
|
||||
blagorost_task_expansion: '',
|
||||
blagorost_task_development: '',
|
||||
return_source_description: '',
|
||||
return_additional_source: '',
|
||||
offer_template_number: '______',
|
||||
});
|
||||
|
||||
const protocolFields: FieldDefinition[] = [
|
||||
{ key: 'approval_protocol_number', label: 'Номер протокола' },
|
||||
{ key: 'approval_protocol_day', label: 'День протокола' },
|
||||
{ key: 'approval_protocol_month', label: 'Месяц протокола' },
|
||||
{ key: 'approval_protocol_year', label: 'Год протокола' },
|
||||
];
|
||||
|
||||
const cooperativeFields: FieldDefinition[] = [
|
||||
{ key: 'cooperative_name', label: 'Наименование кооператива' },
|
||||
{ key: 'cooperative_short_name', label: 'Краткое наименование (ПК)' },
|
||||
{ key: 'website', label: 'Сайт' },
|
||||
{ key: 'chairman_full_name', label: 'ФИО председателя' },
|
||||
];
|
||||
|
||||
const generatorFields: FieldDefinition[] = [
|
||||
{ key: 'generator_program_purpose', label: 'Назначение программы ГЕНЕРАТОР' },
|
||||
{ key: 'eoap_definition', label: 'Определение ЕОАП' },
|
||||
{ key: 'generator_task_goal', label: 'Цель задач ГЕНЕРАТОР' },
|
||||
{ key: 'idea_unit_cost', label: 'Стоимость единицы идеи', type: 'text' },
|
||||
{ key: 'idea_unit_cost_words', label: 'Стоимость единицы идеи прописью', type: 'text' },
|
||||
];
|
||||
|
||||
const blagorostFields: FieldDefinition[] = [
|
||||
{ key: 'blagorost_goal_expansion', label: 'Расширение цели БЛАГОРОСТ' },
|
||||
{ key: 'blagorost_task_expansion', label: 'Задача расширения БЛАГОРОСТ' },
|
||||
{ key: 'blagorost_task_development', label: 'Задача развития БЛАГОРОСТ' },
|
||||
];
|
||||
|
||||
const returnFields: FieldDefinition[] = [
|
||||
{ key: 'return_source_description', label: 'Источник возврата' },
|
||||
{ key: 'return_additional_source', label: 'Дополнительный источник возврата' },
|
||||
{ key: 'offer_template_number', label: 'Номер шаблона оферты', type: 'text' },
|
||||
];
|
||||
|
||||
const allFields = computed(() => [
|
||||
...protocolFields,
|
||||
...cooperativeFields,
|
||||
...generatorFields,
|
||||
...blagorostFields,
|
||||
...returnFields,
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => system.info,
|
||||
(info) => {
|
||||
if (!info) return;
|
||||
form.cooperative_name ||= String(info.coopname || '').toUpperCase();
|
||||
form.cooperative_short_name ||= 'ПК';
|
||||
form.website ||= info.website || info.contacts?.website || '';
|
||||
form.chairman_full_name ||= info.chairman?.full_name || info.chairman_full_name || '';
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function getPayload(): CapitalProgramPrivateData {
|
||||
const cooperativeName = String(form.cooperative_name || '').trim();
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
allFields.value.map(({ key }) => [key, String(form[key] || '').trim()])
|
||||
) as Omit<CapitalProgramPrivateData, 'cooperative_quoted_name'>,
|
||||
cooperative_quoted_name: cooperativeName ? `«${cooperativeName}»` : '',
|
||||
};
|
||||
}
|
||||
|
||||
function validatePayload(payload: CapitalProgramPrivateData): string[] {
|
||||
return allFields.value
|
||||
.filter(({ key }) => !payload[key])
|
||||
.map(({ label }) => label);
|
||||
}
|
||||
|
||||
function clearFieldErrors(): void {
|
||||
for (const key of Object.keys(fieldErrors) as Array<keyof CapitalProgramPrivateData>) {
|
||||
delete fieldErrors[key];
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldErrors(missingLabels: string[]): void {
|
||||
clearFieldErrors();
|
||||
for (const { key, label } of allFields.value) {
|
||||
if (missingLabels.includes(label)) {
|
||||
fieldErrors[key] = 'Заполните поле';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function getDocDataHash(doc: GeneratedPreviewDocument): string | null {
|
||||
if (!isRecord(doc.meta)) return null;
|
||||
const hash = doc.meta.doc_data_hash;
|
||||
return typeof hash === 'string' && hash.length > 0 ? hash : null;
|
||||
}
|
||||
|
||||
async function generateDocument(registry_id: number, payload: CapitalProgramPrivateData): Promise<GeneratedPreviewDocument> {
|
||||
const { [Mutations.Documents.GenerateDocument.name]: result } = await client.Mutation(
|
||||
Mutations.Documents.GenerateDocument.mutation,
|
||||
{
|
||||
variables: {
|
||||
input: {
|
||||
data: {
|
||||
coopname: system.info?.coopname || '',
|
||||
username: session.username,
|
||||
lang: 'ru',
|
||||
registry_id,
|
||||
doc_data: payload,
|
||||
},
|
||||
options: { skip_save: true },
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Документ не был сгенерирован');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function generatePreview() {
|
||||
const payload = getPayload();
|
||||
const missing = validatePayload(payload);
|
||||
if (missing.length) {
|
||||
setFieldErrors(missing);
|
||||
FailAlert(`Заполните параметры документов: ${missing.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
clearFieldErrors();
|
||||
|
||||
try {
|
||||
isGenerating.value = true;
|
||||
const docs = await Promise.all([
|
||||
generateDocument(994, payload),
|
||||
generateDocument(998, payload),
|
||||
]);
|
||||
|
||||
const docDataHash = docs
|
||||
.map(getDocDataHash)
|
||||
.find((hash: unknown): hash is string => typeof hash === 'string' && hash.length > 0);
|
||||
|
||||
if (!docDataHash) {
|
||||
throw new Error('Генератор не вернул doc_data_hash');
|
||||
}
|
||||
|
||||
previewDocuments.value = docs.map((doc, index) => ({
|
||||
registry_id: index === 0 ? 994 : 998,
|
||||
title: doc.full_title,
|
||||
html: doc.html,
|
||||
hash: doc.hash,
|
||||
}));
|
||||
activePreviewTab.value = '994';
|
||||
previewOpen.value = true;
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
capital_program_doc_data_hash: docDataHash,
|
||||
});
|
||||
SuccessAlert('Параметры документов сохранены в PrivateData');
|
||||
} catch (error) {
|
||||
FailAlert(error);
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.capital-doc-params__intro {
|
||||
margin: 0 0 var(--p-4);
|
||||
color: var(--p-ink-secondary);
|
||||
}
|
||||
|
||||
.capital-doc-params__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-3);
|
||||
margin-top: var(--p-2);
|
||||
}
|
||||
|
||||
.capital-doc-params__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--p-4);
|
||||
margin-top: var(--p-6);
|
||||
}
|
||||
|
||||
.capital-doc-params__hint {
|
||||
margin: 0;
|
||||
color: var(--p-ink-secondary);
|
||||
}
|
||||
|
||||
.capital-doc-params__tabs {
|
||||
color: var(--p-primary);
|
||||
}
|
||||
|
||||
.doc-preview :deep([data-doc-param]) {
|
||||
background: var(--p-warn-soft);
|
||||
border-radius: var(--p-r-sm);
|
||||
padding: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1 +1,2 @@
|
||||
export { default as CapitalOnboardingCard } from './CapitalOnboardingCard.vue';
|
||||
export { default as CapitalProgramDocumentParametersWidget } from './CapitalProgramDocumentParametersWidget.vue';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ProfilePage } from 'src/pages/User/ProfilePage';
|
||||
import { WalletPage } from 'src/pages/User/WalletPage';
|
||||
import { MembershipExitPage } from 'src/pages/User/MembershipExitPage';
|
||||
import { MembershipExitConfirmPage } from 'src/pages/User/MembershipExitConfirmPage';
|
||||
import { ConnectionAgreementPage, InstallationCompletedPage } from 'src/pages/Union/ConnectionAgreement';
|
||||
import { UserPaymentMethodsPage } from 'src/pages/User/PaymentMethodsPage';
|
||||
import { ContactsPage } from 'src/pages/Contacts';
|
||||
@@ -43,6 +45,22 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
component: markRaw(WalletPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
// Подтверждение авторизуется токеном из ссылки (мутация публичная),
|
||||
// поэтому страница доступна БЕЗ входа — иначе навигационный гард
|
||||
// редиректит на login-redirect. requiresAuth:false = исключение из auth-гейта.
|
||||
meta: {
|
||||
title: 'Подтверждение выхода',
|
||||
icon: 'logout',
|
||||
roles: [],
|
||||
requiresAuth: false,
|
||||
hidden: true,
|
||||
},
|
||||
path: 'membership-exit/confirm',
|
||||
name: 'membership-exit-confirm',
|
||||
component: markRaw(MembershipExitConfirmPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Удостоверение',
|
||||
@@ -58,7 +76,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
{
|
||||
meta: {
|
||||
title: 'Подключение',
|
||||
icon: 'fas fa-link',
|
||||
icon: 'link',
|
||||
roles: ['user'],
|
||||
conditions: 'isCoop === true && coopname === "voskhod"',
|
||||
requiresAuth: true,
|
||||
@@ -85,7 +103,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
{
|
||||
meta: {
|
||||
title: 'Реквизиты',
|
||||
icon: 'fas fa-link',
|
||||
icon: 'account_balance',
|
||||
roles: ['user', 'member', 'chairman'],
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -161,6 +179,20 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
roles: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Предпоследний пункт: выход из кооператива (не путать с «Выйти из
|
||||
// кабинета» в RailUserCard). Иконка group_remove — не logout.
|
||||
meta: {
|
||||
title: 'Выход из кооператива',
|
||||
icon: 'group_remove',
|
||||
roles: [],
|
||||
requiresAuth: true,
|
||||
},
|
||||
path: 'membership-exit',
|
||||
name: 'membership-exit',
|
||||
component: markRaw(MembershipExitPage),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Поддержка',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/desktop",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.6.24",
|
||||
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
|
||||
"productName": "Desktop App",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user