Compare commits

...

62 Commits

Author SHA1 Message Date
Alex Ant 74dce675b9 fix(install): блокировать переход с формы совета без заполненных данных
Кнопка «Продолжить» проверяла только число слотов совета, а не email и
обязательные поля ФЛ. Добавлена валидация на фронте и в install.interactor,
чтобы частично заполненный состав не уходил на цепь.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:04:43 +03: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 07ec6d9a5b chore(release): publish
Build bootstrap container / build (push) Failing after 1m27s
Release / release (push) Failing after 16m43s
Release / trigger-mono-docs (push) Has been skipped
Release / publish-packages (push) Has been skipped
Release / trigger-coopenomics-docs (push) Has been skipped
2026-06-16 06:02:31 +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
ant 0ae8702ade Merge pull request 'fix(registration): pending-счёт не показывать как «платёж принят»' (#149) from fix/registration-pending-display into dev
Build bootstrap container / build (push) Has been cancelled
Reviewed-on: #149
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-16 05:36:38 +00:00
coopops 2d71706840 fix(registration): pending-счёт не считать «оплачено» — вести на оплату, а не на ожидание
Typecheck / desktop (pull_request) Successful in 13m50s
Typecheck / controller (pull_request) Has been cancelled
Счёт регистрационного взноса создаётся со статусом pending сразу при заходе на
шаг оплаты («счёт выставлен», деньги ещё не получены). При возврате на страницу
(перезагрузка/повторный вход) роутинг считал сам факт наличия платежа за «этап
оплаты пройден» и вёл на экран ожидания, где дефолтная ветка показывала «Ваш
платеж принят» — хотя оплаты не было и статус не менялся.

- SignUp: на экран ожидания/отказа ведём только при PAID/COMPLETED либо
  терминальном статусе (отказ/отмена/возврат); pending → шаг оплаты (QR + поллинг).
- WaitingRegistration: для pending — отдельная ветка «Ожидаем поступление оплаты»
  + кнопка «Перейти к оплате»; «платёж принят» остаётся только для PAID/COMPLETED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 05:29:50 +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
ant 7ab0c31878 Merge pull request '[C28-28..C28-34] feat: шасси системы расходов (MVP-SINGLE) + интеграция Капитала через inter-порт' (#61) from feat/expense-chassis-mvp into dev
Build bootstrap container / build (push) Successful in 3m51s
Reviewed-on: #61
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-15 13:11:52 +00:00
ant cabc589ce8 Merge pull request 'fix(desktop): UTF-8 (bit-11) в именах файлов zip-пакета документов' (#147) from fix/zip-utf8-flag into dev
Reviewed-on: #147
2026-06-15 11:38:01 +00:00
coopops c3086277f4 fix(desktop): zip-пакет документов — флаг UTF-8 (bit-11) в именах файлов
Typecheck / desktop (pull_request) Successful in 13m56s
Typecheck / controller (pull_request) Successful in 13m22s
buildZipArchive писал имена в UTF-8, но general-purpose bit flag = 0 → читатели
(включая верификатор на fflate) декодировали кириллицу как latin1 (мохибейк), и
manifest.json (корректный UTF-8) не находил записи → верификатор «в пакете нет
файлов». Ставим bit-11 в local и central заголовках. Архив теперь корректно
открывается любым ZIP-ридером (Explorer/macOS/fflate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:58:55 +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
213 changed files with 8353 additions and 587 deletions
+27 -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 в
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.6.9-18",
"version": "2026.6.24",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.6.9-18",
"version": "2026.6.24",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.6.9-18",
"version": "2026.6.24",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -1,5 +1,7 @@
#pragma once
#include <eosio/binary_extension.hpp>
using namespace eosio;
using std::string;
@@ -39,11 +41,19 @@ namespace Capital {
asset program_membership_distributed = asset(0, _root_govern_symbol); ///< Распределенная сумма членских взносов по программе
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
asset program_expense_pool = asset(0, _root_govern_symbol); ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
asset program_expense_reserved = asset(0, _root_govern_symbol); ///< Сумма, зарезервированная под approved/authorized программные расходы
config config; ///< Управляемая конфигурация контракта
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
// struct и только через binary_extension — иначе ломается десериализация
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
// перед config напрямую (без extension) и задеплоены на прод — таблица state
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
// пустые), чтобы порядок хвостовых extension оставался консистентным.
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -53,6 +63,16 @@ namespace Capital {
namespace State {
/**
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
* ещё не материализовано (старая запись, созданная до добавления
* program_expense_* через binary_extension).
*/
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
return v.has_value() ? v.value() : asset(0, sym);
}
/**
* @brief Обновляет глобальное состояние новыми значениями.
*
@@ -103,8 +123,12 @@ inline void topup_program_expense_pool(eosio::name coopname, const asset &amount
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
s.global_available_invest_pool -= amount;
s.program_expense_pool += amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -117,12 +141,16 @@ inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
eosio::check(itr->program_expense_pool >= amount,
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
s.program_expense_reserved += amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
reserved += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -135,12 +163,16 @@ inline void release_program_expense(eosio::name coopname, const asset &amount) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
eosio::check(itr->program_expense_reserved >= amount,
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
s.program_expense_pool += amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -154,11 +186,15 @@ inline void consume_program_expense(eosio::name coopname, const asset &amount) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(itr->program_expense_reserved >= amount,
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
@@ -172,11 +208,15 @@ inline void spend_program_expense_pool(eosio::name coopname, const asset &amount
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(itr->program_expense_pool >= amount,
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
}// namespace State
+2 -1
View File
@@ -85,7 +85,8 @@ static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелё
static const std::set<eosio::name> soviet_actions = {
"joincoop"_n, //регистрация пайщика
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
//MEET
"creategm"_n,//предложение повестки планового общего собрание
"completegm"_n, //решение общего собрания пайщиков
@@ -61,6 +61,7 @@ namespace operations {
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
}
// wallet
@@ -242,6 +243,15 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
"Возврат регистрационного взноса при отказе совета" },
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
0, 0,
"Перенос минимального паевого на главный при выходе из кооператива" },
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
@@ -252,6 +252,37 @@ inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
}};
/**
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
*
* Единый источник истины для суммы возврата. При одобрении выхода советом
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
* тем, что реально вернёт контракт.
*
* Состав — только паевые/возвратные USER_SHARED-кошельки:
* w.reg.minshr — минимальный паевой взнос;
* w.wal.share — целевой паевой взнос (ЦК);
* w.cap.blago — паевой взнос в ЦПП «Благорост».
*
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
* w.cap.preimp (пред-импорт-учёт РИД).
*
* Каждому не-главному кошельку сета должна соответствовать операция переноса
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
*/
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
ledger2_wallets::MIN_SHARE_FUND,
ledger2_wallets::SHARE_FUND_PAY,
ledger2_wallets::BLAGOROST_FUND,
}};
/**
* @brief Возвращает required program_id для USER_SHARED-кошелька.
*
@@ -17,6 +17,7 @@
#include "table_registrator_verification.hpp"
#include "table_registrator_candidates.hpp"
#include "table_registrator_candidates_legacy.hpp"
#include "table_registrator_exits.hpp"
// coops (soviet)
#include "coops_access_helpers.hpp"
@@ -0,0 +1,71 @@
#pragma once
#include <optional>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include <eosio/eosio.hpp>
#include "../consts.hpp"
#include "document_core.hpp"
namespace Registrator {
using namespace eosio;
/**
* @ingroup public_tables
* @ingroup public_registrator_tables
* @par table: exits (registrator)
*
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
* процесс возврата паевого взноса.
*
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
* wallet::createwthd→authwthd→completewthd):
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
* - confirmexit → совет одобрил: статус `authorized`, консолидация
* минимального паевого на главный (o.reg.mvmin), резерв
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
* запись стирается;
* - declinexit → отказ совета или отклонение платежа: снятие резерва
* (o.wal.wthdec, если он был сделан) и стирание записи.
*
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
* ledger2), а не доверяется клиенту.
*/
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
name username;
name coopname;
name status; ///< pending | authorized
time_point_sec created_at;
document2 statement; ///< заявление о выходе (registry 200)
document2 approved_statement; ///< решение совета о выходе (авторизация)
checksum256 exit_hash;
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
uint64_t primary_key() const { return username.value; }
checksum256 by_hash() const { return exit_hash; }
};
typedef multi_index<
"exits"_n, exit,
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
exits_index;
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
exits_index primary_index(_registrator, coopname.value);
auto secondary_index = primary_index.get_index<"byhash"_n>();
auto itr = secondary_index.find(hash);
if (itr == secondary_index.end()) {
return std::nullopt;
}
return *itr;
}
} // namespace Registrator
@@ -12,6 +12,12 @@
#include "src/user/declinerfnd.cpp"
#include "src/user/reguser.cpp"
#include "src/exit/exit_helpers.hpp"
#include "src/exit/exitcoop.cpp"
#include "src/exit/confirmexit.cpp"
#include "src/exit/completexit.cpp"
#include "src/exit/declinexit.cpp"
#include "src/account/createbranch.cpp"
#include "src/account/newaccount.cpp"
#include "src/account/updateaccnt.cpp"
@@ -85,6 +85,12 @@ public:
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
@@ -0,0 +1,35 @@
/**
* @brief Завершение возврата паевого взноса при выходе из кооператива.
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
* Возврат назад невозможен — повторное участие только через новую регистрацию.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p gateway
*/
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
require_auth(_gateway);
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
eosio::name username = e->username;
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
e->quantity, username, exit_hash, memo);
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
Registrator::finalize_member_exit(coopname, username);
exits.erase(e);
}
@@ -0,0 +1,76 @@
/**
* @brief Одобрение советом выхода пайщика из кооператива.
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
* без платежа.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @param authorization Документ-решение совета о выходе
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
require_auth(_soviet);
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
eosio::name username = e->username;
// оповещаем пайщика
require_recipient(username);
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
if (balance.amount <= 0) continue;
total_return += balance;
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
}
exits.modify(e, _soviet, [&](auto &row) {
row.status = "authorized"_n;
row.approved_statement = authorization;
row.quantity = total_return;
});
if (total_return.amount > 0) {
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
total_return, username, exit_hash, memo_req);
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
Action::send<createoutpay_interface>(
_gateway,
"createoutpay"_n,
_registrator,
coopname,
username,
exit_hash,
total_return,
_registrator,
"completexit"_n,
"declinexit"_n
);
} else {
// Возвращать нечего — финализируем выход без платежа.
Registrator::finalize_member_exit(coopname, username);
exits.erase(e);
}
}
@@ -0,0 +1,41 @@
/**
* @brief Отказ в выходе из кооператива.
* Вызывается либо советом при отклонении заявления (до резервирования средств),
* либо gateway при отклонении исходящего платежа (после резервирования). Если
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
*/
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
check_auth_and_get_payer_or_fail({_soviet, _gateway});
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::name username = e->username;
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
if (e->status == "authorized"_n && e->quantity.amount > 0) {
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
e->quantity, username, exit_hash, memo);
}
// оповещаем пайщика
require_recipient(username);
exits.erase(e);
}
@@ -0,0 +1,95 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/eosio.hpp>
#include "../../../lib/index.hpp"
/**
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
*
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
* весь lib/index.hpp уже доступен.
*/
namespace Registrator {
using namespace eosio;
/**
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
*
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
* валюте управления.
*/
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
userwallets_index userwallets(_ledger2, coopname.value);
auto idx = userwallets.get_index<"byuserwallet"_n>();
auto it = idx.find(combine_ids(wallet_name.value, username.value));
if (it == idx.end()) {
return asset(0, _root_govern_symbol);
}
return it->available;
}
/**
* @brief Консолидация доступного паевого кошелька пайщика на главный
* (`w.wal.share`) перед резервом возврата при выходе.
*
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
* главный:
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
*
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
* валит транзакцию с явным сообщением — защита от тихой потери средств.
*/
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
eosio::name op;
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
} else {
eosio::check(false,
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
}
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
wallet_name.to_string() + ", username=" + username.to_string();
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
}
/**
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
* аккаунта в registrator.
*
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
* для пайщика начинает падать — он лишён права подавать заявления.
*/
inline void finalize_member_exit(name coopname, name username) {
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
action(
permission_level{_registrator, "active"_n},
_soviet,
"delpartcpnt"_n,
std::make_tuple(coopname, username)
).send();
// блокируем аккаунт в картотеке registrator
accounts_index accounts(_registrator, _registrator.value);
auto account = accounts.find(username.value);
eosio::check(account != accounts.end(), "Аккаунт не найден");
accounts.modify(account, _registrator, [&](auto &a) {
a.status = "blocked"_n;
});
}
} // namespace Registrator
@@ -0,0 +1,57 @@
/**
* @brief Подача заявления на выход из кооператива.
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
* @param coopname Наименование кооператива
* @param username Имя пайщика, выходящего из кооператива
* @param exit_hash Хэш процесса выхода
* @param statement Подписанное заявление о выходе (registry 200)
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
require_auth(coopname);
get_cooperative_or_fail(coopname);
// выйти может только действующий пайщик (не заблокированный)
get_participant_or_fail(coopname, username);
// повторная подача запрещена — у пайщика может быть только один процесс выхода
Registrator::exits_index exits(_registrator, coopname.value);
auto existing = exits.find(username.value);
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
// проверяем подпись заявления о выходе
verify_document_or_fail(statement);
exits.emplace(coopname, [&](auto &e) {
e.username = username;
e.coopname = coopname;
e.status = "pending"_n;
e.created_at = current_time_point();
e.statement = statement;
e.exit_hash = exit_hash;
e.quantity = asset(0, _root_govern_symbol);
});
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
::Soviet::create_agenda(
_registrator,
coopname,
username,
get_valid_soviet_action("leavecoop"_n),
exit_hash,
_registrator, // callback_contract
"confirmexit"_n, // callback при одобрении
"declinexit"_n, // callback при отказе
statement,
std::string("")
);
}
@@ -49,6 +49,7 @@
#include "src/fund/fundwithdraw.cpp"
#include "src/participant/addparticipant.cpp"
#include "src/participant/delparticipant.cpp"
#include "src/participant/setminamt.cpp"
#include "src/participant/block.cpp"
#include "src/participant/cancelreg.cpp"
@@ -139,6 +139,9 @@ public:
//regaccount.cpp
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
//delparticipant.cpp
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
//setminamt.cpp
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
@@ -0,0 +1,38 @@
/**
* @brief Удаление пайщика из кооператива при выходе
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
* пайщик был активным и имел право голоса уменьшает счётчик активных
* пайщиков в registrator (как это делает `block`).
* @param coopname Наименование кооператива
* @param username Наименование выбывающего пайщика
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта в белом списке контрактов
*/
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
get_cooperative_or_fail(coopname);
participants_index participants(_soviet, coopname.value);
auto participant = participants.find(username.value);
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
bool had_vote = participant->has_vote;
bool was_active = participant->status == "accepted"_n;
if (was_active && had_vote) {
action(
permission_level{_soviet, "active"_n},
_registrator,
"decparticpnt"_n,
std::make_tuple(coopname, username)
).send();
}
participants.erase(participant);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.6.9-18",
"version": "2026.6.24",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,129 @@
import { createHash } from 'node:crypto';
import type { DataSource } from 'typeorm';
import mongoose from 'mongoose';
import config from '~/config/config';
type MigrationLogger = {
info: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
const CAPITAL_DOC_REGISTRY_ID = 994;
const TARGET_COOPNAME = 'voskhod';
const COLLECTION_NAME = 'doc_private_data';
const capitalProgramPrivateData = {
approval_protocol_number: '1',
approval_protocol_day: '15',
approval_protocol_month: 'января',
approval_protocol_year: '26',
cooperative_name: 'ВОСХОД',
cooperative_short_name: 'ПК',
cooperative_quoted_name: '«ВОСХОД»',
website: 'https://цифровой-кооператив.рф',
chairman_full_name: 'Муравьев Алексей Николаевич',
generator_program_purpose:
'развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) под названием “Кооперативная Экономика”',
eoap_definition:
'информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”',
generator_task_goal:
'центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений',
idea_unit_cost: '50',
idea_unit_cost_words: 'пятьдесят',
blagorost_goal_expansion:
'вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате',
blagorost_task_expansion:
'расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате',
blagorost_task_development:
'развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений',
return_source_description:
'аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕОАП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий',
return_additional_source:
'взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП',
offer_template_number: '______',
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.map((key) => {
const v = (value as Record<string, unknown>)[key];
return `${JSON.stringify(key)}:${stableStringify(v)}`;
});
return `{${entries.join(',')}}`;
}
function calculateSha256(value: unknown): string {
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
}
export default {
name: 'Backfill capital_program_doc_data_hash for voskhod',
validUntil: new Date('2027-05-01T00:00:00Z'),
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
return true;
}
const hash = calculateSha256(capitalProgramPrivateData);
let mongo: mongoose.Connection | null = null;
try {
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
const db = mongo.db;
const collection = db.collection(COLLECTION_NAME);
await collection.createIndex({ hash: 1 }, { unique: true });
await collection.updateOne(
{ hash },
{
$setOnInsert: {
hash,
registry_id: CAPITAL_DOC_REGISTRY_ID,
payload: capitalProgramPrivateData,
_created_at: new Date(),
},
},
{ upsert: true }
);
const result = await dataSource.query(
`UPDATE extensions
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
updated_at = NOW()
WHERE name = 'capital'`,
[hash]
);
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
return false;
} finally {
await mongo?.close();
}
},
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
return true;
}
await dataSource.query(
`UPDATE extensions
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
updated_at = NOW()
WHERE name = 'capital'`
);
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
return true;
},
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.6.9-18",
"version": "2026.6.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.6.9-18",
"version": "2026.6.24",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.6.9-18",
"version": "2026.6.24",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
+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;
}
@@ -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,42 @@ import { Workflows } from '@coopenomics/notifications';
import { TokenApplicationService } from '~/application/token/services/token-application.service';
import { normalizeUserEmail } from '~/utils/normalize-user-email';
/** Минимум членов совета — как MIN_SOVIET_MEMBERS_COUNT в контракте soviet (3 prod / 1 dev). */
const MIN_SOVIET_MEMBERS_COUNT = config.min_soviet_members_count;
function councilRoleLabel(index: number, role?: string): string {
if (role === 'chairman' || index === 0) return 'председателя совета';
return `члена совета №${index + 1}`;
}
function assertSovietMemberComplete(
member: InstallInputDomainInterface['soviet'][number],
index: number,
): void {
const data = member?.individual_data;
const label = councilRoleLabel(index, member?.role);
const email = typeof data?.email === 'string' ? data.email.trim() : '';
if (!email) {
throw new BadRequestException(`Не указан email для ${label}`);
}
const requiredStringFields: Array<[keyof Cooperative.Users.IIndividualData, string]> = [
['last_name', 'фамилия'],
['first_name', 'имя'],
['full_address', 'адрес регистрации'],
['birthdate', 'дата рождения'],
['phone', 'телефон'],
];
for (const [field, humanName] of requiredStringFields) {
const value = data?.[field];
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`Не заполнено поле «${humanName}» для ${label}`);
}
}
}
@Injectable()
export class InstallInteractor {
constructor(
@@ -118,14 +154,32 @@ export class InstallInteractor {
async install(data: InstallInputDomainInterface): Promise<void> {
const status = await this.monoStatusRepository.getStatus();
const savedVars = await this.varsRepository.get();
const isIncompleteInstallMaintenance =
status === SystemStatus.maintenance && !savedVars?.name;
// Разрешаем установку ТОЛЬКО если система в статусе 'initialized' (после initSystem)
if (status !== SystemStatus.initialized) {
// Разрешаем установку если:
// - initialized (нормальный прогон)
// - maintenance без сохранённых vars (прерванная установка, можно повторить)
if (status !== SystemStatus.initialized && !isIncompleteInstallMaintenance) {
throw new BadRequestException(
`Установка невозможна. Текущий статус: ${status}. Требуется статус: ${SystemStatus.initialized}`
);
}
if (data.soviet.length < MIN_SOVIET_MEMBERS_COUNT) {
throw new BadRequestException(
`Количество членов совета должно быть не менее ${MIN_SOVIET_MEMBERS_COUNT}`
);
}
data.soviet.forEach((member, index) => assertSovietMemberComplete(member, index));
// Повтор после прерванной установки: снимаем maintenance, дальше — обычный прогон.
if (isIncompleteInstallMaintenance) {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
}
const info = await this.blockchainPort.getInfo();
const coop = await this.blockchainPort.getCooperative(config.coopname);
@@ -61,6 +61,15 @@ export class TokenApplicationService {
return this.tokenDomainService.generateVerifyEmailToken(userId);
}
/**
* Генерирует токен подтверждения выхода из кооператива
* @param userId - ID пользователя
* @returns Токен подтверждения выхода
*/
async generateConfirmExitToken(userId: string): Promise<string> {
return this.tokenDomainService.generateConfirmExitToken(userId);
}
/**
* Верифицирует токен
* @param input - данные для верификации
@@ -189,6 +189,10 @@ const envVarsSchema = z.object({
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
MIN_SOVIET_MEMBERS_COUNT: z
.string()
.optional()
.describe('Минимум членов совета при install; пусто — 3 на production, 1 на development/test'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -260,6 +264,11 @@ export default {
},
},
coopname: envVars.data.COOPNAME,
min_soviet_members_count: envVars.data.MIN_SOVIET_MEMBERS_COUNT
? parseInt(envVars.data.MIN_SOVIET_MEMBERS_COUNT, 10)
: envVars.data.NODE_ENV === 'production'
? 3
: 1,
graphql_service: envVars.data.GRAPHQL_SERVICE,
provider_base_url: envVars.data.PROVIDER_BASE_URL,
union: {
@@ -1,6 +1,17 @@
import type { RegistratorContract, SovietContract } from 'cooptypes';
import type { BlockchainAccountInterface } from '~/types/shared';
import type { CandidateDomainInterface } from '../interfaces/candidate-domain.interface';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Входные данные подачи заявления на выход пайщика из кооператива (registrator::exitcoop).
*/
export interface ExitCoopDomainInterface {
coopname: string;
username: string;
exit_hash: string;
statement: ISignedDocumentDomainInterface;
}
export interface AccountBlockchainPort {
getBlockchainAccount(username: string): Promise<BlockchainAccountInterface | null>;
@@ -12,6 +23,13 @@ export interface AccountBlockchainPort {
getUserAccount(username: string): Promise<RegistratorContract.Tables.Accounts.IAccount | null>;
addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void>;
registerBlockchainAccount(candidate: CandidateDomainInterface): Promise<void>;
// Подача заявления на выход пайщика из кооператива (registrator::exitcoop)
exitCoop(data: ExitCoopDomainInterface): Promise<void>;
// Текущий процесс выхода пайщика (registrator::exits), либо null
getExit(coopname: string, username: string): Promise<RegistratorContract.Tables.Exits.IExit | null>;
// Процесс выхода по exit_hash (registrator::exits): on-chain confirmexit отдаёт
// только coopname+exit_hash, username и сумму возврата берём из таблицы по хэшу.
getExitByHash(coopname: string, exit_hash: string): Promise<RegistratorContract.Tables.Exits.IExit | null>;
}
export const ACCOUNT_BLOCKCHAIN_PORT = Symbol('AccountBlockchainPort');
@@ -0,0 +1,11 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Входные данные подачи заявления на выход пайщика из кооператива.
*/
export interface CreateMembershipExitInputDomainInterface {
coopname: string;
username: string;
exit_hash: string;
statement: ISignedDocumentDomainInterface;
}
@@ -22,6 +22,26 @@ export class DocumentDomainService {
) {}
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
const documentData = data.data as Cooperative.Document.IGenerate & {
doc_data?: Record<string, unknown>;
doc_data_hash?: string;
};
if (documentData.doc_data) {
const { doc_data, ...publicDocumentData } = documentData;
const { hash } = documentData.doc_data_hash
? { hash: documentData.doc_data_hash }
: await this.saveDocData(doc_data, documentData.registry_id);
return await this.generatorInfrastructureService.generateDocument({
...data,
data: {
...publicDocumentData,
doc_data_hash: hash,
} as Cooperative.Document.IGenerate,
});
}
return await this.generatorInfrastructureService.generateDocument(data);
}
@@ -0,0 +1,23 @@
/**
* Spec MinIO-бакета `gateway:files` ядровое хранилище файлов платежей.
*
* Содержимое: чеки об оплате (PAYMENT_PROOF) по исходящим платежам единая
* «культура денег» (входящие подтверждаем, исходящие сопровождаем чеком).
* Привязка по `payment_hash`; переиспользуется всеми расширениями.
*
* Ключ объекта: `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
*/
export const GATEWAY_BUCKET = {
name: 'gateway:files',
maxBytes: 20 * 1024 * 1024,
allowedMime: [
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'application/pdf',
] as const,
defaultUrlTtlSeconds: 600,
} as const;
export type GatewayBucketAllowedMime = (typeof GATEWAY_BUCKET.allowedMime)[number];
@@ -113,6 +113,7 @@ export class PaymentDomainEntity implements PaymentDomainInterface {
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат взноса',
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
@@ -0,0 +1,20 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Назначение файла, приложенного к платежу (бакет `gateway:files`).
*
* Ядровая «культура денег»: каждый ИСХОДЯЩИЙ платёж сопровождается чеком об
* оплате (подтверждающий документ для кассира «чем оплатил», для пайщика «на
* что»). Сейчас единственный вид PAYMENT_PROOF; enum оставлен расширяемым.
*/
export enum PaymentFileKind {
PAYMENT_PROOF = 'PAYMENT_PROOF',
}
registerEnumType(PaymentFileKind, {
name: 'PaymentFileKind',
description: 'Тип файла, приложенного к платежу.',
valuesMap: {
PAYMENT_PROOF: { description: 'Чек об оплате — подтверждающий документ по исполненному платежу.' },
},
});
@@ -9,6 +9,10 @@ export enum PaymentTypeEnum {
// Исходящий возврат вступительного и мин. паевого взносов при отказе совета
// в приёме. Отдельный тип, не WITHDRAWAL — чтобы не путать с возвратом паевого.
REGISTRATION_REFUND = 'registration_refund',
// Исходящий возврат всего паевого взноса при выходе пайщика из кооператива.
// Отдельный тип, не WITHDRAWAL — это полный выход с блокировкой аккаунта,
// а не частичный возврат паевого действующему пайщику.
MEMBERSHIP_EXIT = 'membership_exit',
// Исходящая оплата позиции служебной записки-сметы (шасси expense). Создаётся
// автоматически после авторизации СЗ советом; подтверждение кассиром проводит
// on-chain оплату expense::payexp по позиции.
@@ -41,6 +45,7 @@ export const PAYMENT_TYPE_LABELS: Record<PaymentTypeEnum, string> = {
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат паевого взноса',
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
@@ -84,6 +89,7 @@ export const INCOMING_PAYMENT_TYPES = [
export const OUTGOING_PAYMENT_TYPES = [
PaymentTypeEnum.WITHDRAWAL,
PaymentTypeEnum.REGISTRATION_REFUND,
PaymentTypeEnum.MEMBERSHIP_EXIT,
PaymentTypeEnum.EXPENSE,
PaymentTypeEnum.EXPENSE_OVERSPEND,
];
@@ -0,0 +1,24 @@
import { PaymentFileKind } from '../enums/payment-file-kind.enum';
/**
* Запись о файле, приложенном к платежу (чек об оплате). Сам бинарь лежит в
* MinIO-бакете `gateway:files`; в блокчейне файла нет это чистая БД-сущность,
* для проверки целостности используется `checksum_sha256`.
*
* Привязка по `payment_hash` (единый ключ любого платежа). Ключ MinIO:
* `{coopname}/gateway/{payment_hash}/{kind}/{checksum}.{ext}`.
*/
export interface IPaymentFileDatabaseData {
id?: number;
coopname: string;
payment_hash: string;
kind: PaymentFileKind;
checksum_sha256: string;
mime_type: string;
size_bytes: number;
storage_key: string;
/** Оригинальное имя загруженного файла — для отображения и поиска */
original_filename?: string | null;
uploaded_by_username: string;
uploaded_at: Date;
}
@@ -0,0 +1,16 @@
import type { IPaymentFileDatabaseData } from '../interfaces/payment-file-database.interface';
/**
* Реестр файлов, приложенных к платежам (чеки об оплате). Файл чистая БД-сущность
* (MinIO + метаданные), блокчейна за ним нет, поэтому интерфейс не наследует
* sync-репозиторий.
*/
export interface PaymentFileRepository {
create(data: IPaymentFileDatabaseData): Promise<IPaymentFileDatabaseData>;
findById(id: number): Promise<IPaymentFileDatabaseData | null>;
findByChecksum(coopname: string, checksum: string): Promise<IPaymentFileDatabaseData | null>;
findByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]>;
delete(id: number): Promise<void>;
}
export const PAYMENT_FILE_REPOSITORY = Symbol('PaymentFileRepository');
@@ -190,6 +190,22 @@ export class TokenDomainService {
return verifyEmailToken;
}
/**
* Генерирует токен подтверждения выхода из кооператива (ссылка в письме).
* Срок жизни как у верификации email (это тоже подтверждение по почте).
*/
async generateConfirmExitToken(userId: string): Promise<string> {
const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes');
const confirmExitToken = this.generateToken({
userId,
expires: expires.toDate(),
type: tokenTypes.CONFIRM_EXIT,
});
await this.saveToken(confirmExitToken, userId, expires.toDate(), tokenTypes.CONFIRM_EXIT);
return confirmExitToken;
}
/**
* Удаляет токены по критериям
*/
@@ -68,6 +68,9 @@ export class CapitalOnboardingStateDTO {
@Field(() => String, { nullable: true })
onboarding_blagorost_offer_template_hash?: string | null;
@Field(() => String, { nullable: true })
capital_program_doc_data_hash?: string | null;
@Field(() => String)
onboarding_init_at!: string;
@@ -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);
* при переходе pendingactive придёт новая дельта со 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
);
}
}
}
@@ -29,6 +29,10 @@ type OnboardingHashKey =
| 'onboarding_blagorost_provision_hash'
| 'onboarding_blagorost_offer_template_hash';
type CapitalOnboardingConfig = IConfig &
Partial<Record<OnboardingFlagKey, boolean>> &
Partial<Record<OnboardingHashKey | 'onboarding_init_at' | 'onboarding_expire_at' | 'capital_program_doc_data_hash', string>>;
@Injectable()
export class CapitalOnboardingService {
constructor(
@@ -88,10 +92,40 @@ export class CapitalOnboardingService {
}
}
private async loadPlugin(): Promise<ExtensionDomainEntity<IConfig & Record<string, any>>> {
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
private getMetaString(meta: unknown, key: string): string | undefined {
if (!this.isRecord(meta)) return undefined;
const value = meta[key];
return typeof value === 'string' ? value : undefined;
}
private isSignatureInfo(value: unknown): value is ISignedDocumentDomainInterface['signatures'][number] {
if (!this.isRecord(value)) return false;
return (
typeof value.id === 'number' &&
typeof value.signed_hash === 'string' &&
typeof value.signer === 'string' &&
typeof value.public_key === 'string' &&
typeof value.signature === 'string' &&
typeof value.signed_at === 'string' &&
typeof value.meta === 'string'
);
}
private getMetaSignatures(meta: unknown): ISignedDocumentDomainInterface['signatures'] {
if (!this.isRecord(meta)) return [];
const value = meta.signatures;
return Array.isArray(value) ? value.filter((item) => this.isSignatureInfo(item)) : [];
}
private async loadPlugin(): Promise<ExtensionDomainEntity<CapitalOnboardingConfig>> {
const plugin = await this.extensionRepository.findByName('capital');
if (!plugin) throw new Error('Конфигурация расширения capital не найдена');
const pluginConfig = { ...plugin.config } as IConfig & Record<string, any>;
const pluginConfig: CapitalOnboardingConfig = { ...plugin.config };
let needUpdate = false;
if (!pluginConfig.onboarding_init_at) {
@@ -113,7 +147,7 @@ export class CapitalOnboardingService {
return { ...plugin, config: pluginConfig };
}
private buildState(pluginConfig: IConfig & Record<string, any>): CapitalOnboardingStateDTO {
private buildState(pluginConfig: CapitalOnboardingConfig): CapitalOnboardingStateDTO {
return {
generator_program_template_done: !!pluginConfig.onboarding_generator_program_template_done,
onboarding_generator_program_template_hash: pluginConfig.onboarding_generator_program_template_hash || null,
@@ -125,6 +159,7 @@ export class CapitalOnboardingService {
onboarding_blagorost_provision_hash: pluginConfig.onboarding_blagorost_provision_hash || null,
blagorost_offer_template_done: !!pluginConfig.onboarding_blagorost_offer_template_done,
onboarding_blagorost_offer_template_hash: pluginConfig.onboarding_blagorost_offer_template_hash || null,
capital_program_doc_data_hash: pluginConfig.capital_program_doc_data_hash || null,
onboarding_init_at: pluginConfig.onboarding_init_at || '',
onboarding_expire_at: pluginConfig.onboarding_expire_at || '',
};
@@ -141,7 +176,7 @@ export class CapitalOnboardingService {
const hashKey = this.mapStepToHash(data.step);
const normalizedTitle = data.title?.trim().substring(0, 200) || undefined;
if ((plugin.config as any)[flagKey]) {
if (plugin.config[flagKey]) {
return this.buildState(plugin.config);
}
const project_id = uuid();
@@ -168,12 +203,12 @@ export class CapitalOnboardingService {
// Публикуем проект решения в блокчейн сразу после генерации
const documentForPublish: ISignedDocumentDomainInterface = {
version: (generatedDoc.meta as any)?.version || '1.0',
version: this.getMetaString(generatedDoc.meta, 'version') || '1.0',
hash: generatedDoc.hash,
doc_hash: (generatedDoc.meta as any)?.doc_hash || generatedDoc.hash,
meta_hash: (generatedDoc.meta as any)?.meta_hash || generatedDoc.hash,
doc_hash: this.getMetaString(generatedDoc.meta, 'doc_hash') || generatedDoc.hash,
meta_hash: this.getMetaString(generatedDoc.meta, 'meta_hash') || generatedDoc.hash,
meta: generatedDoc.meta,
signatures: (generatedDoc.meta as any)?.signatures || [],
signatures: this.getMetaSignatures(generatedDoc.meta),
};
await this.freeDecisionPort.publishProjectOfFreeDecision({
@@ -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
);
// Не бросаем ошибку, чтобы не блокировать запуск модуля
}
@@ -875,6 +885,7 @@ IssueIdGenerationService,
ProgramShareRegistrationService,
ProgramShareRegistrationSchedulerService,
ProgramShareRegistrationOnUserWalletDeltaListener,
ProgramShareRegistrationOnProjectDeltaListener,
GitCommitMarkersSyncService,
{
provide: ISSUE_LINKED_GIT_COMMIT_REPOSITORY,
@@ -3,8 +3,15 @@ import { config } from '~/config';
import { ProgramShareRegistrationService } from '../../application/services/program-share-registration.service';
/**
* Периодическая синхронизация долей участников (regshare) по балансу программы Благорост.
* Периодическая сверка долей участников (regshare) с балансом программы Благорост.
* Интервал задаётся в конфигурации расширения Capital (минуты); 0 отключено.
*
* Роль reconciliation-бэкстоп, не основной путь. Горячие сценарии покрыты
* событиями: появление проекта `ProgramShareRegistrationOnProjectDeltaListener`,
* изменение баланса `ProgramShareRegistrationOnUserWalletDeltaListener`. Крон
* оставлен, потому что он ещё и ДОобновляет уже зарегистрированные доли при
* дрейфе баланса (контракт `upsert_contributor_segment` это допускает) и
* подбирает события, потерянные при downtime контроллера.
*/
@Injectable()
export class ProgramShareRegistrationSchedulerService implements OnModuleDestroy {
@@ -211,6 +211,27 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort {
};
}
async exitCoop(data: import('~/domain/account/interfaces/account-blockchain.port').ExitCoopDomainInterface): Promise<void> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new BadGatewayException('Не найден приватный ключ для совершения операции');
await this.blockchainService.initialize(data.coopname, wif);
const exitData: RegistratorContract.Actions.ExitCoop.IExitCoop = {
coopname: data.coopname,
username: data.username,
exit_hash: data.exit_hash,
statement: Classes.Document.finalize(data.statement),
};
await this.blockchainService.transact({
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.ExitCoop.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: exitData,
});
}
async addParticipantAccount(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void> {
const wif = await this.vaultDomainService.getWif(data.coopname);
@@ -230,6 +251,34 @@ export class AccountBlockchainAdapter implements AccountBlockchainPort {
return this.blockchainService.getAccount(username);
}
getExit(
coopname: string,
username: string
): Promise<RegistratorContract.Tables.Exits.IExit | null> {
return this.blockchainService.getSingleRow(
RegistratorContract.contractName.production,
coopname,
RegistratorContract.Tables.Exits.tableName,
Name.from(username)
);
}
async getExitByHash(
coopname: string,
exit_hash: string
): Promise<RegistratorContract.Tables.Exits.IExit | null> {
// Таблица exits в scope=coopname мала (одна запись на выходящего пайщика,
// удаляется при завершении), поэтому проще выбрать все строки и найти по
// exit_hash, чем ходить во вторичный индекс byhash.
const rows = await this.blockchainService.getAllRows<RegistratorContract.Tables.Exits.IExit>(
RegistratorContract.contractName.production,
coopname,
RegistratorContract.Tables.Exits.tableName
);
const target = exit_hash.toLowerCase();
return rows.find((r) => String(r.exit_hash).toLowerCase() === target) ?? null;
}
getParticipantAccount(
coopname: string,
username: string
@@ -0,0 +1,40 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
/**
* Заявление пайщика на выход, принятое и подписанное, но ещё НЕ отправленное в
* блокчейн ожидает подтверждения по ссылке из письма. После подтверждения
* запись удаляется (источником истины становится on-chain registrator::exits),
* при отмене тоже удаляется. На один (coopname, username) один активный
* процесс выхода.
*
* statement хранит подписанный документ заявления (как пришёл с клиента),
* чтобы при подтверждении отправить его в registrator::exitcoop без повторной
* подписи пайщиком.
*/
@Entity('membership_exit_requests')
@Index(['coopname', 'username'], { unique: true })
export class MembershipExitRequestEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
coopname!: string;
@Column()
username!: string;
@Column({ name: 'exit_hash', length: 64 })
exit_hash!: string;
@Column({ type: 'jsonb' })
statement!: Record<string, any>;
@Column({ name: 'token', length: 1024 })
token!: string;
@CreateDateColumn({ name: 'created_at' })
created_at!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updated_at!: Date;
}
@@ -0,0 +1,50 @@
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
import { PaymentFileKind } from '~/domain/gateway/enums/payment-file-kind.enum';
export const EntityName = 'payment_files';
/**
* Файл, приложенный к платежу (чек об оплате). Бинарь в MinIO `gateway:files`,
* здесь только метаданные. Привязка по `payment_hash` (единый ключ платежа).
*/
@Entity(EntityName)
@Index(`uq_${EntityName}_checksum`, ['coopname', 'checksum_sha256'], { unique: true })
@Index(`idx_${EntityName}_payment`, ['coopname', 'payment_hash'])
export class PaymentFileEntity {
static getTableName(): string {
return EntityName;
}
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar' })
coopname!: string;
@Column({ type: 'varchar' })
payment_hash!: string;
@Column({ type: 'enum', enum: PaymentFileKind })
kind!: PaymentFileKind;
@Column({ type: 'varchar', length: 64 })
checksum_sha256!: string;
@Column({ type: 'varchar', length: 120 })
mime_type!: string;
@Column({ type: 'integer' })
size_bytes!: number;
@Column({ type: 'varchar', length: 512 })
storage_key!: string;
@Column({ type: 'varchar', length: 255, nullable: true, comment: 'Оригинальное имя загруженного файла — для отображения и поиска' })
original_filename!: string | null;
@Column({ type: 'varchar' })
uploaded_by_username!: string;
@CreateDateColumn({ type: 'timestamp' })
uploaded_at!: Date;
}
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PaymentFileEntity } from '../entities/payment-file.entity';
import type { PaymentFileRepository } from '~/domain/gateway/repositories/payment-file.repository';
import type { IPaymentFileDatabaseData } from '~/domain/gateway/interfaces/payment-file-database.interface';
/**
* TypeORM-репозиторий файлов платежей (чеков об оплате). БД-only сущность
* маппинг симметричный, без доменной логики.
*/
@Injectable()
export class TypeormPaymentFileRepository implements PaymentFileRepository {
constructor(
@InjectRepository(PaymentFileEntity)
private readonly repository: Repository<PaymentFileEntity>
) {}
async create(data: IPaymentFileDatabaseData): Promise<IPaymentFileDatabaseData> {
const entity = this.repository.create({
coopname: data.coopname,
payment_hash: data.payment_hash,
kind: data.kind,
checksum_sha256: data.checksum_sha256,
mime_type: data.mime_type,
size_bytes: data.size_bytes,
storage_key: data.storage_key,
original_filename: data.original_filename ?? null,
uploaded_by_username: data.uploaded_by_username,
uploaded_at: data.uploaded_at,
});
const saved = await this.repository.save(entity);
return this.toDomain(saved);
}
async findById(id: number): Promise<IPaymentFileDatabaseData | null> {
const entity = await this.repository.findOne({ where: { id } });
return entity ? this.toDomain(entity) : null;
}
async findByChecksum(coopname: string, checksum: string): Promise<IPaymentFileDatabaseData | null> {
const entity = await this.repository.findOne({ where: { coopname, checksum_sha256: checksum } });
return entity ? this.toDomain(entity) : null;
}
async findByPayment(coopname: string, paymentHash: string): Promise<IPaymentFileDatabaseData[]> {
const entities = await this.repository.find({
where: { coopname, payment_hash: paymentHash.toLowerCase() },
order: { uploaded_at: 'DESC' },
});
return entities.map((e) => this.toDomain(e));
}
async delete(id: number): Promise<void> {
await this.repository.delete({ id });
}
private toDomain(entity: PaymentFileEntity): IPaymentFileDatabaseData {
return {
id: entity.id,
coopname: entity.coopname,
payment_hash: entity.payment_hash,
kind: entity.kind,
checksum_sha256: entity.checksum_sha256,
mime_type: entity.mime_type,
size_bytes: entity.size_bytes,
storage_key: entity.storage_key,
original_filename: entity.original_filename,
uploaded_by_username: entity.uploaded_by_username,
uploaded_at: entity.uploaded_at,
};
}
}
@@ -24,6 +24,9 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
import { PaymentEntity } from './entities/payment.entity';
import { PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.repository';
import { PaymentFileEntity } from './entities/payment-file.entity';
import { PAYMENT_FILE_REPOSITORY } from '~/domain/gateway/repositories/payment-file.repository';
import { TypeormPaymentFileRepository } from './repositories/typeorm-payment-file.repository';
import { WebPushSubscriptionEntity } from './entities/web-push-subscription.entity';
import { NOTIFICATION_SUBSCRIPTION_PORT } from '~/domain/notification/interfaces/web-push-subscription.port';
import { TypeOrmWebPushSubscriptionRepository } from './repositories/typeorm-web-push-subscription.repository';
@@ -121,6 +124,7 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
MigrationEntity,
CandidateEntity,
PaymentEntity,
PaymentFileEntity,
WebPushSubscriptionEntity,
LedgerOperationEntity,
AgreementTypeormEntity,
@@ -175,6 +179,10 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
provide: PAYMENT_REPOSITORY,
useClass: TypeOrmPaymentRepository,
},
{
provide: PAYMENT_FILE_REPOSITORY,
useClass: TypeormPaymentFileRepository,
},
{
provide: NOTIFICATION_SUBSCRIPTION_PORT,
useClass: TypeOrmWebPushSubscriptionRepository,
@@ -276,6 +284,7 @@ import { NotificationInboxTypeormEntity } from './entities/notification-inbox.ty
MIGRATION_REPOSITORY,
CANDIDATE_REPOSITORY,
PAYMENT_REPOSITORY,
PAYMENT_FILE_REPOSITORY,
NOTIFICATION_SUBSCRIPTION_PORT,
LEDGER_OPERATION_REPOSITORY,
AGREEMENT_REPOSITORY,
@@ -4,6 +4,7 @@ export const tokenTypes = {
RESET_KEY: 'resetPassword',
VERIFY_EMAIL: 'verifyEmail',
INVITE: 'invite',
CONFIRM_EXIT: 'confirmExit',
} as const;
export type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes];
@@ -0,0 +1,68 @@
/**
* Unit-тесты ProgramShareRegistrationOnProjectDeltaListener.
*
* Фокус гейтинг: доли регистрируются СРАЗУ при появлении проекта в статусе
* active, и только в своём кооперативе. На pending (заводим только в active),
* present=false, чужой scope и прочие статусы реакции нет.
*/
import config from '~/config/config';
import { ProgramShareRegistrationOnProjectDeltaListener } from '~/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener';
import { ProjectStatus } from '~/extensions/capital/domain/enums/project-status.enum';
import type { IDelta } from '~/types/common';
const PROJECT_HASH = '011bcd92cc6fc6c3fbac1aa31e5ab302590993fedb666642a5bd2f88e96e6a0e';
function makeServiceStub() {
return { syncProgramSharesForProject: jest.fn(async () => undefined) } as any;
}
function makeListener(service: any) {
return new ProgramShareRegistrationOnProjectDeltaListener(service);
}
function makeDelta(overrides: Partial<IDelta> = {}, value: Record<string, any> = {}): IDelta {
return {
present: true,
scope: config.coopname,
value: { project_hash: PROJECT_HASH, status: ProjectStatus.ACTIVE, ...value },
...overrides,
} as IDelta;
}
describe('ProgramShareRegistrationOnProjectDeltaListener', () => {
it('active в своём кооперативе → регистрирует доли по project_hash', async () => {
const service = makeServiceStub();
await makeListener(service).handleProjectDelta(makeDelta());
expect(service.syncProgramSharesForProject).toHaveBeenCalledWith(config.coopname, PROJECT_HASH);
});
it('pending → не реагирует (заводим только в active, при переходе в active придёт новая дельта)', async () => {
const service = makeServiceStub();
await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.PENDING }));
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
});
it('result → не реагирует (окно закрыто, откат статуса контракт не даёт)', async () => {
const service = makeServiceStub();
await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.RESULT }));
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
});
it('present=false → не реагирует', async () => {
const service = makeServiceStub();
await makeListener(service).handleProjectDelta(makeDelta({ present: false }));
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
});
it('чужой кооператив (scope) → не реагирует', async () => {
const service = makeServiceStub();
await makeListener(service).handleProjectDelta(makeDelta({ scope: 'othercoop' }));
expect(service.syncProgramSharesForProject).not.toHaveBeenCalled();
});
it('ошибка сервиса не пробрасывается (best-effort, бэкстоп — scheduler)', async () => {
const service = { syncProgramSharesForProject: jest.fn(async () => { throw new Error('boom'); }) } as any;
await expect(makeListener(service).handleProjectDelta(makeDelta())).resolves.toBeUndefined();
});
});
+93
View File
@@ -326,6 +326,9 @@ export const AllTypesProps: Record<string,any> = {
},
CreateMatrixAccountInputDTO:{
},
CreateMembershipExitInput:{
statement:"MembershipExitApplicationSignedDocumentInput"
},
CreateOrganizationDataInput:{
bank_account:"BankAccountInput",
@@ -694,6 +697,20 @@ export const AllTypesProps: Record<string,any> = {
mark:"ReportSubmissionMark",
reportType:"ReportType"
},
MembershipExitApplicationGenerateDocumentInput:{
},
MembershipExitApplicationSignedDocumentInput:{
meta:"MembershipExitApplicationSignedMetaDocumentInput",
signatures:"SignatureInfoInput"
},
MembershipExitApplicationSignedMetaDocumentInput:{
},
MembershipExitDecisionGenerateDocumentInput:{
},
MembershipExitStatus: "enum" as const,
ModerateRequestInput:{
},
@@ -715,6 +732,9 @@ export const AllTypesProps: Record<string,any> = {
},
authorizeDecision:{
data:"AuthorizeDecisionInput"
},
cancelMembershipExit:{
},
cancelRequest:{
data:"CancelRequestInput"
@@ -1013,6 +1033,9 @@ export const AllTypesProps: Record<string,any> = {
},
confirmAgreement:{
data:"ConfirmAgreementInput"
},
confirmMembershipExit:{
},
confirmReceiveOnRequest:{
data:"ConfirmReceiveOnRequestInput"
@@ -1038,6 +1061,9 @@ export const AllTypesProps: Record<string,any> = {
createInitialPayment:{
data:"CreateInitialPaymentInput"
},
createMembershipExit:{
data:"CreateMembershipExitInput"
},
createParentOffer:{
data:"CreateParentOfferInput"
},
@@ -1133,6 +1159,14 @@ export const AllTypesProps: Record<string,any> = {
data:"FreeDecisionGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
generateMembershipExitApplication:{
data:"MembershipExitApplicationGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
generateMembershipExitDecision:{
data:"MembershipExitDecisionGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
generateParticipantApplication:{
data:"ParticipantApplicationGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -1336,6 +1370,9 @@ export const AllTypesProps: Record<string,any> = {
uploadExpenseFile:{
data:"UploadExpenseFileInput"
},
uploadPaymentProof:{
data:"UploadPaymentProofInput"
},
verifyEmail:{
data:"VerifyEmailInputDTO"
},
@@ -1393,6 +1430,7 @@ export const AllTypesProps: Record<string,any> = {
},
PaymentDirection: "enum" as const,
PaymentFileKind: "enum" as const,
PaymentFiltersInput:{
direction:"PaymentDirection",
status:"PaymentStatus",
@@ -1778,9 +1816,21 @@ export const AllTypesProps: Record<string,any> = {
},
listReportDrafts:{
filter:"ListReportDraftsFilterInput"
},
membershipExit:{
},
membershipExitReturnPreview:{
},
onecoopGetDocuments:{
data:"GetOneCoopDocumentsInput"
},
paymentFile:{
},
paymentProofs:{
},
process:{
@@ -2080,6 +2130,9 @@ export const AllTypesProps: Record<string,any> = {
},
UploadExpenseFileInput:{
kind:"ExpenseFileKind"
},
UploadPaymentProofInput:{
},
UserStatus: "enum" as const,
VarsInput:{
@@ -3778,6 +3831,22 @@ export const ReturnTypes: Record<string,any> = {
votes_against:"Int",
votes_for:"Int"
},
MembershipExit:{
created_at:"String",
exit_hash:"String",
payment_status:"PaymentStatus",
quantity:"String",
status:"MembershipExitStatus"
},
MembershipExitResult:{
exit_hash:"String",
status:"MembershipExitStatus"
},
MembershipExitReturnPreview:{
minimum_contribution:"String",
share_contribution:"String",
total:"String"
},
MissingRequisiteField:{
key:"String",
label:"String",
@@ -3806,6 +3875,7 @@ export const ReturnTypes: Record<string,any> = {
addPaymentMethod:"PaymentMethod",
addTrustedAccount:"Branch",
authorizeDecision:"Transaction",
cancelMembershipExit:"Boolean",
cancelRequest:"Transaction",
capitalAddAuthor:"CapitalProject",
capitalApproveCommit:"CapitalCommit",
@@ -3899,6 +3969,7 @@ export const ReturnTypes: Record<string,any> = {
completeExtensionOnboardingStep:"ExtensionOnboardingState",
completeRequest:"Transaction",
confirmAgreement:"Transaction",
confirmMembershipExit:"MembershipExitResult",
confirmReceiveOnRequest:"Transaction",
confirmSupplyOnRequest:"Transaction",
createAnnualGeneralMeet:"MeetAggregate",
@@ -3907,6 +3978,7 @@ export const ReturnTypes: Record<string,any> = {
createDepositPayment:"GatewayPayment",
createExpenseProposal:"Transaction",
createInitialPayment:"GatewayPayment",
createMembershipExit:"MembershipExitResult",
createParentOffer:"Transaction",
createProjectOfFreeDecision:"CreatedProjectFreeDecision",
createWebPushSubscription:"CreateSubscriptionResponse",
@@ -3935,6 +4007,8 @@ export const ReturnTypes: Record<string,any> = {
generateExpenseProposalDecisionDocument:"GeneratedDocument",
generateExpenseProposalStatementDocument:"GeneratedDocument",
generateFreeDecision:"GeneratedDocument",
generateMembershipExitApplication:"GeneratedDocument",
generateMembershipExitDecision:"GeneratedDocument",
generateParticipantApplication:"GeneratedDocument",
generateParticipantApplicationDecision:"GeneratedDocument",
generatePrivacyAgreement:"GeneratedDocument",
@@ -3999,6 +4073,7 @@ export const ReturnTypes: Record<string,any> = {
updateSettings:"Settings",
updateSystem:"SystemInfo",
uploadExpenseFile:"ExpenseFile",
uploadPaymentProof:"PaymentFile",
verifyEmail:"Boolean",
voteOnAnnualGeneralMeet:"MeetAggregate",
walmoveWallets:"Ledger2AdjustmentResult"
@@ -4280,6 +4355,20 @@ export const ReturnTypes: Record<string,any> = {
fee_percent:"Float",
tolerance_percent:"Float"
},
PaymentFile:{
checksum_sha256:"String",
coopname:"String",
id:"Int",
kind:"PaymentFileKind",
mime_type:"String",
original_filename:"String",
payment_hash:"String",
read_url:"String",
size_bytes:"Int",
storage_key:"String",
uploaded_at:"DateTime",
uploaded_by_username:"String"
},
PaymentMethod:{
created_at:"DateTime",
data:"PaymentMethodData",
@@ -4592,7 +4681,11 @@ export const ReturnTypes: Record<string,any> = {
getUserWebPushSubscriptions:"WebPushSubscriptionDto",
getWebPushSubscriptionStats:"SubscriptionStatsDto",
listReportDrafts:"ReportDraft",
membershipExit:"MembershipExit",
membershipExitReturnPreview:"MembershipExitReturnPreview",
onecoopGetDocuments:"OneCoopDocumentsResponse",
paymentFile:"PaymentFile",
paymentProofs:"PaymentFile",
process:"ProcessView",
processes:"ProcessSummaryPaginationResult",
searchDocuments:"SearchResult",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.6.9-18",
"version": "2026.6.24",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
+36 -1
View File
@@ -106,6 +106,29 @@ const programMapping: ProgramMappingEntry[] = []
}
}
// ── 5b. LEDGER2_EXIT_REFUND_WALLETS ──────────────────────────────────────
// Сет паевых кошельков, возвращаемых пайщику при выходе. Простой массив имён
// `ledger2_wallets::NAME` — единый источник для контракта (confirmexit) и
// backend-preview.
const exitRefundWallets: string[] = []
{
const body = extractArrayBody('LEDGER2_EXIT_REFUND_WALLETS')
const re = /ledger2_wallets::(\w+)/g
for (const m of body.matchAll(re)) {
const wallet_name = nameMap.get(m[1]!)
if (!wallet_name) throw new Error(`gen-from-cpp: ledger2_wallets::${m[1]} не найден среди имён`)
exitRefundWallets.push(wallet_name)
}
if (exitRefundWallets.length === 0) throw new Error('gen-from-cpp: LEDGER2_EXIT_REFUND_WALLETS пуст')
const sizeMatch = /std::array<eosio::name,\s*(\d+)>\s+LEDGER2_EXIT_REFUND_WALLETS/.exec(hpp)
if (sizeMatch && Number(sizeMatch[1]) !== exitRefundWallets.length)
throw new Error(`gen-from-cpp: распарсено ${exitRefundWallets.length} exit-refund-кошельков, объявлено ${sizeMatch[1]}`)
const known = new Set(walletRegistry.map(w => w.name))
for (const w of exitRefundWallets) {
if (!known.has(w)) throw new Error(`gen-from-cpp: exit-refund ссылается на отсутствующий в реестре кошелёк "${w}"`)
}
}
// ── 6. emit TS ───────────────────────────────────────────────────────────
const lines: string[] = []
lines.push('// AUTO-GENERATED by cooptypes/scripts/gen-from-cpp.ts — DO NOT EDIT.')
@@ -153,6 +176,18 @@ for (const m of programMapping) {
}
lines.push('] as const')
lines.push('')
lines.push('/**')
lines.push(' * Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.')
lines.push(' * Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит')
lines.push(' * этот сет, собирает доступные балансы и ставит их на возврат; backend-preview')
lines.push(' * считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.')
lines.push(' */')
lines.push('export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [')
for (const w of exitRefundWallets) {
lines.push(` ${JSON.stringify(w)},`)
}
lines.push('] as const')
lines.push('')
writeFileSync(OUT_PATH, lines.join('\n'), 'utf8')
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length})`)
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length}, exitRefund=${exitRefundWallets.length})`)
@@ -0,0 +1,15 @@
import * as Permissions from '../../../common/permissions'
import type * as Registrator from '../../../interfaces/registrator'
import { Actors } from '../../../common'
export const authorizations = [{ permissions: [Permissions.active, Permissions.special], actor: Actors._admin }] as const
/**
* Имя действия
*/
export const actionName = 'exitcoop'
/**
* @interface
*/
export type IExitCoop = Registrator.IExitcoop
@@ -28,6 +28,11 @@ export * as CreateAccount from './createAccount'
*/
export * as RegisterUser from './registerUser'
/**
* Действие подачи заявления на выход пайщика из кооператива (возврат паевого взноса)
*/
export * as ExitCoop from './exitCoop'
/**
* Действие регистрации карточки организации в кооперативе
*/
@@ -0,0 +1,19 @@
import type * as Registrator from '../../../interfaces/registrator'
import { Actors } from '../../../common'
/**
* Имя таблицы
*/
export const tableName = 'exits'
/**
* Таблица хранится в области памяти {@link Actors._coopname | кооператива}.
*/
export const scope = Actors._coopname
/**
* @interface
* Реестр заявлений пайщиков на выход из кооператива и сопровождающих их
* процессов возврата паевого взноса.
*/
export type IExit = Registrator.IExit
@@ -8,3 +8,8 @@ export * as Accounts from './accounts'
* Таблица содержит реестр кооперативов системы.
*/
export * as Cooperatives from './cooperatives'
/**
* Таблица содержит реестр заявлений пайщиков на выход из кооператива.
*/
export * as Exits from './exits'
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
import type { IGenerate, IMetaDocument } from '../../document'
import type { ICooperativeData, IVars } from '../../model'
import type { IEntrepreneurData, IIndividualData, IOrganizationData } from '../../users'
export const registry_id = 200
/**
* Интерфейс генерации заявления на выход из состава пайщиков кооператива
*/
export interface Action extends IGenerate {
skip_save: boolean
}
export type Meta = IMetaDocument & Action
// Модель данных
export interface Model {
type: string
individual?: IIndividualData
organization?: IOrganizationData
entrepreneur?: IEntrepreneurData
coop: ICooperativeData
meta: IMetaDocument
vars: IVars
}
export const title = 'Заявление на выход из кооператива'
export const description = 'Форма заявления физического лица, индивидуального предпринимателя или юридического лица о выходе из состава пайщиков потребительского кооператива'
export const context = `<style>
.digital-document h1 {
margin: 0px;
text-align:center;
}
.digital-document {padding: 20px;}
.subheader {
padding-bottom: 20px;
}
.signature {
padding-top: 30px;
}
</style>
<div class="digital-document">
{% if type == 'individual' %}
<h1 class="header">{% trans 'application_exit_individual' %}</h1>
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from' %} {{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}, {% trans 'birthdate' %} {{ individual.birthdate }}, {% trans 'registration_address' %} {{ individual.full_address }}, {% trans 'phone_and_email_notice', individual.phone, individual.email %}{% if vars.passport_request == 'yes' %} {% trans 'passport' %} {{ individual.passport.series }} {{ individual.passport.number }}, {% trans 'passport_code' %} {{ individual.passport.code }}, {% trans 'passport_issued' %} {{ individual.passport.issued_by }} {% trans 'passport_from' %} {{ individual.passport.issued_at }}.{% endif %}</p>
<p>{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}</p>
<p>{% trans 'obligation_to_settle_individual' %}</p>
<div class="signature">
<p>{% trans 'signed_electronically' %}</p>
<p style="text-align: right;">{{ individual.last_name }} {{ individual.first_name }} {{ individual.middle_name }}</p>
</div>
{% elif type == 'entrepreneur' %}
<h1 class="header">{% trans 'application_exit_entrepreneur' %}</h1>
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from_entrepreneur' %} {{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}, {% trans 'birthdate' %} {{ entrepreneur.birthdate }}, {% trans 'registration_address' %} {{ entrepreneur.full_address }}, {% trans 'entrepreneur_details', entrepreneur.details.inn, entrepreneur.details.ogrn %}, {% trans 'phone_and_email_notice', entrepreneur.phone, entrepreneur.email %}</p>
<p>{% trans 'request_to_exit', coop.full_name, coop.details.ogrn, coop.details.inn, coop.details.kpp %}</p>
<p>{% trans 'obligation_to_settle_individual' %}</p>
<div class="signature">
<p>{% trans 'signed_electronically' %}</p>
<p style="text-align: right;">{{ entrepreneur.last_name }} {{ entrepreneur.first_name }} {{ entrepreneur.middle_name }}</p>
</div>
{% elif type == 'organization' %}
<h1 class="header">{% trans 'application_exit_legal_entity' %}</h1>
<p style="text-align: center" class="subheader">{% trans 'of_consumer_cooperative' %} {{ coop.full_name }}<p>
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
<p>{% trans 'to_council_of' %} {{ coop.full_name }} {% trans 'from_legal_entity' %} {{ organization.full_name }}, {% trans 'legal_address' %}: {{ organization.full_address }}, {% trans 'fact_address' %}: {{ organization.fact_address }}, {% trans 'legal_entity_details', organization.details.inn, organization.details.ogrn, organization.details.kpp %}, {% trans 'phone_and_email_notice', organization.phone, organization.email %}</p>
<p>{% trans 'request_to_exit_legal_entity', organization.represented_by.position, organization.represented_by.last_name, organization.represented_by.first_name, organization.represented_by.middle_name, organization.represented_by.based_on, organization.full_name, coop.full_name %}</p>
<p>{% trans 'obligation_to_settle_legal_entity' %}</p>
<div class="signature">
<p>{% trans 'signed_electronically' %}</p>
<p style="text-align: right;">{{ organization.represented_by.last_name }} {{ organization.represented_by.first_name }} {{ organization.represented_by.middle_name }}</p>
</div>
{% endif %}
</div>`
export const translations = {
ru: {
from: 'от',
of_consumer_cooperative: 'Потребительского кооператива',
to_council_of: 'В Совет',
birthdate: 'дата рождения',
registration_address: 'адрес регистрации (как в паспорте): ',
phone_and_email_notice: 'номер телефона: {0}, адрес электронной почты: {1}.',
application_exit_individual: 'Заявление физического лица о выходе из состава пайщиков',
application_exit_entrepreneur: 'Заявление индивидуального предпринимателя о выходе из состава пайщиков',
application_exit_legal_entity: 'Заявление юридического лица о выходе из состава пайщиков',
from_entrepreneur: 'от индивидуального предпринимателя',
from_legal_entity: 'от юридического лица',
legal_address: 'юридический адрес',
fact_address: 'фактический адрес',
entrepreneur_details: 'ИНН {0}, ОГРНИП {1}',
legal_entity_details: 'ИНН {0}, ОГРН {1}, КПП {2}',
request_to_exit: 'В соответствии с Уставом {0} (ОГРН {1}, ИНН {2}, КПП {3}) (далее по тексту Общество) прошу вывести меня из состава пайщиков Общества.',
request_to_exit_legal_entity: 'Заявитель, в лице представителя юридического лица — {0} {1} {2} {3}, действующего на основании {4}, в соответствии с Уставом Общества просит вывести {5} из состава пайщиков {6}.',
obligation_to_settle_individual: 'Обязуюсь погасить все свои обязательства перед Обществом, при наличии таковых, а также получить возврат своих паевых взносов в связи с моим участием в хозяйственной деятельности и целевых потребительских программах Общества, предусмотренном Уставом Общества и его действующих Положений и в соответствии с действующим законодательством РФ.',
obligation_to_settle_legal_entity: 'Заявитель обязуется погасить все свои обязательства перед Обществом, при наличии таковых, а также получить возврат своих паевых взносов в связи с участием в хозяйственной деятельности и целевых потребительских программах Общества, предусмотренном Уставом Общества и его действующих Положений и в соответствии с действующим законодательством РФ.',
signed_electronically: 'Документ подписан электронной подписью.',
passport: 'Паспорт',
passport_code: 'код подразделения',
passport_issued: 'выдан',
passport_from: 'от',
},
}
export const exampleData = {
coop: {
short_name: 'ПК "ВОСХОД"',
full_name: 'потребительский кооператив "Восход"',
city: 'Москва',
details: {
inn: '9728130611',
ogrn: '1247700283346',
kpp: '772801001',
},
},
meta: {
created_at: '04.03.2024 10:54',
},
individual: {
last_name: 'Муравьев',
first_name: 'Алексей',
middle_name: 'Николаевич',
birthdate: '04.03.1990',
phone: '790343432423',
email: 'email@gmail.com',
full_address: 'Советов 3-84',
passport: {
series: '7712',
number: '122112',
issued_by: 'отделом УФМС г. Москва',
issued_at: '10.05.2010',
code: '220-220',
},
},
entrepreneur: {
last_name: 'Муравьев',
first_name: 'Алексей',
middle_name: 'Николаевич',
birthdate: '04.03.1990',
full_address: 'Советов 3-84',
details: {
inn: '34534534534',
ogrn: '345345345',
},
phone: '+734534534534',
email: 'email@gmail.com',
},
organization: {
full_name: 'ООО РОМАШКА',
full_address: 'Советов 3-84',
fact_address: 'Советов 3-84',
represented_by: {
last_name: 'Муравьев',
first_name: 'Алексей',
middle_name: 'Николаевич',
position: 'директор',
based_on: 'решения учредителей №1 от 05.03.2024',
},
details: {
inn: '234234234',
ogrn: '234234234234',
kpp: '772801001',
},
phone: '+35345345345',
email: 'an.mddf@gmail.com',
},
type: 'individual',
vars: {
passport_request: 'yes',
},
}
@@ -0,0 +1,124 @@
import type { IDecisionData, IGenerate, IMetaDocument } from '../../document'
import type { ICooperativeData, IVars } from '../../model'
import type { IEntrepreneurData, IIndividualData, IOrganizationData } from '../../users'
export const registry_id = 201
/**
* Интерфейс генерации решения совета о выходе пайщика из кооператива
*/
export interface Action extends IGenerate {
decision_id: number
}
// Модель данных
export interface Model {
type: string
individual?: IIndividualData
organization?: IOrganizationData
entrepreneur?: IEntrepreneurData
coop: ICooperativeData
meta: IMetaDocument
decision: IDecisionData
vars: IVars
}
export const title = 'Решение совета о выходе пайщика из кооператива'
export const description = 'Форма решения совета о выходе пайщика из состава потребительского кооператива'
export const context = '<style> \nh1 {\nmargin: 0px; \ntext-align:center;\n}\nh3{\nmargin: 0px;\npadding-top: 15px;\n}\n.about {\npadding: 20px;\n}\n.digital-document {\npadding: 20px;\nwhite-space: pre-wrap;\n}\n.subheader {\npadding-bottom: 20px; \n}\ntable {\n width: 100%;\n border-collapse: collapse;\n}\nth, td {\n border: 1px solid #ccc;\n padding: 8px;\n text-align: left;\n word-wrap: break-word; \n overflow-wrap: break-word; \n}\nth {\n background-color: #f4f4f4;\n width: 30%;\n}\n</style>\n\n<div class="digital-document">\n <h1 class="header">{% trans \'protocol_number\', decision.id %}</h1>\n <p style="text-align:center" class="subheader">{% trans \'council_meeting_name\' %} {{vars.full_abbr_genitive}} "{{vars.name}}"</p>\n <p style="text-align: right"> {{ meta.created_at }}, {{ coop.city }}</p>\n <table class="about">\n <tbody>\n <tr>\n <th>{% trans \'meeting_format\' %}</th>\n <td>{% trans \'meeting_format_value\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'meeting_place\' %}</th>\n <td>{{ coop.full_address }}</td>\n </tr>\n <tr>\n <th>{% trans \'meeting_date\' %}</th>\n <td>{{ decision.date }}</td>\n </tr>\n <tr>\n <th>{% trans \'opening_time\' %}</th>\n <td>{{ decision.time }}</td>\n </tr>\n </tbody>\n </table>\n <h3>{% trans \'council_members\' %}</h3>\n <table>\n <tbody>\n {% for member in coop.members %}\n <tr>\n <th>{% if member.is_chairman %}{% trans \'chairman_of_the_council\' %}{% else %}{% trans \'member_of_the_council\' %}{% endif %}</th>\n <td>{{ member.last_name }} {{ member.first_name }} {{ member.middle_name }}</td>\n</tr>\n {% endfor %}\n </tbody>\n </table>\n <h3>{% trans \'meeting_legality\' %}</h3>\n <p>{% trans \'voting_results\', decision.voters_percent %} {% trans \'quorum\' %} {% trans \'chairman_of_the_meeting\', coop.chairman.last_name, coop.chairman.first_name, coop.chairman.middle_name %}.</p>\n <h3>{% trans \'agenda\' %}</h3>\n <table>\n <tbody>\n <tr>\n <th>№</th>\n <td>{% trans \'question\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'agenda_item\' %}</th>\n <td>{% trans \'decision_leavecoop1\' %} {% if type == \'individual\' %}{{individual.last_name}} {{individual.first_name}} {{individual.middle_name}} ({{individual.birthdate}} {% trans \'birthdate\' %}){% endif %}{% if type == \'entrepreneur\' %}{% trans \'entrepreneur\' %} {{entrepreneur.last_name}} {{entrepreneur.first_name}} {{entrepreneur.middle_name}} ({% trans \'ogrnip\' %} {{entrepreneur.details.ogrn}}){% endif %}{% if type == \'organization\' %} {{organization.short_name}} ({% trans \'ogrn\' %} {{organization.details.ogrn}}){% endif %}{% trans \'in_participants\' %} {{ coop.short_name }}.\n </td>\n </tr>\n </tbody>\n </table>\n<h3>{% trans \'voting\' %}</h3>\n<p>{% trans \'vote_results\' %}</p><table>\n <tbody>\n <tr>\n <th>{% trans \'votes_for\' %}</th>\n <td>{{ decision.votes_for }}</td>\n </tr>\n <tr>\n <th>{% trans \'votes_against\' %}</th>\n <td>{{ decision.votes_against }}</td>\n </tr>\n <tr>\n <th>{% trans \'votes_abstained\' %}</th>\n <td>{{ decision.votes_abstained }}</td>\n </tr>\n </tbody>\n </table>\n <h3>{% trans \'decision_made\' %}</h3>\n <table>\n <tbody>\n <tr>\n <tr>\n <th>№</th>\n <td>{% trans \'question\' %}</td>\n </tr>\n\n <th>{% trans \'decision\' %}</th>\n <td>{% trans \'decision_leavecoop2\' %}\n{% if type == \'individual\' %}{{individual.last_name}} {{individual.first_name}} {{individual.middle_name}} {{individual.birthdate}} {% trans \'birthdate\' %} {% endif %}{% if type == \'entrepreneur\' %}{% trans \'entrepreneur\' %} {{entrepreneur.last_name}} {{entrepreneur.first_name}} {{entrepreneur.middle_name}}, {% trans \'ogrnip\' %} {{entrepreneur.details.ogrn}}{% endif %}{% if type == \'organization\' %}{{organization.short_name}}, {% trans \'ogrn\' %} {{organization.details.ogrn}}{% endif %}\n </td>\n </tr>\n </tbody>\n </table>\n <hr>\n<p>{% trans \'closing_time\', decision.time %}</p>\n<div class="signature"><p>{% trans \'signature\' %}</p><p>{% trans \'chairman\' %} {{ coop.chairman.last_name }} {{ coop.chairman.first_name }} {{ coop.chairman.middle_name }}</p></div>\n</div>\n'
export const translations = {
ru: {
meeting_format: 'Форма',
meeting_date: 'Дата',
meeting_place: 'Место',
opening_time: 'Время открытия',
council_members: 'ЧЛЕНЫ СОВЕТА',
voting_results: 'Количество голосов составляет {0}% от общего числа членов Совета.',
meeting_legality: 'СОБРАНИЕ ПРАВОМОЧНО',
chairman_of_the_meeting: 'Председатель собрания совета: {0} {1} {2}',
agenda: 'ПОВЕСТКА ДНЯ',
decision_made: 'РЕШИЛИ',
closing_time: 'Время закрытия собрания совета: {0}.',
protocol_number: 'ПРОТОКОЛ № {0}',
council_meeting_name: 'Собрания Совета',
chairman_of_the_council: 'Председатель совета',
signature: 'Документ подписан электронной подписью.',
chairman: 'Председатель',
birthdate: 'г.р.',
ogrnip: 'ОГРНИП',
entrepreneur: 'ИП',
ogrn: 'ОГРН',
quorum: 'Кворум для решения поставленных на повестку дня вопросов имеется.',
voting: 'ГОЛОСОВАНИЕ',
in_participants: ' о выходе из состава пайщиков',
decision_leavecoop1: 'Рассмотреть заявление о выходе',
decision_leavecoop2: 'Вывести из состава пайщиков',
meeting_format_value: 'заочная',
agenda_item: '1',
votes_for: 'ЗА',
votes_against: 'ПРОТИВ',
votes_abstained: 'ВОЗДЕРЖАЛСЯ',
decision: '1',
question: 'Вопрос',
vote_results: 'По первому вопросу повестки дня проголосовали:',
member_of_the_council: 'Член совета',
},
}
export const exampleData = {
meta: {
created_at: '12.02.2024 00:01',
},
coop: {
city: 'Москва',
full_address: 'Смольная 3-84',
chairman: {
last_name: 'Муравьев',
first_name: 'Алексей',
middle_name: 'Николаевич',
},
short_name: 'ПК "Восход"',
},
decision: {
time: '00:01',
date: '12.02.2024',
votes_for: '3',
votes_against: '0',
votes_abstained: '0',
voters_percent: '100',
id: '1',
},
member: {
is_chairman: true,
last_name: 'Муравьев',
first_name: 'Алексей',
middle_name: 'Николаевич',
},
organization: {
details: {
ogrn: '2222222222',
},
short_name: 'ООО "Ромашка"',
},
type: 'organization',
individual: {
last_name: 'Мартин',
first_name: 'Роберт',
middle_name: 'Иванович',
birthdate: '04.04.2000',
},
entrepreneur: {
last_name: 'Мартин',
first_name: 'Роберт',
middle_name: 'Иванович',
details: {
ogrn: '11111111111',
},
},
vars: {
full_abbr_genitive: 'Потребительского Кооператива',
name: 'ВОСХОД',
},
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,64 @@
export interface CapitalProgramPrivateData {
approval_protocol_number: string
approval_protocol_day: string
approval_protocol_month: string
approval_protocol_year: string
cooperative_name: string
cooperative_short_name: string
cooperative_quoted_name: string
website: string
chairman_full_name: string
generator_program_purpose: string
eoap_definition: string
generator_task_goal: string
idea_unit_cost: string
idea_unit_cost_words: string
blagorost_goal_expansion: string
blagorost_task_expansion: string
blagorost_task_development: string
return_source_description: string
return_additional_source: string
offer_template_number: string
}
export const capitalProgramPrivateDataRequiredFields = [
'approval_protocol_number',
'approval_protocol_day',
'approval_protocol_month',
'approval_protocol_year',
'cooperative_name',
'cooperative_short_name',
'cooperative_quoted_name',
'website',
'chairman_full_name',
'generator_program_purpose',
'eoap_definition',
'generator_task_goal',
'idea_unit_cost',
'idea_unit_cost_words',
'blagorost_goal_expansion',
'blagorost_task_expansion',
'blagorost_task_development',
'return_source_description',
'return_additional_source',
'offer_template_number',
] as const
export function requireCapitalProgramPrivateData(
payload: CapitalProgramPrivateData | null | undefined,
registry_id: number,
): CapitalProgramPrivateData {
if (!payload)
throw new Error(`PrivateData для документа #${registry_id} не найдены: передайте doc_data_hash`)
const missing = capitalProgramPrivateDataRequiredFields.filter((field) => {
const value = payload[field]
return typeof value !== 'string' || value.trim() === ''
})
if (missing.length) {
throw new Error(`PrivateData для документа #${registry_id} заполнены не полностью: ${missing.join(', ')}`)
}
return payload
}
@@ -8,6 +8,9 @@ export * as ConvertToAxonStatement from './51.ConvertToAxonStatement'
export * as ParticipantApplication from './100.ParticipantApplication'
export * as DecisionOfParticipantApplication from './501.DecisionOfParticipantApplication'
export * as ParticipantExitApplication from './200.ParticipantExitApplication'
export * as DecisionOfParticipantExit from './201.DecisionOfParticipantExit'
export * as SelectBranchStatement from './101.SelectBranchStatement'
export * as ProjectFreeDecision from './599.ProjectFreeDecision'
export * as FreeDecision from './600.FreeDecision'
@@ -77,3 +80,5 @@ export * as AnnualGeneralMeetingSovietDecision from './301.AnnualGeneralMeetingS
export * as AnnualGeneralMeetingNotification from './302.AnnualGeneralMeetingNotification'
export * as AnnualGeneralMeetingVotingBallot from './303.AnnualGeneralMeetingVotingBallot'
export * as AnnualGeneralMeetingDecision from './304.AnnualGeneralMeetingDecision'
export * from './capitalProgramPrivateData'
@@ -53,6 +53,17 @@ export interface IChangekey {
public_key: IPublicKey
}
export interface ICompletexit {
coopname: IName
exit_hash: IChecksum256
}
export interface IConfirmexit {
coopname: IName
exit_hash: IChecksum256
authorization: IDocument2
}
export interface IConfirmpay {
coopname: IName
registration_hash: IChecksum256
@@ -122,6 +133,12 @@ export interface IDeclinereg {
reason: string
}
export interface IDeclinexit {
coopname: IName
exit_hash: IChecksum256
reason: string
}
export interface IDelcoop {
registrator: IName
coopname: IName
@@ -151,6 +168,24 @@ export interface IEnabranches {
coopname: IName
}
export interface IExit {
username: IName
coopname: IName
status: IName
created_at: ITimePointSec
statement: IDocument2
approved_statement: IDocument2
exit_hash: IChecksum256
quantity: IAsset
}
export interface IExitcoop {
coopname: IName
username: IName
exit_hash: IChecksum256
statement: IDocument2
}
export interface IInit {
}
@@ -343,6 +343,11 @@ export interface IDeletebranch {
braname: IName
}
export interface IDelpartcpnt {
coopname: IName
username: IName
}
export interface IDisableprog {
coopname: IName
program_id: IUint64
+35 -124
View File
@@ -57,120 +57,56 @@ export interface OperationMeta {
export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
// registrator
{ code: 'o.reg.payent', process_type: 'p.reg.accept', contract: 'registrator',
name: 'PAY_ENTRANCE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry',
debit: 51, credit: 86,
human_name: 'Вступительный взнос пайщика' },
{ code: 'o.reg.payent', process_type: 'p.reg.accept', contract: 'registrator', name: 'PAY_ENTRANCE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry', debit: 51, credit: 86, human_name: 'Вступительный взнос пайщика' },
{ code: 'o.reg.putmin', process_type: 'p.reg.accept', contract: 'registrator',
name: 'PUT_MINSHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr',
debit: 51, credit: 80,
human_name: 'Минимальный паевой взнос пайщика при регистрации' },
{ code: 'o.reg.putmin', process_type: 'p.reg.accept', contract: 'registrator', name: 'PUT_MINSHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr', debit: 51, credit: 80, human_name: 'Минимальный паевой взнос пайщика при регистрации' },
// Двухфазный путь через совет (reguser → confirmpay → confirmreg/declinereg)
{ code: 'o.reg.inpay', process_type: 'p.reg.accept', contract: 'registrator',
name: 'RECEIVE_PAYMENT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.pend',
debit: 51, credit: 76,
human_name: 'Приём регистрационного взноса в ожидание решения совета' },
{ code: 'o.reg.inpay', process_type: 'p.reg.accept', contract: 'registrator', name: 'RECEIVE_PAYMENT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.pend', debit: 51, credit: 76, human_name: 'Приём регистрационного взноса в ожидание решения совета' },
{ code: 'o.reg.setmin', process_type: 'p.reg.accept', contract: 'registrator',
name: 'SETTLE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.minshr',
debit: 76, credit: 80,
human_name: 'Зачисление минимального паевого взноса по решению совета' },
{ code: 'o.reg.setmin', process_type: 'p.reg.accept', contract: 'registrator', name: 'SETTLE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.minshr', debit: 76, credit: 80, human_name: 'Зачисление минимального паевого взноса по решению совета' },
{ code: 'o.reg.setent', process_type: 'p.reg.accept', contract: 'registrator',
name: 'SETTLE_ENTRANCE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.entry',
debit: 76, credit: 86,
human_name: 'Зачисление вступительного взноса по решению совета' },
{ code: 'o.reg.setent', process_type: 'p.reg.accept', contract: 'registrator', name: 'SETTLE_ENTRANCE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.pend', wallet_to: 'w.reg.entry', debit: 76, credit: 86, human_name: 'Зачисление вступительного взноса по решению совета' },
{ code: 'o.reg.refund', process_type: 'p.reg.refund', contract: 'registrator',
name: 'REFUND', wallet_op: 'BURN', wallet_from: 'w.reg.pend', wallet_to: null,
debit: 76, credit: 51,
human_name: 'Возврат регистрационного взноса при отказе совета' },
{ code: 'o.reg.refund', process_type: 'p.reg.refund', contract: 'registrator', name: 'REFUND', wallet_op: 'BURN', wallet_from: 'w.reg.pend', wallet_to: null, debit: 76, credit: 51, human_name: 'Возврат регистрационного взноса при отказе совета' },
{ code: 'o.reg.mvmin', process_type: 'p.wal.wthdrw', contract: 'registrator', name: 'MOVE_MINSHARE', wallet_op: 'TRANSFER', wallet_from: 'w.reg.minshr', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Перенос минимального паевого на главный при выходе из кооператива' },
// wallet
{ code: 'o.wal.depcpl', process_type: 'p.wal.depo', contract: 'wallet',
name: 'COMPLETE_DEPOSIT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
debit: 51, credit: 80,
human_name: 'Внесение пайщиком паевого взноса' },
{ code: 'o.wal.depcpl', process_type: 'p.wal.depo', contract: 'wallet', name: 'COMPLETE_DEPOSIT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Внесение пайщиком паевого взноса' },
{ code: 'o.wal.wthreq', process_type: 'p.wal.wthdrw', contract: 'wallet',
name: 'REQUEST_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.wal.wpend',
debit: null, credit: null,
human_name: 'Резервирование паевого под запрос на возврат' },
{ code: 'o.wal.wthreq', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'REQUEST_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.wal.wpend', debit: null, credit: null, human_name: 'Резервирование паевого под запрос на возврат' },
{ code: 'o.wal.wthdec', process_type: 'p.wal.wthdrw', contract: 'wallet',
name: 'DECLINE_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.wpend', wallet_to: 'w.wal.share',
debit: null, credit: null,
human_name: 'Снятие резерва паевого после отклонения запроса на возврат' },
{ code: 'o.wal.wthdec', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'DECLINE_WITHDRAW', wallet_op: 'TRANSFER', wallet_from: 'w.wal.wpend', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Снятие резерва паевого после отклонения запроса на возврат' },
{ code: 'o.wal.wthcpl', process_type: 'p.wal.wthdrw', contract: 'wallet',
name: 'COMPLETE_WITHDRAW', wallet_op: 'BURN', wallet_from: 'w.wal.wpend', wallet_to: null,
debit: 80, credit: 51,
human_name: 'Возврат паевого взноса пайщику' },
{ code: 'o.wal.wthcpl', process_type: 'p.wal.wthdrw', contract: 'wallet', name: 'COMPLETE_WITHDRAW', wallet_op: 'BURN', wallet_from: 'w.wal.wpend', wallet_to: null, debit: 80, credit: 51, human_name: 'Возврат паевого взноса пайщику' },
// capital (ADR-009: единые программные кошельки `w.cap.blago`/`w.cap.gen`)
// IMPORT и ACCEPT_PROPERTY — Dr 04 (НМА), не Dr 51: импорт/акт-2 фиксируют
// имущественный вклад как РИД, не деньги. Денежные взносы в Благорост — INVEST.
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital',
name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
debit: 4, credit: 80,
human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital', name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago', debit: 4, credit: 80, human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital',
name: 'INVEST', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.cap.blago',
debit: null, credit: null,
human_name: 'Инвестиция в ЦПП «Благорост»' },
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital', name: 'INVEST', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.cap.blago', debit: null, credit: null, human_name: 'Инвестиция в ЦПП «Благорост»' },
{ code: 'o.cap.commit', process_type: 'p.cap.rid', contract: 'capital',
name: 'COMMIT_RID', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.gen',
debit: 8, credit: 80,
human_name: 'Коммит РИД по программе «Генератор»' },
{ code: 'o.cap.commit', process_type: 'p.cap.rid', contract: 'capital', name: 'COMMIT_RID', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.gen', debit: 8, credit: 80, human_name: 'Коммит РИД по программе «Генератор»' },
{ code: 'o.cap.accept', process_type: 'p.cap.rid', contract: 'capital',
name: 'ACCEPT_RID', wallet_op: 'NONE', wallet_from: null, wallet_to: null,
debit: 4, credit: 8,
human_name: 'Приём РИД в паевой фонд' },
{ code: 'o.cap.accept', process_type: 'p.cap.rid', contract: 'capital', name: 'ACCEPT_RID', wallet_op: 'NONE', wallet_from: null, wallet_to: null, debit: 4, credit: 8, human_name: 'Приём РИД в паевой фонд' },
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital',
name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
debit: 4, credit: 80,
human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital', name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago', debit: 4, credit: 80, human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital',
name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp',
debit: 4, credit: 80,
human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital', name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp', debit: 4, credit: 80, human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital',
name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null,
debit: 80, credit: 4,
human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital', name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null, debit: 80, credit: 4, human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital',
name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan',
debit: 58, credit: 51,
human_name: 'Выдача пайщику беспроцентного займа' },
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital', name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan', debit: 58, credit: 51, human_name: 'Выдача пайщику беспроцентного займа' },
{ code: 'o.cap.repay', process_type: 'p.cap.rid', contract: 'capital',
name: 'REPAY', wallet_op: 'TRANSFER', wallet_from: 'w.cap.loan', wallet_to: 'w.wal.share',
debit: 80, credit: 58,
human_name: 'Возврат беспроцентного займа пайщика по акту-2' },
{ code: 'o.cap.repay', process_type: 'p.cap.rid', contract: 'capital', name: 'REPAY', wallet_op: 'TRANSFER', wallet_from: 'w.cap.loan', wallet_to: 'w.wal.share', debit: 80, credit: 58, human_name: 'Возврат беспроцентного займа пайщика по акту-2' },
{ code: 'o.cap.wthcap', process_type: 'p.cap.wthcap', contract: 'capital',
name: 'WITHDRAW_FROM_CAPITAL', wallet_op: 'TRANSFER', wallet_from: 'w.cap.blago', wallet_to: 'w.wal.share',
debit: null, credit: null,
human_name: 'Возврат паевого из ЦПП «Благорост» в Цифровой Кошелёк' },
{ code: 'o.cap.wthcap', process_type: 'p.cap.wthcap', contract: 'capital', name: 'WITHDRAW_FROM_CAPITAL', wallet_op: 'TRANSFER', wallet_from: 'w.cap.blago', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Возврат паевого из ЦПП «Благорост» в Цифровой Кошелёк' },
{ code: 'o.cap.cnvshr', process_type: 'p.cap.rid', contract: 'capital',
name: 'CONVERT_TO_SHARE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.wal.share',
debit: null, credit: null,
human_name: 'Конвертация сегмента: РИД → главный кошелёк' },
{ code: 'o.cap.cnvshr', process_type: 'p.cap.rid', contract: 'capital', name: 'CONVERT_TO_SHARE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.wal.share', debit: null, credit: null, human_name: 'Конвертация сегмента: РИД → главный кошелёк' },
{ code: 'o.cap.cnvbl', process_type: 'p.cap.rid', contract: 'capital',
name: 'CONVERT_TO_BLAGO', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.cap.blago',
debit: null, credit: null,
human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
{ code: 'o.cap.cnvbl', process_type: 'p.cap.rid', contract: 'capital', name: 'CONVERT_TO_BLAGO', wallet_op: 'TRANSFER', wallet_from: 'w.cap.gen', wallet_to: 'w.cap.blago', debit: null, credit: null, human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
{ code: 'o.cap.pgtop', process_type: 'p.cap.pgexp', contract: 'capital',
name: 'PROGRAM_EXPENSE_TOPUP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.pgexp',
@@ -204,59 +140,34 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
human_name: 'Доплата сверх подотчёта (перерасход)' },
// marketplace
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace',
name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
debit: 51, credit: 80,
human_name: 'Подтверждение поставки' },
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace', name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Подтверждение поставки' },
{ code: 'o.mkt.recv', process_type: 'p.mkt.reqst', contract: 'marketplace',
name: 'CONFIRM_RECEIPT', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.payout',
debit: 80, credit: 51,
human_name: 'Подтверждение получения — выплата поставщику' },
{ code: 'o.mkt.recv', process_type: 'p.mkt.reqst', contract: 'marketplace', name: 'CONFIRM_RECEIPT', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.payout', debit: 80, credit: 51, human_name: 'Подтверждение получения — выплата поставщику' },
// soviet
{ code: 'o.sov.axncnv', process_type: 'p.sov.axncnv', contract: 'soviet',
name: 'CONVERT_AXN', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.sov.delgte',
debit: 80, credit: 86,
human_name: 'Трансляция паевого в членский взнос инфраструктуры' },
{ code: 'o.sov.axncnv', process_type: 'p.sov.axncnv', contract: 'soviet', name: 'CONVERT_AXN', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.sov.delgte', debit: 80, credit: 86, human_name: 'Трансляция паевого в членский взнос инфраструктуры' },
// migration
{ code: 'o.mig.minshr', process_type: 'p.mig.trans', contract: 'migration',
name: 'MIN_SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr',
debit: 51, credit: 80,
human_name: 'Транзит: минимальные паевые взносы' },
{ code: 'o.mig.minshr', process_type: 'p.mig.trans', contract: 'migration', name: 'MIN_SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.minshr', debit: 51, credit: 80, human_name: 'Транзит: минимальные паевые взносы' },
{ code: 'o.mig.share', process_type: 'p.mig.trans', contract: 'migration',
name: 'SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
debit: 51, credit: 80,
human_name: 'Транзит: остаток паевых взносов деньгами' },
{ code: 'o.mig.share', process_type: 'p.mig.trans', contract: 'migration', name: 'SHARE', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share', debit: 51, credit: 80, human_name: 'Транзит: остаток паевых взносов деньгами' },
{ code: 'o.mig.entry', process_type: 'p.mig.trans', contract: 'migration',
name: 'ENTRY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry',
debit: 51, credit: 86,
human_name: 'Транзит: вступительные взносы' },
{ code: 'o.mig.entry', process_type: 'p.mig.trans', contract: 'migration', name: 'ENTRY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.reg.entry', debit: 51, credit: 86, human_name: 'Транзит: вступительные взносы' },
// adjustment (ручные корректировки председателя — динамические параметры,
// не идут через ledger2::apply; см. operations.hpp `OPERATION_ADJUSTMENT_REGISTRY`).
{ code: 'o.adj.walmove', process_type: 'p.adj.fix', contract: 'ledger2',
name: 'WALMOVE', wallet_op: null, wallet_from: null, wallet_to: null,
debit: null, credit: null,
human_name: 'Перевод между кошельками',
kind: 'adjustment' },
{ code: 'o.adj.walmove', process_type: 'p.adj.fix', contract: 'ledger2', name: 'WALMOVE', wallet_op: null, wallet_from: null, wallet_to: null, debit: null, credit: null, human_name: 'Перевод между кошельками', kind: 'adjustment' },
{ code: 'o.adj.rev', process_type: 'p.adj.fix', contract: 'ledger2',
name: 'REVERSAL', wallet_op: null, wallet_from: null, wallet_to: null,
debit: null, credit: null,
human_name: 'Откат операции',
kind: 'adjustment' },
{ code: 'o.adj.rev', process_type: 'p.adj.fix', contract: 'ledger2', name: 'REVERSAL', wallet_op: null, wallet_from: null, wallet_to: null, debit: null, credit: null, human_name: 'Откат операции', kind: 'adjustment' },
] as const
const opByCode = new Map<string, OperationMeta>(
LEDGER2_OPERATION_REGISTRY.map((o) => [o.code, o]),
LEDGER2_OPERATION_REGISTRY.map(o => [o.code, o]),
)
export function getOperationMeta(code: string | null | undefined): OperationMeta | undefined {
if (!code) return undefined
if (!code)
return undefined
return opByCode.get(code)
}
@@ -62,3 +62,15 @@ export const LEDGER2_USER_SHARED_PROGRAM_MAPPING: readonly ProgramWalletMapping[
{ wallet_name: "w.exp.adv", required_program_id: 0, program_label: null },
{ wallet_name: "w.reg.pend", required_program_id: 0, program_label: null },
] as const
/**
* Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.
* Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит
* этот сет, собирает доступные балансы и ставит их на возврат; backend-preview
* считает по нему же расчёт на фронте всегда совпадает с тем, что вернёт контракт.
*/
export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [
"w.reg.minshr",
"w.wal.share",
"w.cap.blago",
] as const
@@ -18,6 +18,7 @@
import type { IName } from '../interfaces/ledger2'
export {
LEDGER2_EXIT_REFUND_WALLETS,
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
LEDGER2_WALLET_REGISTRY,
} from './wallets.generated'
@@ -28,6 +29,7 @@ export type {
} from './wallets.generated'
import {
LEDGER2_EXIT_REFUND_WALLETS,
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
LEDGER2_WALLET_REGISTRY,
} from './wallets.generated'
@@ -64,6 +66,26 @@ const walletNamesByProgramId = (() => {
*/
export const MEMBERSHIP_WALLET_NAME = 'w.wal.member'
/**
* wallet_name целевого паевого взноса пайщика (program_id=1, ЦК).
*/
export const SHARE_WALLET_NAME = 'w.wal.share'
/**
* wallet_name минимального паевого взноса пайщика (required_program_id=0
* вне программ, заполняется при регистрации). Возвращается пайщику при выходе.
*/
export const MIN_SHARE_WALLET_NAME = 'w.reg.minshr'
/**
* Сет паевых («боевых») кошельков, возвращаемых пайщику при выходе из кооператива
* (`w.reg.minshr` + `w.wal.share` + `w.cap.blago`). Источник истины контракт
* (`LEDGER2_EXIT_REFUND_WALLETS` в wallets.hpp), сгенерирован в wallets.generated.
* Контракт `confirmexit` пылесосит эти кошельки на возврат; backend-preview
* суммирует их же расчёт всегда совпадает с тем, что вернёт контракт.
*/
export const EXIT_REFUND_WALLET_NAMES: readonly string[] = LEDGER2_EXIT_REFUND_WALLETS
/**
* Все wallet_name'ы, привязанные к какой-либо программе (program_id > 0).
* Исключает кошельки с required_program_id == 0 (например, `w.reg.minshr`).
@@ -32,6 +32,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_USER_SHARED_PRO
"required_program_id": 0,
"wallet_name": "w.cap.preimp",
},
{
"program_label": null,
"required_program_id": 0,
"wallet_name": "w.exp.adv",
},
{
"program_label": null,
"required_program_id": 0,
@@ -67,6 +72,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
"kind": "USER_SHARED",
"name": "w.cap.preimp",
},
{
"human_name": "Подотчётные средства пайщика",
"kind": "USER_SHARED",
"name": "w.exp.adv",
},
{
"human_name": "Регистрационный взнос в ожидании решения совета",
"kind": "USER_SHARED",
@@ -122,5 +132,10 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
"kind": "COOPERATIVE",
"name": "w.mkt.payout",
},
{
"human_name": "Пул программных расходов ЦПП «Благорост»",
"kind": "COOPERATIVE",
"name": "w.cap.pgexp",
},
]
`;
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
EXIT_REFUND_WALLET_NAMES,
LEDGER2_EXIT_REFUND_WALLETS,
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
LEDGER2_WALLET_REGISTRY,
programIdForWallet,
@@ -35,4 +37,13 @@ describe('ledger2 wallets registry (generated from C++)', () => {
it('ЦК split: program_id=1 → share + member', () => {
expect(walletNamesForProgram(1).sort()).toEqual(['w.wal.member', 'w.wal.share'])
})
it('exit-refund сет: ровно три паевых кошелька (minshr + share + blago)', () => {
expect(LEDGER2_EXIT_REFUND_WALLETS).toEqual(['w.reg.minshr', 'w.wal.share', 'w.cap.blago'])
// алиас-обёртка ссылается на тот же сет
expect(EXIT_REFUND_WALLET_NAMES).toEqual(LEDGER2_EXIT_REFUND_WALLETS)
// каждый кошелёк сета зарегистрирован в реестре
const known = new Set(LEDGER2_WALLET_REGISTRY.map(w => w.name))
for (const w of LEDGER2_EXIT_REFUND_WALLETS) expect(known.has(w)).toBe(true)
})
})
@@ -1,2 +1,3 @@
export * from './ui';
export { useCapitalOnboarding } from './model';
export type { CapitalOnboardingState } from './api';
@@ -13,6 +13,8 @@ interface GeneratedDocument {
full_title: string;
}
type CapitalOnboardingStepId = Mutations.Capital.CompleteOnboardingStep.IInput['data']['step'];
export const useCapitalOnboarding = () => {
const systemStore = useSystemStore();
const sessionStore = useSessionStore();
@@ -24,30 +26,40 @@ export const useCapitalOnboarding = () => {
const currentGeneratedDoc = ref<GeneratedDocument | null>(null);
// Маппинг шагов на registry_id
const stepToRegistryId: Record<string, number> = {
const stepToRegistryId: Record<CapitalOnboardingStepId, number> = {
'generator_program_template': 994,
'generation_contract_template': 997,
'generator_offer_template': 995,
'blagorost_program': 998,
'blagorost_offer_template': 999,
};
const capitalProgramDocDataRegistryIds = new Set([994, 995, 998, 999]);
const isCapitalOnboardingStepId = (stepId: string): stepId is CapitalOnboardingStepId => {
return stepId in stepToRegistryId;
};
// Генерация документа для шага
const generateDocument = async (step: ICouncilOnboardingStep): Promise<GeneratedDocument> => {
try {
generatingDocument.value = true;
const registry_id = stepToRegistryId[step.id];
if (!registry_id) {
if (!isCapitalOnboardingStepId(step.id)) {
throw new Error(`Неизвестный шаг онбординга: ${step.id}`);
}
const registry_id = stepToRegistryId[step.id];
const docDataHash = onboardingState.value?.capital_program_doc_data_hash;
if (capitalProgramDocDataRegistryIds.has(registry_id) && !docDataHash) {
throw new Error('Параметры документов ЦПП не заполнены: переустановите или обновите настройки расширения capital');
}
const generateDocInput: Mutations.Documents.GenerateDocument.IInput = {
input: {
data: {
coopname: systemStore.info?.coopname || '',
username: sessionStore.username,
registry_id,
...(docDataHash && capitalProgramDocDataRegistryIds.has(registry_id) ? { doc_data_hash: docDataHash } : {}),
},
},
};
@@ -189,9 +201,13 @@ export const useCapitalOnboarding = () => {
await handleStepClick(step);
}
if (!isCapitalOnboardingStepId(step.id)) {
throw new Error(`Неизвестный шаг онбординга: ${step.id}`);
}
// Подготавливаем данные для отправки
const stepData: Mutations.Capital.CompleteOnboardingStep.IInput['data'] = {
step: step.id as any,
step: step.id,
title: step.title,
question: step.question,
decision: currentGeneratedDoc.value?.html || step.decisionPrefix || step.decision,
@@ -0,0 +1,387 @@
<template lang="pug">
BaseCard(title='Параметры документов ЦПП')
p.capital-doc-params__intro.t-body2
| Заполните поля, которые будут подставлены в положения и оферты программ «ГЕНЕРАТОР» и «БЛАГОРОСТ».
| Перед установкой расширения сформируйте предпросмотр документов: так параметры сохранятся в PrivateData, а в config попадёт только hash.
BaseInput(
:model-value='config?.capital_program_doc_data_hash || ""'
label='Hash PrivateData документов'
hint='Появится после формирования предпросмотра'
readonly
mono
)
BaseBanner(v-if='config?.capital_program_doc_data_hash' variant='pos')
| Параметры сохранены. Hash: {{ config.capital_program_doc_data_hash }}
q-expansion-item(default-opened label='Протокол и кооператив' icon='description')
.row.q-col-gutter-md.q-mt-sm
.col-12.col-md-3(v-for='field in protocolFields' :key='field.key')
BaseInput(
v-model='form[field.key]'
:label='field.label'
:error='fieldErrors[field.key]'
)
.col-12.col-md-6(v-for='field in cooperativeFields' :key='field.key')
BaseInput(
v-model='form[field.key]'
:label='field.label'
:error='fieldErrors[field.key]'
)
q-expansion-item(label='Программа ГЕНЕРАТОР' icon='bolt')
.capital-doc-params__section
BaseInput(
v-for='field in generatorFields'
:key='field.key'
v-model='form[field.key]'
:label='field.label'
:type='field.type || "textarea"'
:autogrow='field.type !== "text"'
:error='fieldErrors[field.key]'
)
q-expansion-item(label='Программа БЛАГОРОСТ' icon='eco')
.capital-doc-params__section
BaseInput(
v-for='field in blagorostFields'
:key='field.key'
v-model='form[field.key]'
:label='field.label'
type='textarea'
autogrow
:error='fieldErrors[field.key]'
)
q-expansion-item(label='Возврат, источники и оферты' icon='article')
.capital-doc-params__section
BaseInput(
v-for='field in returnFields'
:key='field.key'
v-model='form[field.key]'
:label='field.label'
:type='field.type || "textarea"'
:autogrow='field.type !== "text"'
:error='fieldErrors[field.key]'
)
.capital-doc-params__footer
p.capital-doc-params__hint.t-caption
| Предпросмотр генерирует документы без публикации и сохраняет PrivateData по hash.
BaseButton(:loading='isGenerating' @click='generatePreview')
| Сформировать предпросмотр
BaseDialog(v-model='previewOpen' title='Предпросмотр положений ЦПП' size='xl')
.capital-doc-params__preview(v-if='previewDocuments.length')
q-tabs(v-model='activePreviewTab' dense align='left' class='capital-doc-params__tabs')
q-tab(
v-for='doc in previewDocuments'
:key='doc.registry_id'
:name='String(doc.registry_id)'
:label='doc.title'
)
q-separator
q-tab-panels(v-model='activePreviewTab' animated)
q-tab-panel(
v-for='doc in previewDocuments'
:key='doc.registry_id'
:name='String(doc.registry_id)'
)
.doc-preview
DocumentHtmlReader(:html='doc.html' :sanitize='false')
template(#footer)
BaseButton(variant='ghost' @click='previewOpen = false')
| Закрыть
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { Mutations } from '@coopenomics/sdk';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { client } from 'src/shared/api/client';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseBanner, BaseButton, BaseCard, BaseDialog, BaseInput } from 'src/shared/ui/base';
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
type CapitalProgramPrivateData = {
approval_protocol_number: string;
approval_protocol_day: string;
approval_protocol_month: string;
approval_protocol_year: string;
cooperative_name: string;
cooperative_short_name: string;
cooperative_quoted_name: string;
website: string;
chairman_full_name: string;
generator_program_purpose: string;
eoap_definition: string;
generator_task_goal: string;
idea_unit_cost: string;
idea_unit_cost_words: string;
blagorost_goal_expansion: string;
blagorost_task_expansion: string;
blagorost_task_development: string;
return_source_description: string;
return_additional_source: string;
offer_template_number: string;
};
type FieldDefinition = {
key: keyof CapitalProgramPrivateData;
label: string;
type?: 'text' | 'textarea';
};
type CapitalProgramConfig = Record<string, unknown> & {
capital_program_doc_data_hash?: string | null;
};
type GeneratedPreviewDocument = NonNullable<
Mutations.Documents.GenerateDocument.IOutput[typeof Mutations.Documents.GenerateDocument.name]
>;
interface Props {
config: CapitalProgramConfig;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'update:config', value: CapitalProgramConfig): void;
}>();
const system = useSystemStore();
const session = useSessionStore();
const isGenerating = ref(false);
const previewOpen = ref(false);
const activePreviewTab = ref('994');
const previewDocuments = ref<Array<{ registry_id: number; title: string; html: string; hash: string }>>([]);
const fieldErrors = reactive<Partial<Record<keyof CapitalProgramPrivateData, string>>>({});
const form = reactive<Omit<CapitalProgramPrivateData, 'cooperative_quoted_name'>>({
approval_protocol_number: '',
approval_protocol_day: '',
approval_protocol_month: '',
approval_protocol_year: '',
cooperative_name: '',
cooperative_short_name: '',
website: '',
chairman_full_name: '',
generator_program_purpose: '',
eoap_definition: '',
generator_task_goal: '',
idea_unit_cost: '',
idea_unit_cost_words: '',
blagorost_goal_expansion: '',
blagorost_task_expansion: '',
blagorost_task_development: '',
return_source_description: '',
return_additional_source: '',
offer_template_number: '______',
});
const protocolFields: FieldDefinition[] = [
{ key: 'approval_protocol_number', label: 'Номер протокола' },
{ key: 'approval_protocol_day', label: 'День протокола' },
{ key: 'approval_protocol_month', label: 'Месяц протокола' },
{ key: 'approval_protocol_year', label: 'Год протокола' },
];
const cooperativeFields: FieldDefinition[] = [
{ key: 'cooperative_name', label: 'Наименование кооператива' },
{ key: 'cooperative_short_name', label: 'Краткое наименование (ПК)' },
{ key: 'website', label: 'Сайт' },
{ key: 'chairman_full_name', label: 'ФИО председателя' },
];
const generatorFields: FieldDefinition[] = [
{ key: 'generator_program_purpose', label: 'Назначение программы ГЕНЕРАТОР' },
{ key: 'eoap_definition', label: 'Определение ЕОАП' },
{ key: 'generator_task_goal', label: 'Цель задач ГЕНЕРАТОР' },
{ key: 'idea_unit_cost', label: 'Стоимость единицы идеи', type: 'text' },
{ key: 'idea_unit_cost_words', label: 'Стоимость единицы идеи прописью', type: 'text' },
];
const blagorostFields: FieldDefinition[] = [
{ key: 'blagorost_goal_expansion', label: 'Расширение цели БЛАГОРОСТ' },
{ key: 'blagorost_task_expansion', label: 'Задача расширения БЛАГОРОСТ' },
{ key: 'blagorost_task_development', label: 'Задача развития БЛАГОРОСТ' },
];
const returnFields: FieldDefinition[] = [
{ key: 'return_source_description', label: 'Источник возврата' },
{ key: 'return_additional_source', label: 'Дополнительный источник возврата' },
{ key: 'offer_template_number', label: 'Номер шаблона оферты', type: 'text' },
];
const allFields = computed(() => [
...protocolFields,
...cooperativeFields,
...generatorFields,
...blagorostFields,
...returnFields,
]);
watch(
() => system.info,
(info) => {
if (!info) return;
form.cooperative_name ||= String(info.coopname || '').toUpperCase();
form.cooperative_short_name ||= 'ПК';
form.website ||= info.website || info.contacts?.website || '';
form.chairman_full_name ||= info.chairman?.full_name || info.chairman_full_name || '';
},
{ immediate: true }
);
function getPayload(): CapitalProgramPrivateData {
const cooperativeName = String(form.cooperative_name || '').trim();
return {
...Object.fromEntries(
allFields.value.map(({ key }) => [key, String(form[key] || '').trim()])
) as Omit<CapitalProgramPrivateData, 'cooperative_quoted_name'>,
cooperative_quoted_name: cooperativeName ? `«${cooperativeName}»` : '',
};
}
function validatePayload(payload: CapitalProgramPrivateData): string[] {
return allFields.value
.filter(({ key }) => !payload[key])
.map(({ label }) => label);
}
function clearFieldErrors(): void {
for (const key of Object.keys(fieldErrors) as Array<keyof CapitalProgramPrivateData>) {
delete fieldErrors[key];
}
}
function setFieldErrors(missingLabels: string[]): void {
clearFieldErrors();
for (const { key, label } of allFields.value) {
if (missingLabels.includes(label)) {
fieldErrors[key] = 'Заполните поле';
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function getDocDataHash(doc: GeneratedPreviewDocument): string | null {
if (!isRecord(doc.meta)) return null;
const hash = doc.meta.doc_data_hash;
return typeof hash === 'string' && hash.length > 0 ? hash : null;
}
async function generateDocument(registry_id: number, payload: CapitalProgramPrivateData): Promise<GeneratedPreviewDocument> {
const { [Mutations.Documents.GenerateDocument.name]: result } = await client.Mutation(
Mutations.Documents.GenerateDocument.mutation,
{
variables: {
input: {
data: {
coopname: system.info?.coopname || '',
username: session.username,
lang: 'ru',
registry_id,
doc_data: payload,
},
options: { skip_save: true },
},
},
}
);
if (!result) {
throw new Error('Документ не был сгенерирован');
}
return result;
}
async function generatePreview() {
const payload = getPayload();
const missing = validatePayload(payload);
if (missing.length) {
setFieldErrors(missing);
FailAlert(`Заполните параметры документов: ${missing.join(', ')}`);
return;
}
clearFieldErrors();
try {
isGenerating.value = true;
const docs = await Promise.all([
generateDocument(994, payload),
generateDocument(998, payload),
]);
const docDataHash = docs
.map(getDocDataHash)
.find((hash: unknown): hash is string => typeof hash === 'string' && hash.length > 0);
if (!docDataHash) {
throw new Error('Генератор не вернул doc_data_hash');
}
previewDocuments.value = docs.map((doc, index) => ({
registry_id: index === 0 ? 994 : 998,
title: doc.full_title,
html: doc.html,
hash: doc.hash,
}));
activePreviewTab.value = '994';
previewOpen.value = true;
emit('update:config', {
...props.config,
capital_program_doc_data_hash: docDataHash,
});
SuccessAlert('Параметры документов сохранены в PrivateData');
} catch (error) {
FailAlert(error);
} finally {
isGenerating.value = false;
}
}
</script>
<style scoped lang="scss">
.capital-doc-params__intro {
margin: 0 0 var(--p-4);
color: var(--p-ink-secondary);
}
.capital-doc-params__section {
display: flex;
flex-direction: column;
gap: var(--p-3);
margin-top: var(--p-2);
}
.capital-doc-params__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-4);
margin-top: var(--p-6);
}
.capital-doc-params__hint {
margin: 0;
color: var(--p-ink-secondary);
}
.capital-doc-params__tabs {
color: var(--p-primary);
}
.doc-preview :deep([data-doc-param]) {
background: var(--p-warn-soft);
border-radius: var(--p-r-sm);
padding: 0 2px;
}
</style>
@@ -1 +1,2 @@
export { default as CapitalOnboardingCard } from './CapitalOnboardingCard.vue';
export { default as CapitalProgramDocumentParametersWidget } from './CapitalProgramDocumentParametersWidget.vue';
@@ -1,5 +1,7 @@
import { ProfilePage } from 'src/pages/User/ProfilePage';
import { WalletPage } from 'src/pages/User/WalletPage';
import { MembershipExitPage } from 'src/pages/User/MembershipExitPage';
import { MembershipExitConfirmPage } from 'src/pages/User/MembershipExitConfirmPage';
import { ConnectionAgreementPage, InstallationCompletedPage } from 'src/pages/Union/ConnectionAgreement';
import { UserPaymentMethodsPage } from 'src/pages/User/PaymentMethodsPage';
import { ContactsPage } from 'src/pages/Contacts';
@@ -43,6 +45,22 @@ export default async function (): Promise<IWorkspaceConfig[]> {
component: markRaw(WalletPage),
children: [],
},
{
// Подтверждение авторизуется токеном из ссылки (мутация публичная),
// поэтому страница доступна БЕЗ входа — иначе навигационный гард
// редиректит на login-redirect. requiresAuth:false = исключение из auth-гейта.
meta: {
title: 'Подтверждение выхода',
icon: 'logout',
roles: [],
requiresAuth: false,
hidden: true,
},
path: 'membership-exit/confirm',
name: 'membership-exit-confirm',
component: markRaw(MembershipExitConfirmPage),
children: [],
},
{
meta: {
title: 'Удостоверение',
@@ -58,7 +76,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
{
meta: {
title: 'Подключение',
icon: 'fas fa-link',
icon: 'link',
roles: ['user'],
conditions: 'isCoop === true && coopname === "voskhod"',
requiresAuth: true,
@@ -85,7 +103,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
{
meta: {
title: 'Реквизиты',
icon: 'fas fa-link',
icon: 'account_balance',
roles: ['user', 'member', 'chairman'],
requiresAuth: true,
},
@@ -161,6 +179,20 @@ export default async function (): Promise<IWorkspaceConfig[]> {
roles: [],
},
},
{
// Предпоследний пункт: выход из кооператива (не путать с «Выйти из
// кабинета» в RailUserCard). Иконка group_remove — не logout.
meta: {
title: 'Выход из кооператива',
icon: 'group_remove',
roles: [],
requiresAuth: true,
},
path: 'membership-exit',
name: 'membership-exit',
component: markRaw(MembershipExitPage),
children: [],
},
{
meta: {
title: 'Поддержка',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/desktop",
"version": "2026.6.9-18",
"version": "2026.6.24",
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
"productName": "Desktop App",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",

Some files were not shown because too many files have changed in this diff Show More