Compare commits

...

103 Commits

Author SHA1 Message Date
coopops 5e58c173ee chore(release): publish
Build bootstrap container / build (push) Successful in 3m45s
Release / publish-packages (push) Successful in 29m23s
Release / release (push) Successful in 29m7s
Release / trigger-mono-docs (push) Successful in 2s
Release / trigger-coopenomics-docs (push) Successful in 2s
2026-07-21 20:19:26 +00:00
coopops c1ce7a095d [989-10][@ant] fix: убрать поле СФР и сделать поля организации/подписанта в форме нулёвых отчётов read-only
Поле «Регистрационный номер СФР» в форме ЕФС-1 не использовалось генератором вовсе (в документ и в имя файла идёт только pfrRegNumber) — убрано вместе с ложной проверкой в fss4.generator.ts, требовавшей его заполнения.

Остальные поля секций «Организация»/«Подписант» в ZeroReportEditor.vue (ФИО, СНИЛС, ИНН/КПП/ОКВЭД/ОГРН/ОКТМО, рег. номер ПФР, должность, тип подписанта) всегда приходят из Реквизитов/БД кооператива и правка через этот черновик никуда не сохранялась — редактируемые поля создавали иллюзию, что правка что-то меняет. Переведены в read-only (readonly+disable, как уже сделано для орг-полей в SettingsPage) — правка доступна только на странице Реквизитов. Раздел «Отчётный год/период/корректировка» остался редактируемым — он не приходит из Реквизитов, это per-документ данные.
2026-07-21 20:17:24 +00:00
coopops 995f65f524 [989-10][@ant] fix: убрать лишний поясняющий хинт у поля «Рег. номер ПФР» — по замечанию пользователя ни у одного другого поля в реквизитах/форме ЕФС-1 такого хинта нет, заполняющий и так знает, что это за номер 2026-07-21 19:12:09 +00:00
coopops 5298e55a7b [989-10][@ant] fix: ЕФС-1 (4-ФСС) — использовать рег. номер ПФР, а не СФР, в имени файла и <ЕФС8:РегНомер>
Сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1 отклоняли выгруженный XML: "Рег. номер отправителя из наименования файла не соответствует ни рег. номеру ПФР организации, ни рег. номеру СФР". Причина в двух местах:
1) generateGenericFileName для FSS4 хардкодил сегмент отправителя как "0000000000" — реальный номер туда не попадал вообще (тот же класс бага, что чинили в idFile для нулёвок ранее).
2) fss4.generator.ts писал в <ЕФС8:РегНомер> sfrRegNumber (10-значный унифицированный номер СФР) — но внешние системы сверяют этот тег с рег. номером ПФР организации (формат XXX-XXX-XXXXXX), который у них уже настроен через канал ФСС ЭДО, а не с номером СФР.

Добавлено отдельное обязательное поле pfrRegNumber (report_requisites.pfr_reg_number, XXX-XXX-XXXXXX) — не заменяет sfrRegNumber (оставлен, вдруг понадобится), а используется отдельно для генерации ЕФС-1: и в сегменте имени файла, и в теге <ЕФС8:РегНомер>. Заодно сузили маску sfrRegNumber до строго 10 цифр (раньше маска дублировала формат ПФР и два поля выглядели взаимозаменяемыми — по замечанию в ревью). Цепочка regen пройдена (generate-schema → generate-client → sdk build), controller tsc и desktop vue-tsc чистые, добавлены/обновлены unit-тесты Fss4Generator.
2026-07-21 18:45:12 +00:00
coopops db28ba1137 [989-10][@ant] fix: чинить генерацию имени файла нулёвых отчётов (idFile/ИдФайл) — терялся код формы ФНС/СФР
generateGenericFileName брал префикс как первый underscore-сегмент имени XSD (xsd.split('_')[0]), а для всех форм с xsdFile вида 'NO_<Форма>_...' первый сегмент всегда равен 'NO' — код формы (NDFL6.2, RASCHSV, PERSSVFL, USN, UVISCHSUMNAL) терялся из имени файла. Внешние системы (в частности приёмка Сбера) отклоняли выгруженный XML как нераспознанный по имени, а после ручного добавления '_NDFL6.2_' в имя падала уже сверка тега <ИдФайл> с фактическим именем файла — оба генерировались одной и той же испорченной функцией. Исправлено: префикс строится из первых двух сегментов XSD-имени (slice(0, 2)), что совпадает с реальным форматом имени файла ФНС/СФР и с эталонными фикстурами tests/fixtures/reports-references/*.
2026-07-21 16:23:21 +00:00
coopops 6a05e88ba1 [989-10][@ant] fix: убрать stack-раскладку (иконка над текстом) у кнопок панели действий в ReportEditorDialog расширения reports — canon-паддинг q-btn (0 14px, без вертикального зазора) при вертикальной раскладке icon+label прижимал иконку к верхней границе кнопки, из-за чего панель «Действия»/«Скачать»/«Отметить сданным» выглядела недоделанной; иконка возвращена в строку рядом с текстом 2026-07-21 15:56:09 +00:00
coopops 09eaa6192c [reports][@ant] fix: показывать серверные ошибки валидации формы отчёта для полей шапки и строк баланса — счётчик «Ошибок: N» был непрозрачным без единой подсказки
BuhotchEditor/ZeroReportEditor подключали fieldErrors только для части полей
(организация, часть подписанта). Поля шапки (reportYear/period/docDate/
correctionNumber) и все строки баланса (BalanceRowEditor — otch/prev/prePrev)
не имели inline-подсветки вообще, хотя backend (validateReportEdits) исправно
возвращал path+message. В проде это привело к невидимой ошибке
organization.ogrn — 12 vs 13 цифр (опечатка в реквизитах, отдельно исправлена
data-фиксом в organizations, не кодом).

- BalanceRowEditor: добавлены basePath/fieldErrors пропсы, подсветка otch/prev/prePrev.
- BuhotchEditor: прокинул field-errors во все 6 BalanceRowEditor, подключил
  header.reportYear/docDate/correctionNumber.
- ZeroReportEditor: подключил header.reportYear/period.
- ReportEditorDialog: добавлен явный список ошибок (path → человекочитаемое
  сообщение) в панели действий — страховка на случай ещё не подключённых полей.
2026-07-21 15:45:20 +00:00
coopops 81df662cfa chore(release): publish
Build bootstrap container / build (push) Successful in 46s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / release (push) Successful in 34m17s
Release / trigger-mono-docs (push) Successful in 2s
Release / publish-packages (push) Failing after 13m36s
2026-07-21 08:50:38 +00:00
coopops 845c13605d fix(reports): разрешить 10-значный рег.номер СФР наравне с XXX-XXX-XXXXXX
XSD-схема ЕФС-1 (ТипРегНомерОбщ) допускает регистрационный номер
страхователя в двух форматах: действующий XXX-XXX-XXXXXX (12 цифр,
3-3-6) или прежний — 10 цифр без разделителей. Валидация на бэке и
фронте (форма реквизитов кооператива + редактор нулевого отчёта
ЕФС-1) принимала только 12-значный формат с тире, из-за чего
кооперативы со старым 10-значным номером ФСС не могли его сохранить.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 08:43:46 +00:00
Alex Ant 76b4988da8 migrate 2026-07-20 11:34:49 +03:00
coopops 3db80a30e9 chore(release): publish
Release / release (push) Failing after 2m16s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Build bootstrap container / build (push) Successful in 49s
2026-07-19 17:01:42 +00:00
coopops 1b457b8f87 fix(controller): stop migration failures being masked as success
Backfill-миграция V2.3.2 падала на testnet TS-ошибкой компиляции
(mongo.db возможно undefined, strictNullChecks), но deploy-скрипт
(blue-green-deploy.sh) рапортовал успех — 2.3.2 не появилась в
таблице migrations, extensions.capital_program_doc_data_hash осталась
пустой, а Semaphore-лог показывал "Database migrations completed
successfully".

Причина ложноположительного результата — не баг деплой-скрипта, а
Sentry: в production Sentry.init() (enabled: config.env==='production')
ставит process.on('unhandledRejection', ...) с дефолтным mode:'warn',
который перехватывает необработанный reject из bootstrap()'а --migrate
ветки и не роняет процесс — в отличие от штатного поведения голого
Node. В dev (Sentry выключен) тот же баг воспроизводимо валит процесс
exit 1; на testnet — exit 0. Подтверждено локальным воспроизведением
обеих версий на идентичном коде.

Фикс на двух уровнях:
1. index.ts: явный try/catch + process.exit(1) вокруг --migrate и
   --migrations-only веток. Не полагаемся на ambient unhandled-rejection
   поведение (Sentry/Node/версия) — exit code теперь детерминирован
   независимо от окружения.
2. V2.3.2: mongo.db действительно типизирован как возможно undefined —
   явная runtime-проверка с throw вместо каста.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 16:57:59 +00:00
coopops ba6d3d1310 chore(release): publish
Build bootstrap container / build (push) Successful in 1m10s
Release / publish-packages (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Release / release (push) Successful in 28m13s
Release / trigger-coopenomics-docs (push) Has been skipped
2026-07-19 15:20:08 +00:00
ant 8e01373179 Merge pull request 'feat(capital): CPP document templates via PrivateData' (#154) from feature/capital-cpp-private-data-templates into dev
Build bootstrap container / build (push) Successful in 3m30s
Reviewed-on: #154
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-07-19 15:14:39 +00:00
coopops 31940461c6 fix(desktop): remove electron dependency, fix latent typecheck errors
Typecheck / desktop (pull_request) Successful in 15m11s
Typecheck / controller (pull_request) Successful in 13m55s
electron был не нужен вообще — тянулся транзитивно только через
неиспользуемую devDependency @vue/devtools (нет ни одного импорта в
коде). Убрана из desktop/package.json и из onlyBuiltDependencies
корня; lockfile пересобран. Это устраняет флап CI: pnpm install
изредка валился на сетевой ошибке при скачивании ~200MB бинарника
electron (run #745 и run #732 на этом PR), из-за чего typecheck
падал до первой TS-проверки.

Так как install перестал падать раньше срока, vue-tsc теперь
реально доходит до конца и вскрывает предсуществующие баги:

- useCapitalProgramDocParams.ts, CapitalProgramDocumentParametersWidget.vue:
  импорт `CapitalProgramPrivateData` напрямую из 'cooptypes' никогда не
  работал — тип лежит в Cooperative.Registry, не на верхнем уровне
  (остальной код capital-расширения уже импортирует так же). Каст
  результата Object.fromEntries (индексная сигнатура) в интерфейс с
  именованными полями TS отклоняет как insufficient overlap; вместо
  unknown-каста — каст в Record<EditableFieldKey, string> (тот же
  паттерн, что и в соседней createEmptyForm), который затем обычной
  структурной проверкой присваивается в CapitalProgramPrivateData,
  т.к. набор из 11 полей совпадает один в один.
- CapitalProgramDocumentParametersWidget.vue: legacy draft.activeTab
  стал optional при переходе на wizardStepKey и больше не
  записывается читалкой драфта — добавлен фолбэк на дефолт 994,
  как и в самой readCapitalProgramDocParamsDraft.
- CapitalProgramInlineDocumentPreview.vue: querySelectorAll с
  составным CSS-селектором типизируется как NodeListOf<Element>;
  cleanupEditors использовал узел как HTMLTextAreaElement без
  сужения (соседняя syncInlineEditors уже делает `as
  HTMLTextAreaElement` для той же ситуации).
- ExtensionInstall.vue: prop schema был типизирован как unknown,
  хотя передаётся напрямую в ZodForm, которому нужен
  IExtensionConfigSchema — типизирован точно под фактическое
  использование.
- sdk/queries/paymentMethods/getPaymentMethods.ts: `const name`
  был без export вопреки конвенции всех остальных query-модулей
  (Queries.X.Y.name используется для чтения ключа GraphQL-ответа).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 14:25:47 +00:00
coopops 37b0227735 fix(ci): skip electron binary download in typecheck workflow
Typecheck / desktop (pull_request) Failing after 9m12s
Typecheck / controller (pull_request) Has been cancelled
electron тянется транзитивно через @vue/devtools-electron (dev-only,
приложение devtools вне браузера) и попадает в onlyBuiltDependencies —
pnpm install качает ~200MB бинарник с github releases, который здесь
никогда не запускается. Изредка скачивание валит install сетевым
таймаутом/TLS-обрывом до того, как дело доходит до vue-tsc/tsc.

Run #745 (PR #154): desktop job упал именно на этом шаге, не на
типах — коммит 2cfb37c5d7 тут ни при чём, чистая инфраструктурная
нестабильность CI-раннера.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 14:07:54 +00:00
coopops 2cfb37c5d7 fix(agenda): resolve program-agreement links via soviet::newresolved, not wallet::signagree
Typecheck / desktop (pull_request) Failing after 6m47s
Typecheck / controller (pull_request) Has been cancelled
Прошлый фикс менял receiver=coopname на data.coopname прямо в explorer-запросе
к wallet::signagree — рабочий, но не объясняющий, почему стандартный
receiver-фильтр не подходил именно здесь: wallet::signagree не делает
require_recipient(coopname) сам по себе, поэтому explorer индексирует его
только под receiver='wallet'.

На самом деле уведомление уже существует и не требует правки контракта:
Soviet::make_complete_document (lib/core/soviet/soviet.hpp) на КАЖДЫЙ вызов —
из soviet::sndagreement И из wallet::signagree одинаково — шлёт inline
soviet::newresolved/newsubmitted, а require_recipient(coopname/username)
зашит централизованно внутри самих newresolved.cpp/newsubmitted.cpp
(soviet/src/doc/*.cpp). Проверено на живом хэше оферты Благорост: newresolved
уже приходит с receiver=voskhod, receiver=veszmgpjteqs, receiver=soviet — без
единой правки на контрактах.

Правильный fallback для findLinkedAgreementDocument — искать soviet::newresolved
(с обычным receiver=coopname, как везде), а не действие wallet::signagree
напрямую (у которого никакого receiver-уведомления и не предполагалось).
Расширять notification-поверхность wallet::signagree не нужно — единственное
место для этого уже newresolved.cpp/newsubmitted.cpp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:48:57 +00:00
coopops 1c605dd2a5 fix(agenda): resolve wallet::signagree linked documents by data.coopname, not receiver
Typecheck / desktop (pull_request) Failing after 9m39s
Typecheck / controller (pull_request) Has been cancelled
findLinkedAgreementDocument искал wallet::signagree по фильтру receiver=coopname,
но wallet::signagree не делает require_recipient(coopname) (в отличие от
Soviet::make_complete_document) — explorer индексирует это действие только с
receiver='wallet'. Фильтр receiver=coopname всегда давал 0 строк, поэтому
buildLinkedAggregate молча возвращал null, а программные оферты (capital:
Благорост/Генератор, program_id>0, подписываются через wallet::signagree, не
soviet::sndagreement) пропадали из links повестки совета — платформенные
соглашения (wallet/signature/privacy/user, идут через sndagreement) отображались,
а оферта ЦПП — нет, хотя её hash был на месте в meta.links и сам документ был
в реестре.

Воспроизведено и проверено на живом кандидате voskhod: 5 хэшей в meta.links,
но до фикса только 4 резолвились в документ-агрегаты. Скоуп по кооперативу
берём из payload действия (data.coopname), а не из индекса receiver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:36:58 +00:00
coopops b1b1775277 fix(contracts): migrate voskhod programs.program_type back to 'capital'
Typecheck / desktop (pull_request) Failing after 8m46s
Typecheck / controller (pull_request) Successful in 13m44s
Автоматическая CI/CD-миграция (soviet::migrate) раньше переименовывала
programs.program_type[id=4] voskhod в 'blagorost', но coagreements.type для
той же программы никогда не переименовывался и остаётся 'capital' — отсюда
расхождение двух таблиц на одном и том же боевом кооперативе, которое видно
любым прямым запросом к цепочке и вносит путаницу.

'capital' — каноничное значение по всей цепочке решений в этой сессии: это
то, что реально в coagreements.type, то, на что рассчитывает _capital_program
в lib/consts.hpp, и то, что теперь везде в controller/boot. Новый блок в
migrate() приводит programs.program_type к нему же — идемпотентно, сработает
автоматически на следующем деплое soviet, без ручных действий на проде.
soviet и capital пересобраны локально — компилируются чисто.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:35:37 +00:00
coopops 5f4281ac01 revert(contracts): _capital_program back to 'capital', drop stray blagorost migration
Typecheck / desktop (pull_request) Failing after 8m52s
Typecheck / controller (pull_request) Failing after 6m44s
Единственный источник правды — продовая цепочка voskhod: coagreements.type
для program_id=4 буквально 'capital' (проверено прямым запросом к
185.70.105.87), это боевое неизменяемое значение. Contract-константа
_capital_program в lib/consts.hpp была переименована в 'blagorost' коммитом
e1538bb5ab (2026-01-26) и никогда не доезжала до прода — из-за этого dev-цепочки
собирали программу с другим именем и падали на 'Недопустимый тип программы'
при попытке засеять её как 'capital'.

Заодно убран автоматический CI/CD-migrate в soviet::migrate(), который на
каждом деплое насильно переименовывал programs.program_type[id=4] voskhod в
'blagorost' — он и был причиной, что programs.program_type на проде уже
'blagorost', а coagreements.type там же остался 'capital' (миграция трогала
только одну из двух таблиц). Раз откатываем контракт на 'capital' насовсем —
эта миграция больше не нужна и мешает: на следующем деплое она снова
рассинхронила бы данные.

Boot-скрипты и BLAGOROST_AGREEMENT_TYPE в controller уже используют 'capital'
(предыдущий коммит) — теперь везде единое значение, dev-цепочка при
pnpm run reboot соберёт programs+coagreements так же, как боевой voskhod, без
какой-либо миграции на проде.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:58:50 +00:00
coopops d35e88d951 revert(capital): keep BLAGOROST_AGREEMENT_TYPE='capital', fix boot seed drift instead
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Прямой запрос к продовой цепочке voskhod (185.70.105.87) показал реальный
on-chain тип программы Благорост — 'capital' (program_id=4), не 'blagorost'.
Прод годами работает с этим значением, ошибок в логах нет. Предыдущий коммит
(5360daa471) поменял константу на 'blagorost' по данным ТОЛЬКО локальной
dev-цепочки mono-ai-2 — для прода это было бы регрессией. Откатываю.

Настоящий источник дрейфа — boot-сидинг: components/boot/src/init/cooperative.ts
createPrograms() и src/scripts/seed-capital/phases/01-programs.ts заводили
новый кооператив (createprog) с type='blagorost', расходясь с давно
задеплоенным продовым значением. Правлю сидинг под реальность, а не наоборот
(та же логика, что 'дрейф стандарт↔код — прав контракт': on-chain состояние
неизменяемо и авторитетно, код подстраивается). Заодно поправлены прочие
литералы 'blagorost' в тестах boot (capital.test.ts, capital-import.test.ts,
08-investments.ts, tests/capital/consts.ts), которые создавали/подписывали
программу под тем же неверным именем.

> monocoop@ reboot /home/admin/mono-ai-2
> cd components/boot/scripts && bash reboot.sh

Останавливаем и удаляем контейнеры с volumes...
Останавливаем blockchain контейнер...
Удаляем blockchain data...
Пересоздаем и запускаем базы данных...
Ждем готовности MongoDB...
MongoDB готов!
Ждем готовности PostgreSQL...
PostgreSQL готов!
Запускаем boot процесс...

> @coopenomics/boot@2026.6.24 boot /home/admin/mono-ai-2/components/boot
> esno src/index.ts boot

Node is healthy. Continue. {
  server_version: 'ee002f93',
  chain_id: 'db79c8409645082749ca50640d6f4ee511575acf26c4e2c8e4748e6bf6a01ed4',
  head_block_num: 10,
  last_irreversible_block_num: 9,
  last_irreversible_block_id: '000000093c561104c62b9058c82eab12559686eba92fdbe4bcf4df9bf2600131',
  head_block_id: '0000000ae4bffe027ec2a2c89ca92832422a84ec052649f1a80436fff5c61327',
  head_block_time: '2026-07-19T11:48:57.500',
  head_block_producer: 'eosio',
  virtual_block_cpu_limit: 1008036,
  virtual_block_net_limit: 1058054,
  block_cpu_limit: 1000000,
  block_net_limit: 1048576,
  server_version_string: 'v6.0.0',
  fork_db_head_block_num: 10,
  fork_db_head_block_id: '0000000ae4bffe027ec2a2c89ca92832422a84ec052649f1a80436fff5c61327',
  server_full_version_string: 'v6.0.0-ee002f9372237aa07b05879df772a95f9d54867f-dirty',
  total_cpu_weight: 0,
  total_net_weight: 0,
  earliest_available_block_num: 1,
  last_irreversible_block_time: '2026-07-19T11:48:57.000'
}
Created account: eosio.token
Created account: eosio.bpay
Created account: eosio.vpay
Created account: eosio.msig
Created account: eosio.wrap
Created account: eosio.power
Created account: eosio.saving
Created account: registrator
Created account: capital
Created account: loan
Created account: ledger
Created account: ledger2
Created account: test
Created account: meet
Created account: wallet
Created account: soviet
Created account: branch
Created account: marketplace
Created account: draft
Created account: gateway
Created account: fund
Created account: contributor
Created account: apps
Created account: expense
Created account: voskhod
ok -> init activation:  { result: 'ok' }
on set:  {
  name: 'eosio.boot',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.abi
contract setted:  eosio
Фича активирована:  ACTION_RETURN_VALUE
Фича активирована:  CONFIGURABLE_WASM_LIMITS2
Фича активирована:  BLOCKCHAIN_PARAMETERS
Фича активирована:  GET_SENDER
Фича активирована:  FORWARD_SETCODE
Фича активирована:  ONLY_BILL_FIRST_AUTHORIZER
Фича активирована:  RESTRICT_ACTION_TO_SELF
Фича активирована:  DISALLOW_EMPTY_PRODUCER_SCHEDULE
Фича активирована:  FIX_LINKAUTH_RESTRICTION
Фича активирована:  REPLACE_DEFERRED
Фича активирована:  NO_DUPLICATE_DEFERRED_ID
Фича активирована:  ONLY_LINK_TO_EXISTING_PERMISSION
Фича активирована:  RAM_RESTRICTIONS
Фича активирована:  WEBAUTHN_KEY
Фича активирована:  WTMSIG_BLOCK_SIGNATURES
Фича активирована:  GET_CODE_HASH
Фича активирована:  GET_BLOCK_NUM
Фича активирована:  CRYPTO_PRIMITIVES
on set:  {
  name: 'eosio.system',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.abi
contract setted:  eosio
on set:  {
  name: 'eosio.token',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token',
  target: 'eosio.token'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.abi
contract setted:  eosio.token
on set:  {
  name: 'eosio.msig',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.msig',
  target: 'eosio.msig'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.msig/eosio.msig.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.msig/eosio.msig.abi
contract setted:  eosio.msig
on set:  {
  name: 'eosio.wrap',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.wrap',
  target: 'eosio.wrap'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.wrap/eosio.wrap.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.wrap/eosio.wrap.abi
contract setted:  eosio.wrap
on set:  {
  name: 'registrator',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/registrator',
  target: 'registrator'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/registrator/registrator.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/registrator/registrator.abi
contract setted:  registrator
on set:  {
  name: 'soviet',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/soviet',
  target: 'soviet'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/soviet/soviet.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/soviet/soviet.abi
contract setted:  soviet
on set:  {
  name: 'marketplace',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/marketplace',
  target: 'marketplace'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/marketplace/marketplace.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/marketplace/marketplace.abi
contract setted:  marketplace
on set:  {
  name: 'draft',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/draft',
  target: 'draft'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/draft/draft.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/draft/draft.abi
contract setted:  draft
on set:  {
  name: 'branch',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/branch',
  target: 'branch'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/branch/branch.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/branch/branch.abi
contract setted:  branch
on set:  {
  name: 'gateway',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/gateway',
  target: 'gateway'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/gateway/gateway.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/gateway/gateway.abi
contract setted:  gateway
on set:  {
  name: 'fund',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/fund',
  target: 'fund'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/fund/fund.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/fund/fund.abi
contract setted:  fund
on set:  {
  name: 'ledger',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/ledger',
  target: 'ledger'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger/ledger.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger/ledger.abi
contract setted:  ledger
on set:  {
  name: 'ledger2',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/ledger2',
  target: 'ledger2'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger2/ledger2.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/ledger2/ledger2.abi
contract setted:  ledger2
on set:  {
  name: 'test',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/test',
  target: 'test'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/test/test.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/test/test.abi
contract setted:  test
on set:  {
  name: 'contributor',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/contributor',
  target: 'contributor'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/contributor/contributor.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/contributor/contributor.abi
contract setted:  contributor
on set:  {
  name: 'capital',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/capital',
  target: 'capital'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/capital/capital.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/capital/capital.abi
contract setted:  capital
on set:  {
  name: 'wallet',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/wallet',
  target: 'wallet'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/wallet/wallet.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/wallet/wallet.abi
contract setted:  wallet
on set:  {
  name: 'loan',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/loan',
  target: 'loan'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/loan/loan.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/loan/loan.abi
contract setted:  loan
on set:  {
  name: 'meet',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/meet',
  target: 'meet'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/meet/meet.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/meet/meet.abi
contract setted:  meet
on set:  {
  name: 'apps',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/apps',
  target: 'apps'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/apps/apps.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/apps/apps.abi
contract setted:  apps
on set:  {
  name: 'expense',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/expense',
  target: 'expense'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/expense/expense.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/expense/expense.abi
contract setted:  expense
создаём токен
Токен создан:  { issuer: 'eosio', maximum_supply: '1000000000.0000 AXON' }
выпускаем токены
Токен выпущен:  { to: 'eosio', quantity: '100000.0000 AXON', memo: '' }
Updated account permissions: eosio.saving -> eosio.saving
Updated account permissions: registrator -> registrator
Updated account permissions: capital -> capital
Updated account permissions: loan -> loan
Updated account permissions: ledger -> ledger
Updated account permissions: ledger2 -> ledger2
Updated account permissions: test -> test
Updated account permissions: meet -> meet
Updated account permissions: wallet -> wallet
Updated account permissions: soviet -> soviet
Updated account permissions: branch -> branch
Updated account permissions: marketplace -> marketplace
Updated account permissions: draft -> draft
Updated account permissions: gateway -> gateway
Updated account permissions: fund -> fund
Updated account permissions: contributor -> contributor
Updated account permissions: apps -> apps
Updated account permissions: expense -> expense
Updated account permissions: voskhod -> registrator
Системный контракт инициализирован { version: 0, core: '4,AXON' }
Эмиссия инициализирована {
  init_supply: '1000.0000 AXON',
  tact_duration: 86400,
  emission_factor: 0.618
}
Система аренды инициализирована { args: { powerup_days: 1, min_powerup_fee: '0.0001 AXON' } }
Шаблон создан:  1
Шаблон создан:  2
Шаблон создан:  3
Шаблон создан:  4
Шаблон создан:  50
Шаблон создан:  51
Шаблон создан:  100
Шаблон создан:  101
Шаблон создан:  200
Шаблон создан:  201
Шаблон создан:  300
Шаблон создан:  301
Шаблон создан:  302
Шаблон создан:  303
Шаблон создан:  304
Шаблон создан:  501
Шаблон создан:  599
Шаблон создан:  600
Шаблон создан:  699
Шаблон создан:  700
Шаблон создан:  701
Шаблон создан:  702
Шаблон создан:  800
Шаблон создан:  801
Шаблон создан:  802
Шаблон создан:  900
Шаблон создан:  901
Шаблон создан:  994
Шаблон создан:  995
Шаблон создан:  996
Шаблон создан:  997
Шаблон создан:  998
Шаблон создан:  999
Шаблон создан:  1000
Шаблон создан:  1001
Шаблон создан:  1002
Шаблон создан:  1003
Шаблон создан:  1004
Шаблон создан:  1005
Шаблон создан:  1006
Шаблон создан:  1007
Шаблон создан:  1010
Шаблон создан:  1011
Шаблон создан:  1020
Шаблон создан:  1025
Шаблон создан:  1030
Шаблон создан:  1040
Шаблон создан:  1041
Шаблон создан:  1042
Шаблон создан:  1050
Шаблон создан:  1051
Шаблон создан:  1060
Шаблон создан:  1061
Шаблон создан:  1062
Шаблон создан:  1070
Шаблон создан:  1071
Шаблон создан:  1072
Шаблон создан:  1080
Шаблон создан:  1090
Шаблон создан:  2010
Шаблон создан:  2011
Арендуем ресурсы провайдеру
Аренда ресурсов:  {
  payer: 'eosio',
  receiver: 'voskhod',
  days: 1,
  payment: '10000.0000 AXON',
  transfer: true
}
{ from: 'eosio', to: 'voskhod', quantity: '1000.0000 AXON', memo: '' }
Перевод токенов:  { from: 'eosio', to: 'voskhod', quantity: '1000.0000 AXON', memo: '' }
Базовая инфраструктура установлена
Провайдер добавлен:  {
  username: 'voskhod',
  type: 'coop',
  short_name: 'ПК "Восход"',
  full_name: 'Потребительский Кооператив "ВОСХОД"',
  represented_by: {
    first_name: 'Иван',
    last_name: 'Иванов',
    middle_name: 'Иванович',
    position: 'Председатель',
    based_on: 'Решение общего собрания №1'
  },
  country: 'Российская Федерация',
  city: 'Москва',
  fact_address: '117593 г. Москва, муниципальный округ Ясенево, проезд Соловьиный, дом 1, помещение 1/1',
  full_address: '117593 г. Москва, муниципальный округ Ясенево, проезд Соловьиный, дом 1, помещение 1/1',
  email: 'copenomics@yandex.ru',
  phone: '+71234567890',
  details: { inn: '9728130611', ogrn: '1247700283346', kpp: '772801001' }
}
Vars сохранены с указанным _id и _created_at
Все документы удалены из коллекции sync
Все документы удалены из коллекции actions
Все документы удалены из коллекции deltas
Добавляем пайщика ant
{
  coopname: 'voskhod',
  referer: '',
  username: 'ant',
  type: 'individual',
  created_at: '2025-01-15T10:00:00',
  initial: '100.0000 RUB',
  minimum: '200.0000 RUB',
  spread_initial: true,
  meta: 'Основатель кооператива ВОСХОД',
  registration_hash: '1e87d2a7d35f1be95da5a676ae799262e970647d0ba69cd19c0eaff0afd5b488'
}
Добавлен пользователь:  ant
Устанавливаем дефолтный публичный ключ для ant
{
  coopname: 'voskhod',
  changer: 'voskhod',
  username: 'ant',
  public_key: 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV'
}
Изменён ключ для пользователя:  ant
Инициализируем пользователей в PostgreSQL
Подключение к PostgreSQL установлено для инициализации пользователей
Инициализировано 1 пользователей в PostgreSQL
Инициализируем vault в PostgreSQL
Подключение к PostgreSQL установлено для инициализации vault
Vault инициализирован в PostgreSQL
Создаём совет
{
  coopname: 'voskhod',
  username: 'ant',
  type: 'soviet',
  members: [
    {
      username: 'ant',
      is_voting: true,
      position_title: 'Председатель совета',
      position: 'chairman'
    }
  ],
  name: 'Совет',
  description: 'Совет кооператива ВОСХОД'
}
Создан совет:  {
  coopname: 'voskhod',
  username: 'ant',
  type: 'soviet',
  members: [
    {
      username: 'ant',
      is_voting: true,
      position_title: 'Председатель совета',
      position: 'chairman'
    }
  ],
  name: 'Совет',
  description: 'Совет кооператива ВОСХОД'
}
Создаём программы и соглашения
params {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'marketplace',
  title: 'КООПЛЕЙС',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'relative',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 10000,
  meta: '',
  is_can_coop_spend_share_contributions: false
}
Программа установлена:  {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'marketplace',
  title: 'КООПЛЕЙС',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'relative',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 10000,
  meta: '',
  is_can_coop_spend_share_contributions: false
}
params {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'generator',
  title: 'Целевая потребительская программа "Генератор"',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'free',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 0,
  meta: '',
  is_can_coop_spend_share_contributions: true
}
Программа установлена:  {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'generator',
  title: 'Целевая потребительская программа "Генератор"',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'free',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 0,
  meta: '',
  is_can_coop_spend_share_contributions: true
}
params {
  coopname: 'voskhod',
  username: 'voskhod',
  type: 'capital',
  title: 'Целевая потребительская программа "Благорост"',
  announce: '',
  description: '',
  preview: '',
  images: '',
  calculation_type: 'free',
  fixed_membership_contribution: '0.0000 RUB',
  membership_percent_fee: 0,
  meta: '',
  is_can_coop_spend_share_contributions: true
}
 ELIFECYCLE  Command failed with exit code 1.
Запускаем parser...
Запускаем контроллер...
Перезапуск завершен! пересоздаёт blockchain-data с нуля и гоняет
> monocoop@ boot /home/admin/mono-ai-2
> lerna run boot --scope @coopenomics/boot

> @coopenomics/boot:boot

> @coopenomics/boot@2026.6.24 boot /home/admin/mono-ai-2/components/boot
> esno src/index.ts boot
Node is healthy. Continue. {
  server_version: 'ee002f93',
  chain_id: 'db79c8409645082749ca50640d6f4ee511575acf26c4e2c8e4748e6bf6a01ed4',
  head_block_num: 123,
  last_irreversible_block_num: 122,
  last_irreversible_block_id: '0000007adc2e2215250e7ffbd9c7961faa675f57c3134be365b336292fda7e72',
  head_block_id: '0000007bf4fa5276948ff1ea2dce877a2032ac28be3a753271efd198ed5f79d5',
  head_block_time: '2026-07-19T11:49:54.000',
  head_block_producer: 'eosio',
  virtual_block_cpu_limit: 1128638,
  virtual_block_net_limit: 1184640,
  block_cpu_limit: 1000000,
  block_net_limit: 1048576,
  server_version_string: 'v6.0.0',
  fork_db_head_block_num: 123,
  fork_db_head_block_id: '0000007bf4fa5276948ff1ea2dce877a2032ac28be3a753271efd198ed5f79d5',
  server_full_version_string: 'v6.0.0-ee002f9372237aa07b05879df772a95f9d54867f-dirty',
  total_cpu_weight: 999999997,
  total_net_weight: 999999997,
  earliest_available_block_num: 1,
  last_irreversible_block_time: '2026-07-19T11:49:53.500'
}
error -> init activation:  AxiosError: Request failed with status code 500
    at settle (file:///home/admin/mono-ai-2/node_modules/.pnpm/axios@1.13.6/node_modules/axios/lib/core/settle.js:20:7)
    at IncomingMessage.handleStreamEnd (file:///home/admin/mono-ai-2/node_modules/.pnpm/axios@1.13.6/node_modules/axios/lib/adapters/http.js:844:13)
    at IncomingMessage.emit (node:events:531:35)
    at endReadableNT (node:internal/streams/readable:1698:12)
    at process.processTicksAndRejections (node:internal/process/task_queues:89:21)
    at Axios.request (file:///home/admin/mono-ai-2/node_modules/.pnpm/axios@1.13.6/node_modules/axios/lib/core/Axios.js:46:41)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async startInfra (/home/admin/mono-ai-2/components/boot/src/init/infra.ts:45:22)
    at async boot (/home/admin/mono-ai-2/components/boot/src/init/booter.ts:6:22)
    at async Command.<anonymous> (/home/admin/mono-ai-2/components/boot/src/index.ts:144:7) {
  isAxiosError: true,
  code: 'ERR_BAD_RESPONSE',
  config: {
    transitional: {
      silentJSONParsing: true,
      forcedJSONParsing: true,
      clarifyTimeoutError: false,
      legacyInterceptorReqResOrdering: true
    },
    adapter: [ 'xhr', 'http', 'fetch' ],
    transformRequest: [ [Function: transformRequest] ],
    transformResponse: [ [Function: transformResponse] ],
    timeout: 0,
    xsrfCookieName: 'XSRF-TOKEN',
    xsrfHeaderName: 'X-XSRF-TOKEN',
    maxContentLength: -1,
    maxBodyLength: -1,
    env: { FormData: [Function], Blob: [class Blob] },
    validateStatus: [Function: validateStatus],
    headers: Object [AxiosHeaders] {
      Accept: 'application/json, text/plain, */*',
      'Content-Type': 'application/json',
      'User-Agent': 'axios/1.13.6',
      'Content-Length': '102',
      'Accept-Encoding': 'gzip, compress, deflate, br'
    },
    method: 'post',
    url: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
    data: '{"protocol_features_to_activate":["0ec7e080177b2c02b278d5088611686b49d739925a92d9bfcacd7fc6b74053bd"]}',
    allowAbsoluteUrls: true
  },
  request: <ref *1> ClientRequest {
    _events: [Object: null prototype] {
      abort: [Function (anonymous)],
      aborted: [Function (anonymous)],
      connect: [Function (anonymous)],
      error: [Function (anonymous)],
      socket: [Function (anonymous)],
      timeout: [Function (anonymous)],
      finish: [Function: requestOnFinish]
    },
    _eventsCount: 7,
    _maxListeners: undefined,
    outputData: [],
    outputSize: 0,
    writable: true,
    destroyed: true,
    _last: false,
    chunkedEncoding: false,
    shouldKeepAlive: true,
    maxRequestsOnConnectionReached: false,
    _defaultKeepAlive: true,
    useChunkedEncodingByDefault: true,
    sendDate: false,
    _removedConnection: false,
    _removedContLen: false,
    _removedTE: false,
    strictContentLength: false,
    _contentLength: 102,
    _hasBody: true,
    _trailer: '',
    finished: true,
    _headerSent: true,
    _closed: true,
    _header: 'POST /v1/producer/schedule_protocol_feature_activations HTTP/1.1\r\n' +
      'Accept: application/json, text/plain, */*\r\n' +
      'Content-Type: application/json\r\n' +
      'User-Agent: axios/1.13.6\r\n' +
      'Content-Length: 102\r\n' +
      'Accept-Encoding: gzip, compress, deflate, br\r\n' +
      'Host: 127.0.0.1:8898\r\n' +
      'Connection: keep-alive\r\n' +
      '\r\n',
    _keepAliveTimeout: 0,
    _onPendingData: [Function: nop],
    agent: Agent {
      _events: [Object: null prototype],
      _eventsCount: 2,
      _maxListeners: undefined,
      options: [Object: null prototype],
      defaultPort: 80,
      protocol: 'http:',
      requests: [Object: null prototype] {},
      sockets: [Object: null prototype] {},
      freeSockets: [Object: null prototype],
      keepAliveMsecs: 1000,
      keepAlive: true,
      maxSockets: Infinity,
      maxFreeSockets: 256,
      scheduling: 'lifo',
      maxTotalSockets: Infinity,
      totalSocketCount: 1,
      agentKeepAliveTimeoutBuffer: 1000,
      [Symbol(shapeMode)]: false,
      [Symbol(kCapture)]: false
    },
    socketPath: undefined,
    method: 'POST',
    maxHeaderSize: undefined,
    insecureHTTPParser: undefined,
    joinDuplicateHeaders: undefined,
    path: '/v1/producer/schedule_protocol_feature_activations',
    _ended: true,
    res: IncomingMessage {
      _events: [Object],
      _readableState: [ReadableState],
      _maxListeners: undefined,
      socket: null,
      httpVersionMajor: 1,
      httpVersionMinor: 1,
      httpVersion: '1.1',
      complete: true,
      rawHeaders: [Array],
      rawTrailers: [],
      joinDuplicateHeaders: undefined,
      aborted: false,
      upgrade: false,
      url: '',
      method: null,
      statusCode: 500,
      statusMessage: 'Internal Server Error',
      client: [Socket],
      _consuming: false,
      _dumped: false,
      req: [Circular *1],
      _eventsCount: 4,
      responseUrl: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
      redirects: [],
      [Symbol(shapeMode)]: true,
      [Symbol(kCapture)]: false,
      [Symbol(kHeaders)]: [Object],
      [Symbol(kHeadersCount)]: 8,
      [Symbol(kTrailers)]: null,
      [Symbol(kTrailersCount)]: 0
    },
    aborted: false,
    timeoutCb: null,
    upgradeOrConnect: false,
    parser: null,
    maxHeadersCount: null,
    reusedSocket: true,
    host: '127.0.0.1',
    protocol: 'http:',
    _redirectable: Writable {
      _events: [Object],
      _writableState: [WritableState],
      _maxListeners: undefined,
      _options: [Object],
      _ended: true,
      _ending: true,
      _redirectCount: 0,
      _redirects: [],
      _requestBodyLength: 102,
      _requestBodyBuffers: [],
      _eventsCount: 3,
      _onNativeResponse: [Function (anonymous)],
      _currentRequest: [Circular *1],
      _currentUrl: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
      _timeout: null,
      [Symbol(shapeMode)]: true,
      [Symbol(kCapture)]: false
    },
    [Symbol(shapeMode)]: false,
    [Symbol(kCapture)]: false,
    [Symbol(kBytesWritten)]: 0,
    [Symbol(kNeedDrain)]: false,
    [Symbol(corked)]: 0,
    [Symbol(kChunkedBuffer)]: [],
    [Symbol(kChunkedLength)]: 0,
    [Symbol(kSocket)]: Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _closeAfterHandlingError: false,
      _events: [Object],
      _readableState: [ReadableState],
      _writableState: [WritableState],
      allowHalfOpen: false,
      _maxListeners: undefined,
      _eventsCount: 6,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: null,
      _server: null,
      timeout: 5000,
      parser: null,
      _httpMessage: null,
      [Symbol(async_id_symbol)]: -1,
      [Symbol(kHandle)]: [TCP],
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: Timeout {
        _idleTimeout: 5000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 11165,
        _onTimeout: [Function: bound ],
        _timerArgs: undefined,
        _repeat: null,
        _destroyed: false,
        [Symbol(refed)]: false,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 3415,
        [Symbol(triggerId)]: 3413,
        [Symbol(kAsyncContextFrame)]: undefined
      },
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(shapeMode)]: true,
      [Symbol(kCapture)]: false,
      [Symbol(kSetNoDelay)]: true,
      [Symbol(kSetKeepAlive)]: true,
      [Symbol(kSetKeepAliveInitialDelay)]: 1,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0
    },
    [Symbol(kOutHeaders)]: [Object: null prototype] {
      accept: [Array],
      'content-type': [Array],
      'user-agent': [Array],
      'content-length': [Array],
      'accept-encoding': [Array],
      host: [Array]
    },
    [Symbol(errored)]: null,
    [Symbol(kHighWaterMark)]: 65536,
    [Symbol(kRejectNonStandardBodyWrites)]: false,
    [Symbol(kUniqueHeaders)]: null
  },
  response: {
    status: 500,
    statusText: 'Internal Server Error',
    headers: Object [AxiosHeaders] {
      server: 'nodeos/v6.0.0',
      'access-control-allow-origin': '*',
      'content-type': 'application/json',
      'content-length': '371'
    },
    config: {
      transitional: [Object],
      adapter: [Array],
      transformRequest: [Array],
      transformResponse: [Array],
      timeout: 0,
      xsrfCookieName: 'XSRF-TOKEN',
      xsrfHeaderName: 'X-XSRF-TOKEN',
      maxContentLength: -1,
      maxBodyLength: -1,
      env: [Object],
      validateStatus: [Function: validateStatus],
      headers: [Object [AxiosHeaders]],
      method: 'post',
      url: 'http://127.0.0.1:8898/v1/producer/schedule_protocol_feature_activations',
      data: '{"protocol_features_to_activate":["0ec7e080177b2c02b278d5088611686b49d739925a92d9bfcacd7fc6b74053bd"]}',
      allowAbsoluteUrls: true
    },
    request: <ref *1> ClientRequest {
      _events: [Object: null prototype],
      _eventsCount: 7,
      _maxListeners: undefined,
      outputData: [],
      outputSize: 0,
      writable: true,
      destroyed: true,
      _last: false,
      chunkedEncoding: false,
      shouldKeepAlive: true,
      maxRequestsOnConnectionReached: false,
      _defaultKeepAlive: true,
      useChunkedEncodingByDefault: true,
      sendDate: false,
      _removedConnection: false,
      _removedContLen: false,
      _removedTE: false,
      strictContentLength: false,
      _contentLength: 102,
      _hasBody: true,
      _trailer: '',
      finished: true,
      _headerSent: true,
      _closed: true,
      _header: 'POST /v1/producer/schedule_protocol_feature_activations HTTP/1.1\r\n' +
        'Accept: application/json, text/plain, */*\r\n' +
        'Content-Type: application/json\r\n' +
        'User-Agent: axios/1.13.6\r\n' +
        'Content-Length: 102\r\n' +
        'Accept-Encoding: gzip, compress, deflate, br\r\n' +
        'Host: 127.0.0.1:8898\r\n' +
        'Connection: keep-alive\r\n' +
        '\r\n',
      _keepAliveTimeout: 0,
      _onPendingData: [Function: nop],
      agent: [Agent],
      socketPath: undefined,
      method: 'POST',
      maxHeaderSize: undefined,
      insecureHTTPParser: undefined,
      joinDuplicateHeaders: undefined,
      path: '/v1/producer/schedule_protocol_feature_activations',
      _ended: true,
      res: [IncomingMessage],
      aborted: false,
      timeoutCb: null,
      upgradeOrConnect: false,
      parser: null,
      maxHeadersCount: null,
      reusedSocket: true,
      host: '127.0.0.1',
      protocol: 'http:',
      _redirectable: [Writable],
      [Symbol(shapeMode)]: false,
      [Symbol(kCapture)]: false,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(kNeedDrain)]: false,
      [Symbol(corked)]: 0,
      [Symbol(kChunkedBuffer)]: [],
      [Symbol(kChunkedLength)]: 0,
      [Symbol(kSocket)]: [Socket],
      [Symbol(kOutHeaders)]: [Object: null prototype],
      [Symbol(errored)]: null,
      [Symbol(kHighWaterMark)]: 65536,
      [Symbol(kRejectNonStandardBodyWrites)]: false,
      [Symbol(kUniqueHeaders)]: null
    },
    data: { code: 500, message: 'Internal Service Error', error: [Object] }
  },
  status: 500
}
on set:  {
  name: 'eosio.boot',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.boot/eosio.boot.abi
contract setted:  eosio
on set:  {
  name: 'eosio.system',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system',
  target: 'eosio'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.system/eosio.system.abi
contract setted:  eosio
on set:  {
  name: 'eosio.token',
  path: '/home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token',
  target: 'eosio.token'
}
wasm_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.wasm
abi_path /home/admin/mono-ai-2/components/contracts/build/contracts/system/contracts/eosio.token/eosio.token.abi
 ELIFECYCLE  Command failed with exit code 1.

 Lerna (powered by Nx)   Running target boot for project @coopenomics/boot failed

Failed tasks:

- @coopenomics/boot:boot

Hint: run the command with --verbose for more details.

 ELIFECYCLE  Command failed with exit code 1.
(booter.ts → cooperative.ts createPrograms) — именно так локальная dev-цепочка
получила 'blagorost' вместо 'capital' и воспроизвела баг, которого на проде
никогда не было.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:50:04 +00:00
coopops 5360daa471 fix(capital): correct BLAGOROST_AGREEMENT_TYPE to match on-chain 'blagorost'
Typecheck / desktop (pull_request) Failing after 8m53s
Typecheck / controller (pull_request) Has been cancelled
Константа хранила 'capital', но boot/src/init/cooperative.ts createPrograms()
сеет программу с type='blagorost' на генезисе (soviet::createprog), на всех
кооперативах. account.adapter.ts искал coagreement по 'capital' — не находил,
programId откатывался на 0, и вместо wallet::signagree уходил
soviet::sndagreement, который на цепи падает: get_coagreement_or_fail не
находит type='capital' в coagreements — «Соглашение указанного типа не
найдено». Ловилось при приёме платежа регистрации (registerBlockchainAccount
из gateway.interactor при PaymentType.REGISTRATION), падало для КАЖДОГО
участника, зарегистрированного по офферте Благорост.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:41:03 +00:00
coopops 652f7c722e fix(cooptypes): add missing space after colon before eoap_definition placeholder
Typecheck / desktop (pull_request) Failing after 8m50s
Typecheck / controller (pull_request) Successful in 13m45s
п.1.1 «а именно:{{ doc_data.eoap_definition }}» без пробела — во всех шести
шаблонах ЦПП (994/995/996/998/999/1000), фраза идентична во всех программах.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:46:00 +00:00
coopops a2b132b457 fix(registration): resolve doc_data_hash for extension offers via registry spec
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Шаблоны оферт ЦПП (#996/#1000) с c7824545cf требуют PrivateData, но общий
путь регистрации (RegistrationDocumentsService) не передавал doc_data_hash —
регистрация пайщика по GENERATION/CAPITALIZATION падала на генерации превью.

Ядро остаётся слепым к расширениям: AgreementRegistrationSpec получает
опциональный resolve_doc_data_hash, который расширение-владелец прикрепляет
к своим офертам при регистрации в реестре. Capital резолвит hash свежим
чтением своего конфига на каждую генерацию (совет может пересохранить
параметры без перезапуска). Гранулярность — per-оферта: при разделении
параметров по программам ядро менять не потребуется.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:41:57 +00:00
coopops a6cc71d6a3 fix(extension): unwrap TypeORM [rows, count] tuple in patchConfig UPDATE...RETURNING
TypeORM возвращает для UPDATE/DELETE кортеж [rows, affectedCount] (в отличие
от INSERT с голым rows-массивом) — без распаковки row оказывался кортежем и
все поля config читались как undefined, роняя buildState онбординга.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:41:45 +00:00
coopops b67581ce80 fix(extension): atomic jsonb config patch to stop onboarding flag lost-updates
Typecheck / desktop (pull_request) Failing after 22m0s
Typecheck / controller (pull_request) Successful in 13m37s
extensions.config is a single jsonb blob shared by every flag/hash the L1
onboarding wizards (capital, chairman) write to. Every write site did the
classic read-modify-write: findByName() the whole row, spread+mutate one key
in app memory, then update() the whole config back. Two DecisionTrackedEvent
handlers firing concurrently (two council decisions tracked near-simultaneously)
each read the same stale snapshot and each write their own flag back — whichever
write lands last wins and silently reverts the other one's flag to its old value.
Symptom hit live: onboarding_generator_program_template_done reverted to false
(with its hash still present, proving the decision really was tracked) after
onboarding_generation_contract_template_done raced it to true.

Added ExtensionDomainRepository.patchConfig(name, patch), implemented as a single
`UPDATE extensions SET config = config || patch::jsonb ... RETURNING *` — no
app-side read before the write, Postgres serializes concurrent UPDATEs on the
row so two different-key patches can no longer clobber each other. Replaced
every config read-modify-write in capital's and chairman's onboarding services
(the events handler's flag write, loadPlugin's init/expire timestamps, the
hash writes in completeStep/completeGeneralMeet, saveProgramDocDataHash) with
this atomic patch. update() is untouched for other callers.

Live data recovered manually: onboarding_generator_program_template_done was
patched back to true for voskhod/capital (hash was already present, confirming
the decision had in fact been tracked before the race reverted the flag).
2026-07-19 07:56:00 +00:00
coopops 024d46ff64 fix update 2026-07-19 07:55:49 +00:00
coopops 9030d28c6b fix(cooptypes): remove п.8.1 «Под Сайтом» from BlagorostProgramTemplate, renumber
Typecheck / desktop (pull_request) Failing after 9m37s
Typecheck / controller (pull_request) Successful in 14m3s
По решению: универсал-документ (ЦПП БЛАГОРОСТ_финал_универсал.docx.rtf,
~/blago/production/shared) — новый правильный эталон. В нём раздел 8 «Прочие условия»
содержит только 2 пункта (8.1 «Общество и Участник...», 8.2 «Изменения и дополнения...»),
без пункта про «Под Сайтом... прочие программные продукты» — ни в Положении, ни в
Оферте БЛАГОРОСТ (universal) такого пункта нет.

Убрал п.8.1 из rawContext, сдвинул 8.2→8.1, 8.3→8.2. Ссылка "(п. 7.1.)" в новом 8.2
не требует правки — форс-мажор остаётся разделом 7 независимо от сдвига в разделе 8.
Теперь 998 совпадает с универсалом точь-в-точь (сверено скриптом по номерам пунктов
и по тексту под каждым номером).
2026-07-19 07:31:02 +00:00
coopops 6e0e68c1e7 fix(cooptypes): correct cross-reference in GeneratorProgramTemplate п.12.3
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Systematic clause-numbering comparison against Положение_ЦПП_ГЕНЕРАТОР_универсал.docx.rtf
(~/blago/production/shared) found п.12.3 pointing at "(п. 12.1.)" — that's the "Под Сайтом"
definition clause, unrelated. The universal document (and the sentence's own subject —
"изменения условий ЦПП... возможность внесения которых прямо предусмотрена настоящим
Положением") both point to п.11.1, the форс-мажор/legislative-changes clause that actually
grants this right. Fixed the reference in rawContext only; left the orphaned translations
copy (994's dead legacy i18n block, unused by rendering) untouched.
2026-07-19 07:26:44 +00:00
coopops c079785019 fix(cooptypes): remove orphaned п.5.2 in BlagorostProgramTemplate, renumber section 5
Typecheck / desktop (pull_request) Failing after 9m2s
Typecheck / controller (pull_request) Successful in 13m50s
Сверил с ЦПП БЛАГОРОСТ_финал_универсал.docx.rtf (~/blago/production/shared) — в
актуальной версии Положения самостоятельный п.5.2 «Основной экономический эффект
функционирования Платформы достигается за счет членских взносов Участников при
расширении круга пользователей Платформы» отсутствует: его смысл слит в п.5.1
("...экономический эффект от функционирования которого заключается в следующем: ...").
Оферта (999/1000.Blagorost*, уже сверено) была такой всегда — 4.1 (источник) сразу
за которым 4.2 (дополнительный источник), без промежуточного пункта.

Убрал п.5.2 из rawContext, сдвинул 5.3–5.10 на 5.2–5.9, поправил внутреннюю ссылку
"указанными в п. 5.9." → "указанными в п. 5.8." в новом 5.9 (бывший 5.10). Дублирующий
текст в дохлом export const translations (не используется рендером, см. context) не трогал.
2026-07-18 18:55:46 +00:00
coopops bbc6453b24 fix(capital): await onSaved before advancing wizard past БЛАГОРОСТ step
Typecheck / desktop (pull_request) Failing after 8m49s
Typecheck / controller (pull_request) Successful in 13m45s
goNext() checked getFirstAvailableCouncilGroup() (which reads isDocParamsReady,
derived from onboardingState.capital_program_doc_data_hash) synchronously right
after saveParams() resolved. But saveParams() fired options.onSaved(hash) without
awaiting it, and the card's onSaved handler did `void handleDocParamsSaved()` —
an async refetch of onboarding state — so isDocParamsReady was still stale at the
moment goNext() decided which council group to jump to. getFirstAvailableCouncilGroup()
returned null, setWizardStepKey never ran, and the wizard just re-rendered the same
БЛАГОРОСТ step, looking like the just-saved params were never registered.

Fix: onSaved is now awaited inside saveParams(), and the card passes the
handleDocParamsSaved() promise through instead of firing it and forgetting it.
2026-07-18 18:27:57 +00:00
coopops b5eb0ea51f fix(capital): address PR #154 review — proper interface instead of as-cast, IInput['data'] canon
Typecheck / desktop (pull_request) Failing after 23m23s
Typecheck / controller (pull_request) Failing after 21m57s
document-domain.service.ts: replaced the inline `as Cooperative.Document.IGenerate & {...}`
hack with a dedicated GenerateDocumentWithPrivateDataDomainInterface for the doc_data
pre-generation branch (Cooperative.Document.IGenerate already carries a `[key: string]: any`
index signature, so the narrow interface is assignable without any cast at all).

Onboarding api/index.ts: saveProgramDocDataHash now takes
Mutations.Capital.SaveCapitalProgramDocDataHash.IInput['data'] and forwards it as-is to
variables.data, matching the SDK IInput['data'] canon used by completeStep right above it
instead of taking a bare string and rebuilding the payload inline. Updated both call sites.
2026-07-18 15:39:57 +00:00
coopops a072364d00 fix(capital): auto-generate CPP doc preview on step entry, restore section header formatting
Typecheck / desktop (pull_request) Failing after 20m30s
Typecheck / controller (pull_request) Successful in 12m40s
Preview for ГЕНЕРАТОР/БЛАГОРОСТ params no longer requires a manual button click on
first visit — onMounted now calls ensurePreview for the active wizard step. Also
fixes wizardStepOrder typing (was narrower than the runtime string values it holds).

Restores <h3> centered section-title markup in registry templates 994/995/996/998/999/1000
that was lost during the "parameterize CPP templates via private data" refactor, when
translations+context i18n structure was flattened into raw HTML.
2026-07-17 15:21:01 +00:00
coopops ed9b0879d5 chore(release): publish
Build bootstrap container / build (push) Successful in 2m42s
Release / release (push) Successful in 22m23s
Release / publish-packages (push) Successful in 14m53s
Release / trigger-coopenomics-docs (push) Successful in 2s
Release / trigger-mono-docs (push) Successful in 2s
2026-07-17 14:41:21 +00:00
coopops c94043e77f fix(docker): не скачивать бинарники electron/puppeteer в mono-base
Их postinstall тянет тяжёлые сетевые загрузки (Electron zip, Chromium),
не нужные ни для build (unbuild/tsc/vite), ни для runtime — периодически
валил release job TLS-таймаутом, из-за чего publish-packages (needs: release)
не запускался вообще.
2026-07-17 14:40:39 +00:00
coopops c1a8f3e637 fix(boot/Dockerfile): изолировать pnpm install от electron/puppeteer
Build bootstrap container / build (push) Successful in 2m37s
--filter не ограничивает lifecycle-скрипты общего lockfile store — тянулись
и падали по сети чужие тяжёлые постинсталлы (electron/puppeteer из
desktop/notifications), boot их не использует. --ignore-scripts + точечный
pnpm rebuild --filter решают.
2026-07-17 14:13:35 +00:00
coopops 15fdf35dd3 chore(release): publish
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
Build bootstrap container / build (push) Failing after 2m10s
Release / release (push) Failing after 15m13s
Release / publish-packages (push) Has been skipped
2026-07-17 14:02:54 +00:00
coopops 79b89695d1 chore(release): publish 2026-07-17 13:58:44 +00:00
coopops 7b27d0805c chore(release): publish
Release / release (push) Successful in 43m2s
Release / publish-packages (push) Has been cancelled
Release / trigger-coopenomics-docs (push) Has been cancelled
Release / trigger-mono-docs (push) Has been cancelled
Build bootstrap container / build (push) Successful in 46s
2026-07-17 12:54:54 +00:00
coopops e089dbd3fb ci(release): чинить publish-packages — генерировать controller/schema.gql перед publish
С 24 июня (минимум) publish-packages падает на каждом релизе main:
@coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
читает components/controller/schema.gql, которого нет в чекауте и никто не
генерирует — ENOENT. lerna publish атомарный, поэтому НИ ОДИН пакет не
публикуется. Итог: main давно на v2026.7.9, а npm видел последний раз
2026.5.30-3 — полтора месяца фиксов (включая убранный kpp из
BankAccountDetailsInput, PR #81) не долетали до потребителей SDK.

Фикс: добавлен шаг generate-schema (изолированный, GraphQLSchemaBuilderModule,
без БД) ПОСЛЕ lerna run build — порядок важен, иначе ts-node падает на
устаревших dist соседних workspace-пакетов (проверено локально: cooptypes →
factory → inter → notifications, каждый был stale и валил компиляцию
controller'а по цепочке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:54:33 +00:00
coopops 5230fab7c6 fix(capital): add blagorost_goal_reason field, stop reusing blagorost_goal_expansion in clause 2.1.1
Typecheck / desktop (pull_request) Failing after 9m28s
Typecheck / controller (pull_request) Successful in 14m6s
Clause 2.1.1 of БЛАГОРОСТ Положение/Оферта needs different text than the
intro paragraph, but both were wired to the same doc_data.blagorost_goal_expansion
variable (confirmed against the original "универсал" RTF sources), so 2.1.1
rendered with the wrong sentence in all three registries (998, 999, 1000).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 07:41:24 +00:00
Alex Ant 345aadbd6a update
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 14m12s
2026-07-14 22:31:19 +03:00
Alex Ant 33f1ac1a15 fix(capital): restore CPP templates from RTF sources and improve parameter constructor
Typecheck / desktop (pull_request) Failing after 18m32s
Typecheck / controller (pull_request) Failing after 6m52s
Align cooptypes/factory templates 994–1000 with original document wording,
correct doc_data substitution points, update voskhod backfill values, and
add onboarding UI with descriptive field labels and doc data hash saving.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 14:27:27 +03:00
Alex Ant 5de7e7c65b update 2026-07-13 20:53:21 +03:00
coopops bfbcd2d57f fix(desktop): починить «Продолжить» в оверлее выбора КУ
Кнопка снова submit формы из footer, BranchSelector синхронизирует selectedBranch, ошибки генерации документа показываются пользователю.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:02:19 +00:00
coopops ff5df68bce chore(release): publish
Release / publish-packages (push) Failing after 21m11s
Release / release (push) Successful in 1h35m7s
Release / trigger-coopenomics-docs (push) Successful in 3s
Release / trigger-mono-docs (push) Successful in 3s
Build bootstrap container / build (push) Successful in 10m29s
2026-07-09 05:49:14 +00:00
coopops a948f07a1b fix(desktop): закрепить кнопки оверлея выбора КУ на мобильных
Кнопки «Продолжить» и «подписать» вынесены в footer BaseDialog, чтобы они не уезжали за пределы экрана при прокрутке контента.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 05:48:16 +00:00
coopops 85bd044cd6 fix(desktop): перезагрузка списка КУ при показе оверлея выбора
Оверлей выбора кооперативного участка теперь подгружает список при переходе
в мажоритарный режим, показывает лоадер и блокирует «Продолжить» без выбора.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 20:46:55 +00:00
Alex Ant 45feef2676 fix(capital): correct CPP template mapping and onboarding widget canon
Typecheck / desktop (pull_request) Failing after 13m22s
Typecheck / controller (pull_request) Failing after 13m26s
Fix swapped doc_data placeholders in generator/blagorost templates, align the install widget with Base* components, and use one shared PrivateData hash in factory tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 21:49:30 +03:00
Alex Ant 02ce9400a2 fix(capital): remove unsafe any from CPP onboarding changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 11:05:20 +05:00
Alex Ant bfa2830650 feat(capital): collect CPP document parameters in onboarding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 10:44:02 +05:00
Alex Ant c7824545cf feat(capital): parameterize CPP templates via private data 2026-06-28 09:50:03 +05:00
Alex Ant b9107e9ee9 chore(release): publish
Build bootstrap container / build (push) Successful in 1m45s
Release / release (push) Failing after 55m53s
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
Release / trigger-mono-docs (push) Has been skipped
2026-06-24 22:04:51 +05:00
Alex Ant 4d151befe0 fix(install): блокировать «Продолжить» до минимума совета, удаление без ограничений.
На prod требуется 3 человека (на dev — 1): кнопка и подсказка, состав редактируется свободно до перехода на следующий шаг.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:03:12 +05:00
Alex Ant eb16d85abc fix(install): разблокировать мастер при прерванной установке и валидировать состав совета по окружению.
Не показываем заглушку техобслуживания на /install, разрешаем повтор install из maintenance без vars, проверяем минимум членов совета до записи в цепь (3 на prod, 1 на dev).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:35:01 +05:00
ant ee5f7e1850 Merge pull request 'feat: выход из кооператива (выход пайщика, возврат паевого взноса)' (#137) from feat/membership-exit into dev
Reviewed-on: #137
2026-06-22 19:56:24 +00:00
Alex Ant 10bacaad13 refactor(membership-exit): обновление интерфейса и логики выхода из кооператива
Typecheck / desktop (pull_request) Failing after 25m29s
Typecheck / controller (pull_request) Successful in 27m23s
Изменены компоненты, связанные с выходом из кооператива. В меню заменены иконки и пути для поддержки и выхода. Обновлены метаданные и названия для кнопок и страниц, чтобы улучшить пользовательский интерфейс. В ExitButton добавлены параметры для настройки иконки и метки. Упрощена структура MembershipExitPage с акцентом на ключевые шаги выхода. Исправлены комментарии и типы для лучшего понимания кода.
2026-06-23 00:26:38 +05:00
coopops 1f68169b94 feat(exit): возврат при выходе — сет из 3 паевых кошельков (minshr+share+blago)
Typecheck / desktop (pull_request) Failing after 9m37s
Typecheck / controller (pull_request) Successful in 12m46s
confirmexit теперь обходит сет LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
w.wal.share + w.cap.blago): собирает доступные L3-балансы каждого (>0),
консолидирует на главный паевой (w.reg.minshr→o.reg.mvmin, w.cap.blago→
o.cap.wthcap) и ставит полную сумму на возврат единым платежом. Раньше
хардкодил только minshr+share — паевой в Благоросте оставался висеть.

Сет задан один раз в wallets.hpp (источник истины), генерируется gen:from-cpp
в cooptypes (LEDGER2_EXIT_REFUND_WALLETS / EXIT_REFUND_WALLET_NAMES) и обходится
backend-preview (getReturnPreview) — расчёт на фронте всегда совпадает с тем,
что реально вернёт контракт. Подпись «планируемая сумма / итог фиксирует Совет»
убрана из ExitButton и ExitOverlay (сумма теперь авторитетна).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:36:52 +00:00
coopops 95891b2319 fix(desktop): развёл иконки меню «Подключение» и «Реквизиты»
Typecheck / desktop (pull_request) Failing after 8m41s
Typecheck / controller (pull_request) Failing after 6m39s
Обе были 'fas fa-link' (одинаковые + FontAwesome запрещён каноном). На Material:
- Подключение → 'link'
- Реквизиты → 'account_balance' (банковские реквизиты)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:38:23 +00:00
coopops f41f1444b1 fix(desktop): реестр пайщиков — статус «Вышел из кооператива», дата вступления, без удаления
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Successful in 13m48s
- статус: вышедший пайщик (participant_account стёрт delpartcpnt + user_account
  blocked) больше не показывается «Активный пайщик» — добавлен терминал
  «Вышел из кооператива» по user_account.status
- дата вступления: фолбэк participant_account.created_at → user_account.registered_at
  (у вышедших пайщик-запись стёрта, дата больше не «отсутствует»)
- убрана кнопка удаления пайщика (таблица + карточка + диалог + машинерия):
  пайщика из реестра не удаляют

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:48:14 +00:00
coopops da450ec605 fix(desktop): «платёжка» → «чек об оплате» в UI реестра платежей
Typecheck / desktop (pull_request) Failing after 6m42s
Typecheck / controller (pull_request) Has been cancelled
Подсказки/лейблы переименованы под каноничную терминологию (чек об оплате —
подтверждающий документ, не «платёжка»):
- тултипы «Чек об оплате приложен / не приложен» (desktop + mobile)
- fileKindLabel PAYMENT_PROOF → «Чек об оплате»
- расчётный ПЛАТЁЖ возврата/доплаты (вместо «платёжка») в подсказках/алертах
  ReportExpenseAdvance + согласование рода
- комментарии приведены к новой терминологии (чек / расчётный платёж)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:16:31 +00:00
coopops 7fdfd8385a feat(gateway): ядровый «чек об оплате» исходящих платежей по payment_hash
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 14m26s
Культура денег: входящие — подтверждением, исходящие — чеком. Единый механизм
на уровне ядра (gateway), привязка по payment_hash — один на все исходящие
(возврат паевого/withdrawal/registration-refund/аванс расхода), переиспользуется
расширениями. Контроль мягкий: статус не трогаем, но зеркалим proof_count →
реестр рисует «чек приложен / не приложен».

Backend (core gateway):
- таблица payment_files + бакет gateway:files (@UseBucket), PaymentFilesService
  (upload/read-url/list/delete + зеркалирование proof_count в платёж)
- enum PaymentFileKind (PAYMENT_PROOF), DTO, резолвер uploadPaymentProof /
  paymentProofs / paymentFile; провайдеры в typeorm.module + gateway.module

SDK: selectors/mutations/queries gateway + zeus regen

Desktop:
- features/Payment/AttachPaymentProof — панель чека по payment-hash
- реестр: панель + индикатор «чек приложен» для ЛЮБОГО исходящего (PAID/COMPLETED)
- конвергенция expense: чек ушёл в ядро, AttachExpenseProof оставляет только
  закрывающие документы (DIRECT)

Терминология: «чек об оплате» (не «платёжка»/«первичка»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:17:09 +00:00
coopops 39433753c2 fix(membership-exit): терминальный экран после выхода + гард повторного выхода
Typecheck / desktop (pull_request) Failing after 8m47s
Typecheck / controller (pull_request) Has been cancelled
После completexit строка registrator::exits стирается (терминал=erase) →
getMembershipExit возвращал null → оверлей пропадал, кабинет разблокировался,
пайщик видел стол и мог подать выход повторно (гарда не было).

- enum MembershipExitStatus += COMPLETED
- getMembershipExit: терминальная фаза по blocked-аккаунту + персистентному
  MEMBERSHIP_EXIT-платежу (сумма возврата + статус 'Оплачено')
- createMembershipExit: гард — заблокированный аккаунт не может выйти повторно
  (контракт уже блокирует через get_participant_or_fail; отсекаем раньше)
- ExitOverlay: терминальная фаза 'Вы вышли из кооператива' (сумма + 'Оплачено'
  + ожидайте поступления), она же UX-гард — перекрывает кабинет
- SDK Zeus regen под новый enum

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:57:54 +00:00
coopops 560321853c Merge remote-tracking branch 'origin/dev' into feat/membership-exit
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-16 08:42:23 +00:00
ant f42b703098 Merge pull request 'ci(release): независимый деплой окружений (testnet/main разным темпом)' (#150) from ci/independent-env-deploy into dev
Reviewed-on: #150
2026-06-16 08:17:55 +00:00
ant d7e08785eb Merge pull request 'fix(capital): регистрация долей Благороста при создании проекта по событию (не ждать крон)' (#151) from fix/capital-share-onproject-event into dev
Reviewed-on: #151
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-16 08:15:29 +00:00
coopops 7efd19f1bd chore(release): publish
Build bootstrap container / build (push) Has been cancelled
Release / publish-packages (push) Failing after 8m37s
Release / release (push) Successful in 27m21s
Release / trigger-coopenomics-docs (push) Successful in 1s
Release / trigger-mono-docs (push) Successful in 2s
2026-06-16 08:11:23 +00:00
coopops cce099748a fix(capital/state): program_expense_* в хвост через binary_extension — фикс нечитаемой таблицы state на проде
Инцидент 2026-06-16: коммит d02c0ce (18 мая) добавил program_expense_pool и
program_expense_reserved в global_state ПЕРЕД полем config напрямую, без
binary_extension. После деплоя на прод таблица state перестала читаться:
запись сериализована старым layout (config сразу после
program_membership_cumulative_reward_per_share), а новый ABI ждёт два asset
перед config → unpack натыкается на байты config (get table → "Invalid
symbol ...Y@" = double 100.0 = config.expense_pool_percent). Любой action,
читающий global_state, падал.

Фикс: оба поля перенесены в ХВОСТ struct (после config) и обёрнуты в
eosio::binary_extension<asset>. Старая запись прода читается без изменений
(хвостовые extension опциональны → пусто = 0), новая логика программных
расходов сохранена. Поля в таблицу EOSIO дописываются только в конец и только
binary_extension'ом.

utility-функции State:: (topup/reserve/release/consume/spend) переведены на
optional-семантику через ext_or_zero(); инвариант — оба поля материализуются
вместе, чтобы порядок хвостовых extension был консистентным. Прямого доступа к
полям вне State:: нет. Собрано в CDT-докере: capital.wasm слинкован, в ABI
program_expense_* → asset$ после config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:06:16 +00:00
coopops 3d04f76446 fix(capital): заводить доли только в active-проектах, не в pending
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
По решению пользователя заход долей Благороста — только в активные проекты.
В pending больше не заводим: при переходе pending→active придёт новая дельта
capital::projects со status=active, на ней листенер и зарегистрирует.

- Листенер ProgramShareRegistrationOnProjectDeltaListener: гейт pending|active → только active.
- ProgramShareRegistrationService.findActiveProjects (путь крона и wallet-листенера): фильтр pending|active → только active.
- Тест: pending теперь НЕ реагирует; active — позитивный кейс.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:04:08 +00:00
coopops 532e34c53b ci(release): promote по умолчанию шлёт текущий HEAD (не последний тег)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Дефолт promote.sh — текущий HEAD чекаута; второй аргумент по-прежнему задаёт явный
ref (ветка/тег/sha). Типичный путь: cut на dev → promote testnet → promote main,
все три от того же HEAD. testnet/main остаются независимыми FF-указателями.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:45:04 +00:00
coopops a0e0c9310c fix(desktop): confirm-выхода — auth-aware навигация вместо молчаливого бойнса на логин
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Ссылка из письма открывается часто без сессии кабинета (другой браузер/инкогнито/
после logout). Подтверждение работает по токену без входа, но wallet защищён —
гард молча редиректил на login-redirect, и пайщик не понимал, что произошло.

- есть сессия → 'Перейти в кабинет' → wallet (глобальный ExitOverlay сам покажет
  'на рассмотрении Совета')
- нет сессии → 'Войти в кабинет' → signin + пояснение, что войти нужно для слежения
  за статусом и суммой возврата

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:40:02 +00:00
coopops 0d51973a7b fix(capital): регистрировать доли Благороста при создании проекта по событию, без ожидания крона
Typecheck / desktop (pull_request) Successful in 13m55s
Typecheck / controller (pull_request) Has been cancelled
Доли пайщиков Благороста регистрировались в проект только периодическим
scheduler'ом (regshare) и точечным листенером на дельту кошелька. Новый
проект баланс не меняет → wallet-листенер молчит, и наполнение нового
проекта зависело ТОЛЬКО от крона. Если компонент быстро прогнали
pending→…→result до тика крона, пайщики в него не попадали, а откат
result→active контракт не допускает — доли в компоненте терялись
безвозвратно (voskhod, компонент 011bcd92…, 2026-06-16).

- Новый ProgramShareRegistrationOnProjectDeltaListener на
  delta::capital::projects: при появлении проекта в статусе pending|active
  сразу регистрирует доли всех активных пайщиков (неблокирующе, fire-and-forget,
  переиспользуя ядро syncContributor — тот же путь, что и крон).
- ProgramShareRegistrationService.syncProgramSharesForProject — обход пайщиков
  по одному проекту; syncContributor переведён с ProjectDomainEntity[] на
  projectHashes: string[].
- Крон ОСТАВЛЕН как reconciliation-бэкстоп: он ещё и ДОобновляет уже
  зарегистрированные доли при дрейфе баланса (контракт upsert_contributor_segment
  это допускает) и подбирает события, потерянные при downtime контроллера.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:30:10 +00:00
coopops 9c2729d243 ci(release): независимый деплой окружений — testnet/main как FF-указатели на теги dev
Typecheck / desktop (pull_request) Successful in 13m58s
Typecheck / controller (pull_request) Successful in 13m15s
Паровоз dev→testnet→main заменён независимыми указателями: каждое окружение —
самостоятельный fast-forward-указатель на выбранный релизный тег dev. Прод и тест
катятся разным темпом и на разные версии одновременно, без диверджа и конфликтов
(оба — FF-указатели на линейную историю dev).

- promote.sh <testnet|main> [ref]: FF выбранного тега (по умолчанию — последний на
  origin/dev) на ветку окружения; main больше не зависит от testnet; только вперёд.
- release.yaml: workflow_dispatch получил inputs environment+ref — откат на старую
  версию / редеплой без бампа / hotfix в один контур (то, что FF не умеет). Сборка
  идёт из вычекнутого ref, окружение — из inputs.environment. Авто-триггер по
  изменению lerna.json сохранён.
- RELEASE.md: модель и инварианты переписаны под независимые указатели.

Принципы деплоя сохранены: бамп версии один раз на dev, образы по версии,
BUILD_MODE по окружению, webhooks per env, npm publish/доки только на main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 06:49:12 +00:00
coopops 00e15be65e fix(desktop): убрал '*/' из SCSS-комментария — ломал sass-парсер
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Successful in 14m13s
'text-h*/inline' досрочно закрывал блочный комментарий → expected '{'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:31:10 +00:00
coopops a349cfcf6a fix(desktop): диалог выхода — придавил заголовок заявления к канону + soft-панель итога
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- заголовок документа из DocumentHtmlReader (3rem) прижат к h2 по канону,
  :deep + !important как в ReadStatement.vue
- 'Сумма к возврату' оформлена soft-панелью (label+hint слева, mono-значение
  справа) вместо висящего снизу текста

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:26:03 +00:00
coopops 102eeb319e fix(desktop): суммы выхода через formatAsset2Digits + одна сумма без разбивки
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
- Суммы к возврату форматируются канонной formatAsset2Digits ("300.0000 RUB"
  → "300,00 RUB") в диалоге заявления и в оверлее — вместо сырого asset.
- Убрана разбивка "целевой + минимальный" (оба паевые, суммируются): показываем
  одну строку "Сумма к возврату".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:59:14 +00:00
coopops 2f1e078fe2 fix(desktop): один лоадер в диалоге заявления о выходе
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Было две крутилки рядом («Формирование заявления…» + «Расчёт суммы…»).
Теперь заявление и сумма грузятся параллельно (Promise.allSettled) под одним
центрированным лоадером и показываются вместе. Тот же стиль лоадера — на фазе
проверки реквизитов. Сумма некритична: документ подписывается и без предрасчёта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:55:22 +00:00
coopops 554798b74b fix(desktop): пункт «Выход из кооператива» в конец меню + надёжный гейт реквизитов
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- Пункт меню «Выход из кооператива» перенесён в самый низ (после «Поддержки»),
  иконка group_remove вместо logout — чтобы не сливаться с кнопкой «Выйти»
  (выход из кабинета, дверь-logout) внизу панели.
- Гейт реквизитов сделан fail-open: блокируем выход ТОЛЬКО при достоверно
  пустом списке методов; null/непонятный ответ не блокирует. + реактивный
  фолбэк: если бэкенд отклонил подачу из-за отсутствия реквизитов — диалог
  переключается на баннер. Авторитетный источник — бэкенд (createMembershipExit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:44:34 +00:00
coopops b8568eff4c feat(desktop): статус платежа в оверлее выхода + страница заявления по канону
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Has been cancelled
- ExitOverlay: чип статуса исходящего платежа у суммы (ожидает оплаты/оплачивается/
  оплачено/ошибка) — берётся из payment_status, меняется по мере обработки кассиром.
- MembershipExitPage переверстана на AuthCard (иконка-предупреждение + заголовок +
  ключевые шаги процедуры + запуск подачи) — единый канон с оверлеем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:28:16 +00:00
coopops 448eb1de80 feat(membership-exit): статус исходящего платежа в getMembershipExit
getMembershipExit подтягивает платёж возврата по hash=exit_hash и отдаёт его
payment_status (PaymentStatus enum, nullable). Фронт показывает реальный статус
платежа кассира (ожидает оплаты → оплачено), а не только статус процесса выхода.
Сумма к возврату — зафиксированная советом on-chain (= сумма платежа), не preview.

+ codegen (controller/sdk zeus, селектор MembershipExit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:28:16 +00:00
coopops 8fae69d979 feat(desktop): экран выхода — собран в карточку AuthCard
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Has been cancelled
Контент больше не плавает по пустому экрану: статус выхода — в центрированной
карточке AuthCard (accent-стрип + мягкая тень, канонный hero-контейнер).
Иконка статуса в soft-плитке (primary/pos), заголовок, пояснение, сумма к
возврату на surface-2, действия в футере с разделителем. Всё на токенах --p-*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:12:55 +00:00
coopops 1b1b8b3246 feat(desktop): экран выхода по канону + кнопка выхода из личного кабинета
Typecheck / desktop (pull_request) Failing after 6m51s
Typecheck / controller (pull_request) Has been cancelled
- ExitOverlay переверстан на канонный EmptyState (иконка-плитка + заголовок +
  приглушённый текст + слот действий) вместо россыпи text-h5/64px-иконки.
- Добавлена кнопка «Выйти из личного кабинета» (logout) на оба экрана выхода:
  при активном выходе аккаунт заблокирован, и выйти из сессии было нечем.
  После logout gate обнуляется, редирект на signin; при повторном входе экран
  выхода снова показывается (статус живёт on-chain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:05:26 +00:00
coopops 72a8a3d0c3 feat(desktop): гейт реквизитов в выходе из кооператива
Typecheck / desktop (pull_request) Failing after 8m35s
Typecheck / controller (pull_request) Successful in 13m35s
Перед подписанием заявления о выходе проверяем наличие реквизитов пайщика
(getPaymentMethods). Если их нет — вместо формы показываем баннер «установите
реквизиты для получения возврата паевого взноса» + кнопку перехода на страницу
реквизитов (payment-methods). Бэкенд проверяет то же при подаче (страховка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:17:46 +00:00
coopops 56cd422440 feat(membership-exit): исходящий платёж возврата паевого + гейт реквизитов
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Закрываем пробел: при одобрении советом выхода (on-chain confirmexit) кассир
не видел платёж возврата — его никто не создавал в реестре gateway.

- Гейт реквизитов: createMembershipExit отклоняет подачу, если у пайщика нет
  ни одного платёжного метода («установите реквизиты для возврата паевого»).
- MembershipExitAuthorizationListener (@OnEvent action::registrator::confirmexit):
  по exit_hash берёт из таблицы exits username + сумму возврата, заводит
  исходящий платёж MEMBERSHIP_EXIT (PENDING — совет одобрил, сразу кассиру) по
  реквизитам пайщика (метод по умолчанию). hash платежа = exit_hash, поэтому
  подтверждение кассой через default-ветку processOutgoingPayment вызовет
  completeOutcome → registrator::completexit (списание + блокировка).
- getExitByHash в account-порт/адаптер: confirmexit отдаёт только coopname+exit_hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:13:38 +00:00
coopops 949b5139af chore(codegen): regen Zeus после merge — membership-exit + expense в одной схеме
После merge dev zeus-клиенты были взяты из dev (без операций выхода).
Интроспекция живого coopback → schema.gql → generate-client вернула обе
группы операций (createMembershipExit/confirm/cancel + expense-шасси).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:13:38 +00:00
coopops 50d5d71c8d Merge branch 'dev' into feat/membership-exit
Втягиваем инфраструктуру исходящих платежей (шасси расходов C28) для
создания возврата паевого взноса в gateway при выходе пайщика.

Конфликты (объединены обе стороны):
- gateway payment-type.enum / payment-domain.entity: MEMBERSHIP_EXIT + EXPENSE*
- zeus-клиенты (controller, sdk): взята версия dev, регенерация следом

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:30:43 +00:00
coopops 8d9bc5f592 chore(codegen): controller Zeus-клиент — типы MembershipExit
Typecheck / desktop (pull_request) Successful in 13m43s
Typecheck / controller (pull_request) Successful in 13m28s
Регенерация Zeus-клиента контроллера под GraphQL-операции выхода
(CreateMembershipExitInput, MembershipExitStatus, signed-document inputs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:27:22 +00:00
coopops 82ea0a4c03 fix(desktop): страница подтверждения выхода — без requiresAuth
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Маршрут membership-exit/confirm авторизуется токеном из ссылки (мутация
confirmMembershipExit публичная), поэтому навигационный гард не должен
редиректить его на login-redirect. requiresAuth:false исключает страницу из
auth-гейта — ссылку из письма можно открыть без активной сессии.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:00:16 +00:00
coopops b728bc6295 fix(membership-exit): письмо подтверждения — неблокирующая отправка + лог ссылки
Typecheck / desktop (pull_request) Successful in 13m37s
Typecheck / controller (pull_request) Has been cancelled
Черновик заявления сохраняется до отправки письма; если провайдер писем
недоступен, приём заявления не падает (try/catch + error-лог), пайщик может
отменить. Ссылка подтверждения пишется в debug-лог для диагностики.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:20:39 +00:00
coopops 59585dc4ea feat(desktop): выход из кооператива с email-подтверждением — UI
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- ExitButton: диалог теперь показывает сгенерированное заявление (DocumentHtmlReader)
  с предупреждением «читайте внимательно, необратимо» + сумма к возврату, затем
  «Подписать и подать» (generateApplication → показ → submitSignedApplication).
- ExitOverlay: новое состояние AWAITING_CONFIRMATION — «пройдите по ссылке из письма»
  + кнопка «Отменить выход»; после перехода/отмены overlay переключается на
  ончейн-статус (рассмотрение Совета → одобрено → выплаты).
- useExitGate: isAwaitingConfirmation + cancelExit (Mutations.MembershipExit.CancelMembershipExit).
- model: generateApplication / submitSignedApplication / confirmExit вместо единого processMembershipExit.
- MembershipExitConfirmPage + маршрут membership-exit/confirm (цель ссылки из письма):
  onMounted confirmExit(token) → loadExitStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:19:30 +00:00
coopops 6bba3c7b76 feat(sdk): confirmMembershipExit/cancelMembershipExit + MembershipExitResult.status
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Регенерация Zeus-клиента (интроспекция живого coopback) + ручные обёртки:
- Mutations.MembershipExit.ConfirmMembershipExit (token → MembershipExitResult)
- Mutations.MembershipExit.CancelMembershipExit (coopname/username → Boolean)
- membershipExitResultSelector += status (enum += AWAITING_CONFIRMATION)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:12:59 +00:00
coopops 9ee57d1e05 feat(membership-exit): email-подтверждение выхода — backend + письмо
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Двойное подтверждение необратимого выхода (закрытия аккаунта): заявление
подписывается и принимается сразу, но в блокчейн уходит только после перехода
по ссылке из письма (по аналогии с verify-email/reset-key токенами).

- notifications: воркфлоу MembershipExitConfirmation (письмо со ссылкой confirmationUrl).
- token: тип CONFIRM_EXIT + generateConfirmExitToken (домен + application).
- entity membership_exit_requests (off-chain черновик подписанного заявления; uniq coopname+username; удаляется при confirm/cancel).
- MembershipExitStatus += AWAITING_CONFIRMATION; MembershipExitResult += status.
- service: createMembershipExit теперь сохраняет черновик + шлёт письмо (НЕ цепь);
  confirmMembershipExit(token) проверяет токен и шлёт exitcoop в цепь;
  cancelMembershipExit удаляет черновик+токен; getMembershipExit отдаёт off-chain фазу.
- resolver: + confirmMembershipExit (публичная, по токену) + cancelMembershipExit (auth+владелец).

Проверено вживую: мутации в схеме, enum/Result обновлены, таблица создана (synchronize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:08:03 +00:00
coopops 0c995b1bca fix(membership-exit): заявление о выходе (200) — только цифровая подпись, без шапки утверждения
Typecheck / desktop (pull_request) Successful in 13m59s
Typecheck / controller (pull_request) Has been cancelled
Заявление о выходе из кооператива подписывается цифровой подписью (signatures[]),
поэтому из документа убрано поле собственноручной подписи (рукописная картинка):
- cooptypes 200: убраны Action.signature/Model.signature, <img src="{{ signature }}">,
  signature из exampleData;
- factory Actions/200: убрана логика поиска/сохранения подписи в mongo;
  Templates/200: убрано свойство signature из AJV-схемы;
- controller DTO: убрано поле signature (@IsString) из меты заявления.
  Это и вызывало "signature must be a string" — ValidationPipe (422, не логируется)
  рубил генерацию, т.к. фронт картинку не передаёт.

Также откат самодуманной шапки "ФОРМА УТВЕРЖДЕНА решением Собрания Совета (Протокол №)":
исходная форма заявления её не содержит. Убраны vars.participant_exit_application
(cooptypes IVars + factory VarsSchema + фикстура теста), проверка протокола в фабрике
и переводы APPROVED/approved_by_council/protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:47:30 +00:00
coopops 5aea77bdcd feat(desktop): отдельная страница выхода + глобальная блокировка кабинета на время процедуры
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Successful in 13m1s
По уточнению UX:
- кнопка выхода убрана из шапки кошелька; заведена отдельная страница стола
  пайщика «Выход из кооператива» (route membership-exit) с описанием процесса
  (добровольно по заявлению; аккаунт закрывается и удаляется; паевой возвращается
  в срок по Уставу кооператива — без хардкода срока) + кнопка с переподтверждением;
- глобальный gate: пока активен процесс выхода (registrator::exits через query
  membershipExit), ExitOverlay (maximized, без закрытия) блокирует весь кабинет
  и показывает только статус заявления (ожидание Совета / одобрено) и планируемую
  сумму возврата — элегантно, по канону. Эталон — SelectBranchOverlay;
- useExitGate (статус+сумма, опрос) + watch-exit-overlay (поллинг 15с) +
  монтаж в App.vue + регистрация в init-app; после подачи статус подтягивается
  сразу;
- текст переподтверждения переформулирован (необратимо, запуск возврата +
  решение Совета).

ESLint чисто. Type-check — в CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:45:34 +00:00
coopops 3ac1b168d7 feat(controller,sdk): запрос статуса выхода пайщика (membershipExit) для блокировки кабинета
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- query membershipExit(coopname, username) → {exit_hash, status, quantity, created_at}
  или null; читает chain-таблицу registrator::exits; enum MembershipExitStatus
  (PENDING/AUTHORIZED); self-/operator-доступ;
- AccountBlockchainPort/adapter.getExit — чтение exits по username;
- SDK: Queries.MembershipExit.GetMembershipExit + селектор.

Нужен фронту для глобального gate: пока есть активный выход — кабинет блокируется,
показывается только статус заявления и сумма возврата. tsc контроллера + SDK чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:36:02 +00:00
coopops 2e42cac14c feat(desktop): UI выхода пайщика из кооператива
Typecheck / desktop (pull_request) Successful in 13m52s
Typecheck / controller (pull_request) Successful in 13m19s
- feature Membership/ExitFromCoop: кнопка «Выход из кооператива» (BaseButton
  danger) на WalletPage в шапке рядом с возвратом паевого; диалог-предупреждение
  (BaseDialog+Form+BaseBanner) с предрасчётом суммы возврата
  (MembershipExitReturnPreview) и текстом «паевой будет возвращён, аккаунт
  заблокирован, возврат невозможен»; submit генерит заявление (200), подписывает
  и подаёт createMembershipExit (push exitcoop). Канон-компоненты, Material-иконки;
- feature Membership/GenerateMembershipExitDecision: генерация решения совета (201);
- process-decisions: handler `leavecoop` в реестре повесток — Совет генерирует
  решение по выходу так же, как по вступлению/возврату.

ESLint чисто. Type-check — в CI (полный vue-tsc локально не гоняем).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:48:16 +00:00
coopops c1a940d1cd feat(sdk): codegen + операции выхода из кооператива (Zeus-клиент + Mutations/Queries)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- регенерирован Zeus-клиент (src/zeus) из обновлённой схемы контроллера —
  типы MembershipExit*Input / MembershipExitResult / MembershipExitReturnPreview;
- Mutations.MembershipExit: GenerateMembershipExitApplication /
  GenerateMembershipExitDecision / CreateMembershipExit;
- Queries.MembershipExit.MembershipExitReturnPreview;
- селекторы membershipExitResult / membershipExitReturnPreview.

tsc + сборка SDK — чисто. schema.gql не коммитим (gitignored-артефакт).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:37:59 +00:00
coopops 90f3a892a6 feat(controller): backend выхода пайщика из кооператива (генерация 200/201 + подача + предрасчёт)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- PaymentTypeEnum.MEMBERSHIP_EXIT (исходящий) + лейблы;
- document-DTO заявления (200) и решения совета (201) — зеркало join-документов;
- ParticipantInteractor.generateMembershipExit{Application,Decision} (registry 200/201);
- AccountBlockchainPort/adapter.exitCoop — push registrator::exitcoop с подписанным
  заявлением (прямой transact кооперативным ключом, как reguser);
- модуль membership-exit: resolver+service с мутациями
  generateMembershipExitApplication / generateMembershipExitDecision /
  createMembershipExit и query membershipExitReturnPreview (сумма возврата по
  L3-балансам w.wal.share + w.reg.minshr; итог фиксирует контракт на confirmexit);
  self-service для пайщика + операторские роли (chairman/member);
- cooptypes Ledger2.SHARE_WALLET_NAME / MIN_SHARE_WALLET_NAME — именованные
  константы кошельков вместо магических строк.

tsc --noEmit контроллера — чисто. Codegen Zeus-клиента — отдельным коммитом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:30:01 +00:00
coopops 941030f58f feat(cooptypes): TS-зеркала выхода из кооператива (registrator + soviet)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
- interfaces/registrator: IExitcoop/IConfirmexit/ICompletexit/IDeclinexit + IExit
  (таблица exits) — синхронно с обновлённым ABI registrator;
- interfaces/soviet: IDelpartcpnt;
- action-зеркало registrator ExitCoop (член-инициируемое действие, как RegisterUser);
  внутренние коллбэки confirmexit/completexit/declinexit конструируются каскадом
  из soviet/gateway и в зеркалах не нуждаются (как confirmreg/addpartcpnt);
- table-зеркало registrator Exits (scope=coopname) для чтения статуса выхода и
  суммы возврата на бэкенде/фронте.

tsc --noEmit cooptypes — чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:08:07 +00:00
coopops dbda7ca5ba feat(contracts): оркестрация выхода пайщика из кооператива (registrator + soviet)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Жизненный цикл выхода — зеркало вступления (reguser→confirmreg) и возврата
паевого (wallet::createwthd→authwthd→completewthd):

- registrator::exitcoop    — пайщик подаёт заявление о выходе (registry 200),
  создаётся реестр exits (status=pending) и повестка совета `leavecoop`;
- registrator::confirmexit  — совет одобрил: контракт сам считает сумму возврата
  по L3-балансам ledger2 (мин. + целевой паевой), консолидирует минимальный на
  главный (o.reg.mvmin), резервирует сумму (o.wal.wthreq) и шлёт исходящий
  платёж в gateway; нулевой паевой → финализация без платежа;
- registrator::completexit  — кассир подтвердил выплату: проводка Дт80/Кт51
  (o.wal.wthcpl), пайщик удаляется (soviet::delpartcpnt), аккаунт блокируется;
- registrator::declinexit   — отказ совета или платежа: снятие резерва
  (o.wal.wthdec, если был), пайщик остаётся в кооперативе.

Новое: таблица registrator::exits, soviet_action `leavecoop`, soviet::delpartcpnt
(стирание пайщика, зеркало addpartcpnt; уменьшает счётчик активных). Новых
ledger2-кодов не вводилось — переиспользованы o.wal.wthreq/wthcpl/wthdec и ранее
добавленный o.reg.mvmin. registrator уже в contracts_whitelist → вправе применять
wallet-операции. Обе сборки (soviet/registrator) проходят в docker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:02:56 +00:00
coopops 93e13d6a14 feat(contracts): ledger2-операция o.reg.mvmin (мин. паевой → главный при выходе)
Typecheck / desktop (pull_request) Successful in 13m38s
Typecheck / controller (pull_request) Has been cancelled
Операция-фундамент для консолидации при выходе из кооператива: переносит
минимальный паевой (w.reg.minshr) на главный паевой кошелёк (w.wal.share)
через TRANSFER без проводки (оба кошелька на счёте 80), чтобы вернуть его
вместе с основным паевым через wallet-withdraw (o.wal.wthcpl, Дт80/Кт51).

- operations.hpp: объявление + запись OPERATION_REGISTRY (static_assert OK,
  registrator скомпилирован в docker dicoop/blockchain).
- operations.ts: ручное TS-зеркало (синхронизация обеих сторон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:28:50 +00:00
coopops afeb728bec fix(factory): заявление о выходе (200) — паспорт по условию + ЭП-подпись
Typecheck / desktop (pull_request) Successful in 13m33s
Typecheck / controller (pull_request) Successful in 13m15s
- Паспортные данные физлица выводятся при vars.passport_request=='yes',
  как в заявлении на вступление (100).
- Внизу документа «Документ подписан электронной подписью.» вместо
  «личная подпись заявителя» (по правке пользователя).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:59:51 +00:00
coopops ee60ece571 feat(factory): документы выхода из кооператива (registry 200/201)
Typecheck / desktop (pull_request) Successful in 13m34s
Typecheck / controller (pull_request) Successful in 13m13s
Заявление о выходе из состава пайщиков (200) с условным рендером по трём
ролям (физлицо/ИП/юрлицо) — зеркало 100.ParticipantApplication, текст по
утверждённой форме. Решение совета о выходе (201) — зеркало 501.

- cooptypes: registry 200/201 (Model + context + переводы + exampleData),
  поле vars.participant_exit_application для шапки «ФОРМА УТВЕРЖДЕНА».
- factory: Templates/Actions 200/201, регистрация в индексах и factories-map.
- test: генерация 200 в трёх ролях + 201; сидинг participant_exit_application.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:24:19 +00:00
282 changed files with 10185 additions and 1347 deletions
+39 -3
View File
@@ -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 }}
+8
View File
@@ -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 -1
View File
@@ -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,
+8 -1
View File
@@ -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 -1
View File
@@ -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-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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 -1
View File
@@ -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
+3 -2
View File
@@ -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 -1
View File
@@ -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;
},
};
+2 -2
View File
@@ -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 -1
View File
@@ -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… (530 с без вывода — норма, не прерывайте)');
const result = spawnSync(zeusBin, zeusArgs, {
stdio: 'inherit',
cwd: controllerRoot,
+2
View File
@@ -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,
@@ -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;
}
@@ -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}`;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 {}
@@ -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);
}
}
@@ -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})`
);
}
}
@@ -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}`;
}
}
@@ -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');
@@ -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;
}
@@ -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;
}
@@ -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,
];
@@ -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>;
}
@@ -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,
};
}
@@ -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;
@@ -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
);
}
}
}
@@ -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({
@@ -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);
}
}
@@ -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'
);
@@ -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);
}
}
@@ -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
);
}
@@ -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';
@@ -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 {
@@ -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 завершён последним шагом, эмиттим событие'
);
@@ -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()
@@ -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;
}
@@ -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),
@@ -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;
}
@@ -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}$/;
@@ -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;
@@ -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}`;
}
@@ -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,
@@ -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;
@@ -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();
@@ -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,
+23 -5
View File
@@ -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