Compare commits
103 Commits
v2026.6.16
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e58c173ee | |||
| c1ce7a095d | |||
| 995f65f524 | |||
| 5298e55a7b | |||
| db28ba1137 | |||
| 6a05e88ba1 | |||
| 09eaa6192c | |||
| 81df662cfa | |||
| 845c13605d | |||
| 76b4988da8 | |||
| 3db80a30e9 | |||
| 1b457b8f87 | |||
| ba6d3d1310 | |||
| 8e01373179 | |||
| 31940461c6 | |||
| 37b0227735 | |||
| 2cfb37c5d7 | |||
| 1c605dd2a5 | |||
| b1b1775277 | |||
| 5f4281ac01 | |||
| d35e88d951 | |||
| 5360daa471 | |||
| 652f7c722e | |||
| a2b132b457 | |||
| a6cc71d6a3 | |||
| b67581ce80 | |||
| 024d46ff64 | |||
| 9030d28c6b | |||
| 6e0e68c1e7 | |||
| c079785019 | |||
| bbc6453b24 | |||
| b5eb0ea51f | |||
| a072364d00 | |||
| ed9b0879d5 | |||
| c94043e77f | |||
| c1a8f3e637 | |||
| 15fdf35dd3 | |||
| 79b89695d1 | |||
| 7b27d0805c | |||
| e089dbd3fb | |||
| 5230fab7c6 | |||
| 345aadbd6a | |||
| 33f1ac1a15 | |||
| 5de7e7c65b | |||
| bfbcd2d57f | |||
| ff5df68bce | |||
| a948f07a1b | |||
| 85bd044cd6 | |||
| 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 | |||
| 102eeb319e | |||
| 2f1e078fe2 | |||
| 554798b74b | |||
| b8568eff4c | |||
| 448eb1de80 | |||
| 8fae69d979 | |||
| 1b1b8b3246 | |||
| 72a8a3d0c3 | |||
| 56cd422440 | |||
| 949b5139af | |||
| 50d5d71c8d | |||
| 8d9bc5f592 | |||
| 82ea0a4c03 | |||
| b728bc6295 | |||
| 59585dc4ea | |||
| 6bba3c7b76 | |||
| 9ee57d1e05 | |||
| 0c995b1bca | |||
| 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 в
|
||||
@@ -366,6 +390,18 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
|
||||
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
|
||||
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
|
||||
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
|
||||
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
|
||||
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
|
||||
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
|
||||
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
|
||||
- name: Generate controller schema.gql (required by sdk prepublishOnly)
|
||||
working-directory: components/controller
|
||||
run: pnpm run generate-schema
|
||||
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -45,6 +45,14 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
|
||||
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
|
||||
COPY . .
|
||||
|
||||
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
|
||||
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
|
||||
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
|
||||
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
|
||||
# Отключаем сами загрузки, пакеты всё равно ставятся.
|
||||
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
|
||||
PUPPETEER_SKIP_DOWNLOAD=true
|
||||
|
||||
# Используем существующий lockfile. Если он не совпадает с workspace —
|
||||
# падать сразу, не дрейфовать незаметно.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.6.16",
|
||||
"version": "2026.7.21-1",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -35,7 +35,14 @@ RUN npm install -g pnpm@10.33.0
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
|
||||
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
|
||||
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
|
||||
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
|
||||
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
|
||||
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
|
||||
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
|
||||
&& pnpm rebuild --filter "@coopenomics/boot..."
|
||||
|
||||
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
|
||||
# свой bundle с inlined workspace-deps.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.6.16",
|
||||
"version": "2026.7.21-1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -76,7 +76,7 @@ export class CooperativeClass {
|
||||
await this.blockchain.createProgram({
|
||||
coopname,
|
||||
username: coopname,
|
||||
type: 'blagorost',
|
||||
type: 'capital',
|
||||
title: 'Целевая потребительская программа "Благорост"',
|
||||
announce: '',
|
||||
description: '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
|
||||
* (id=4, type='blagorost') + capital::setconfig.
|
||||
* (id=4, type='capital') + capital::setconfig.
|
||||
*
|
||||
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
|
||||
* setconfig — только если `state` пуст.
|
||||
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'blagorost',
|
||||
type: 'capital',
|
||||
}
|
||||
await blockchain.api.transact({
|
||||
actions: [{
|
||||
|
||||
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
|
||||
}
|
||||
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
|
||||
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
|
||||
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
|
||||
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
|
||||
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'blagorost',
|
||||
type: 'capital',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'blagorost',
|
||||
type: 'capital',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
|
||||
|
||||
export const ratePerHour = '1000.0000 RUB'
|
||||
export const sourceProgramName = 'generator'
|
||||
export const capitalProgramName = 'blagorost'
|
||||
export const capitalProgramName = 'capital'
|
||||
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
|
||||
export const circulationAccountId = 80_000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.6.16",
|
||||
"version": "2026.7.21-1",
|
||||
"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
|
||||
|
||||
@@ -80,12 +80,13 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
|
||||
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
|
||||
|
||||
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
|
||||
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
|
||||
static constexpr eosio::name _capital_program = "capital"_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);
|
||||
}
|
||||
@@ -12,14 +12,22 @@
|
||||
void soviet::migrate() {
|
||||
require_auth(_soviet); // Проверяем авторизацию
|
||||
|
||||
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
|
||||
eosio::name coopname = "voskhod"_n;
|
||||
programs_index programs(_soviet, coopname.value);
|
||||
auto program_it = programs.find(4);
|
||||
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
|
||||
programs.modify(program_it, _soviet, [&](auto &pr) {
|
||||
pr.program_type = "blagorost"_n;
|
||||
});
|
||||
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
|
||||
// Предыдущая версия этой миграции переименовывала programs.program_type в
|
||||
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
|
||||
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
|
||||
// 'capital' — каноничное значение (используется coagreements.type, contract-
|
||||
// константой _capital_program и всем controller-кодом); эта миграция приводит
|
||||
// programs.program_type к нему же. Идемпотентно.
|
||||
{
|
||||
eosio::name coopname = "voskhod"_n;
|
||||
programs_index programs(_soviet, coopname.value);
|
||||
auto program_it = programs.find(4);
|
||||
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
|
||||
programs.modify(program_it, _soviet, [&](auto &pr) {
|
||||
pr.program_type = "capital"_n;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.6.16",
|
||||
"version": "2026.7.21-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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 = {
|
||||
generator_program_purpose:
|
||||
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
|
||||
eoap_definition:
|
||||
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
|
||||
generator_task_goal:
|
||||
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
|
||||
idea_unit_cost:
|
||||
"50",
|
||||
idea_unit_cost_words:
|
||||
"пятьдесят",
|
||||
blagorost_goal_expansion:
|
||||
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
|
||||
blagorost_goal_reason:
|
||||
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
|
||||
blagorost_task_expansion:
|
||||
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
|
||||
blagorost_task_development:
|
||||
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
|
||||
return_source_description:
|
||||
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
|
||||
return_additional_source:
|
||||
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
|
||||
};
|
||||
|
||||
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;
|
||||
if (!db) {
|
||||
throw new Error('Подключение к MongoDB установлено, но 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.16",
|
||||
"version": "2026.7.21-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.6.16",
|
||||
"version": "2026.7.21-1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.6.16",
|
||||
"version": "2026.7.21-1",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
|
||||
@@ -14,6 +14,7 @@ const { spawnSync } = require('child_process');
|
||||
|
||||
const controllerRoot = path.resolve(__dirname, '..');
|
||||
const schemaPath = path.join(controllerRoot, 'schema.gql');
|
||||
const zeusConfigPath = path.join(controllerRoot, 'graphql-zeus.json');
|
||||
const sdkSrc = path.resolve(controllerRoot, '..', 'sdk', 'src');
|
||||
const localZeusDir = path.join(controllerRoot, 'zeus');
|
||||
const sdkZeusDir = path.join(sdkSrc, 'zeus');
|
||||
@@ -40,6 +41,12 @@ if (!fs.existsSync(sdkSrc)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// graphql-zeus 7.x: yargs кладёт path в argv.path, а CLIClass читает args._[0].
|
||||
// Без graphql-zeus.json config-maker уходит в inquirer и «зависает» без TTY.
|
||||
if (!fs.existsSync(zeusConfigPath)) {
|
||||
fs.writeFileSync(zeusConfigPath, `${JSON.stringify({ urlOrPath: './schema.gql' }, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
let zeusBin;
|
||||
let zeusIsWin;
|
||||
try {
|
||||
@@ -51,6 +58,7 @@ try {
|
||||
|
||||
const zeusArgs = ['./schema.gql', '../sdk/src', '--subscriptions', 'graphql-ws'];
|
||||
const startedAt = Date.now();
|
||||
console.log('generate-client: graphql-zeus… (5–30 с без вывода — норма, не прерывайте)');
|
||||
const result = spawnSync(zeusBin, zeusArgs, {
|
||||
stdio: 'inherit',
|
||||
cwd: controllerRoot,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,15 +4,11 @@ import type { GetDocumentsInputDTO } from '../dto/get-documents-input.dto';
|
||||
import type { PaginationResultDomainInterface } from '~/domain/common/interfaces/pagination.interface';
|
||||
import type { DocumentPackageAggregateDomainInterface } from '~/domain/document/interfaces/document-package-aggregate-domain.interface';
|
||||
import type { GenerateAnyDocumentInputDTO } from '../dto/generate-any-document-input.dto';
|
||||
import { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service';
|
||||
import { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentService {
|
||||
constructor(
|
||||
private readonly documentInteractor: DocumentInteractor,
|
||||
private readonly generatorService: GeneratorInfrastructureService
|
||||
) {}
|
||||
constructor(private readonly documentInteractor: DocumentInteractor) {}
|
||||
|
||||
async getDocumentsAggregate(
|
||||
data: GetDocumentsInputDTO
|
||||
@@ -34,7 +30,7 @@ export class DocumentService {
|
||||
}
|
||||
|
||||
async generateAnyDocument(input: GenerateAnyDocumentInputDTO): Promise<DocumentDomainEntity> {
|
||||
return this.generatorService.generateDocument({
|
||||
return this.documentInteractor.generateDocument({
|
||||
data: input.data as any,
|
||||
options: input.options,
|
||||
});
|
||||
|
||||
@@ -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,9 @@ 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;
|
||||
|
||||
@Injectable()
|
||||
export class InstallInteractor {
|
||||
constructor(
|
||||
@@ -118,14 +121,30 @@ 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}`
|
||||
);
|
||||
}
|
||||
|
||||
// Повтор после прерванной установки: снимаем 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;
|
||||
}
|
||||
+17
-9
@@ -6,7 +6,7 @@ import {
|
||||
UserCertificateDomainService,
|
||||
USER_CERTIFICATE_DOMAIN_SERVICE,
|
||||
} from '~/domain/user/services/user-certificate-domain.service';
|
||||
import { Cooperative, SovietContract, WalletContract } from 'cooptypes';
|
||||
import { Cooperative, SovietContract } from 'cooptypes';
|
||||
import { getActions } from '~/utils/getFetch';
|
||||
import type { DocumentPackageAggregateDomainInterface } from '../interfaces/document-package-aggregate-domain.interface';
|
||||
import type { StatementDetailAggregateDomainInterface } from '../interfaces/statement-detail-aggregate-domain.interface';
|
||||
@@ -298,9 +298,17 @@ export class DocumentPackageV1Aggregator {
|
||||
|
||||
/**
|
||||
* Находит on-chain действие-носитель подписанного связанного документа по doc_hash.
|
||||
* Сначала ищет в реестре соглашений совета (soviet::newagreement), затем —
|
||||
* в программных подписях кошелька (wallet::signagree). Возвращает поле document
|
||||
* найденного действия либо null.
|
||||
* Сначала ищет в реестре соглашений совета (soviet::newagreement — второе,
|
||||
* явное уведомление, которое дополнительно шлёт soviet::sndagreement), затем —
|
||||
* в общем уведомлении о завершённом документе (soviet::newresolved), которое
|
||||
* централизованно шлёт make_complete_document (lib/core/soviet/soviet.hpp) на
|
||||
* КАЖДЫЙ вызов — и из soviet::sndagreement, и из wallet::signagree — с
|
||||
* require_recipient(coopname), заданным один раз внутри newresolved.cpp.
|
||||
* Программные оферты (capital: Благорост/Генератор, program_id>0) идут через
|
||||
* wallet::signagree и не порождают newagreement, но newresolved у них есть
|
||||
* всегда — расширять notification-поверхность самого wallet::signagree не
|
||||
* нужно, единственное место для этого уже newresolved.cpp.
|
||||
* Возвращает поле document найденного действия либо null.
|
||||
*/
|
||||
private async findLinkedAgreementDocument(
|
||||
linkHash: string
|
||||
@@ -321,10 +329,10 @@ export class DocumentPackageV1Aggregator {
|
||||
const sovietDocument = sovietAgreement?.results?.[0]?.data?.document;
|
||||
if (sovietDocument) return sovietDocument;
|
||||
|
||||
const walletAgreement = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
|
||||
const resolvedAgreement = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
|
||||
filter: JSON.stringify({
|
||||
account: WalletContract.contractName.production,
|
||||
name: WalletContract.Actions.SignAgreement.actionName,
|
||||
account: SovietContract.contractName.production,
|
||||
name: SovietContract.Actions.Registry.NewResolved.actionName,
|
||||
receiver: process.env.COOPNAME,
|
||||
'data.document.doc_hash': docHashFilter,
|
||||
}),
|
||||
@@ -332,8 +340,8 @@ export class DocumentPackageV1Aggregator {
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const walletDocument = walletAgreement?.results?.[0]?.data?.document;
|
||||
if (walletDocument) return walletDocument;
|
||||
const resolvedDocument = resolvedAgreement?.results?.[0]?.data?.document;
|
||||
if (resolvedDocument) return resolvedDocument;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { Cooperative } from 'cooptypes';
|
||||
|
||||
/**
|
||||
* Узкая задача пред-генерации: документ, у которого часть полей приватна и
|
||||
* хранится off-chain (`doc_data`) — `DocumentDomainService.generateDocument`
|
||||
* сохраняет `doc_data` через `saveDocData` и публикует дальше только
|
||||
* `doc_data_hash`. См. `Cooperative.Document.IDocDataRef` в cooptypes —
|
||||
* on-chain-сторона того же контракта.
|
||||
*/
|
||||
export interface GenerateDocumentWithPrivateDataDomainInterface extends Cooperative.Document.IGenerate {
|
||||
doc_data?: Record<string, unknown>;
|
||||
doc_data_hash?: string;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { DocumentPackageAggregator } from '../aggregators/document-package.aggre
|
||||
import { getActions } from '~/utils/getFetch';
|
||||
import { toDotNotation } from '~/utils/toDotNotation';
|
||||
import type { ISignedDocumentDomainInterface } from '../interfaces/signed-document-domain.interface';
|
||||
import type { GenerateDocumentWithPrivateDataDomainInterface } from '../interfaces/generate-document-with-private-data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentDomainService {
|
||||
@@ -22,6 +23,23 @@ export class DocumentDomainService {
|
||||
) {}
|
||||
|
||||
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
|
||||
const documentData: GenerateDocumentWithPrivateDataDomainInterface = data.data;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await this.generatorInfrastructureService.generateDocument(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface ExtensionDomainRepository<TConfig = any> {
|
||||
create(data: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>>;
|
||||
find(filter?: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>[]>;
|
||||
update(data: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>>;
|
||||
/**
|
||||
* Атомарно мёржит `patch` в jsonb-колонку `config` одним UPDATE (`config || patch`),
|
||||
* без чтения-изменения-записи всего объекта в памяти приложения. Конкурентные вызовы
|
||||
* с разными ключами patch не теряют изменения друг друга (в отличие от `update()`,
|
||||
* который перезаписывает весь config целиком и подвержен lost update при гонке).
|
||||
*/
|
||||
patchConfig(name: string, patch: Partial<TConfig>): Promise<ExtensionDomainEntity<TConfig>>;
|
||||
}
|
||||
|
||||
export const EXTENSION_REPOSITORY = Symbol('ExtensionDomainRepository'); // Создаем уникальный токен
|
||||
|
||||
@@ -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');
|
||||
@@ -60,6 +60,14 @@ export interface IAgreementConfigItem {
|
||||
* Порядок отображения на фронтенде (сортировка)
|
||||
*/
|
||||
order: number;
|
||||
|
||||
/**
|
||||
* Резолвер hash'а приватных данных документа (doc_data_hash), если шаблон
|
||||
* оферты на фабрике требует PrivateData. Заполняется только для
|
||||
* extension-оферт из AgreementRegistrationSpec; платформенные соглашения
|
||||
* его не имеют. В GraphQL DTO не попадает (поля мапятся декораторами).
|
||||
*/
|
||||
resolve_doc_data_hash?: () => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,4 +55,18 @@ export interface AgreementRegistrationSpec {
|
||||
* реестр удаляет все записи с этим extension_name.
|
||||
*/
|
||||
extension_name: string;
|
||||
|
||||
/**
|
||||
* Опциональный резолвер hash'а приватных данных документа (doc_data_hash).
|
||||
* Если шаблон оферты на фабрике требует PrivateData (например, параметры
|
||||
* ЦПП, утверждённые советом), расширение предоставляет резолвер — ядро
|
||||
* вызывает его перед каждой генерацией документа этой оферты и передаёт
|
||||
* результат в фабрику как doc_data_hash.
|
||||
*
|
||||
* Резолвер вызывается на каждую генерацию (не кэшируется): источник
|
||||
* значения — конфигурация расширения, которая может обновляться без
|
||||
* перезапуска (совет пересохранил параметры программ). Ядро не знает,
|
||||
* откуда берётся hash — это контракт расширения с его фабричным шаблоном.
|
||||
*/
|
||||
resolve_doc_data_hash?: () => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ export class AgreementConfigurationService {
|
||||
link_to_statement: true,
|
||||
applicable_account_types: spec.applicable_account_types,
|
||||
order: spec.order,
|
||||
resolve_doc_data_hash: spec.resolve_doc_data_hash,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -132,11 +132,17 @@ export class RegistrationDocumentsService {
|
||||
): Promise<IGeneratedRegistrationDocument> {
|
||||
this.logger.debug(`Генерация документа: ${config.id} (registry_id=${config.registry_id})`);
|
||||
|
||||
// Если шаблон оферты требует PrivateData, расширение-владелец предоставило
|
||||
// резолвер hash'а в спеке регистрации — ядро не знает ни источника значения,
|
||||
// ни того, какие registry_id этого требуют.
|
||||
const doc_data_hash = config.resolve_doc_data_hash ? await config.resolve_doc_data_hash() : undefined;
|
||||
|
||||
const document = await this.documentInteractor.generateDocument({
|
||||
data: {
|
||||
coopname,
|
||||
username,
|
||||
registry_id: config.registry_id,
|
||||
...(doc_data_hash ? { doc_data_hash } : {}),
|
||||
},
|
||||
options: {
|
||||
skip_save: false, // Сохраняем документ в базу для последующей сверки
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет токены по критериям
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,14 @@ export class CapitalOnboardingStepInputDTO {
|
||||
decision!: string;
|
||||
}
|
||||
|
||||
@InputType('SaveCapitalProgramDocDataInput')
|
||||
export class SaveCapitalProgramDocDataInputDTO {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
doc_data_hash!: string;
|
||||
}
|
||||
|
||||
@ObjectType('CapitalOnboardingState')
|
||||
export class CapitalOnboardingStateDTO {
|
||||
@Field(() => Boolean)
|
||||
@@ -68,6 +76,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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -26,10 +26,17 @@ import type { IConfig } from '../../capital-extension.module';
|
||||
* • при завершённом L1 — две оферты (generator/blagorost) и две
|
||||
* программы (generation/capitalization);
|
||||
* • идемпотентность гарантируется AgreementRegistryService.
|
||||
*
|
||||
* `resolveDocDataHash` — резолвер hash'а PrivateData параметров ЦПП,
|
||||
* прикрепляется к обеим офертам: их фабричные шаблоны (#996/#1000) требуют
|
||||
* doc_data. Сейчас все документы ЦПП читают единый набор параметров
|
||||
* (capital_program_doc_data_hash); если параметры разделятся по программам,
|
||||
* каждая спека получит собственный резолвер — ядро менять не потребуется.
|
||||
*/
|
||||
export function registerCapitalInAgreementRegistry(
|
||||
port: AgreementRegistrationPort,
|
||||
extensionConfig: IConfig
|
||||
extensionConfig: IConfig,
|
||||
resolveDocDataHash?: () => Promise<string | undefined>
|
||||
): boolean {
|
||||
const onboardingDone =
|
||||
extensionConfig.onboarding_generator_program_template_done &&
|
||||
@@ -52,6 +59,7 @@ export function registerCapitalInAgreementRegistry(
|
||||
applicable_account_types: [],
|
||||
order: 6,
|
||||
extension_name: CAPITAL_EXTENSION_NAME,
|
||||
resolve_doc_data_hash: resolveDocDataHash,
|
||||
});
|
||||
|
||||
port.registerAgreement({
|
||||
@@ -70,6 +78,7 @@ export function registerCapitalInAgreementRegistry(
|
||||
applicable_account_types: [],
|
||||
order: 5,
|
||||
extension_name: CAPITAL_EXTENSION_NAME,
|
||||
resolve_doc_data_hash: resolveDocDataHash,
|
||||
});
|
||||
|
||||
port.registerProgram({
|
||||
|
||||
+17
-1
@@ -3,7 +3,11 @@ 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 { CapitalOnboardingStepInputDTO, CapitalOnboardingStateDTO } from '../dto/onboarding.dto';
|
||||
import {
|
||||
CapitalOnboardingStepInputDTO,
|
||||
CapitalOnboardingStateDTO,
|
||||
SaveCapitalProgramDocDataInputDTO,
|
||||
} from '../dto/onboarding.dto';
|
||||
import { CapitalOnboardingService } from '../services/onboarding.service';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
@@ -34,4 +38,16 @@ export class CapitalOnboardingResolver {
|
||||
): Promise<CapitalOnboardingStateDTO> {
|
||||
return this.onboardingService.completeStep(data, currentUser?.username);
|
||||
}
|
||||
|
||||
@Mutation(() => CapitalOnboardingStateDTO, {
|
||||
name: 'saveCapitalProgramDocDataHash',
|
||||
description: 'Сохранить hash PrivateData параметров документов ЦПП',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async saveProgramDocDataHash(
|
||||
@Args('data', { type: () => SaveCapitalProgramDocDataInputDTO }) data: SaveCapitalProgramDocDataInputDTO,
|
||||
): Promise<CapitalOnboardingStateDTO> {
|
||||
return this.onboardingService.saveProgramDocDataHash(data.doc_data_hash);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -73,13 +73,12 @@ export class CapitalOnboardingEventsService {
|
||||
|
||||
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
|
||||
|
||||
// Обновляем флаг завершения шага
|
||||
const updatedConfig = {
|
||||
...plugin.config,
|
||||
// Атомарный частичный UPDATE (config || patch) вместо read-modify-write всего
|
||||
// config целиком: два DecisionTrackedEvent для разных шагов, обработанные
|
||||
// конкурентно, иначе теряют флаг друг друга (lost update на общем jsonb-блобе).
|
||||
const updated = await this.extensionRepository.patchConfig('capital', {
|
||||
[flagKey]: true,
|
||||
};
|
||||
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
} as Partial<IConfig>);
|
||||
|
||||
this.logger.info(`Флаг ${flagKey} установлен в true`);
|
||||
|
||||
@@ -89,7 +88,7 @@ export class CapitalOnboardingEventsService {
|
||||
// restartApp('capital'), чтобы initialize(config) увидел свежий config
|
||||
// и зарегистрировал оферты/программы в платформенном реестре.
|
||||
// Совет ничего не нажимает (раздел 7.1 плана C28-10, вариант (в)).
|
||||
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
|
||||
if (!wasAlreadyDone && this.isL1Complete(updated.config as IConfig)) {
|
||||
this.logger.info(
|
||||
'[ONBOARDING_COMPLETED] capital L1 завершён последним шагом, эмиттим событие для auto-restart'
|
||||
);
|
||||
|
||||
+73
-20
@@ -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,32 +92,65 @@ 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;
|
||||
const patch: Partial<CapitalOnboardingConfig> = {};
|
||||
if (!pluginConfig.onboarding_init_at) {
|
||||
pluginConfig.onboarding_init_at = new Date().toISOString();
|
||||
needUpdate = true;
|
||||
patch.onboarding_init_at = pluginConfig.onboarding_init_at;
|
||||
}
|
||||
|
||||
if (!pluginConfig.onboarding_expire_at) {
|
||||
const start = new Date(pluginConfig.onboarding_init_at);
|
||||
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
|
||||
needUpdate = true;
|
||||
patch.onboarding_expire_at = pluginConfig.onboarding_expire_at;
|
||||
}
|
||||
|
||||
if (needUpdate) {
|
||||
await this.extensionRepository.update({ ...plugin, config: pluginConfig });
|
||||
return { ...plugin, config: pluginConfig };
|
||||
if (Object.keys(patch).length > 0) {
|
||||
// Точечный merge только изменившихся полей — полная перезапись config
|
||||
// целиком (как раньше) конкурирует с параллельными записями других шагов
|
||||
// онбординга (флаги, хэши) и теряет их изменения (lost update).
|
||||
const updated = await this.extensionRepository.patchConfig('capital', patch);
|
||||
return { ...plugin, config: updated.config };
|
||||
}
|
||||
|
||||
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 +162,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 || '',
|
||||
};
|
||||
@@ -135,13 +173,28 @@ export class CapitalOnboardingService {
|
||||
return this.buildState(plugin.config);
|
||||
}
|
||||
|
||||
public async saveProgramDocDataHash(docDataHash: string): Promise<CapitalOnboardingStateDTO> {
|
||||
await this.loadPlugin();
|
||||
const normalizedHash = docDataHash.trim();
|
||||
|
||||
if (!normalizedHash) {
|
||||
throw new Error('Hash PrivateData документов ЦПП не может быть пустым');
|
||||
}
|
||||
|
||||
const updated = await this.extensionRepository.patchConfig('capital', {
|
||||
capital_program_doc_data_hash: normalizedHash,
|
||||
} as Partial<CapitalOnboardingConfig>);
|
||||
|
||||
return this.buildState(updated.config as CapitalOnboardingConfig);
|
||||
}
|
||||
|
||||
public async completeStep(data: CapitalOnboardingStepInputDTO, username: string): Promise<CapitalOnboardingStateDTO> {
|
||||
const plugin = await this.loadPlugin();
|
||||
const flagKey = this.mapStepToFlag(data.step);
|
||||
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 +221,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({
|
||||
@@ -183,12 +236,12 @@ export class CapitalOnboardingService {
|
||||
document: documentForPublish,
|
||||
});
|
||||
|
||||
// Сохраняем hash в конфиге для отображения на фронтенде
|
||||
const updatedConfig = {
|
||||
...plugin.config,
|
||||
// Сохраняем hash в конфиге для отображения на фронтенде — точечный merge
|
||||
// (не полная перезапись config), чтобы не потерять конкурентно записанные
|
||||
// флаги/хэши других шагов онбординга (lost update на общем jsonb-блобе).
|
||||
const updated = await this.extensionRepository.patchConfig('capital', {
|
||||
[hashKey]: generatedDoc.hash,
|
||||
};
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
} as Partial<CapitalOnboardingConfig>);
|
||||
|
||||
// Регистрируем правило отслеживания в фабрике
|
||||
const varsField = this.mapStepToVarsField(data.step);
|
||||
@@ -204,6 +257,6 @@ export class CapitalOnboardingService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.buildState(updatedConfig);
|
||||
return this.buildState(updated.config as CapitalOnboardingConfig);
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
);
|
||||
// Не бросаем ошибку, чтобы не блокировать запуск модуля
|
||||
}
|
||||
@@ -644,7 +654,25 @@ export class CapitalPlugin extends BaseExtModule {
|
||||
// Если L1 ещё не завершён — реестр остаётся пустым, SignUp не предлагает
|
||||
// программы capital (раздел 4.1 req 44 проекта 13).
|
||||
try {
|
||||
const registered = registerCapitalInAgreementRegistry(this.agreementRegistrationPort, extensionConfig as IConfig);
|
||||
// Резолвер читает конфиг заново на каждую генерацию: совет может
|
||||
// пересохранить параметры ЦПП (новый hash) без перезапуска расширения,
|
||||
// а спека в реестре регистрируется один раз на initialize.
|
||||
const resolveCapitalProgramDocDataHash = async (): Promise<string | undefined> => {
|
||||
const ext = await this.extensionRepository.findByName(this.name);
|
||||
const hash = ext?.config?.capital_program_doc_data_hash?.trim();
|
||||
if (!hash) {
|
||||
throw new Error(
|
||||
'Параметры документов ЦПП не заполнены: отсутствует capital_program_doc_data_hash в конфигурации capital'
|
||||
);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const registered = registerCapitalInAgreementRegistry(
|
||||
this.agreementRegistrationPort,
|
||||
extensionConfig as IConfig,
|
||||
resolveCapitalProgramDocDataHash
|
||||
);
|
||||
if (registered) {
|
||||
this.logger.log('[CAPITAL.REGISTRY] зарегистрировано 2 оферты + 2 программы capital');
|
||||
} else {
|
||||
@@ -875,6 +903,7 @@ IssueIdGenerationService,
|
||||
ProgramShareRegistrationService,
|
||||
ProgramShareRegistrationSchedulerService,
|
||||
ProgramShareRegistrationOnUserWalletDeltaListener,
|
||||
ProgramShareRegistrationOnProjectDeltaListener,
|
||||
GitCommitMarkersSyncService,
|
||||
{
|
||||
provide: ISSUE_LINKED_GIT_COMMIT_REPOSITORY,
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GENERATOR_OFFER_AGREEMENT_ID = 'generator_offer';
|
||||
export const BLAGOROST_OFFER_AGREEMENT_ID = 'blagorost_offer';
|
||||
|
||||
export const GENERATOR_AGREEMENT_TYPE = 'generator';
|
||||
// On-chain имя оферты Благорост в `soviet::coagreements` — 'capital' (program_id=4).
|
||||
// Контроллер обязан слать sndagreement/signagree с этим значением, иначе
|
||||
// `get_coagreement_or_fail` падает с «Соглашение указанного типа не найдено».
|
||||
// On-chain имя оферты Благорост в `soviet::coagreements` (_capital_program в
|
||||
// lib/consts.hpp контракта) — 'capital' (program_id=4). Подтверждено прямым
|
||||
// запросом к продовой цепочке voskhod (185.70.105.87). Значение здесь ОБЯЗАНО
|
||||
// совпадать буквально с контрактной константой — иначе account.adapter.ts не
|
||||
// находит coagreement по type, программный signagree подменяется на
|
||||
// sndagreement, и `get_coagreement_or_fail` падает с «Соглашение указанного
|
||||
// типа не найдено».
|
||||
export const BLAGOROST_AGREEMENT_TYPE = 'capital';
|
||||
|
||||
export const GENERATION_PROGRAM_KEY = 'GENERATION';
|
||||
|
||||
+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 {
|
||||
|
||||
+8
-11
@@ -74,8 +74,6 @@ export class ChairmanOnboardingEventsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = { ...plugin.config };
|
||||
|
||||
// Определяем ключ флага для обновления на основе metadata
|
||||
const flagKey = this.mapStepToFlag(step);
|
||||
|
||||
@@ -84,16 +82,15 @@ export class ChairmanOnboardingEventsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAlreadyDone = !!(cfg as any)[flagKey];
|
||||
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
|
||||
|
||||
// Обновляем флаг выполнения шага
|
||||
const patch: Partial<IConfig> = {
|
||||
// Атомарный частичный UPDATE (config || patch) вместо read-modify-write всего
|
||||
// config целиком: несколько DecisionTrackedEvent (согласия/подписи), обработанные
|
||||
// конкурентно, иначе теряют флаг друг друга (lost update на общем jsonb-блобе) —
|
||||
// тот же класс бага, что чинили в capital-онбординге.
|
||||
const updated = await this.extensionRepository.patchConfig('chairman', {
|
||||
[flagKey]: true,
|
||||
};
|
||||
|
||||
const updatedConfig = { ...cfg, ...patch };
|
||||
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: updatedConfig };
|
||||
await this.extensionRepository.update(updated);
|
||||
} as Partial<IConfig>);
|
||||
|
||||
this.logger.info(`Онбординг обновлён: ${flagKey} = true (решение ${result.decision_id})`);
|
||||
|
||||
@@ -101,7 +98,7 @@ export class ChairmanOnboardingEventsService {
|
||||
// завершённым, эмиттим ONBOARDING_COMPLETED_EVENT.
|
||||
// У chairman нет L2-оферт в реестре сейчас, но событие важно как
|
||||
// сигнал «L1 кооператива закрыт» — раздел 7.1 C28-10.
|
||||
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
|
||||
if (!wasAlreadyDone && this.isL1Complete(updated.config as IConfig)) {
|
||||
this.logger.info(
|
||||
'[ONBOARDING_COMPLETED] chairman L1 завершён последним шагом, эмиттим событие'
|
||||
);
|
||||
|
||||
+19
-15
@@ -99,22 +99,25 @@ export class ChairmanOnboardingService {
|
||||
if (!plugin) throw new Error('Конфигурация расширения chairman не найдена');
|
||||
const config = { ...plugin.config };
|
||||
|
||||
let needUpdate = false;
|
||||
const patch: Partial<IConfig> = {};
|
||||
if (!config.onboarding_init_at) {
|
||||
config.onboarding_init_at = new Date().toISOString();
|
||||
needUpdate = true;
|
||||
patch.onboarding_init_at = config.onboarding_init_at;
|
||||
}
|
||||
|
||||
if (!config.onboarding_expire_at) {
|
||||
const start = new Date(config.onboarding_init_at);
|
||||
const expire = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
config.onboarding_expire_at = expire.toISOString();
|
||||
needUpdate = true;
|
||||
patch.onboarding_expire_at = config.onboarding_expire_at;
|
||||
}
|
||||
|
||||
if (needUpdate) {
|
||||
await this.extensionRepository.update({ ...plugin, config });
|
||||
return { ...plugin, config };
|
||||
if (Object.keys(patch).length > 0) {
|
||||
// Точечный merge только изменившихся полей — полная перезапись config
|
||||
// целиком конкурирует с параллельными записями флагов/хэшей других шагов
|
||||
// онбординга и теряет их изменения (lost update на общем jsonb-блобе).
|
||||
const updated = await this.extensionRepository.patchConfig('chairman', patch);
|
||||
return { ...plugin, config: updated.config };
|
||||
}
|
||||
|
||||
return plugin;
|
||||
@@ -198,12 +201,12 @@ export class ChairmanOnboardingService {
|
||||
document: documentForPublish,
|
||||
});
|
||||
|
||||
// Сохраняем hash в конфиге для отображения на фронтенде
|
||||
const updatedConfig: IConfig = {
|
||||
...plugin.config,
|
||||
// Сохраняем hash в конфиге для отображения на фронтенде — точечный merge
|
||||
// (не полная перезапись config), чтобы не потерять конкурентно записанные
|
||||
// флаги/хэши других шагов онбординга (lost update на общем jsonb-блобе).
|
||||
const updated = await this.extensionRepository.patchConfig('chairman', {
|
||||
[hashKey]: generatedDoc.hash,
|
||||
};
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
} as Partial<IConfig>);
|
||||
|
||||
// Регистрируем правило отслеживания в фабрике
|
||||
const varsField = this.mapStepToVarsField(data.step);
|
||||
@@ -219,7 +222,7 @@ export class ChairmanOnboardingService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.buildState(updatedConfig);
|
||||
return this.buildState(updated.config);
|
||||
}
|
||||
|
||||
// Сохраняем hash общего собрания, флаг закроется после newresolved
|
||||
@@ -233,8 +236,9 @@ export class ChairmanOnboardingService {
|
||||
// Если hash не пришёл (не должно быть), не затираем существующее значение
|
||||
const meetHash = proposal_hash || plugin.config.onboarding_general_meet_hash || '';
|
||||
|
||||
const updatedConfig: IConfig = { ...plugin.config, onboarding_general_meet_hash: meetHash };
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
const updated = await this.extensionRepository.patchConfig('chairman', {
|
||||
onboarding_general_meet_hash: meetHash,
|
||||
} as Partial<IConfig>);
|
||||
|
||||
// Регистрируем правило отслеживания общего собрания
|
||||
await this.decisionTrackingPort.registerTrackingRule({
|
||||
@@ -247,6 +251,6 @@ export class ChairmanOnboardingService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.buildState(updatedConfig);
|
||||
return this.buildState(updated.config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Возможности
|
||||
|
||||
✨ **Удобное общение** - групповые чаты, личные сообщения, голосовые и видео-звонки
|
||||
🔐 **Безопасность** - сообщения зашифрованы и защищены
|
||||
👥 **Для всех участников** - каждый член кооператива автоматически получает доступ к чату пайщиков своего кооператива и общему чату кооперативной экономики.
|
||||
👥 **Для членов совета** - каждый член совета добавляется в зашифрованный чат совета своего кооператива.
|
||||
📱 **На всех устройствах** - работает на компьютере, телефоне и планшете
|
||||
⚡ **Быстрая установка** - просто установите расширение, и оно заработает
|
||||
- ✨ **Удобное общение** — групповые чаты, личные сообщения, голосовые и видео-звонки
|
||||
- 🔐 **Безопасность** — сообщения зашифрованы и защищены
|
||||
- 👥 **Для всех участников** — каждый член кооператива автоматически получает доступ к чату пайщиков своего кооператива и общему чату кооперативной экономики
|
||||
- 👥 **Для членов совета** — каждый член совета добавляется в зашифрованный чат совета своего кооператива
|
||||
- 📱 **На всех устройствах** — работает на компьютере, телефоне и планшете
|
||||
- ⚡ **Быстрая установка** — просто установите расширение, и оно заработает
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ObjectType, Field, InputType, registerEnumType } from '@nestjs/graphql';
|
||||
import { IsOptional, IsString, IsIn, Matches, MaxLength } from 'class-validator';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
import { SFR_REG_NUMBER_PATTERN, PFR_REG_NUMBER_PATTERN } from '../../domain/patterns';
|
||||
|
||||
export enum RequisiteSource {
|
||||
DATABASE = 'database',
|
||||
@@ -43,6 +44,7 @@ export class ReportRequisitesViewDTO {
|
||||
@Field(() => RequisiteFieldViewDTO) oktmo!: RequisiteFieldViewDTO;
|
||||
@Field(() => RequisiteFieldViewDTO) okpo!: RequisiteFieldViewDTO;
|
||||
@Field(() => RequisiteFieldViewDTO) sfrRegNumber!: RequisiteFieldViewDTO;
|
||||
@Field(() => RequisiteFieldViewDTO) pfrRegNumber!: RequisiteFieldViewDTO;
|
||||
@Field(() => RequisiteFieldViewDTO) chairmanPosition!: RequisiteFieldViewDTO;
|
||||
@Field(() => RequisiteFieldViewDTO) signerSnils!: RequisiteFieldViewDTO;
|
||||
@Field(() => RequisiteFieldViewDTO) signerRepDoc!: RequisiteFieldViewDTO;
|
||||
@@ -65,7 +67,10 @@ export class ReportRequisitesViewDTO {
|
||||
* ОКТМО — `\d{8}` или `\d{11}`
|
||||
* ОКПО — `\d{8}` или `\d{10}`
|
||||
* СНИЛС — `\d{3}-\d{3}-\d{3} \d{2}` (14 симв.) или 11 цифр подряд
|
||||
* РегНом СФР — `\d{3}-\d{3}-\d{6}` (14 симв. с тире)
|
||||
* РегНом СФР — `\d{10}` (10 цифр подряд, без тире)
|
||||
* РегНом ПФР — `\d{3}-\d{3}-\d{6}` (14 симв. с тире), только этот формат —
|
||||
* отдельное поле от РегНом СФР, требуется для ЕФС-1 (внешние
|
||||
* бухгалтерские системы сверяют его при приёме отчёта)
|
||||
*/
|
||||
@InputType('UpdateReportRequisitesInput')
|
||||
export class UpdateReportRequisitesInputDTO {
|
||||
@@ -99,12 +104,18 @@ export class UpdateReportRequisitesInputDTO {
|
||||
@Matches(/^\d{8}(\d{2})?$/, { message: 'ОКПО — 8 или 10 цифр' })
|
||||
okpo?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — XXX-XXX-XXXXXX' })
|
||||
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — 10 цифр' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{3}-\d{3}-\d{6}$/, { message: 'Рег. номер СФР — XXX-XXX-XXXXXX (14 симв.)' })
|
||||
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег. номер СФР — 10 цифр' })
|
||||
sfrRegNumber?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(PFR_REG_NUMBER_PATTERN, { message: 'Рег. номер ПФР — XXX-XXX-XXXXXX' })
|
||||
pfrRegNumber?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
+8
-1
@@ -21,6 +21,7 @@ import {
|
||||
OKTMO_PATTERN,
|
||||
OKVED_PATTERN,
|
||||
SFR_REG_NUMBER_PATTERN,
|
||||
PFR_REG_NUMBER_PATTERN,
|
||||
SNILS_PATTERN,
|
||||
DATE_DDMMYYYY_PATTERN,
|
||||
} from '../../domain/patterns';
|
||||
@@ -200,9 +201,14 @@ export class ZeroReportSignerEditsInputDTO {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — XXX-XXX-XXXXXX' })
|
||||
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — 10 цифр' })
|
||||
sfrRegNumber!: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@Matches(PFR_REG_NUMBER_PATTERN, { message: 'Рег.номер ПФР — XXX-XXX-XXXXXX' })
|
||||
pfrRegNumber!: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -219,6 +225,7 @@ export class ZeroReportSignerEditsDTO {
|
||||
@Field(() => String, { nullable: true }) repDoc!: string | null;
|
||||
@Field(() => String, { nullable: true }) snils!: string | null;
|
||||
@Field(() => String, { nullable: true }) sfrRegNumber!: string | null;
|
||||
@Field(() => String, { nullable: true }) pfrRegNumber!: string | null;
|
||||
@Field(() => String, { nullable: true }) chairmanPosition!: string | null;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -53,6 +53,7 @@ export class ReportRequisitesResolver {
|
||||
oktmo: input.oktmo ?? undefined,
|
||||
okpo: input.okpo ?? undefined,
|
||||
sfr_reg_number: input.sfrRegNumber ?? undefined,
|
||||
pfr_reg_number: input.pfrRegNumber ?? undefined,
|
||||
chairman_position: input.chairmanPosition ?? undefined,
|
||||
signer_snils: input.signerSnils ?? undefined,
|
||||
signer_rep_doc: input.signerRepDoc ?? undefined,
|
||||
@@ -109,6 +110,7 @@ function toView(m: MergedRequisites): ReportRequisitesViewDTO {
|
||||
oktmo: f(m.oktmo),
|
||||
okpo: f(m.okpo),
|
||||
sfrRegNumber: f(m.sfrRegNumber),
|
||||
pfrRegNumber: f(m.pfrRegNumber),
|
||||
chairmanPosition: f(m.chairmanPosition),
|
||||
signerSnils: f(m.signerSnils),
|
||||
signerRepDoc: f(m.signerRepDoc),
|
||||
|
||||
+8
@@ -51,6 +51,14 @@ export interface ZeroReportSignerShape {
|
||||
snils: string | null;
|
||||
/** Регистрационный номер в СФР — обязателен для ЕФС-1. */
|
||||
sfrRegNumber: string | null;
|
||||
/**
|
||||
* Регистрационный номер в ПФР (XXX-XXX-XXXXXX) — обязателен для ЕФС-1,
|
||||
* попадает и в <ЕФС8:РегНомер>, и в имя файла. Отдельное поле от
|
||||
* sfrRegNumber: сторонние бухгалтерские системы (СБИС и др.) при приёме
|
||||
* ЕФС-1 сверяют номер отправителя именно с рег. номером ПФР организации,
|
||||
* не с 10-значным номером СФР.
|
||||
*/
|
||||
pfrRegNumber: string | null;
|
||||
/** Должность председателя — для ЕФС-1 (<УТ8:Должность>). */
|
||||
chairmanPosition: string | null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ export interface ReportInput {
|
||||
correctionNumber?: number;
|
||||
/** СНИЛС председателя (для ПСВ) */
|
||||
signerSnils?: string;
|
||||
/** Регистрационный номер страхователя в СФР/ПФР (для ЕФС-1). */
|
||||
/** Регистрационный номер страхователя в СФР (для ЕФС-1). Legacy-путь — актуальные генераторы берут ZeroReportSignerShape.pfrRegNumber отдельно. */
|
||||
sfrRegNumber?: string;
|
||||
/** Должность руководителя для подачи в СФР (по умолчанию «Председатель Совета»). */
|
||||
chairmanPosition?: string;
|
||||
|
||||
@@ -43,8 +43,19 @@ export const OKPO_PATTERN = /^\d{8}(\d{2})?$/;
|
||||
// СНИЛС: XXX-XXX-XXX YY (с пробелом/тире) или 11 цифр подряд.
|
||||
export const SNILS_PATTERN = /^(\d{3}-\d{3}-\d{3} \d{2}|\d{11})$/;
|
||||
|
||||
// Регистрационный номер СФР: XXX-XXX-XXXXXX (3-3-6).
|
||||
export const SFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
|
||||
// Регистрационный номер страхователя в СФР: только 10 цифр без разделителей
|
||||
// (унифицированный номер СФР, ТипРегНомерОбщ в XSD ЕФС-1). Формат
|
||||
// XXX-XXX-XXXXXX сюда больше не входит — он теперь отдельное поле
|
||||
// PFR_REG_NUMBER_PATTERN, чтобы два похожих номера не путались в одной маске.
|
||||
export const SFR_REG_NUMBER_PATTERN = /^\d{10}$/;
|
||||
|
||||
// Регистрационный номер страхователя в ПФР: только формат XXX-XXX-XXXXXX
|
||||
// (3-3-6, 12 цифр с тире) — старый/«действующий» формат, который сверяют
|
||||
// сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1. Это
|
||||
// отдельное поле от sfrRegNumber (10-значный унифицированный номер СФР):
|
||||
// внешние системы могут не знать организацию по 10-значному номеру, но
|
||||
// знать по этому.
|
||||
export const PFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
|
||||
|
||||
/** Дата в формате `DD.MM.YYYY` (XSD `xs:date`-совместимый ФНС-формат). */
|
||||
export const DATE_DDMMYYYY_PATTERN = /^\d{2}\.\d{2}\.\d{4}$/;
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ export interface ReportRequisitesRecord {
|
||||
oktmo: string | null;
|
||||
okpo: string | null;
|
||||
sfr_reg_number: string | null;
|
||||
pfr_reg_number: string | null;
|
||||
chairman_position: string | null;
|
||||
signer_snils: string | null;
|
||||
signer_rep_doc: string | null;
|
||||
@@ -28,6 +29,7 @@ export interface UpsertReportRequisitesInput {
|
||||
oktmo?: string | null;
|
||||
okpo?: string | null;
|
||||
sfr_reg_number?: string | null;
|
||||
pfr_reg_number?: string | null;
|
||||
chairman_position?: string | null;
|
||||
signer_snils?: string | null;
|
||||
signer_rep_doc?: string | null;
|
||||
|
||||
+27
-7
@@ -203,7 +203,12 @@ export class ReportEditsBuilderService {
|
||||
|
||||
return {
|
||||
header: {
|
||||
idFile: this.generateGenericFileName(reportType, merged.inn.value ?? '', merged.kpp.value ?? ''),
|
||||
idFile: this.generateGenericFileName(
|
||||
reportType,
|
||||
merged.inn.value ?? '',
|
||||
merged.kpp.value ?? '',
|
||||
merged.pfrRegNumber.value,
|
||||
),
|
||||
versProgram: 'Платформа отчётности кооператива 1.0',
|
||||
docDate: formatDate(new Date()),
|
||||
reportYear: year,
|
||||
@@ -230,6 +235,7 @@ export class ReportEditsBuilderService {
|
||||
repDoc: merged.signerRepDoc.value,
|
||||
snils: merged.signerSnils.value,
|
||||
sfrRegNumber: merged.sfrRegNumber.value,
|
||||
pfrRegNumber: merged.pfrRegNumber.value,
|
||||
chairmanPosition:
|
||||
merged.chairmanPosition.value || merged.chairmanPositionFromOrg.value,
|
||||
},
|
||||
@@ -243,19 +249,33 @@ export class ReportEditsBuilderService {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private generateGenericFileName(reportType: ReportType, inn: string, kpp: string): string {
|
||||
// Упрощённый префикс по типу отчёта — точный формат имени зависит от
|
||||
// формы (NO_NDFL6.2_..., СФР_..._ЕФС-1_...), но для предварительного
|
||||
// idFile достаточно. Генератор сам перезапишет его при необходимости.
|
||||
private generateGenericFileName(
|
||||
reportType: ReportType,
|
||||
inn: string,
|
||||
kpp: string,
|
||||
pfrRegNumber?: string | null,
|
||||
): string {
|
||||
// Префикс = первые два underscore-сегмента имени XSD (например,
|
||||
// 'NO_NDFL6.2_1_231_...xsd' → 'NO_NDFL6.2', 'UT_UVISCHSUMNAL_...xsd' →
|
||||
// 'UT_UVISCHSUMNAL') — это и есть код формата в имени файла по формату
|
||||
// ФНС/СФР. Брать только первый сегмент нельзя: для всех форм с 'NO_'
|
||||
// в начале он всегда равен 'NO' и код формы (NDFL6.2/RASCHSV/PERSSVFL/
|
||||
// USN) терялся — импорт стороннего ПО (Сбер и др.) отклонял файл как
|
||||
// нераспознанный по имени.
|
||||
const xsd = REPORT_CONFIG[reportType]?.xsdFile ?? 'REPORT';
|
||||
const prefix = xsd.split('_')[0] ?? 'REPORT';
|
||||
const prefix = xsd.split('_').slice(0, 2).join('_') || 'REPORT';
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const unit = `${inn}${kpp}`;
|
||||
const tax = kpp.substring(0, 4);
|
||||
const uuid = randomUUID();
|
||||
if (reportType === ReportType.FSS4) {
|
||||
return `СФР_0000000000_ЕФС-1_${dateStr}_${uuid}`;
|
||||
// Сегмент отправителя в имени файла — рег. номер ПФР организации, не
|
||||
// заглушка из нулей: сторонние бухгалтерские системы (СБИС и др.)
|
||||
// сверяют этот сегмент со своим профилем организации и отклоняют файл,
|
||||
// если он не совпадает ни с известным им номером ПФР, ни с номером
|
||||
// СФР. См. также <ЕФС8:РегНомер> в fss4.generator.ts — то же значение.
|
||||
return `СФР_${pfrRegNumber || '0000000000'}_ЕФС-1_${dateStr}_${uuid}`;
|
||||
}
|
||||
return `${prefix}_${tax}_${tax}_${unit}_${dateStr}_${uuid}`;
|
||||
}
|
||||
|
||||
+5
@@ -39,6 +39,8 @@ export interface MergedRequisites {
|
||||
oktmo: RequisiteField;
|
||||
okpo: RequisiteField;
|
||||
sfrRegNumber: RequisiteField;
|
||||
/** Рег. номер страхователя в ПФР (XXX-XXX-XXXXXX) — отдельный от sfrRegNumber, требуется для ЕФС-1. */
|
||||
pfrRegNumber: RequisiteField;
|
||||
chairmanPosition: RequisiteField;
|
||||
signerSnils: RequisiteField;
|
||||
signerRepDoc: RequisiteField;
|
||||
@@ -94,6 +96,7 @@ const REQUIRED_BY_TYPE: Record<ReportType, RequiredFieldSpec[]> = {
|
||||
[ReportType.FSS4]: [
|
||||
{ key: 'oktmo', label: 'ОКТМО', source: 'manual' },
|
||||
{ key: 'sfrRegNumber', label: 'Регистрационный номер в СФР', source: 'manual' },
|
||||
{ key: 'pfrRegNumber', label: 'Регистрационный номер в ПФР', source: 'manual' },
|
||||
{ key: 'chairmanPosition', label: 'Должность руководителя', source: 'manual' },
|
||||
],
|
||||
[ReportType.PSV]: [{ key: 'signerSnils', label: 'СНИЛС подписанта', source: 'manual' }],
|
||||
@@ -152,6 +155,7 @@ export class ReportRequisitesService {
|
||||
oktmo: mn(manual?.oktmo),
|
||||
okpo: mn(manual?.okpo),
|
||||
sfrRegNumber: mn(manual?.sfr_reg_number),
|
||||
pfrRegNumber: mn(manual?.pfr_reg_number),
|
||||
chairmanPosition: mn(manual?.chairman_position),
|
||||
signerSnils: mn(manual?.signer_snils),
|
||||
signerRepDoc: mn(manual?.signer_rep_doc),
|
||||
@@ -218,6 +222,7 @@ export class ReportRequisitesService {
|
||||
oktmo: input.oktmo ?? undefined,
|
||||
okpo: input.okpo ?? undefined,
|
||||
sfr_reg_number: input.sfr_reg_number ?? undefined,
|
||||
pfr_reg_number: input.pfr_reg_number ?? undefined,
|
||||
chairman_position: input.chairman_position ?? undefined,
|
||||
signer_snils: input.signer_snils ?? undefined,
|
||||
signer_rep_doc: input.signer_rep_doc ?? undefined,
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import { Entity, Column, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
*
|
||||
* Хранятся поля, которых нет в профиле организации (`IOrganizationData` в
|
||||
* основной БД кооператива): ОКВЭД, ОКФС, ОКОПФ, ОКТМО, ОКПО, регномер СФР,
|
||||
* регномер ПФР (для ЕФС-1 — отдельный от regномера СФР, см. patterns.ts),
|
||||
* должность руководителя, СНИЛС подписанта, описание доверенности. Плюс
|
||||
* возможные override'ы телефона и адреса (если в отчётности нужен не тот,
|
||||
* что в профиле организации).
|
||||
@@ -34,6 +35,9 @@ export class ReportRequisitesEntity {
|
||||
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||
sfr_reg_number?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||
pfr_reg_number?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
chairman_position?: string | null;
|
||||
|
||||
|
||||
+15
-4
@@ -66,8 +66,14 @@ export class Fss4Generator implements IReportGenerator {
|
||||
const edits = input as ZeroReportEditsShape;
|
||||
const fileName = edits.header.idFile;
|
||||
const errors: string[] = [];
|
||||
if (!edits.signer.sfrRegNumber) {
|
||||
errors.push('Для ЕФС-1 обязателен рег. номер страхователя в СФР (поле signer.sfrRegNumber)');
|
||||
if (!edits.signer.pfrRegNumber) {
|
||||
// В документ идёт только pfrRegNumber (см. strah.ele('ЕФС8:РегНомер')
|
||||
// ниже) — sfrRegNumber в ЕФС-1 не используется вовсе, поэтому не
|
||||
// проверяем его здесь, хотя в реквизитах кооператива оно и остаётся
|
||||
// обязательным полем (может понадобиться другим формам/интеграциям).
|
||||
errors.push('Для ЕФС-1 обязателен рег. номер страхователя в ПФР (поле signer.pfrRegNumber)');
|
||||
}
|
||||
if (errors.length) {
|
||||
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
|
||||
}
|
||||
try {
|
||||
@@ -100,8 +106,13 @@ export class Fss4Generator implements IReportGenerator {
|
||||
const efs1 = edsfr.ele('ЕФС-1');
|
||||
|
||||
const strah = efs1.ele('Страхователь');
|
||||
// sfrRegNumber гарантированно определён — guard в generate() выше.
|
||||
strah.ele('ЕФС8:РегНомер').txt(signer.sfrRegNumber ?? '').up();
|
||||
// Тег «Действующий регистрационный номер страхователя» (см. XSD
|
||||
// efs-types.xsd/ТипСтрахователь) — сюда идёт номер ПФР, а не СФР:
|
||||
// сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1 сверяют
|
||||
// рег. номер отправителя в имени файла и в этом теге со своим профилем
|
||||
// организации, где обычно заведён именно старый номер ПФР (XXX-XXX-XXXXXX).
|
||||
// pfrRegNumber гарантированно определён — guard в generate() выше.
|
||||
strah.ele('ЕФС8:РегНомер').txt(signer.pfrRegNumber ?? '').up();
|
||||
strah.ele('ЕФС8:Наименование').txt(organization.orgName).up();
|
||||
strah.ele('УТ8:ИНН').txt(organization.inn).up();
|
||||
strah.ele('УТ8:КПП').txt(organization.kpp).up();
|
||||
|
||||
+2
@@ -39,6 +39,7 @@ export class ReportRequisitesTypeormRepository implements ReportRequisitesReposi
|
||||
if (input.oktmo !== undefined) row.oktmo = input.oktmo;
|
||||
if (input.okpo !== undefined) row.okpo = input.okpo;
|
||||
if (input.sfr_reg_number !== undefined) row.sfr_reg_number = input.sfr_reg_number;
|
||||
if (input.pfr_reg_number !== undefined) row.pfr_reg_number = input.pfr_reg_number;
|
||||
if (input.chairman_position !== undefined) row.chairman_position = input.chairman_position;
|
||||
if (input.signer_snils !== undefined) row.signer_snils = input.signer_snils;
|
||||
if (input.signer_rep_doc !== undefined) row.signer_rep_doc = input.signer_rep_doc;
|
||||
@@ -66,6 +67,7 @@ export class ReportRequisitesTypeormRepository implements ReportRequisitesReposi
|
||||
oktmo: entity.oktmo ?? null,
|
||||
okpo: entity.okpo ?? null,
|
||||
sfr_reg_number: entity.sfr_reg_number ?? null,
|
||||
pfr_reg_number: entity.pfr_reg_number ?? null,
|
||||
chairman_position: entity.chairman_position ?? null,
|
||||
signer_snils: entity.signer_snils ?? null,
|
||||
signer_rep_doc: entity.signer_rep_doc ?? null,
|
||||
|
||||
@@ -62,15 +62,33 @@ async function bootstrap() {
|
||||
// Проверяем, был ли запущен режим миграций
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--migrate')) {
|
||||
await migrateData();
|
||||
process.exit(0);
|
||||
try {
|
||||
await migrateData();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// migrateData() уже логирует причину; здесь важен только ненулевой
|
||||
// exit code. В production Sentry.init() ставит свой глобальный
|
||||
// process.on('unhandledRejection', ...) с дефолтным mode: 'warn' —
|
||||
// он перехватывает необработанный reject этого await и НЕ роняет
|
||||
// процесс (в отличие от штатного поведения Node без Sentry). Без
|
||||
// явного process.exit(1) здесь deploy-скрипт получал ложноположительный
|
||||
// exit code 0 при реально упавшей миграции (кейс V2.3.2 на testnet
|
||||
// 2026-07-19 — TS-ошибка компиляции миграции проглатывалась именно так).
|
||||
logger.error('Процесс миграции завершился с ошибкой, выходим с кодом 1', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, был ли запущен режим только миграций (для обратной совместимости)
|
||||
if (args.includes('--migrations-only')) {
|
||||
await migrateData();
|
||||
logger.info('Режим только миграций - миграции выполнены, сервер не будет запущен');
|
||||
process.exit(0);
|
||||
try {
|
||||
await migrateData();
|
||||
logger.info('Режим только миграций - миграции выполнены, сервер не будет запущен');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Процесс миграции завершился с ошибкой, выходим с кодом 1', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Подключение к MongoDB
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user