Compare commits

...

199 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
coopops c200a834eb [C28-67][@ant] fix: spec адаптера расходов — убраны стейл-кейсы authExp/declineExp
Typecheck / desktop (pull_request) Successful in 14m23s
Typecheck / controller (pull_request) Successful in 13m43s
Контроллерный tsc --noEmit (CI) падал: spec тестировал adapter.authExp /
adapter.declineExp, которых нет в ExpensesBlockchainAdapter по дизайну.
authexp/declexp исполняет контракт soviet как callbacks решения совета —
backend-адаптер несёт только 6 прямых actions (createexp/payexp/reportexp/
returnexp/overspendexp/closeexp). Зафиксировано в port, mutations-service и
комментарии адаптера. Прав код — приводим тест к дизайну, удаляя два кейса.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:04:28 +00:00
coopops 56121c2346 [C28-66][@ant] fix: type-errors шасси расходов — vue-tsc в CI
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
CI впервые дошёл до vue-tsc по ветке и вскрыл накопленный долг типов
(локально vue-tsc не гоняем). Чиним точечно, без смены логики:

- uploaded_at — Zeus-скаляр даты типизирован как {}: new Date(String(...))
  и date:String(...) в ProgramExpensePage, ExpenseDetailPage и трёх
  Payment-panel (Attach/Settlement/Report).
- ExpenseDetailPage: loadPayments options требует sortOrder → 'DESC';
  :key платежа допускает null → ключ pay.hash ?? idx.
- PaymentsPage: routeUsername null → undefined под prop :username.
- expenses/model: снят реэкспорт несуществующего IAuthorizeProposalDraft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:53:27 +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 3064e4b7ae [C28-65][@ant] refactor: платежи расхода — один запрос по proposal_hash
Typecheck / desktop (pull_request) Failing after 8m27s
Typecheck / controller (pull_request) Failing after 8m22s
ExpenseDetailPage переведён со сбора платежей по реконструированным хэшам на
один запрос getPayments({ coopname, proposal_hash }) — фильтр из C28-64. Убрана
скопированная серверная формула settlementPaymentHash/sha256Hex из
shared/lib/expenses: фронт больше не дублирует деривацию хэшей расчётных
платёжек. Запрос type-agnostic — ловит все связанные платежи любого типа,
устойчив к добавлению новых видов. Сортировка: выдача/оплата раньше расчётных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:17:16 +00:00
coopops 435634bfd9 [C28-64][@ant] feat: фильтр платежей по proposal_hash в gateway + regen SDK
Добавлен фильтр PaymentFiltersInput.proposal_hash — возвращает все платежи,
связанные с расходом (служебной запиской). Связь платёж→расход уже зашита
бэкендом в json-поле blockchain_data.proposal_hash (и у платежа выдачи аванса/
оплаты организации, и у расчётных платёжек возврата/доплаты); фильтр извлекает
его через json-оператор ->> в typeorm-репозитории. Так связанные платежи
достаются одним запросом по расходу, без реконструкции хэшей на фронте.

regen: schema → generate-client → sdk build (zeus-клиент controller+sdk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:17:06 +00:00
coopops 0203bf2405 [C28-63][@ant] feat: движения платежей в «Истории состояний» расхода
Typecheck / desktop (pull_request) Failing after 8m34s
Typecheck / controller (pull_request) Has been cancelled
В историю состояний расхода добавлены события платежей: выдача аванса/оплата
организации, возврат недорасхода, доплата перерасхода, отклонения. Берутся из тех
же linkedPayments (C28-61), без отдельного запроса. Тип/иконка/текст по статусу
(исполнен / оплачен кассой / создан / отклонён), сумма и причина отказа — в
описании; сортируются в общую ленту по дате updated_at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:54:19 +00:00
coopops 91cfc0f0af [C28-62][@ant] feat: кнопка «Назад» (к реестру расходов) в детали расхода
Из детали расхода нельзя было вернуться в реестр расходов. Добавлен canon
back-link под шапкой (как в MeetDetails/DocumentDetails): router.push на
expenses-registry. Виден всегда, даже пока расход грузится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:52:28 +00:00
coopops 882646d6aa [C28-61][@ant] feat: платежи по расходу в деталях СЗ + переход в реестр платежей
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Has been cancelled
В детали расхода (стол совета → реестр расходов → расход) добавлена секция
«Платежи по расходу»: выдача аванса/оплата организации и, если был перерасчёт по
чекам, расчётная платёжка (возврат недорасхода / доплата перерасхода). Реквизиты
«куда уходил платёж», назначение и причина отклонения — внутри PaymentDetails.

Платежи собираются по ДЕТЕРМИНИРОВАННЫМ хэшам, а не по username: у позиции-
организации платёж принадлежит кооперативу, у аванса — пайщику-получателю, и у
каждой позиции свой получатель — единого владельца нет. Хэш выдачи = item_hash,
расчётные = sha256('expense-settlement:coop:item:kind') (новый settlementPaymentHash
в shared/lib/expenses, точная копия серверного generateHashFromString). Точечный
hash-фильтр gateway не требует листать общий реестр и нового бэкенд-поля.

Клик «Открыть в реестре платежей» → реестр кассира, отфильтрованный по владельцу
(:username? в PaymentsPage), с фокусом на платёж (виджет раскрывает его по ?focus=hash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:25:26 +00:00
coopops 9d830e7b91 [C28-60][@ant] fix: показывать причину отклонения платежа (CANCELLED)
Кассир пишет причину в диалоге «Отклонить», бэкенд сохраняет её в payment.message
и ставит статус CANCELLED — но PaymentDetails показывал message только при FAILED,
поэтому причина нигде не отображалась. Добавлен canon-баннер (.banner--neg) с
причиной для CANCELLED: видно сразу при раскрытии платежа. Компонент общий —
работает и на столе совета, и на столе пайщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:25:06 +00:00
coopops 4453dd9cfd [C28-59][@ant] feat: кликабельная ссылка «Основание» → раскрыть платёж выдачи аванса
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Failing after 7m58s
В блоке «Основание расчёта» (C28-58) добавлена кнопка «Открыть платёж выдачи аванса»:
кассир жмёт → реестр раскрывает исходный платёж-аванс (его hash == item_hash расчётной
платёжки) и прокручивает к нему. Видно, сколько выдавалось и что в чеке, без поиска
строки того же пайщика вручную. id навешены на tr/pay-card для scrollIntoView; если
платёж аванса не на текущей странице — подсказка.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:37:59 +00:00
coopops e0202b49dd [C28-58][@ant] feat: блок «Основание расчёта» у settlement-платёжки в реестре
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Has been cancelled
Кассир (и пайщик) при раскрытии расчётной платёжки (возврат недорасхода / доплата
перерасхода) видит основание прямо в строке — без поиска исходного аванса среди
сотен строк реестра:
- ссылка на СЗ (№), что оплачивали, выдано авансом, заявлено по чекам, сумма расчёта
- список подтверждающих документов (чеки REPORT_FILE) со ссылками на открытие

Новый feature ExpenseSettlementBasis (self-contained: грузит позицию СЗ + файлы по
proposal_hash/item_hash из blockchain_data платёжки). Подключён в ListOfPaymentsWidget
для EXPENSE_RETURN/EXPENSE_OVERSPEND на обоих столах (desktop+mobile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:26:23 +00:00
coopops ca9fd8f8de [C28-57][@ant] fix: запретить повторный отчёт по авансу + показывать заявленный факт
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Корень: позиция on-chain остаётся PAID до подтверждения расчётной платёжки кассой
(reportexp отложен), поэтому отчёт можно было подать многократно на разные суммы.
Идемпотентность createSettlementPayment — лишь по виду (возврат/доплата), из-за чего
отчёт 400→200 плодил две платёжки, а 500 молча возвращал старую.

Backend (авторитетный фикс):
- reportExpenseItem: guard по report_state платежа аванса — SETTLEMENT_PENDING/CLOSED
  → BadRequestException «Отчёт по этой позиции уже подан»
- markAdvanceReportState сохраняет reported_amount (заявленный факт) рядом со статусом
- тест на guard + обновлён ассерт SETTLEMENT_PENDING

Frontend:
- ReportExpenseAdvancePanel: новые props report-state/reported-amount; форма
  (AmountInput+кнопка) доступна только пока отчёт не подан (canReport); после подачи —
  «Отчёт подан на N ₽, ждём расчёт», остаётся лишь загрузка доп.документов
- ListOfPaymentsWidget: прокидывает report_state/reported_amount в панели на обоих столах

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:21:50 +00:00
coopops f2489fc592 [C28-56][@ant] fix: сузить поле суммы в отчёте пайщика + поправить назначение возврата
Typecheck / desktop (pull_request) Failing after 8m18s
Typecheck / controller (pull_request) Failing after 7m58s
- ReportExpenseAdvancePanel: AmountInput в .report-advance__amount (max-width 240px),
  чтобы значение не висело во всю ширину панели с разрывом «лейбл … сумма»
- memo возврата: «выданных под аванс под отчёт» → «выданных авансом под отчёт»

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 05:45:21 +00:00
coopops 9ac810f34a [C28-55][@ant] fix: вернуть «Направление» в реестр (перспектива стола) + универсальные уведомления о платеже
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 8m0s
Направление платежа (ListOfPaymentsWidget):
- колонка «Направление» снова показывается на ОБОИХ столах;
- on-chain direction всегда относительно кооператива (INCOMING = в кооператив).
  Стол совета (!hideActions) так и видит; на личном столе пайщика (hideActions)
  перспектива обратная — direction инвертируется (исходящий из кооператива =
  поступление пайщику). Хелперы displayDirection/directionLabel/directionHint,
  tooltip с пояснением («В кооператив»/«Из кооператива» vs «Поступление вам»/
  «Списание с вас»). colspan/skeleton/min-width поправлены под доп. колонку.

Уведомления о платеже — без привязки к типу:
- workflow payment-refunded (любой исходящий PAID пайщику) больше не «Возврат
  взноса выполнен», а универсальное «Платёж выполнен» (тем же каналом идут
  аванс под отчёт, доплата по перерасходу и пр. — тип платежа неизвестен).
  payment-paid уже был универсальным («Платёж принят» для входящего).
- комментарий в payment-notification.service приведён в соответствие.

ESLint ✓, notifications build ✓ (id platyozh-vypolnen резолвится в каталоге).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 05:13:00 +00:00
coopops 5c14bdb0a4 [C28-54][@ant] fix(desktop): пул расходов = баланс реального кошелька + загрузка доп. документов после отчёта
Typecheck / desktop (pull_request) Failing after 8m26s
Typecheck / controller (pull_request) Failing after 7m59s
Q1 (расхождение 900 vs 50): на странице программных расходов «Доступно»
теперь = остаток кошелька w.cap.pgexp (source_wallet, с которого payexp
реально списывает оплату), а не счётчик state.program_expense_pool. Счётчик
двигался на резерве, кошелёк — на оплате, поэтому кассир видел одну сумму, а
оплата обламывалась на другой. Карточка «Зарезервировано» (счётчик) убрана,
чтобы не смешивать два контура учёта на одной карточке кошелька.

Q2 (нет поля для доп. документов после «Отчёт принят»): FileUploader в
ReportExpenseAdvancePanel вынесен из ветки isAwaitingReport — теперь доступен
и в состоянии REPORTED («Приложите дополнительный документ»), как и обещает
текст «дополнительные документы дополнят его автоматически».

ESLint ✓. Визуальная проверка — по скриншотам.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:54:16 +00:00
coopops ab9b8ddc66 [C28-53][@ant] fix(desktop): перевёрстка диалога пополнения пула + реестра платежей кассира
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Has been cancelled
Диалог «Пополнение пула программных расходов»:
- убрана тёмная плашка под кнопками (footer-bar на --p-canvas) — кнопки на
  поверхности диалога через штатный BaseDialog __foot;
- снят двойной паддинг (.form поверх body) и uppercase-eyebrow («прыгающие шрифты»);
- BaseInput → AmountInput с суффиксом валюты в узкой обёртке (не во всю ширину).

Реестр платежей (стол кассира), последовательная подача:
- AttachExpenseProofPanel и ReportExpenseAdvancePanel получили опциональный
  проп step (номер+заголовок) — Этап 1 «Подтвердите оплату», Этап 2 «Отчёт
  пайщика»; обёрнуты в .expense-flow с хайрлайн-разделителем. Пустых этапов у
  DIRECT нет (панель отчёта сама скрывается);
- в отчёте пайщика порядок изменён на «сумма → чек» (сначала AmountInput, видно
  недо/перерасход, затем приложить чек, потом кнопка);
- дружелюбные подписи, больше воздуха (gap --p-3/--p-4), снята плотность.

ESLint ✓. Визуальная проверка — по скриншотам пользователя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:44:34 +00:00
coopops b21df2b017 [C28-52][@ant] chore(sdk): regen schema/client после doc_data-перестройки 2010/2011
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Failing after 8m49s
generate-schema + generate-client + sdk build после C28-51. Zeus-клиент
(controller/zeus + sdk/src/zeus) знает ExpenseProposalSignedItemInput +
doc_data_hash на signed-meta 2010; ExpenseProposalDecisionItemInput (2011)
без requisites/payment_purpose/recipient_name. schema.gql — gitignored-артефакт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:49:23 +00:00
coopops 0fcb8abd39 [C28-51][@ant] feat(expenses): реквизиты СЗ-2010/2011 off-chain через doc_data (не публикуем в блокчейн)
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Has been cancelled
Реквизиты получателя, имя и назначение платежа больше не уходят в blockchain
meta документа. Перестроено на паттерн doc_data из marketplace2 (epic5):
приватный payload сохраняется off-chain в DocDataService фабрики, в meta едет
только doc_data_hash; при генерации/регенерации фабрика подмешивает приватную
часть в позиции по number — полнотекстовый документ не страдает.

2010 (СЗ-смета):
- cooptypes: IExpenseItem → публичный (number/description/amount/recipient_type/
  mechanics); новые IExpensePrivateItem/PrivateData/IExpenseRenderItem; Action
  extends IDocDataRef + doc_data_hash; Model.items = render-позиции.
- factory: Action подгружает PrivateData по doc_data_hash и склеивает по number;
  meta строится из публичной data; ExpenseRenderItemSchema для модели рендера.
- controller: DTO расщеплён — вход генерации «богатый» (приватные поля → сервер
  кладёт в doc_data), подписываемая meta = публичные позиции + doc_data_hash.
  Сервис: saveDocData → Action с публичными items. Десктоп не меняется (подпись
  идёт по meta, возвращённой сервером).

2011 (Решение совета): латентный канал закрыт структурно — item-DTO лишён
requisites/payment_purpose/recipient_name; шаблон протокола их и не рендерил.
Публичная ExpenseItemSchema (5 полей) для модели 2011.

cooptypes build ✓, factory build ✓, ESLint ✓, unit 11/11 ✓.
Codegen (schema/client/sdk) — отдельным шагом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:35:57 +00:00
coopops c69f0f24d8 [C28-50][@ant] feat(factory): перенести инфру doc_data из marketplace2 (приватные данные документа off-chain)
Typecheck / desktop (pull_request) Failing after 8m40s
Typecheck / controller (pull_request) Failing after 8m0s
Выборочный перенос переиспользуемого паттерна doc_data из marketplace2
(исходный коммит 10158b5b33, 598-8) — БЕЗ marketplace-документов 1102/1103,
которых нет в dev (их cooptypes-реестры и controller-сервисы тянули конфликты
modify/delete и сломали бы сборку). 1102/1103 придут штатно при мерже marketplace2.

Что такое doc_data: приватные поля документа (реквизиты/ПДн) сохраняются в Mongo
(коллекция doc_private_data) через DocDataService.save(payload, registry_id) →
{hash}; on-chain в meta публикуется только doc_data_hash (sha256). При генерации
фабрика подгружает payload по хэшу и отдаёт шаблону под зарезервированной
переменной {{ doc_data.* }}. Так реквизиты в блокчейн не попадают, но документ
регенерируем и верифицируем по слепку.

Перенесено (идентично marketplace2 — будущий мерж без конфликтов):
- factory: Services/DocData + обвязка Factory/index.ts (saveDocData/getDocData) +
  реэкспорт в Services/Databazor + Generator.saveDocData/getDocData в src/index.ts;
- controller: GeneratorPort/GeneratorInfrastructureService/DocumentDomainService —
  проброс saveDocData/getDocData;
- cooptypes: IDocDataRef { doc_data_hash } в модели документа.

cooptypes build + factory typecheck/build зелёные. Контур: следующим шагом
перенастроить СЗ-2010 на doc_data для реквизитов (отдельной задачей).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:15:15 +00:00
coopops 245a8fbd1d [C28-49][@ant] feat(expense): расшить отчёт по авансу — статус отчёта, отчёт за пайщика, закрывающие документы
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Failing after 8m3s
Разводит три независимых акта вокруг аванса под отчёт, которые UI прежде смешивал:
- Платёж (кассир + платёжка PAYMENT_PROOF) — без изменений.
- Отчёт по авансу (факт + чек REPORT_FILE → reportexp) — пайщик ИЛИ кассир «за пайщика».
- Закрывающие документы организации (новый ExpenseFileKind.CLOSING_DOC) — только DIRECT.

Бэкенд:
- ExpenseReportState (AWAITING/SETTLEMENT_PENDING/CLOSED/NOT_REQUIRED) зеркалится в
  payment.blockchain_data.report_state: в reportExpenseItem (CLOSED/SETTLEMENT_PENDING)
  и в inter-адаптере reportItem в момент on-chain reportexp (CLOSED). AWAITING — дефолт.
- CLOSING_DOC + valuesMap-описания видов файлов; SDK regen.

Desktop:
- Личный стол: убран кассирский значок «Платёжка приложена» (чужая бухгалтерия),
  добавлен бейдж статуса отчёта рядом со статусом платежа.
- Стол совета: кассир может отчитаться за пайщика (ReportExpenseAdvancePanel onBehalf,
  самоскрытие для DIRECT) + отдельная секция закрывающих документов для оплат организациям.

Авторизация reportExpenseItem уже допускала совет по любой строке — путь «за пайщика»
работает без правок guard'ов. Unit 11/11, ESLint чист, SDK typecheck зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:47:02 +00:00
coopops a0005362e0 [C28-48][@ant] fix(expense): UX расчёта аванса — суммы 2 знака, реквизиты, направление
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Failing after 7m56s
Правки по тест-прогону недо-/перерасхода:

- Суммы в реестре платежей (таблица+карточки) и инпут факта — formatAsset2Digits
  (2 знака), а не сырой on-chain precision=4.
- «Направление» (входящий/исходящий — относительно кооператива) скрыто на личном
  столе пайщика (hideActions): на его столе семантика обратная и путала. На столе
  совета остаётся (там перспектива кооператива корректна).
- PaymentDetails: убран дамп «Данные блокчейна» (JSON); реквизиты показываются при
  любом направлении (входящий возврат — реквизиты кооператива, исходящая
  доплата — реквизиты пайщика); «Сумма к переводу» — 2 знака.
- Settlement-платёжка несёт payment_details с реквизитами: возврат → банк
  кооператива (куда платит пайщик), доплата → снимок реквизитов пайщика (куда
  платит кооператив). Назначение: «Возврат неиспользованных средств, выданных под
  аванс под отчёт» / «Доплата по перерасходу аванса под отчёт».
- После отчёта реестр перезагружается и сразу раскрывает заведённую платёжку
  расчёта (по settlement_payment_hash) — пайщик видит реквизиты, не догадываясь
  нажать «развернуть».

Юнит-тесты расходов зелёные (11/11). GraphQL-схема не менялась — SDK regen не нужен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:04:05 +00:00
coopops 3ab773c4b6 [C28-47][@ant] feat(expense): SDK-regen + desktop — инпут факта и расчёт разницы
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Failing after 8m17s
Фронт-слой контура недо-/перерасхода аванса.

- payment-domain.entity: exhaustive typeLabels по 2 новым типам платежей.
- SDK regen (Zeus controller+sdk) под reportExpenseItem → ExpenseReportResult,
  optional actual_amount и enum ExpenseReportOutcome.
- SDK: селектор expenseReportResultSelector (outcome / settlement_amount /
  settlement_payment_hash / transaction); reportExpenseItem использует его,
  api панели возвращает результат.
- desktop ReportExpenseAdvancePanel: инпут «Фактически потрачено по чекам»
  (префилл выданным авансом), подсказка о недо-/перерасходе, кнопка «Отчитаться»
  вместо авто-сабмита по первому чеку. По исходу: CLOSED — «Отчёт принят»;
  RETURN_PENDING/OVERSPEND_PENDING — пояснение про платёжку расчёта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:56:06 +00:00
coopops c739c4e10a [C28-46][@ant] feat(expense): расчёт недо-/перерасхода аванса через платёжки
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Has been cancelled
Замыкаю контур факт→возврат/доплата для аванса под отчёт. Контракт уже имел
returnexp/overspendexp с двусторонними ledger2-проводками, но фронт/бэкенд их не
звали: отчёт слепо закрывал позицию на actual=аванс без сверки факта (дыра
недорасхода — пайщик мог недоотчитаться и оставить разницу).

Бэкенд-слой:
- 2 типа платежей: EXPENSE_RETURN (входящий возврат недорасхода) и
  EXPENSE_OVERSPEND (исходящая доплата перерасхода) + labels + direction-списки.
- inter-порт шасси: returnItem/overspendItem/reportItem (зеркало payItem),
  адаптер реализует через returnExp/overspendExp/reportExp.
- gateway-ветки: подтверждение кассиром EXPENSE_RETURN (income) → returnexp +
  reportexp; EXPENSE_OVERSPEND (outcome) → overspendexp + reportexp. proposal_hash
  и item_hash берутся из blockchain_data (hash платёжки уникальный, не item_hash).
- reportExpenseItem(actual_amount?): факт==аванс → reportexp сразу (CLOSED);
  недорасход → входящая платёжка на |разницу| (RETURN_PENDING); перерасход →
  исходящая (OVERSPEND_PENDING); reportexp отложен до подтверждения кассиром
  (контракт принимает settlement только на PAID-позиции). Дельта в минорных
  единицах по precision. Идемпотентность платёжки по детерминированному хэшу.
- Новый дискриминированный результат ExpenseReportResultDTO + enum
  ExpenseReportOutcome. Юнит-тесты на все три исхода + идемпотентность.

Контракт expense не изменён. Авторитет на сумму факта — пайщик (по чекам).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:43:55 +00:00
coopops e70b368163 [C28-45][@ant] revert: вернуть MyAdvancesPage как каркас стола кассира (не удалять scaffold)
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Failing after 8m13s
Откат удаления из C28-44: страницы воркспейса expenses (MyAdvances/Cashier/AdminApprove) — намеренный рабочий КАРКАС будущего стола кассира, а не мёртвый код. Решено их не удалять, а сохранить: соберём стол кассира из них позже, тогда «Мои авансы» выведем туда. Сейчас воркспейс не привязан к столу → в меню их нет и ничего на них не ведёт (то, что и нужно).

— восстановлены MyAdvancesPage.vue + роут expenses-my-advances + экспорт из barrel.
— в install.ts добавлен комментарий-маркер: это каркас, не удалять, ничего на него не ведёт намеренно; рабочие ссылки — на /:coopname/user/payments.
— ссылку напоминателя об авансах НЕ откатываем: остаётся на личные «Платежи» (/user/payments), как просил пользователь — всё ведёт через страницу платежей/реестр платежей, не на каркасные страницы.
— README приведён в соответствие (каркас, а не «удалена»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:10:40 +00:00
coopops 832ea5fd96 [C28-44][@ant] chore: удалить мёртвую страницу «Мои авансы» + перецелить ссылку напоминателя
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
MyAdvancesPage была смонтирована только в воркспейсе expenses, который ни к одному столу не привязан (меню столов приходит с бэкенда; setRoutes вешает пункт лишь если воркспейс есть в столе). Страница открывалась только по прямому URL — пользователь её не видел ни разу. Авансы пайщика-получателя и так видны на его личной странице «Платежи» (/:coopname/user/payments, ListOfPaymentsWidget + ReportExpenseAdvancePanel) — отдельная страница дублировала и была мёртвой.

— desktop: удалён MyAdvancesPage.vue, его роут expenses-my-advances и экспорт из barrel; импорт из install.ts.
— controller: напоминатель об авансах при нескольких авансах вёл на /expenses/my/advances → перецелен на /:coopname/user/payments (личные «Платежи»); один аванс по-прежнему ведёт на сам расход.
— notifications: комментарий схемы payload обновлён.
— README/E2E: ссылки и таблица страниц обновлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:06:28 +00:00
coopops 2fbf08c19a [C28-43][@ant] fix: исходящий платёж организации больше не висит в личном реестре инициатора СЗ
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Has been cancelled
Симптом: пайщик создаёт расход с получателем-организацией (не он сам) — и этот исходящий платёж появляется у него в личном кабинете на странице платежей, хотя он лишь инициатор, а деньги идут не к нему.

Корень: expense-payments.listener создавал gateway-платёж по позиции расхода с username = entity.username (создатель СЗ) для org-получателя. Личный реестр платежей фильтрует по username (loadPayments({username: me})), поэтому платёж кооператива организации оказывался на столе инициатора.

Фикс: владелец org-платежа = сам КООПЕРАТИВ (entity.coopname), а не инициатор. В личных реестрах пайщиков (фильтр по username) он больше не виден; кассир видит его в общем реестре платежей кооператива (loadPayments без username). Аванс под отчёт по-прежнему принадлежит пайщику-получателю (item.recipient) — он у него и отображается. На on-chain payexp правка не влияет: проводка берёт coopname/proposal_hash/item_hash, username не участвует (см. gateway.interactor.processOutgoingPayment).

Касается только НОВЫХ платежей; уже созданные org-платежи с username=создатель не переписываются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:54:41 +00:00
coopops 7155551837 [C28-42][@ant] fix: суммы в карточках расходов программы Благорост — формат asset (1 000,00 RUB)
Typecheck / desktop (pull_request) Failing after 8m34s
Typecheck / controller (pull_request) Failing after 8m2s
Карточки списка показывали сырой total_planned «1000.0000 RUB». ExpenseProposalListRow.total_planned по контракту типа — уже отформатированная строка; страница подавала сырьё. Оборачиваем в formatAsset2Digits в listRows ProgramExpensesPage (как суммы в сводке/детали).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:32:04 +00:00
coopops 65b792e015 [C28-41][@ant] feat: история состояний в детали расхода Совета + страница расходов программы → «Расходы»
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 7m56s
— ExpenseDetailPage (стол Совета): добавлена секция «История состояний» (ActivityTimeline, group-by-date) — как на странице расхода программы Благорост. Лента собирается из фактов в данных: создание СЗ, подпись заявления, утверждение/отклонение советом, приложенные документы, подача отчёта, закрытие расхода; актор — ФИО создателя. Отдельной журнальной таблицы у шасси нет.
— capital/install.ts: страница расходов программы переименована «Расходы программы» → «Расходы» (title в meta). Доступ только совету — roles ['chairman','member'] уже стояли. Попутно заменена запрещённая каноном FontAwesome-иконка fa-solid fa-receipt → Material receipt_long на обоих роутах (список + деталь).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:54:04 +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 04c0b1195a [C28-40][@ant] fix: таблицы расходов схлопывались и рвали текст посимвольно — !important на min-width (как в реестре платежей)
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Failing after 8m10s
— симптом: колонка «Пайщик» (ФИО) в реестре расходов рвалась по буквам в столбик.
— причина: глобальный канон components.css форсит `.table{min-width:0!important}`; мой `.table{min-width:…}` без !important проигрывал → колонки схлопывались уже контента и слова ломались посимвольно. Плюс `overflow-wrap: anywhere` на ячейках добивал.
— фикс (эталон — ListOfPaymentsWidget): `.table{table-layout:fixed!important; min-width:…!important}` + `overflow-wrap: break-word` (не anywhere) на текстовых ячейках. При нехватке ширины таблица скроллится в .table-scroll, а не ломает слова. Применено и к таблице позиций в детали (тот же латентный баг).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:54:57 +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 22e30672a3 [C28-39][@ant] fix: реестр и деталь расхода — чистка шапок, ФИО вместо аккаунта, кликабельные чеки, мелочи вёрстки
Typecheck / desktop (pull_request) Failing after 8m27s
Typecheck / controller (pull_request) Failing after 7m59s
По скриншот-ревью пользователя.

Реестр расходов (ExpensesRegistryPage):
— убрана шапка PageHead («Шасси расходов» / «Реестр расходов» / подзаголовок) — сразу таблица.
— колонка «Пайщик»: ФИО первой строкой (из сертификата подписанта СЗ — её подписывает создатель, доп. запрос не нужен), ниже имя аккаунта мелким моно с копированием по клику (copyToClipboard).
— добавлена колонка-шеврон справа (chevron_right) — видно, что строка открывается; подсвечивается на hover.

Деталь расхода (ExpenseDetailPage):
— убрана шапка PageHead; статус — чипом вверху справа (канон detail-страниц). Переверстана одноколоночно секциями с заголовками-эйбрау (Сводка / Документы / Строки расходов / Чеки и подтверждения) — как на странице расхода программы.
— Сводка: «Пайщик» = ФИО (из сертификата подписанта), отдельная строка «Аккаунт» (моно, копируемая); «Хеш» (рус. ярлык вместо «Hash») переносится и копируется (DataRow mono, vertical).
— «Строки расходов»: счётчик с правильным русским множественным («1 строка / 2 строки / 5 строк») вместо «1 строк».
— «Чеки и подтверждения»: имя файла — гиперссылка, клик открывает документ в новой вкладке (read_url короткоживущий — запрашиваем свежий по id через новый getExpenseFileReadUrl, как в capital); кто приложил — ФИО (из карты подписей СЗ+протокола), а не имя аккаунта; убраны storage_key/размер.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:21:12 +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 ae80b7f00b [C28-38][@ant] feat: стол совета «Расходы» → реестр всех расходов кооператива (вместо страницы пулов)
Typecheck / desktop (pull_request) Failing after 8m41s
Typecheck / controller (pull_request) Failing after 8m1s
— зачем: на столе совета «Расходы» показывал карточки кошельков-пулов и проваливался с редиректом на страницу программы Благорост. Нужен реестр-наблюдение: единая таблица ВСЕХ расходов кооператива по всем пулам без фильтра, с колонкой кошелька-источника; клик по строке → деталь расхода (та же информация, что на странице расхода). Фильтр по конкретному пулу остаётся на странице расходов программы.

— soviet/install.ts: монтирует ExpensesRegistryPage (title «Реестр расходов», маршрут soviet-expenses-registry) вместо ExpenseWalletsPage; редиректа на программу больше нет. Воркспейс expenses активен → generic-роут expenses-detail резолвится глобально.
— ExpensesRegistryPage: + колонки «Назначение» (описание первой позиции) и «Кошелёк (пул)» (резолв кода source_wallet → человеческое имя через listExpenseWallets(), fallback — сам код); суммы план/факт через formatAsset2Digits (2 знака); убрана колонка «Хеш» (шум, есть в детали).
— ExpenseDetailPage (generic, куда ведёт реестр): поднят до канона страницы расхода — документы СЗ/протокол рендерятся каноном ExpenseProposalDocuments (раскрывающийся BaseDocument с подписями) вместо голых хешей; суммы сводки и позиций через formatAsset2Digits; источник средств показан как имя пула; убрано техническое поле «Действие» (operation_code).
— ExpenseWalletsPage остаётся в barrel'е (не смонтирован); listExpenseWallets/registerExpenseWallet теперь служат картой код-кошелька→имя для реестра. README обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:45:44 +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 6778561228 [C28-37][@ant] fix: лист документа в диалоге — центрируем в col-md-8, не на всю ширину — зачем: A4 на широком экране растягивался во весь макси-диалог. Обёртка .row.justify-center > .col-12.col-md-8 (канон ComplexDocument/ExpandableDocument)
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 7m57s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:16:47 +00:00
coopops 311ce09c2e [C28-36][@ant] fix: документы расхода рендерим каноном BaseDocument, не голым DocumentPreview — зачем: статья «голый html» расползалась без стилей и без подписей. ExpenseProposalDocuments теперь во всплывашке (BaseDialog maximized) показывает BaseDocument — ShadowHtml со стилями документа/таблиц + раскрывающийся блок DocumentSignatures (кто подписал). Пропы статья/протокол типизированы IDocumentAggregate, statement_doc/decision_doc ложатся напрямую (как decisionAggregate в ExpandableDocument у собраний)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:12:46 +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
coopops 875ca6edee [C28-35][@ant] feat: документы расхода (СЗ + протокол) на странице расхода + правки лейблов/формата — зачем: со страницы расхода нужно открывать заявление и протокол решения совета, единообразно везде. Доменный канон ExpenseProposalDocuments (shared/ui/domain): клик по строке СЗ/протокола открывает документ во всплывашке (BaseDialog maximized + DocumentPreview, html уже приходит в statement_doc/decision_doc.rawDocument). На ProgramExpensePage: секция «Документы» после сводки; суммы (по смете/фактически/план/факт позиций) — через formatAsset2Digits (2 знака); «Первичные документы» → «Подтверждающие документы»; в таймлайне «Платёжка: приложен документ» → «Приложен документ». Канон статусов: REPORTED «Отчитана» → «Отчёт получен» (shared/lib/expenses)
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Failing after 7m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:48:33 +00:00
coopops e6090d9200 [C28-34][@ant] feat: фоновый напоминатель об отчёте по авансу под отчёт — зачем: закрыть момент, когда пайщик получил аванс и не отчитался. @Interval-воркер в extensions/expenses сканирует зеркало расходов, находит ADVANCE-позиции в PAID без отчёта, шлёт пайщику-получателю недельный агрегированный дайджест (workflow ExpenseAdvanceReportReminder) со ссылкой на сам расход (/:coopname/expenses/:hash) или список «Мои авансы»; только получателю, кассиру/совету ничего, ORG пропускаются. Троттлинг раз в неделю — через идемпотентность Центра уведомлений (поле period=ISO-неделя в payload), без своей таблицы. Грейс до первого письма и интервал тика — env (EXPENSE_ADVANCE_REMINDER_*, дефолты 3 дня / 6 ч). Точка отсчёта — proposal.updated_at; deadline из формы пока не персистится в цепи — отмечено follow-up в README
Typecheck / desktop (pull_request) Failing after 8m33s
Typecheck / controller (pull_request) Failing after 8m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:52:04 +00:00
coopops 999eee2d84 [C28-33][@ant] docs: README по UI-виджетам шасси расходов и рецепту подключения пула — зачем: стол КУ собирается по документу без возврата в контекст Благороста
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Failing after 8m5s
2026-06-12 18:10:33 +00:00
coopops ed52abe86c [C28-33][@ant] refactor: виджеты шасси расходов — в shared/ui/domain — зачем: стол КУ должен собираться из готовых компонентов без возврата в контекст Благороста. ExpenseCreateDialog (форма + черновик в localStorage + генерация и подпись СЗ; пул-источник, ключ черновика и подача на чейн — пропы) и ExpenseProposalList (список СЗ пула, данные подаёт страница) — общие доменные виджеты; подписи статусов/механик/файлов — src/shared/lib/expenses; capital переведён на них первым потребителем (тонкая обёртка CreateProgramExpenseDialog + listRows на ProgramExpensesPage), старые labels — реэкспорт
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-12 18:05:32 +00:00
coopops 06c922abf1 [C28-33][@ant] feat: фабричная переиспользуемость шасси расходов + кошельки расходов на столе совета + закрытие расхода + UX отчёта — зачем: шасси должно настраиваться на любой пул без правок контракта. EXPENSE_OPERATION_SETS в ledger2 (source_wallet → 5 кодов операций, compile-time валидация) — в expense.cpp нет захардкоженных кодов; реестр registerExpenseWallet: capital регистрирует пул из install.ts, стол совета рендерит страницу «Расходы» (WalletCard + проваливание в реестр пула); кнопка «Закрыть расход» (совет, REPORT_SUBMITTED → closeexp), роль мутации chairman/member; файлы расхода: регистронезависимый маппинг item_hash (чейн UPPER vs storage lower — документы «исчезали»), секция «Прочие документы»; отчёт: выбор файла = мгновенная загрузка + автоподача reportexp, дозагрузка дополняет отчёт, у кассира платёжка грузится без кнопки; source_wallet в СЗ-документе — w.cap.pgexp
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Failing after 7m54s
2026-06-12 17:15:46 +00:00
coopops 8346d97326 [C28-33][@ant] fix: локаторы процессов p.cap.pgexp и p.exp.expns в process-hash-locator — зачем: fail-fast валидатор контроллера падал на старте после добавления операций пула расходов в cooptypes
Typecheck / desktop (pull_request) Failing after 8m38s
Typecheck / controller (pull_request) Failing after 8m6s
2026-06-12 13:40:45 +00:00
coopops 23bb2ad51f [C28-33][@ant] fix!: оплата СЗ — из кооперативного пула расходов w.cap.pgexp, не с личного кошелька пайщика — зачем: payexp списывал w.cap.blago (USER_SHARED) с L3 инициатора, уменьшая его паевые взносы; topupprogexp теперь наполняет кошелёк пула (o.cap.pgtop), подотчёт w.exp.adv числится на получателе аванса; имена o.exp.*/p.exp.expns в cooptypes-словари (названия операций в UI); форма: пайщик-получатель только физлицо, поле ФИО по канону; страница расхода: кнопка «К расходам», без дубля суммы, позиции отклонённой СЗ не «ожидают оплаты»
Typecheck / desktop (pull_request) Failing after 8m39s
Typecheck / controller (pull_request) Failing after 8m12s
2026-06-12 13:19:20 +00:00
coopops 23cd31861a [C28-33][@ant] feat: страница расхода (позиции+реквизиты+файлы+история состояний по канону), выбор пайщика-получателя по ФИО, черновик формы в localStorage, отчёт пайщика по авансу чеком из реестра платежей, создание СЗ только совету — зачем: наполнение карточки расхода и полный цикл аванса под отчёт
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Failing after 8m5s
2026-06-12 12:15:01 +00:00
coopops 918f30073a [C28-33][@ant] fix: реестр платежей — компактные DataRow с копированием вместо растянутых CopyableInput, оригинальное имя файла платёжки + рабочее открытие по свежему read_url, отметка «платёжка приложена» у исходящих расходных платежей (proof_count в blockchain_data)
Typecheck / desktop (pull_request) Failing after 8m36s
Typecheck / controller (pull_request) Failing after 8m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:59:02 +00:00
coopops f9d09a74cc [C28-33][@ant] feat: исходящие платежи расходов в реестре (тип EXPENSE: листенер на authexp + payexp при подтверждении кассой, реквизиты и назначение из снимка), протокол 2011 по канону решений совета (кворум/голоса/повестка через decision_id), подтверждение оплаты файлом в реестре, карточка расхода без механики и с № СЗ
Typecheck / desktop (pull_request) Failing after 8m28s
Typecheck / controller (pull_request) Failing after 8m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:58:28 +00:00
coopops cb0056f716 [C28-33][@ant] fix: кнопки расходов в топбар через canon Teleport (#header-actions-host), фикс генерации решения 2011 (payment_purpose/deadline/fund_name в DTO), СЗ 2010 — место и дата под заголовком, подпись с отступом, фон футера диалога убран
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Failing after 8m0s
2026-06-12 07:15:06 +00:00
coopops 7a7e5a04f0 [C28-33][@ant] feat: кнопки расходов в PageHead (создать → пополнить), короткий № СЗ и formatAsset по канону фабрики, автоназначение платежа «Аванс под отчёт» для пайщика — описание позиции переназвано «Что оплачиваем», пункты документа без списка
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Failing after 8m7s
2026-06-12 06:45:07 +00:00
coopops c8a15511ee [C28-33][@ant] feat: СЗ 2010 по канону вёрстки реестра + назначение платежа отдельным полем после реквизитов — фиксируется в снимке для кассира; «оплата по счету», «Организация/ИП», алерт «передано в совет», хинт механики убран
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Failing after 7m57s
2026-06-12 06:07:44 +00:00
coopops 61038a6f8c [C28-33][@ant] feat: срок «в срок до» и фонд списания в СЗ 2010 + инвариант механики оплаты — пайщику только аванс под отчёт, организации только прямая оплата (форма/бэкенд/контракт); фикс 422 на пустом recipient организации
Typecheck / desktop (pull_request) Failing after 8m28s
Typecheck / controller (pull_request) Failing after 8m1s
2026-06-11 20:28:35 +00:00
coopops dfe73601e8 [C28-33][@ant] feat: реквизиты получателей по payment_method со снимком в БД + СЗ 2010 по бумажному образцу — полные реквизиты в документ подставляет сервер, фронт видит сокращённые; вычищены осиротевшие authorizeExpenseReport/declineExpenseReport
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 20:01:43 +00:00
coopops 1f3e9d93c7 [C28-33][@ant] feat: утверждение программного расхода через решение совета — createexp ставит вопрос в повестку (soviet::createagenda), authexp/declexp стали callback'ами совета; кнопка председателя убрана
Typecheck / desktop (pull_request) Failing after 9m36s
Typecheck / controller (pull_request) Successful in 13m38s
2026-06-11 19:20:18 +00:00
coopops 67dde7cf45 [C28-33][@ant] feat(capital): рассмотрение программного расхода председателем
Typecheck / desktop (pull_request) Failing after 8m22s
Typecheck / controller (pull_request) Successful in 13m19s
На карточке расхода в статусе «Создан» председатель видит кнопку «Рассмотреть» —
открывается диалог авторизации СЗ (утвердить с протоколом 2011 / отклонить с
причиной). Фиксы диалога: отклонение теперь идёт через declineExpenseReport →
declexp (раньше ошибочно через authexp, который безусловно ставил AUTHORIZED);
убраны ссылки на удалённый operation_code (назначение = перечень позиций).
Инициатор в карточке — ФИО (creator_name). Тексты карточек пулов укорочены.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:26:19 +00:00
coopops 06a5cf2464 [C28-33][@ant] feat(capital): creator_name в выводе программного расхода
В карточке показывался username (ant) — канон требует ФИО/название организации.
Бэкенд резолвит имя через ACCOUNT_DATA_PORT.getDisplayName (батчем по уникальным
creators, при ошибке остаётся username). Codegen: schema + zeus + sdk selector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:26:19 +00:00
coopops 8073eb8642 [C28-33][@ant] fix(desktop): WalletCard — перенос заголовков вместо marquee
Бегущая строка убрана полностью (раздражала и читалась плохо): заголовок и
подпись переносятся максимум на две строки с многоточием, полный текст — в
title-тултипе. Выпилены measure/ResizeObserver/tabindex и keyframes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:26:18 +00:00
coopops 986b5e0cf2 [C28-33][@ant] fix(expenses): callback.data нормализуется в hex-строку при синке зеркала
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Has been cancelled
On-chain callback.data — vector<char>; парсер десериализует в Uint8Array, который
в jsonb-зеркале становится {} (пустой) или {"0":..}. GraphQL-поле data — String,
сериализация ответа capitalProgramExpenses падала «String cannot represent value: {}»
и UI показывал пустой список при живой строке в зеркале. Нормализуем в hex ещё
в дельта-маппере (на записи), существующая строка поправлена UPDATE'ом.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:02:38 +00:00
coopops 8c09d9f2ae [C28-33][@ant] feat(boot): pnpm run reboot:blago — перезапуск dev-среды Благороста одной командой
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
extra_reboot (чистая цепь + совет + dev-shortcut онбординга capital — без него
после reboot снова 5 решений совета руками) → ожидание готовности controller →
seed-capital --up-to=04-contributor (программы, проекты, регистрация ant).
Глубина сида настраивается: SEED_UP_TO=08-investments pnpm run reboot:blago.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:57:00 +00:00
coopops 3532245012 [C28-33][@ant] fix(desktop): укорочен locked-label на странице программных расходов
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 13m27s
Канон-класс .wallet__locked-line — white-space:nowrap + inline-flex, рассчитан
на короткий лейбл; длинный «Зарезервировано под активные расходы» не переносился
и вылезал за карточку. Лейбл → «Зарезервировано» (смысл ясен из контекста
страницы), общий компонент не трогаем.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:28 +00:00
coopops a08cb7b2b6 [C28-33][@ant] fix(controller): идемпотентное сохранение blockchain-action
global_sequence — глобально-уникальный монотонный id действия в истории цепи.
При re-scan/replay redis-стрима то же действие доставляется повторно → INSERT
падал на unique-индексе (23505) → consumer не ACK'ал сообщение и зацикливал
recoverOwnPending/reclaimStalePending (дикий спам duplicate-key в логах). Теперь
дубль трактуется как «уже сохранено»: возвращаем существующую запись, не бросаем.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:28 +00:00
coopops 8b2b9893df [C28-33][@ant] fix(expense): программные расходы зеркалятся в БД и видны в списке
Парсер не был подписан на контракт `expense` (нет в subscribedContracts) →
дельты `expense::proposals` не эмитились → postgres-зеркало `expense_proposals`
оставалось пустым → список «Программных расходов пока нет», хотя на цепи СЗ и
резерв создавались. Добавил `expense` в subscribedContracts парсера.

Плюс: `coopname` добавлен явным полем в таблицу `proposals` контракта (struct +
EOSLIB_SERIALIZE + проставление в createexp) и в cooptypes IProposal. Таблица
scoped по coopname, но зеркало требует coopname в строке — дублируем полем по
канону (как capital/soviet), а не выводим из scope дельты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:16 +00:00
coopops 0a3e1c97b7 [C28-33][@ant] fix(expense): proposal_hash в meta документа 2010 — приведено к канону 2011 (поле в Action/Model cooptypes + Base-meta DTO + JSONSchema шаблона + фабрика кладёт в combinedData + фронт передаёт при генерации); раньше proposal_hash был только в signed-DTO → подписанный документ не проходил валидацию «Field proposal_hash not provided»
Typecheck / desktop (pull_request) Failing after 8m40s
Typecheck / controller (pull_request) Successful in 13m25s
2026-06-11 16:57:50 +00:00
coopops dd5d61b0b4 [C28-33][@ant] fix(cooptypes): шаблон 2010 — nunjucks-тег {% elif %} вместо liquid-овского {% elsif %} (движок рендера nunjucks, как в шаблонах 100/101); рендер падал «unknown block tag: elsif»
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 16:40:05 +00:00
coopops 44e9bb24a9 [C28-33][@ant] fix(factory): фабрики 2010/2011 сами тянут coop/vars/user из хранилища (resolveParallel-канон) — раньше ждали их в data и валидация падала «должно иметь обязательное поле coop»
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 16:34:13 +00:00
coopops 1fcf8523df [C28-33][@ant] feat(expense): per-позиционная механика оплаты — operation_code удалён из proposal/createexp/createpgexp, ledger2-код выводится из item.mechanics при payexp; в одном СЗ смешиваются аванс и прямая оплата; UI — селект способа на каждой позиции
Typecheck / desktop (pull_request) Failing after 8m39s
Typecheck / controller (pull_request) Successful in 13m15s
2026-06-11 13:22:34 +00:00
coopops 42c179d5d0 [C28-33][@ant] fix(desktop): marquee WalletCard — бежит только строка под курсором/фокусом (не вся карточка), скорость одинаковая у всех строк (длительность цикла из величины переполнения, linear)
Typecheck / desktop (pull_request) Successful in 14m17s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:48:47 +00:00
coopops 794cb43de6 [C28-33][@ant] fix(factory): зарегистрировать фабрики 2010/2011 (ExpenseProposalStatement/Decision) в Registry и карте factories — шаблоны были написаны и экспортированы, но генератор их не находил («Фабрика для документа #2010 не найдена»)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 12:42:17 +00:00
coopops bae8bdb52f [C28-33][@ant] fix(desktop): marquee кошельков — бег обеих строк только на hover/фокус (в покое «…», tabindex лишь при переполнении для тапа), иконка удаления позиции расхода — в слот #icon-left (icon-only не рендерит дефолтный слот, иконка была невидима)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:19:55 +00:00
coopops 1759c2ac4e [C28-33][@ant] feat(capital): балансы пулов на странице программных расходов + чистка диалога — карточки «Глобальный инвест-пул» и «Пул программных расходов» (чтение живьём с цепи через capitalState: добавлены поля program_expense_pool/reserved в GraphQL DTO и interactor, без участия БД), из диалога пополнения убраны технические идентификаторы пулов
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:13:11 +00:00
coopops 7bddb72fa1 [C28-33][@ant] fix(desktop-capital): заголовок карточки расхода — описание первой позиции; поля description на уровне расхода нет ни в шасси, ни в inter-порте, vue-tsc валил CI после regen
Typecheck / desktop (pull_request) Successful in 13m53s
Typecheck / controller (pull_request) Successful in 14m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:51:02 +00:00
coopops 7f35ef3710 [C28-29][@ant] merge: актуальный dev в ветку шасси — синхронизация перед объединённым PR
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Has been cancelled
# Conflicts:
#	components/contracts/cpp/lib/core/ledger2/wallets.hpp
#	components/controller/schema.gql
#	components/controller/zeus/const.ts
#	components/controller/zeus/index.ts
#	components/cooptypes/src/ledger2/wallets.generated.ts
#	components/sdk/src/zeus/const.ts
#	components/sdk/src/zeus/index.ts
2026-06-11 04:30:51 +00:00
coopops fe915bd1d5 [C28-33][@ant] merge: интеграция Капитал → шасси расходов (PR #67) в зонтичную ветку шасси — объединение двух PR в один 2026-06-11 04:20:20 +00:00
claude 75cd0976e2 fix(desktop): PropType<BaseButtonSize> в DeletePaymentMethodButton — разблокировка desktop-CI всех PR в dev (#128) 2026-06-10 22:30:58 +00:00
coopops d50b619f75 [C28-32][@ant] fix(desktop-expenses): String(uploaded_at) для formatDate — zeus-скаляр Date типизирован как {}, vue-tsc валил CI
Typecheck / desktop (pull_request) Successful in 13m38s
Typecheck / controller (pull_request) Successful in 13m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:24:32 +00:00
coopops 714d618320 [C28-32][@ant] fix(desktop-expenses): String(uploaded_at) для formatDate — zeus-скаляр Date типизирован как {}, vue-tsc валил CI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:24:26 +00:00
coopops dd0097b56c [C28-31][@ant] fix(expenses-spec): спека trigger-сервиса отстала от конструктора с EventEmitter2 — TS2554 валил CI controller; тест проверяет эмит EXPENSE_CAPITALIZATION_REQUIRED вместо снесённого TODO-лога
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 13m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:12:03 +00:00
coopops 773ecd89c2 [C28-31][@ant] fix(expenses-spec): спека trigger-сервиса отстала от конструктора с EventEmitter2 — TS2554 валил CI controller; тест проверяет эмит EXPENSE_CAPITALIZATION_REQUIRED вместо снесённого TODO-лога
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:11:58 +00:00
coopops aeadf60239 [C28-32][@ant] fix(desktop-expenses): SuccessAlert принимает одну строку + Number(size_bytes) — vue-tsc валил CI desktop
Typecheck / desktop (pull_request) Failing after 8m55s
Typecheck / controller (pull_request) Failing after 8m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:50:06 +00:00
coopops e678d62e82 [C28-33][@ant] fix(desktop-capital): SuccessAlert принимает одну строку — те же сигнатурные ошибки vue-tsc в диалогах программных расходов
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:49:52 +00:00
coopops 5cb8ef3175 [C28-32][@ant] fix(desktop-expenses): SuccessAlert принимает одну строку + Number(size_bytes) — vue-tsc валил CI desktop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:49:48 +00:00
coopops 4f9df9012a [C28-33][@ant] fix(desktop-capital): механика следует из способа оплаты СЗ + явный резолв получателя по типу — смешение ADVANCE/DIRECT в одном СЗ режется контрактом, а recipient уходил пустой строкой из-за ?? при заполненном recipient_name
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:42:46 +00:00
coopops 73fc79fa88 [C28-33][@ant] fix(boot): деплой контракта expense — аккаунт + code permissions; без него шасси не поднимается на свежем стенде
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:42:45 +00:00
coopops e073a6d9bc [C28-33][@ant] fix(expense+capital): авторизация inline-связки и state machine шасси — e2e-цепочка не проходила ни по одному сценарию
- expense::createexp принимает контракты-инициаторы по contracts_whitelist
  (capital@eosio.code не имеет coopname@active — inline падал по authority);
  capital::createpgexp шлёт inline от _capital@active.
- capital::onpgexpdone: require_auth(_expense) вместо _capital — callback
  шасси шёл с authority expense@active и всегда падал.
- payexp: cap actual<=plan + DIRECT-item сразу REPORTED с пересчётом статуса
  proposal — DIRECT-only СЗ навсегда зависал в PARTIALLY_PAID.
- returnexp/overspendexp: settlement-семантика по PRD — статус item остаётся
  PAID, отчёт закрывает item штатным reportexp (раньше RETURNED/OVERSPENT
  были терминальными тупиками: перерасход блокировал closeexp навсегда).
- declexp: только CREATED/AUTHORIZED — decline после оплат разъезжался
  с учётом пула в capital (возвращал весь резерв при ушедших деньгах).
- onpgexpdone CLOSED: перерасход сверх резерва списывается из
  program_expense_pool (spend_program_expense_pool).
- names.hpp: redefinition CREATE_PROGRAM_EXPENSE + несуществующий
  Names::Capital::Callbacks — контракты не компилировались вовсе.
- verify_document_or_fail(statement, {creator}) в createpgexp; снос
  мёртвых set_program_approved/set_program_authorized.

Оба контракта собраны: expense.wasm + capital.wasm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:42:28 +00:00
coopops ec5b5ee403 fix(desktop): SSR-гидратация ломала ВСЕ маршруты — пустые страницы до F5
Найдена реальная причина «белого экрана» на первом SSR-заходе (доказано
DOM-диагностикой + __INITIAL_STATE__ прода: 87 сериализованных "component"
в desktops.workspaces[].routes):

SSR-сервер кладёт RouteRecordRaw вместе с component в Pinia-стор; Quasar
сериализует стейт в __INITIAL_STATE__; Vue-компонент не переживает JSON
(render-функция выпадает). Клиент гидратируется мёртвыми маршрутами,
registerWorkspaceMenus регистрирует их в router (routes=108 сразу),
initExtensions живые не добавляет («маршрут уже есть») → каждая страница
рендерится пустой при работающем layout/меню. F5 «лечит», потому что SW
отдаёт SPA-shell без гидратации (Config.js-путь, routes=13 → живые 108).
Локально не воспроизводилось: dev-режим SPA, без SSR.

Фикс: на клиенте перед loadDesktop зачищаем гидратированные routes из
workspaces — живые добавит useInitExtensionsProcess, ровно как при
SPA-заходе. Диагностика [BOOTRACE] остаётся до подтверждения на проде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:34:29 +00:00
coopops 787b2ca786 [C28-33][@ant] merge: база feat/expense-chassis-mvp (zeus fix) в ветку интеграции 2026-06-10 18:22:51 +00:00
coopops 4eb72309d4 [C28-31][@ant] fix(sdk): Zeus regen с ExpenseProposal*GenerateDocumentInput — sdk tsc падал на ручных обёртках без типов
Typecheck / desktop (pull_request) Failing after 8m19s
Typecheck / controller (pull_request) Failing after 8m14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:22:27 +00:00
coopops 93fda756aa Revert "feat(desktop): сайт кооператива в scope_extensions динамического PWA-манифеста"
This reverts commit adea7038d3.
2026-06-10 12:09:51 +00:00
coopops adea7038d3 feat(desktop): сайт кооператива в scope_extensions динамического PWA-манифеста
Переходы из установленного PWA на сайт кооператива (vars.website из
публичного getSystemInfo, кэш 5 мин) остаются в окне приложения вместо
выброса в браузер. Заодно явные id и scope для стабильной identity.
Работает в Chrome/Edge 138+ на десктопе; сайт должен отдать
/.well-known/web-app-origin-association с {"https://<домен-лк>/": {"scope": "/"}}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:15 +00:00
coopops cb014d7b10 [C28-33][@ant] refactor(capital-desktop): asset symbol/precision из системного стора + строгий тип generate-input
R4: убрал хардкод "RUB" и "1000.0000 RUB" из feature ProgramExpense.
 - submitTopup принимает raw число "10000" → форматирует через formatToAsset
   с info.symbols.root_govern_symbol/precision.
 - submitProgramExpense форматирует total_amount + planned_amount каждого item
   через тот же helper (UX-долг "amount без IMask" закрыт по сумме).
 - Placeholder'ы Dialog'ов вычисляются computed'ом из system.info.symbols.

R1 (частично): тип `GenerateStatementInput = Mutations.Expense.GenerateExpense
ProposalStatementDocument.IInput['data']` + деструктуризация результата через
computed `[Mutations.X.Y.name]` — убраны 2 из 4 `as any` каста (input + index).
Оставшиеся (signed-as-any и create-input-as-any) — отдельный refactor PR через
signed-doc adapter в shared/lib/document.

Source-wallet вынесен в локальную map SOURCE_WALLET_BY_OPERATION с throw на
неизвестный код — backend-derivation (R3) отдельной задачей.
2026-06-03 16:17:01 +00:00
coopops 9c29cc3260 [C28-33][@ant] refactor(common): buildPaginationResult helper для inter-port consumers
Вынес общий PaginationInput → PaginationResult helper из capital-service в
~/application/common/dto/pagination.dto. Consumer'ы inter-port'ов (без своего
Repository.findAndCount) получают канон-сборку без дубля логики totalPages/
currentPage.

Также `paginationInputToOffset(options)` — стандартная конверсия для адаптеров,
работающих offset-form (большинство inter-port read API).

Capital ProgramExpensesManagementService переведён на оба helper'а — ужал
listProgramExpenses до 10 строк.
2026-06-03 16:16:45 +00:00
coopops 4f15fc7c7c [C28-33][@ant] fix(capital): item-enum + canon-резолвер программных расходов
ProgramExpenseItemOutputDTO теперь отдаёт ExpenseMechanics / ExpenseRecipientType /
ExpenseItemStatus вместо magic Int — это явное нарушение canon'а GraphQL "enum
вместо строковых литералов / number". Сервис маппит number→enum зеркально
expenses-extension. Резолвер возвращает PaginationResult<T> через canon-сигнатуру
вместо «as never». Десктоп подменил локальные string-union'ы на Zeus enum
(ExpenseRecipientType / ExpenseMechanics / ExpenseProposalStatus).

Schema regen + Zeus regen + SDK build.
2026-06-03 14:29:14 +00:00
coopops bec306f97a [C28-33][@ant] fix(desktop-capital): привести страницу/диалоги к канону /_dev/ui
Why: использовал несуществующие токены/классы — UI бы рендерился со сломанными цветами/иконками. Привёл к канону:
- BaseChip: `:variant` (pos/accent/warn/neg/info/neutral), не `:label/:tone` (нет такого API).
- BaseButton: иконка через `<template #icon-left>`, не `icon='X'` (нет такого пропа).
- CSS-токены: `--p-ink`/`--p-ink-2` (не `--p-text`/`--p-muted`), `--p-fs-meta` (не `--p-fs-body-xs`).
- Утилитарные классы: `t-h2`/`t-sm`/`t-eyebrow`/`t-muted` из components.css, не `t-section`/`t-body-sm`.
2026-06-03 13:23:12 +00:00
coopops eb39e1a456 [C28-33][@ant] fix(expense): callback включает coopname — иначе capital::onpgexpdone не десериализуется
Why: send_callback_if_any слал (proposal_hash, status, amount, data), а capital::onpgexpdone ждёт (coopname, expense_hash, status, total_actual, data). Скоуп таблицы progexpenses = coopname, без него инициатор не найдёт запись. Применимо ко всем будущим consumer'ам шасси (marketplace, EMP).
2026-06-03 13:17:59 +00:00
coopops 1296b85199 [C28-33][@ant] fix(capital): маппинг enum→uint8 для mechanics/recipient_type — было Number('ADVANCE')=NaN
Why: ExpenseMechanics и ExpenseRecipientType — string enums; Number(str) даёт NaN, wharfkit не сериализует. Зеркалит маппинг из expenses-mutations.service.ts:57-63.
2026-06-03 13:15:08 +00:00
coopops 8a80c15fdb [C28-33][@ant] feat(desktop-capital): страница «Управление расходами программы»
Тонкая consumer-страница Капитала над шасси расходов. Доступ chairman+member.

  entities/ProgramExpense — store + api (Queries.Capital.GetProgramExpense{s}).
  features/ProgramExpense/CreateProgramExpense — useCreateProgramExpense:
    generateUniqueHash → reuse `generateExpenseProposalStatementDocument` шасси
    → подпись через DigitalDocument (registry 2010, Эпик 4 pipeline)
    → Mutations.Capital.CreateProgramExpense.
  features/ProgramExpense/TopupPool — useTopupProgramExpensePool:
    Mutations.Capital.TopupProgramExpense.
  pages/ProgramExpensesPage — list по канону mono-platform (BaseCard/Chip/
    EmptyState/--p-* токены), два диалога в шапке.
  install.ts — route `/:coopname/capital/program-expenses` (name
    `capital-program-expenses`, иконка receipt, agreementsBase, roles=
    chairman+member).
2026-06-03 13:05:12 +00:00
coopops d30e2ab025 [C28-33][@ant] feat(sdk): Zeus regen + Mutations/Queries.Capital.{Create,Topup,GetProgram}Expense
Регенерация после backend-расширения. Добавлены manual wrappers (SDK Mutations/
Queries.Capital — не auto-generated, обёртки над Zeus):

  Mutations.Capital.CreateProgramExpense (capitalCreateProgramExpense)
  Mutations.Capital.TopupProgramExpense  (capitalTopupProgramExpensePool)
  Queries.Capital.GetProgramExpenses     (capitalProgramExpenses + pagination)
  Queries.Capital.GetProgramExpense      (capitalProgramExpense by hash)

Селекторы:
  programExpenseSelector / programExpensesPaginationSelector
  + items/callback вложенные селекторы.
2026-06-03 12:30:47 +00:00
coopops c2b0e12115 [C28-33][@ant] feat(capital-backend): расходы программы через шасси (resolver+service)
Тонкое расширение Capital для программных расходов: чисто consumer над
шасси `expense` (только Капитал-специфика, остальное обслуживает шасси).

Чейн:
  - CapitalBlockchainPort.createProgramExpense → capital::createpgexp.
  - CapitalBlockchainPort.topupProgramExpense  → capital::topupprogexp.

Backend:
  - ProgramExpensesManagementService — write через capital, read через
    INTER_EXPENSE_CHASSIS (list по owner='capital'+action='onpgexpdone' +
    карточка по hash). Никакого собственного TypeORM-mirror.
  - ProgramExpensesResolver — chairman/member guards, mutation+query.
  - DTO: CreateProgramExpenseInputDTO (items+statement registry 2010),
    TopupProgramExpenseInputDTO, ProgramExpenseOutputDTO.
  - Регистрация в capital-extension.module.

actual_amount/status в createProgramExpense инициализируются 0/0 серверно
(их C++ дефолтит, но cooptypes требует все поля IItem).

PR2 (Капитал на шасси).
2026-06-03 12:12:58 +00:00
coopops 7323572bfc [C28-33][@ant] feat(cooptypes): capital программные расходы через шасси expense
Адаптация blagorost-cooptypes под архитектуру «capital = инициатор + callback,
шасси expense обслуживает весь flow». Из blagorost-E2 берётся минимальный набор
интерфейсов под программный расход, без debt/role/duplicate-action типов.

Изменения:
- ICreatepgexp: amount/description упрощены, добавлены operation_code+items[]
  (передаются inline в шасси expense::createexp).
- ITopupprogexp — без изменений (пополнение пула).
- IOnpgexpdone — НОВЫЙ callback от шасси, payload (hash, status, total_actual, data).
- IApprvpgexp/IAuthpgexp/IPgexppay/IDeclpgexp — УДАЛЕНЫ (шасси expense замещает).
- IProgramExpense table-interface — сохранён.
- import IItem из expense interface для типизации items[].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops 1c82bdebe4 [C28-33][@ant] feat(capital+expense): соединение шасси expense с capital через inline-action+callback
capital теперь только инициатор+получатель результата для программного расхода —
весь flow обслуживает шасси expense. Раньше blagorost-ветка дублировала
approve/auth/pay/decline на стороне capital.

Изменения capital:
- удалены apprvpgexp/authpgexp/pgexppay/declpgexp (дубль шасси).
- createpgexp переписан: вместо самообслуживания резервирует пул и шлёт inline
  expense::createexp с callback={capital, onpgexpdone}.
- сигнатура createpgexp принимает operation_code+items[] (передаётся в шасси).
- добавлен onpgexpdone(coopname, hash, status, total_actual, data) — callback от
  шасси на CLOSED/DECLINED, обновляет program_expense_pool и удаляет progexpense.

Изменения expense (шасси):
- callback payload расширен полем status (ExpenseDomain::ProposalStatus) — инициатор
  отличает CLOSED от DECLINED.
- declexp теперь шлёт callback тоже (раньше только closeexp).

Изменения names.hpp:
- удалён CAPITAL_RESOLVE_PROGRAM_EXPENSE / AUTHORIZE_PROGRAM_EXPENSE /
  DECLINE_PROGRAM_EXPENSE / CONFIRM_PROGRAM_EXPENSE_PAYMENT.
- добавлен ON_PROGRAM_EXPENSE_DONE.
- добавлен External::CREATE_EXPENSE_PROPOSAL для inline в шасси.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops 5660b06199 feat(capital/expense): расходы программы — createpgexp/apprvpgexp/authpgexp/pgexppay/declpgexp + таблица progexpenses
Story 1.5 эпика «Благорост» (blago issue 51F-17).

Полный цикл целевого расхода программы «Благорост» (не привязанного к проекту):
от заявки председателя до подтверждения оплаты gateway. Источник средств —
program_expense_pool (Story 1.6), резерв снимается окончательно в pgexppay.

PRD-B1: «createprogexp/approveprogex/authprogexp/progexppay/declprogexp». Имена
действий сокращены под 12-символьный лимит EOSIO — семантика та же.

Изменения:
- expenses.hpp: struct program_expense + таблица progexpenses (scope coopname)
  + helper'ы create/get/set_program_approved/set_program_authorized/delete
- 5 новых cpp actions в expense_managment/program_expenses/:
  - createpgexp — резервирует в State::reserve_program_expense, создаёт запись
  - apprvpgexp — председатель → soviet::create_agenda с CAPITAL_RESOLVE_PROGRAM_EXPENSE
  - authpgexp — совет → ::Gateway::create_outpay (через Action::send), статус AUTHORIZED
  - pgexppay — gateway callback → consume_program_expense + Ledger2::apply PAY_EXPENSE + delete
  - declpgexp — отказ от coopname/_soviet/_gateway → release_program_expense + delete
- names.hpp: CREATE_PROGRAM_EXPENSE, AUTHORIZE_PROGRAM_EXPENSE, DECLINE_PROGRAM_EXPENSE,
  CONFIRM_PROGRAM_EXPENSE_PAYMENT, SovietActions::CAPITAL_RESOLVE_PROGRAM_EXPENSE

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops d02c0ce66f feat(capital/state): program_expense_pool + topupprogexp
Story 1.6 эпика «Благорост» (blago issue 51F-18).

Программные расходы (Story 1.5) списываются из дедикейтнутого пула, а не из
аллокаций проектов. До этого коммита поля для такого пула в global_state не было.

Изменения:
- global_state.program_expense_pool — доступная сумма программы под расходы
- global_state.program_expense_reserved — зарезервированная под approved/authorized
  программные расходы (Story 1.5: createprogexp резервирует, declprogexp возвращает,
  exppaycnfrm списывает окончательно)
- State::topup_program_expense_pool / reserve_program_expense /
  release_program_expense / consume_program_expense — utility-функции для Story 1.5
- topupprogexp(coopname, amount) action — председатель переводит amount из
  global_available_invest_pool в program_expense_pool; деньги остаются на
  ledger2-кошельке BLAGOROST_FUND, меняется только разбивка по назначению

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:03:57 +00:00
coopops 67c5b96a18 [C28-33][@ant] feat(inter+expenses): порт InterExpenseChassisPort + adapter
Typecheck / desktop (pull_request) Failing after 7m14s
Typecheck / controller (pull_request) Failing after 6m35s
Шасси расходов получает inter-порт для consumer-extension'ов (capital,
marketplace, EMP). Порт — read-only: list-view и карточка proposal'а
по hash. Mutations approve/authorize/pay/report/close/decline остаются
прямыми GraphQL-endpoint'ами шасси, consumer их НЕ оборачивает.

inter:
  - tokens.ts: INTER_EXPENSE_CHASSIS = Symbol.for(...)
  - expense-chassis.port.ts: InterExpenseChassisPort + DTO (Proposal/Item/
    Callback/Status). Enum-статусов = зеркало доменного ExpenseProposalStatus.
  - index.ts: реэкспорт.

extensions/expenses (шасси-backend):
  - infrastructure/inter/expenses-inter-expense-chassis.adapter.ts —
    тонкий read через ExpenseProposalTypeormEntity. Проекция доменных
    полей в нейтральный inter-DTO, без зависимости inter → controller.
  - Регистрация adapter'а в expenses-extension.module + экспорт.

inter-communication-bridge.module:
  - Импорт ExpensesExtensionModule + provider INTER_EXPENSE_CHASSIS =
    useExisting ExpensesInterExpenseChassisAdapter.

PR1 (шасси-собственное), будет использовано в PR2 (Капитал на шасси).
2026-06-03 12:03:20 +00:00
coopops 1bca8cf34d [C28-32][@ant] feat(desktop+backend): подпись Classes.Document для CreateDialog/AuthorizePage + generate-mutations 2010/2011
Typecheck / desktop (pull_request) Failing after 6m29s
Typecheck / controller (pull_request) Failing after 6m30s
- Backend: новые mutations generateExpenseProposalStatementDocument (2010) и generateExpenseProposalDecisionDocument (2011) на канонe wallet (Throttle + GqlJwtAuthGuard + RolesGuard, через GeneratorInfrastructureService).
- DTOs документов 2010/2011: расширены вложенными типами header/items/decision-body (registry-Action поля проброшены в GraphQL).
- expenses-extension.module: подключён GeneratorInfrastructureModule.
- expenses-mutations.service: добавлены generateExpenseProposalStatementDocument/generateExpenseProposalDecisionDocument; spec обновлён под новый конструктор.
- SDK: handwritten Mutations.Expense.GenerateExpenseProposalStatementDocument/GenerateExpenseProposalDecisionDocument + index re-export.
- Desktop API: generateExpenseProposalStatementDocument/generateExpenseProposalDecisionDocument + authorizeExpenseReport wrappers.
- Desktop model: useExpenseProposalActions composable (submitProposal: generate→sign→createExpenseProposal; authorizeProposal: generate→sign→authorizeExpenseReport).
- ExpenseProposalCreateDialog.vue: полный flow подписи через DigitalDocument.sign; убран sha256-плейсхолдер; убран баннер «ждёт расшивки».
- ExpenseProposalAuthorizeDialog.vue: новый диалог решения председателя с автоматической генерацией+подписью документа 2011.
- ExpensesAdminAuthorizePage: добавлена кнопка «Авторизовать» рядом с «Открыть»; открывает диалог авторизации.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:32:08 +00:00
coopops bf73659100 [C28-31][@ant] feat(backend): расшивка createExpenseProposal/authorizeExpenseReport + capital-trigger event
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- Расширил ExpensesBlockchainPort до 8 actions (+createExp/authExp с document2).
- Adapter: реализованы createExp/authExp через BlockchainService.transact на ключе кооператива (canon с capital).
- DTO: CreateExpenseProposalInputDTO теперь несёт username/operation_code/source_wallet/items[]/callback/statement (signed document2 registry 2010); AuthorizeExpenseReportInputDTO — decision (signed document2 registry 2011).
- Service: убраны два NotImplementedException — mutation полностью пробрасывает payload в port.
- ExpensesCapitalTriggerService: TODO log заменён на emit платформенного события `expense::capitalization::required` с реальным payload (coopname/proposal_hash/username/total_actual/block_num); capital extension Эпика 5 подпишется и сделает capital::createrid.
- cooptypes/expense.IDocument2 синхронизирован с C++ struct (version/hash/doc_hash/meta_hash/meta/signatures).
- Spec'и адаптера/сервиса: добавлены кейсы createExp/authExp; удалён ожидавший 501 кейс.
- README expenses-extension: scaffold-stub → implemented.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:17:51 +00:00
coopops 8eb3996895 [C28-32][@ant] feat(desktop): ExpenseProposalCreateDialog — форма-конструктор СЗ с массивом items на canon
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
BaseDialog (size=lg) с BaseInput / q-select (recipient_type, mechanics) + динамическим массивом item-card'ов и валидацией canSubmit.

При submit:
- canonical JSON.stringify полей payload → SHA-256 через crypto.subtle (детерминированный proposal_hash);
- Mutations.Expense.CreateExpenseProposal({ coopname, proposal_hash }) — бэкенд резервирует placeholder, далее signature-pipeline document2.

Info-баннер информирует пайщика что подача откроется после Эпика 2.

7/7 страниц #104 расшито. Mutation-цепочка одобрить/авторизовать/оплатить — следующий шаг после расшивки document2 signature-pipeline.
2026-06-03 07:40:15 +00:00
coopops 530136510f [C28-32][@ant] feat(desktop): CashierPage — 4 таба кассы на canon (Queries.Expense.ExpenseProposalsByCooperative)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
PageTabs с 4 вкладками: К оплате (AUTHORIZED) · Ждут отчёта (PARTIALLY_PAID) · На авторизации (REPORT_SUBMITTED) · Закрытые (CLOSED). Счётчики по факту items в загруженной выборке.

Структура таблицы — тот же canon: TableSkeleton + .table-wrap + .table-foot + EmptyState (per-tab title/body). Действия (оплатить + чек → MinIO, авторизовать отчёт) появятся после расшивки Эпика 2 signature-pipeline.

6/7 страниц #104 расшито. Остался только ExpenseProposalCreateDialog (mutation create — заблокирован document2 stub).
2026-06-03 07:37:27 +00:00
coopops ecd0d7e66a [C28-32][@ant] feat(desktop): ExpenseDetailPage — экран доказательств на canon (proposal + items + docs + files)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Двухколоночный layout:
- Левая: BaseCard «Сводка» (DataRow per поле) + BaseCard «Строки расходов» (items table + статус-бейдж).
- Правая: BaseCard «Документы» (statement_doc 2010, decision_doc 2011) + BaseCard «Чеки» (ExpenseFiles c MinIO storage_key).

Источники:
- Queries.Expense.ExpenseProposal — по proposal_hash из route.
- Queries.Expense.ExpenseFilesByProposal — по coopname+proposal_hash.

Маппинг item-статусов и mechanics в model/expenseItemStatus.ts (label+variant).

5/7 страниц #104 расшито.
2026-06-03 07:35:05 +00:00
coopops 5c5657149e [C28-32][@ant] feat(desktop): председательские очереди Approve/Authorize на canon
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
ExpensesAdminApprovePage — таблица proposals в status=CREATED (in-memory filter после ExpenseProposalsByCooperative).
ExpensesAdminAuthorizePage — таблица proposals в status=REPORT_SUBMITTED (тот же подход).

Обе по канону: PageHead + TableSkeleton + .table-wrap + .table-foot + EmptyState + переход в /expenses/:hash.
Mutation-действия (одобрить/авторизовать) появятся после расшивки Эпика 2 signature-pipeline на document2.

4/7 страниц #104 расшито.
2026-06-03 07:31:02 +00:00
coopops 68122f5d32 [C28-32][@ant] feat(desktop): MyAdvancesPage через Queries.Expense.ExpenseProposalsByMember — реестр своих авансов на canon
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Table-based страница «Мои авансы» по тому же канону что ExpensesRegistryPage:
PageHead + TableSkeleton + .table-wrap + BaseBadge статуса + .table-foot + EmptyState.
Источник — useSessionStore().username + route.params.coopname.

Thin API: api/queries/getExpenseProposalsByMember.ts на Queries.Expense.ExpenseProposalsByMember.

2/7 страниц #104 расшито.
2026-06-03 07:28:33 +00:00
coopops e7b7770c4a [C28-32][@ant] feat(desktop): расшивка ExpensesRegistryPage через Queries.Expense.ExpenseProposalsByCooperative
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
- api/queries/getExpenseProposalsByCooperative — thin wrapper над SDK,
  возвращает PaginatedExpenseProposals (типизировано через IInput/IOutput).
- api/index.ts — barrel-экспорт.
- model/expenseProposalStatus — label/variant maps для 7 значений
  Zeus.ExpenseProposalStatus (CREATED/AUTHORIZED/PARTIALLY_PAID/REPORT_SUBMITTED/
  CLOSED/DECLINED/UNDEFINED) + helpers с дефолтом neutral.
- ExpensesRegistryPage.vue: pug + .table-wrap canon, TableSkeleton(6 cols),
  EmptyState (receipt_long 48px), BaseBadge с variant из map, BaseButton ghost
  "Загрузить ещё", монохешчик в .t-mono-sm. coopname из route.params,
  sortBy=created_at DESC, page-by-page до hasMore.

Open: tooltip с FQH хеша, sort по колонкам, filter-bar по статусу — будут
вместе со страницами /expenses/admin/{approve,authorize} и /my/advances
(своя сигнатура запроса by-member).
2026-06-03 07:23:29 +00:00
coopops bfdabcfded [C28-31][@ant] feat(sdk): expense namespace — 8 mutations + 6 queries + selectors
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Mutations.Expense: CreateExpenseProposal / PayExpenseItem / ReportExpenseItem /
ReturnExpenseItem / OverspendExpenseItem / SubmitExpenseReport /
AuthorizeExpenseReport / DeclineExpenseReport.

Queries.Expense: ExpenseProposal / ExpenseProposalsByCooperative /
ExpenseProposalsByMember / ExpenseFile / ExpenseFilesByItem /
ExpenseFilesByProposal.

Selectors.expense*: ExpenseProposal (с items + statement/decision DocumentAggregate),
ExpenseItem, ExpenseFile, ExpenseProposalsPagination.

Zeus regen + re-exports в mutations/queries/selectors/index.ts.

Разблокирует #103 backend хвост и #104 UI расшивку 7 страниц end-to-end.
2026-06-03 07:18:45 +00:00
coopops a7bd5aae5c [C28-31][@ant] feat(controller): createExpenseProposal stub — ждёт document2 statement_doc
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
8-я mutation симметрично authorizeExpenseReport: NotImplementedException
со ссылкой на statement_doc (type=2010) от signature-pipeline UI Эпика 2.
Регистрирует GraphQL-сигнатуру end-to-end, чтобы SDK + UI видели mutation
до подключения document2.

jest 8/8 passed.
2026-06-03 06:17:07 +00:00
coopops 33d8163d08 [C28-31][@ant] test(controller): contract-spec adapter — formats transact для 6 actions
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
7 тестов проверяют что каждое из 6 действий expense-контракта уходит в
BlockchainService.transact с правильным форматом:
  account = ExpenseContract.contractName.production
  name    = ExpenseContract.Actions.<X>.actionName
  authorization = [{actor: coopname, permission: 'active'}]

Защита от дрейфа cooptypes actionName → adapter имя. Также verify
HttpApiError 502 при отсутствии WIF в vault'е (initialize/transact не зовутся).

jest 7/7 passed.
2026-06-03 05:51:24 +00:00
coopops 531b9574fe [C28-31][@ant] feat(controller): добавляю overspendExp — 6-я работающая mutation
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Доплата при перерасходе (expense::overspendexp, ADVANCE-механика):
- OverspendExpenseItemInputDTO (coopname/proposal_hash/item_hash/overspend_amount)
- ExpensesBlockchainPort.overspendExp + adapter (transact с coop-ключом)
- ExpensesMutationsService.overspendExpenseItem
- ExpenseMutationsResolver.overspendExpenseItem (@AuthRoles chairman)
- spec: 7-й тест action-mapping

Теперь 6 из 8 actions контракта expense работают через port:
payexp / reportexp / returnexp / overspendexp / closeexp / declexp.
createexp + authexp ждут document2 от signature-pipeline UI Эпика 2.

tsc EXIT=0, jest 7/7 passed.
2026-06-03 05:49:42 +00:00
coopops fbfd0a259e [C28-32][@ant] feat(desktop): регистрация expenses extension в registry
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Шасси расходов подключено к init-installed-extensions:
- expenses → expensesInstall в extensionsRegistry
- install.ts: убран TODO-комментарий о блокировке C28-31

Workspace 'expenses' (icon: receipt_long) с 6 routes (registry/admin/cashier/my)
становится доступен ролям chairman + user из конфигурации кооператива.
Stub-страницы внутри ждут расшивки через SDK Zeus после generate-client.
2026-06-03 05:46:00 +00:00
coopops b4dd4b335a [C28-31][@ant] feat(controller): расшивка 5 mutations через ExpensesBlockchainPort
Typecheck / desktop (pull_request) Successful in 13m18s
Typecheck / controller (pull_request) Successful in 13m12s
После Эпика 0 cooptypes/expense — подключаю реальный chain-submit через
port+adapter (канон CapitalBlockchainAdapter):

- ExpensesBlockchainPort: 5 actions без document2 (payexp/reportexp/returnexp/closeexp/declexp)
- ExpensesBlockchainAdapter: BlockchainService.transact + vault-ключ кооператива
- ExpensesMutationsService DI на port — 5 мутаций работают, authorize ждёт decision_doc Эпика 2
- spec обновлён: 6 тестов через mocked port, action-mapping verify

createexp + authexp ждут signature-pipeline UI Эпика 2 (document2 type=2010/2011).
overspendexp подключится с UI Phase 2.

tsc EXIT=0, jest 6/6 passed.
2026-06-03 05:29:41 +00:00
coopops 2fcbe3612f [C28-29][@ant] feat(cooptypes): Эпик 0 — типы контракта expense (8 actions + proposals table)
Typecheck / desktop (pull_request) Successful in 13m22s
Typecheck / controller (pull_request) Successful in 13m5s
- interfaces/expense.ts: 8 action input + IItem/IProposal/ICallbackHandler (hand-written из expense.hpp до ABI regen)
- contracts/expense/{actions,tables,index}: тонкие обёртки в стиле capital — authorizations + actionName + типовой re-export
- common/names: _expense ({production:'expense',testnet:'expense'})
- interfaces/index + contracts/index: re-export ExpenseContract
- ledger2/wallets.generated: +w.exp.adv (prebuild gen-from-cpp подцепил свежий wallet)

build EXIT=0, dist exports ExpenseContract; smoke node: contractName=expense, 8 actions, 1 table
2026-06-03 05:18:06 +00:00
coopops ece4d59c56 [C28-31][@ant] test(controller): smoke-контракт mutations — все 6 методов throw NotImplementedException до Эпика 0
Typecheck / desktop (pull_request) Successful in 13m29s
Typecheck / controller (pull_request) Successful in 13m30s
2026-06-03 05:02:44 +00:00
coopops ce01d48df7 [C28-31][@ant] test(controller): unit-тест capital-trigger — фильтр CLOSED + total_actual
Typecheck / desktop (pull_request) Successful in 13m20s
Typecheck / controller (pull_request) Successful in 12m56s
Покрывает 5 кейсов: REPORT_SUBMITTED/AUTHORIZED/DECLINED → no-op;
CLOSED без total_actual → warn; CLOSED + total_actual → log с
TODO-ссылкой на Эпик 0. Защищает контракт триггера на будущее, когда
skeleton будет заменён на реальный capital::createrid через
WriteMutationPool.
2026-06-03 04:48:45 +00:00
coopops cb22349401 [C28-31][@ant] feat(controller): authorize/decline СЗ-отчёта — skeleton mutations
Typecheck / desktop (pull_request) Successful in 13m52s
Typecheck / controller (pull_request) Successful in 13m11s
GraphQL surface: authorizeExpenseReport (председатель утверждает,
триггерит capital-trigger → capitalization Благороста) +
declineExpenseReport (с reason, переход в DECLINED, capitalization
НЕ запускается). Skeleton — NotImplementedException, chain-submit
после Эпика 0 (cooptypes regen для expense::authexp / declineexp).
2026-06-03 04:34:22 +00:00
coopops f1dd62cfce [C28-31][@ant] feat(controller): capital-trigger skeleton — capitalization Благороста на CLOSED
Typecheck / desktop (pull_request) Successful in 13m27s
Typecheck / controller (pull_request) Successful in 13m2s
ExpenseProposalSyncService override handleSyncDelta → emit
`entitysynced::expense::proposals` после save (per-contract pubsub,
canon controller/CLAUDE.md). ExpensesCapitalTriggerService подписан,
проверяет переход в CLOSED + total_actual → log skeleton. Прямой
DI на CapitalExtensionModule ждёт Эпика 0 (cooptypes regen +
WriteMutationPool для capital::createrid-эквивалента).
2026-06-02 21:45:42 +00:00
coopops f24e3fffbf [C28-31][@ant] feat(controller): DocumentAggregate для statement_doc/decision_doc СЗ
Typecheck / desktop (pull_request) Successful in 13m46s
Typecheck / controller (pull_request) Successful in 13m10s
- `ExpensesManagementService.buildProposalDocumentAggregates(entity)` собирает
  оба подписанных дока (заявление + решение) через `DocumentAggregationService`
  (платформенный domain-сервис, `~/domain/document`); по EMPTY_HASH (`0x000…`)
  возвращает `null` — это валидное состояние chain-mirror'а, отличающееся от
  `undefined` («дока нет / зеркало ещё не пришло»).
- `ExpenseProposalOutputDTO.statement_doc` / `decision_doc` теперь типизированные
  `DocumentAggregateDTO | null` — UI сразу видит подпись/хэш/raw-документ
  единообразно с agreement/agenda/meet резолверами. Boolean-флаги `has_*_doc` сняты.
- `ExpenseProposalResolver` подключает агрегаты:
  - одиночный fetch — последовательно;
  - paginated — `Promise.all` по странице (10–20 смет на запрос — OK).
- `ExpensesExtensionModule` импортирует `DocumentDomainModule` для DI.

controller-tsc — 0 ошибок.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:15:01 +00:00
coopops 45a9381c4b [C28-31][@ant] feat(controller): submitExpenseReport + pagination для списков смет
Typecheck / desktop (pull_request) Successful in 13m19s
Typecheck / controller (pull_request) Successful in 13m4s
- `SubmitExpenseReportInputDTO` + `ExpenseMutationsResolver.submitExpenseReport`
  (chairman/member/user) — финализация СЗ-отчёта пайщиком когда все items закрыты;
  сервис — skeleton `NotImplementedException`, реальный chain-submit (`expense::closeexp`)
  подключим после Эпика 0 (cooptypes regen).
- `findByCoopnamePaginated` / `findByUsernamePaginated` в `ExpenseProposalRepository`
  + TypeORM-имплементация на `findAndCount` (sort по `_created_at DESC`, limit ≤200).
- `listProposalsByCooperativePaginated` / `ByMemberPaginated` в service'е.
- `ExpenseProposalResolver` — paginated по канону controller'а
  (`PaginationInputDTO` + `createPaginationResult('PaginatedExpenseProposals')`).

controller-tsc — 0 ошибок.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:45:36 +00:00
coopops 25e2d99046 [C28-31][@ant] feat(controller): expense files service + upload mutation
Typecheck / desktop (pull_request) Successful in 13m17s
Typecheck / controller (pull_request) Successful in 12m50s
- ExpenseFilesService с @UseBucket(EXPENSES_BUCKET):
  upload (base64 → SHA-256 verify → MinIO put → БД-запись),
  getReadUrl, listByProposal/Item, deleteFile;
  детерминированный ключ MinIO {coopname}/{proposal}/{item|_proposal}/{kind}/{checksum}.{ext};
  409 на повтор по checksum (per-coopname idempotence).
- UploadExpenseFileInputDTO: валидация mime allowlist + size_bytes + SHA-256 regex.
- ExpenseFileOutputDTO с короткоживущим read_url (HMAC-signed, TTL бакета).
- ExpenseFilesResolver: uploadExpenseFile + expenseFile/expenseFilesByProposal/ByItem.
- FileStorageInfrastructureModule.forFeature([ExpenseFilesService]) wire.

tsc --noEmit: 0 ошибок.
2026-06-02 20:16:16 +00:00
coopops 9c8449408f [C28-31][@ant] feat(controller): expense mutation DTO + skeleton resolver
Typecheck / desktop (pull_request) Successful in 13m1s
Typecheck / controller (pull_request) Successful in 13m1s
- pay/report/return-expense-item.input.ts с asset-regex валидацией
- ExpensesMutationsService: skeleton с throw NotImplementedException
  (chain-submit через write-mutation pool подключится после Эпика 0
  cooptypes regen для контракта expense)
- ExpenseMutationsResolver: 3 Mutation (chairman / member+user)
  возвращают TransactionDTO — sigнатура зафиксирована в схеме сразу

GraphQL: payExpenseItem (chairman), reportExpenseItem (chairman/member/user),
returnExpenseItem (chairman/member/user).

tsc --noEmit: 0 ошибок.
2026-06-02 19:44:26 +00:00
coopops e248ddb2b0 [C28-31][@ant] feat(controller): expense read resolver + output DTO
Typecheck / desktop (pull_request) Successful in 13m9s
Typecheck / controller (pull_request) Successful in 12m48s
- ExpenseProposalOutputDTO + ExpenseItemOutputDTO + fromDomain mapper
- mechanics/recipient_type/item_status: uint8 → enum
- ExpenseProposalResolver — 3 query (byHash / byCooperative / byMember)
- Auth: chairman/member для списка по коопу, +user для своих
2026-06-02 19:15:14 +00:00
coopops 89f7ded6fc [C28-31][@ant] feat(controller): expenses syncer + management read-service
Typecheck / desktop (pull_request) Successful in 13m8s
Typecheck / controller (pull_request) Successful in 12m45s
- ExpenseProposalSyncService — extends AbstractEntitySyncService, OnEvent fork::*
- ExpensesManagementService — read-only (proposalByHash / byCoop / byMember)
- DI wire в ExpensesExtensionModule
2026-06-02 18:58:28 +00:00
coopops 9359e6b324 [C28-31][@ant] feat(controller): expenses repositories + database module + DI wire
Typecheck / desktop (pull_request) Successful in 13m18s
Typecheck / controller (pull_request) Successful in 12m50s
ExpenseProposalTypeormRepository extends BaseBlockchainRepository (sync_key
proposal_hash, findByCoopname/findByUsername/findByProposalHash).
ExpenseFileTypeormRepository — БД-only, поиск по checksum/proposal/item.
ExpensesDatabaseModule — TypeOrm.forFeature на 2 зеркала + EntityVersion.
ExpensesExtensionModule подаёт EXPENSE_PROPOSAL_REPOSITORY / EXPENSE_FILE_REPOSITORY
через useClass; EntityVersioningService приходит из @Global() TypeOrmModule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:54:12 +00:00
coopops e8f5d7604e [C28-31][@ant] feat(controller): expenses infra — typeorm-зеркала, mappers, delta-mapper
Typecheck / desktop (pull_request) Successful in 13m14s
Typecheck / controller (pull_request) Successful in 12m58s
ExpenseProposalTypeormEntity (proposal_hash sync_key, items/docs в jsonb),
ExpenseFileTypeormEntity (БД-only, unique по coopname+checksum).
ExpenseProposalMapper TypeORM↔Domain (toUpdateEntity = только block_num/present
по канону: блокчейн-поля обновляются через AbstractEntitySyncService).
ExpenseFileMapper — symmetric copy.
ExpenseContractInfoService — паттерны таблиц для parser2.
ExpenseProposalDeltaMapper extends AbstractBlockchainDeltaMapper, lowercase
proposal_hash, signed-docs через DomainToBlockchainUtils.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:44:24 +00:00
coopops f7a5defa95 [C28-31][@ant] feat(controller): expenses domain — interfaces, entity, repo tokens
Typecheck / desktop (pull_request) Successful in 13m15s
Typecheck / controller (pull_request) Successful in 12m47s
ExpenseProposalDomainEntity на BaseDomainEntity (зеркалит ExpenseDomain::proposal)
+ IExpenseProposalBlockchainData/DatabaseData, IExpenseItemBlockchainData/DatabaseData,
IExpenseFileDatabaseData (только БД, файл в MinIO).
Repository-интерфейсы + DI-токены EXPENSE_PROPOSAL_REPOSITORY, EXPENSE_FILE_REPOSITORY.
mapStatusToDomain зеркалит uint8_t status, неизвестное → UNDEFINED.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:34:28 +00:00
coopops 1c46fd0338 [C28-31][@ant] feat(controller): scaffold expenses extension — enum'ы + bucket + module wiring
Typecheck / desktop (pull_request) Successful in 13m4s
Typecheck / controller (pull_request) Successful in 13m4s
Шаг 1 из C28-31 (backend шасси расходов):
- 5 enum'ов (registerEnumType): ProposalStatus / ItemStatus / Mechanics / RecipientType / FileKind — зеркалят expense.hpp
- EXPENSES_BUCKET (constants/expenses-bucket.ts): MinIO-bucket spec для платёжек/чеков/возвратов
- ExpensesExtensionModule (минимальный) подключён в ExtensionsModule.register() (не desktop-расширение, без записи в AppRegistry)

Дальше: domain entities (composite db/bc/derived), TypeORM-зеркало, delta-mapper, sync-service, resolver. Реальная sync-инфра — после ABI regen контракта expense.
2026-06-02 18:24:07 +00:00
coopops 8897a93e83 [C28-29][@ant] feat(contracts): новый контракт expense — 8 actions, шасси расходов
Typecheck / desktop (pull_request) Successful in 13m4s
Typecheck / controller (pull_request) Successful in 12m44s
components/contracts/cpp/expense/:
- expense.hpp: 1 таблица proposals (scope=coopname; secondary by_hash/by_username/by_status),
  типы item, callback_handler, enum Mechanics/RecipientType/ProposalStatus/ItemStatus.
- expense.cpp: 8 actions (createexp/authexp/declexp/payexp/reportexp/closeexp/returnexp/overspendexp).
- CMakeLists.txt: стандартный add_contract(expense expense expense.cpp).

components/contracts/cpp/lib/consts.hpp:
- _expense = expense_n + добавлен в contracts_whitelist.

components/contracts/CMakeLists.txt:
- add_contract_build(expense).

Принцип v3:
- Один процесс p.exp.expns, 5 ledger2 operation_codes (o.exp.{blgadv,blgdir,advrpt,advret,over}).
- createexp принимает operation_code+source_wallet+callback в payload — контракт агностичен к источнику.
- payexp на DIRECT-механике сразу помечает item REPORTED (BURN blago = один полный шаг).
- payexp на ADVANCE — отдельный reportexp пайщиком после чека.
- closeexp шлёт inline action на сохранённый callback (contract,action,data) если он непустой.
- overspendexp — две последовательные Ledger2::apply (OVERSPEND + ADVANCE_REPORT) в одной транзакции.
2026-06-02 18:17:12 +00:00
coopops 42165ec3e8 [C28-28][@ant] feat(ledger2): шасси расходов — 1 кошелёк, 1 процесс, 5 операций
Typecheck / desktop (pull_request) Successful in 13m21s
Typecheck / controller (pull_request) Successful in 13m6s
wallets.hpp:
- w.exp.adv ADVANCE_HOLD (USER_SHARED) — кошелёк-резерв подотчёта пайщика
- mapping без program-gate: source-программа проверяется контрактом expense

processes.hpp:
- p.exp.expns PROPOSAL — единый процесс расхода по СЗ

operations.hpp (namespace operations::expense):
- o.exp.blgadv BLAGO_ADVANCE  — TRANSFER blago→adv, Дт 08 / Кт 51
- o.exp.blgdir BLAGO_DIRECT   — BURN blago,       Дт 08 / Кт 51
- o.exp.advrpt ADVANCE_REPORT — BURN adv,         без проводки (canal уже сделан)
- o.exp.advret ADVANCE_RETURN — TRANSFER adv→blago, Дт 51 / Кт 08 (зеркало blgadv)
- o.exp.over   OVERSPEND      — TRANSFER blago→adv, Дт 08 / Кт 51 (перерасход)

Принцип: расход не уменьшает паевой фонд (80) — меняется только форма актива
51 → 08. Канал 08/51 строится ровно один раз: на blgadv/blgdir. ADVANCE_REPORT —
только BURN кошелька-резерва. Все ack v3 (см. NAMING-C28-28.md commit 143d9a9d30).
2026-06-02 17:58:31 +00:00
coopops 143d9a9d30 [C28-28][@ant] docs: v3 — все ack получены, callback как переменная при createexp, проводки 08/51 для обеих механик Благороста
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m8s
2026-06-02 17:56:10 +00:00
coopops bf6a5fe8a3 [C28-34][@ant] docs: 2012/2013/2014 убраны — это файлы (MinIO), не document2; assertions на o.exp.* коды
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m4s
2026-06-02 17:31:06 +00:00
coopops 043a52abf3 [C28-28][@ant] docs: универсальное шасси v2 — без 60/71, 5 op-кодов, payload-флаг capital trigger
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m32s
2026-06-02 17:08:01 +00:00
coopops b9c96bed6d [C28-28][@ant] docs: naming proposal — 6 op-кодов вместо 5 + 60/71 счета + capital trigger
Typecheck / desktop (pull_request) Successful in 13m34s
Typecheck / controller (pull_request) Successful in 13m23s
2026-06-02 16:09:49 +00:00
coopops 61f35b1ef9 [C28-34][@ant] docs: план e2e Journey A/B — scaffold-stub до выбора фреймворка и закрытия C28-28..33
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m28s
E2E.md в expenses/:
- Journey A (DIRECT-оплата Yandex Cloud из BLAGOROST_POOL): 10 шагов + matrix ledger2/document2 assertions
- Journey B (ADVANCE master+svetlana 2 items): 9 шагов + matrix
- Helpers-skeleton (loginAs, seedBlagorostPool, mockCouncilAuthorize, fixture*, assertLedgerOp/Document2)
- Performance gates ≤ 3 мин на сценарий
- Прежде чем писать — спросить про Cypress vs Playwright (канон-naming-soglasovyvat)
- Зависимости + дорожная карта расшивки

E2E framework в desktop отсутствует; реальные spec'и пишутся после C28-28..C28-33 и согласования инфраструктуры.
2026-06-02 15:37:22 +00:00
coopops 9757f5ec6b [C28-33][@ant] docs: план интеграции Благороста + cleanup-чеклист — doc-scaffold до C28-29/31/32
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m22s
INTEGRATIONS.md в expenses/:
- BLAGOROST_POOL flow через существующий capital::createprogexp (никаких новых contract-actions)
- Каталог 9 универсальных событий шасси для Notification Center
- Backward-compat редиректы /programs/blagorost/expenses → /expenses?source=BLAGOROST_POOL
- Cleanup-чеклист legacy program-expense: на ветке пусто (re-grep ProgramExpense → 0 файлов; PR #59 не залит)
- Шаблоны 1010/1011 — не существуют, удалять нечего; роль закрывают 2010/2011 из C28-30
- Расшивка после закрытия C28-29/C28-31/C28-32

Не трогает рабочие сервисы (только новый .md в extensions/expenses/, не зарегистрировано в extensions-registry).
2026-06-02 15:35:55 +00:00
coopops 6e0fb185b6 [C28-32][@ant] feat: UI scaffold-stub expenses extension — 7 page stubs + dialog + install.ts на canon до C28-31
Typecheck / desktop (pull_request) Successful in 13m33s
Typecheck / controller (pull_request) Successful in 13m37s
2026-06-02 15:08:37 +00:00
coopops 3537c7d89b [C28-31][@ant] docs: scaffold-stub нового expenses extension — план файловой структуры до C28-29
Typecheck / desktop (pull_request) Successful in 13m44s
Typecheck / controller (pull_request) Successful in 13m20s
C28-31 (backend extension шасси расходов) логически блокируется C28-29 (контракт expenses) и C28-28 (P0 коды ledger2). Чтобы зонтичный PR не простаивал и следующий цикл шёл механически — кладём README с планируемой структурой файлов, MinIO-спекой бакета `expenses:files` и черновой GraphQL-схемой.

Когда расшивается C28-28 → C28-29 → этот README раскрывается в код по списку.
2026-06-02 14:47:15 +00:00
coopops 1fa41b2b39 [C28-30][@ant] feat: добавить 2 шаблона document2 для шасси расходов (2010/2011) — единый источник СЗ-сметы и протокола-1 для MVP-SINGLE
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 13m4s
Реализация Эпика 2 шасси системы расходов (волна 6 проекта 14).

cooptypes/cooperative/registry/:
- 2010.ExpenseProposalStatement — СЗ-смета с массивом items (description, amount,
  recipient_type SELF/MEMBER/ORG, mechanics ADVANCE/DIRECT, recipient_name, requisites),
  заголовком proposal (description/total_amount/items_count/source_wallet). LiquidJS
  context с таблицей позиций, переводы ru.
- 2011.ExpenseProposalDecision — протокол-1 утверждения/отказа председателем.
  Поля decision {kind: approve|decline, reason?, protocol_number?, protocol_date?},
  переиспользование IExpenseItem/IExpenseProposalHeader из 2010 через cross-import.

factory/src/:
- Schema/ExpenseItemSchema — JSONSchemaType для ExpenseItem, ExpenseProposalHeader,
  ExpenseProposalDecisionBody.
- Templates/2010+2011 — обёртки по образцу 1010 ExpenseStatement / 1011 ExpenseDecision,
  но с массивом items в Schema.
- Actions/2010+2011 — генераторы pdf через DocFactory с маппингом meta+coop+user+vars
  +proposal+items[+decision].

factory/test/documents-1000-plus.test.ts:
- 3 golden-pdf теста (2010 СЗ, 2011 approve, 2011 decline + reason).

Legacy 1010/1011 НЕ удалены — будут выпилены в C28-33 после миграции backend/UI
на новые ID (чтобы не порвать PR #55 на марафоне рефакторинга).

Решение 2026-06-02: только 2 шаблона document2 в MVP (2010, 2011); остальные слоты
(payment_proof, report_files, return_proof, протокол-2) — файлы в MinIO/expense_files
без factory-цепочки. Тяжёлая document2-цепочка нужна только для юридически-весомых
актов СЗ-смета + протокол совета; первичные документы внешнего происхождения и
events приёмки идут отдельным механизмом.

Refs: C28-30, PRD f8 раздел 8.16 + override 2026-06-02, образцы 1010.ExpenseStatement
+ 1011.ExpenseDecision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:39:37 +00:00
467 changed files with 29854 additions and 730 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,
+2 -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-утилита инициализации блокчейна и кооператива",
@@ -26,6 +26,7 @@
"boot:remote": "esno src/index.ts boot:remote",
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
"create-coop": "esno src/index.ts create-coop",
"clear": "./scripts/clear.sh",
"start": "./scripts/restart.sh",
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Полный перезапуск dev-среды для теста Благороста одной командой:
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
# 2) ожидание готовности controller (GraphQL отвечает);
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
#
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
bash "$SCRIPT_DIR/extra_reboot.sh"
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
echo "Ждём готовности controller ($GRAPHQL_URL)..."
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
sleep 3
done
echo "Controller готов."
UP_TO="${SEED_UP_TO:-04-contributor}"
echo "Сидируем capital до фазы $UP_TO..."
cd "$SCRIPT_DIR/.."
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
+5
View File
@@ -128,4 +128,9 @@ export default [
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'expense',
path: path.join(userBase, 'expense'),
target: 'expense',
},
]
+4
View File
@@ -154,6 +154,10 @@ export default {
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'expense',
code_permissions_to: ['expense'],
},
// {
// name: provider_chairman,
// },
@@ -0,0 +1,33 @@
{
"protocol_feature_type": "builtin",
"dependencies": [
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
],
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "SAVANNA"
}
+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
View File
@@ -58,6 +58,7 @@ add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(expense)
add_contract_build(system)
# Опции для тестов
@@ -0,0 +1,65 @@
/**
* @brief Инициировать программный расход через шасси expense.
*
* capital — только инициатор. Резервирует средства в @c program_expense_pool,
* создаёт скелет записи progexpense (status=CREATED, как маркер pending), затем
* шлёт inline action `expense::createexp(...)` с callback handler `{capital, onpgexpdone}`.
* Шасси expense обслуживает весь дальнейший flow (авторизация совета, выдача аванса,
* отчёт, закрытие либо отклонение) и на терминальном переходе шлёт inline `onpgexpdone`.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (= proposal_hash в шасси)
* @param creator Инициатор расхода
* @param items Позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* @param description Описание для отчётности
* @param statement Документ-заявление (ProgramExpenseStatement)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname.
*/
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement, {creator});
eosio::check(!items.empty(), "Программный расход должен содержать хотя бы один item");
// Сумма расхода — сумма planned_amount всех items.
asset amount = items.front().planned_amount;
amount.amount = 0;
for (const auto &it : items) {
amount += it.planned_amount;
}
Wallet::validate_asset(amount);
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
auto exist = Capital::get_program_expense(coopname, expense_hash);
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
// Резерв в пуле до отправки в шасси — две параллельные заявки не превысят пул.
Capital::State::reserve_program_expense(coopname, amount);
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
amount, description, statement);
// Inline action в шасси expense с callback handler на capital::onpgexpdone.
// data пустой — capital резолвит запись по expense_hash, дополнительный payload не нужен.
ExpenseDomain::callback_handler callback{
.contract = _capital,
.action = Names::Capital::ON_PROGRAM_EXPENSE_DONE,
.data = std::vector<char>{}
};
// Authority — _capital: у capital@eosio.code нет coopname@active, а
// expense::createexp принимает контракты-инициаторы по contracts_whitelist.
eosio::action(
eosio::permission_level{_capital, "active"_n},
_expense,
Names::External::CREATE_EXPENSE_PROPOSAL,
std::make_tuple(coopname, creator, expense_hash,
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
).send();
}
@@ -0,0 +1,65 @@
/**
* @brief Callback от шасси expense на финализацию program-расхода.
*
* Шасси expense шлёт его inline (authority — _expense@active) когда закончило
* flow (closeexp или declexp). capital не делает ledger2-проводок —
* они уже сделаны шасси на payexp/reportexp/returnexp/overspendexp. capital
* только обновляет @c program_expense_pool и удаляет запись из @c progexpenses.
*
* status == ExpenseDomain::ProposalStatus::CLOSED (4):
* total_actual <= reserved → consume(total_actual) + release(остаток);
* total_actual > reserved (был overspendexp) → consume(reserved) +
* прямое списание превышения из program_expense_pool.
* status == ExpenseDomain::ProposalStatus::DECLINED (5):
* release(reserved); decline возможен только до оплат (total_actual == 0).
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (ключ в progexpenses)
* @param status ExpenseDomain::ProposalStatus
* @param total_actual Фактически потраченная сумма
* @param data Произвольный payload от инициатора (не используется на MVP)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _expense — inline-callback шасси.
*/
void capital::onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data) {
require_auth(_expense);
auto pgexp = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
const auto reserved = pgexp.amount;
if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::CLOSED)) {
eosio::check(total_actual.symbol == reserved.symbol,
"Символ total_actual не совпадает с зарезервированной суммой");
eosio::check(total_actual.amount >= 0,
"total_actual не может быть отрицательным");
if (total_actual.amount <= reserved.amount) {
if (total_actual.amount > 0) {
Capital::State::consume_program_expense(coopname, total_actual);
}
const asset leftover = reserved - total_actual;
if (leftover.amount > 0) {
Capital::State::release_program_expense(coopname, leftover);
}
} else {
// Перерасход (overspendexp в шасси): резерв списываем полностью,
// превышение — напрямую из свободного пула программных расходов.
Capital::State::consume_program_expense(coopname, reserved);
const asset excess = total_actual - reserved;
Capital::State::spend_program_expense_pool(coopname, excess);
}
} else if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::DECLINED)) {
// Шасси отклоняет СЗ только до первой оплаты — резерв возвращается целиком.
eosio::check(total_actual.amount == 0,
"DECLINED с ненулевым total_actual — нарушение инварианта шасси");
Capital::State::release_program_expense(coopname, reserved);
} else {
eosio::check(false, "onpgexpdone принимает только статусы CLOSED либо DECLINED");
}
Capital::Expenses::delete_program_expense(coopname, pgexp.id);
}
@@ -0,0 +1,33 @@
/**
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
*
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp) —
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
* при расходах не изменяются.
*
* @param coopname Кооператив (scope state)
* @param amount Сумма пополнения (символ строго совпадает с программой)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
*/
void capital::topupprogexp(name coopname, eosio::asset amount) {
require_auth(coopname);
Capital::State::topup_program_expense_pool(coopname, amount);
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
// на 51 с момента взносов — выделяется только назначение под расходы).
// Синтетический process_hash — у пополнения нет естественного анкера.
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
name{}, process_hash, "capital:topupprogexp");
}
@@ -95,6 +95,12 @@
#include "app/debt_managment/create_debt/declinedebt.cpp"
#include "app/debt_managment/settle_debt/settledebt.cpp"
// Управление программными расходами через шасси expense.
// capital — инициатор + получатель callback, шасси обслуживает весь flow.
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
#include "app/expense_managment/program_expenses/createpgexp.cpp"
#include "app/expense_managment/program_expenses/onpgexpdone.cpp"
// Управление расходами
#include "app/expense_managment/create_expense/approveexpns.cpp"
#include "app/expense_managment/create_expense/capauthexpns.cpp"
@@ -48,6 +48,7 @@ constexpr uint128_t CRPS_PRECISION_FACTOR = 100000000000000ULL; ///< Масшт
#include <eosio/asset.hpp>
#include "../lib/index.hpp"
#include "domain/index.hpp"
#include "../expense/expense.hpp" // ExpenseDomain::item / callback_handler — для inline-action в шасси
using namespace eosio;
@@ -322,4 +323,38 @@ public:
[[eosio::action]]
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
// Программные расходы через шасси expense.
// capital — инициатор + получатель callback, шасси держит весь flow расхода.
[[eosio::action]]
void topupprogexp(name coopname, eosio::asset amount);
/**
* @brief Инициировать программный расход через шасси expense.
* Резервирует amount в program_expense_pool, создаёт запись progexpense,
* шлёт inline action expense::createexp с callback={capital, onpgexpdone}.
* Шасси обслуживает весь flow (auth/pay/report/close|decline) и шлёт callback.
*
* items[] — позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* (item.mechanics: ADVANCE — аванс, DIRECT — прямая оплата).
*/
[[eosio::action]]
void createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement);
/**
* @brief Callback от шасси expense на финализацию program-расхода.
* Шасси шлёт его inline (authority — _expense@active, require_auth(_expense))
* после терминального перехода flow (CLOSED либо DECLINED).
*
* status == CLOSED → consume(total_actual) + release(остаток резерва);
* при total_actual > reserved (перерасход) превышение
* списывается напрямую из program_expense_pool.
* status == DECLINED → release(reserved); decline возможен только до оплат.
*/
[[eosio::action]]
void onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data);
};
@@ -178,8 +178,96 @@ namespace Capital::Expenses {
Capital::expense_index expenses(_capital, coopname.value);
auto expense = expenses.find(expense_id);
eosio::check(expense != expenses.end(), "Расход не найден");
expenses.erase(expense);
}
} // namespace Capital::Expenses
namespace Capital {
/**
* @brief Таблица программных расходов — целевые списания «Благорост»,
* не привязанные к конкретному проекту.
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): progexpenses
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
uint64_t id; ///< Внутренний ключ
name coopname; ///< Имя кооператива
name username; ///< Инициатор расхода (председатель / казначей)
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
uint64_t fund_id; ///< Идентификатор фонда списания
eosio::asset amount; ///< Сумма расхода
std::string description; ///< Описание (для отчётности)
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
document2 approved_statement; ///< Записка, принятая председателем
document2 authorization; ///< Решение совета
time_point_sec spended_at = current_time_point();
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_expense_hash() const { return expense_hash; }
};
typedef eosio::multi_index<
"progexpenses"_n, program_expense,
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
> program_expense_index;
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
program_expense_index t(_capital, coopname.value);
auto idx = t.get_index<"byhash"_n>();
auto itr = idx.find(hash);
if (itr == idx.end()) return std::nullopt;
return program_expense(*itr);
}
} // namespace Capital
namespace Capital::Expenses {
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
auto v = Capital::get_program_expense(coopname, expense_hash);
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
return v.value();
}
inline void create_program_expense(
eosio::name coopname,
const checksum256 &expense_hash,
eosio::name username,
const eosio::asset &amount,
const std::string &description,
const document2 &statement
) {
Capital::program_expense_index t(_capital, coopname.value);
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
t.emplace(coopname, [&](auto &e) {
e.id = id;
e.coopname = coopname;
e.username = username;
e.expense_hash = expense_hash;
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
e.status = Status::CREATED;
e.spended_at = current_time_point();
e.expense_statement = statement;
e.amount = amount;
e.description = description;
});
}
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.erase(itr);
}
} // namespace Capital::Expenses
@@ -1,5 +1,7 @@
#pragma once
#include <eosio/binary_extension.hpp>
using namespace eosio;
using std::string;
@@ -40,7 +42,18 @@ namespace Capital {
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
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)
};
@@ -50,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 Обновляет глобальное состояние новыми значениями.
*
@@ -78,7 +101,7 @@ inline global_state get_global_state(name coopname) {
/**
* @brief Проверяет что контракт инициализирован.
*
*
* @param coopname Имя кооператива.
*/
inline void is_initialized(name coopname) {
@@ -86,6 +109,116 @@ inline void is_initialized(name coopname) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
}
/**
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
* Вызывается из action topupprogexp (председатель).
*/
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
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->global_available_invest_pool >= 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;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Резервирует средства под approved/authorized программный расход:
* program_expense_pool -= amount, program_expense_reserved += amount.
*/
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
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(ext_or_zero(itr->program_expense_pool, amount.symbol) >= 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);
pool -= amount;
reserved += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Освобождает зарезервированное при отклонении программного расхода:
* program_expense_reserved -= amount, program_expense_pool += amount.
*/
inline void release_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
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(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= 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);
reserved -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Окончательное списание программного расхода (callback onpgexpdone, CLOSED):
* program_expense_reserved -= amount. Деньги уже ушли через шасси expense,
* ledger2-проводки сделаны на payexp/reportexp/returnexp.
*/
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
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(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= 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);
reserved -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Прямое списание из свободного пула программных расходов — превышение
* факта над резервом при закрытии расхода с перерасходом (overspendexp
* в шасси выдал доплату из BLAGOROST_FUND сверх зарезервированного).
*/
inline void spend_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
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(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
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);
pool -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
}// namespace State
}// namespace Capital
@@ -0,0 +1,10 @@
cmake_minimum_required(VERSION 3.5)
project(expense)
find_package(cdt)
add_contract(expense expense expense.cpp)
if(IS_TESTNET)
target_compile_definitions(expense PUBLIC IS_TESTNET=1)
endif()
@@ -0,0 +1,425 @@
// expense.cpp — шасси расходов (первый потребитель: Благорост).
//
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
// Кошелёк-пул приходит в createexp параметром source_wallet; ledger2-коды всех
// операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
// (фабричная настройка — подключение нового пула не меняет этот контракт).
// Механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
// callback-handler сохраняется как переменная.
#include "expense.hpp"
namespace D = ExpenseDomain;
// ── helpers ──────────────────────────────────────────────────────────────────
namespace {
asset zero_like(const asset& a) { return asset{0, a.symbol}; }
asset sum_planned(const std::vector<D::item>& items) {
if (items.empty()) return asset{0, eosio::symbol{"RUB", 4}};
asset total = zero_like(items.front().planned_amount);
for (const auto& it : items) total += it.planned_amount;
return total;
}
// Набор ledger2-кодов операций шасси по кошельку-источнику СЗ. Один источник =
// один набор (EXPENSE_OPERATION_SETS в ledger2) — в коде expense нет
// захардкоженных operation_code, контракт настраивается на любой пул.
const ExpenseOperationSet& expense_ops_for(eosio::name source_wallet) {
const auto* set = find_expense_operation_set(source_wallet);
eosio::check(set != nullptr,
"Для кошелька-источника не настроен набор операций шасси расходов (EXPENSE_OPERATION_SETS)");
return *set;
}
D::proposals_index get_proposals(eosio::name self, eosio::name coopname) {
return D::proposals_index(self, coopname.value);
}
auto find_proposal(D::proposals_index& tbl, const eosio::checksum256& proposal_hash) {
auto idx = tbl.get_index<"byhash"_n>();
return idx.find(proposal_hash);
}
void send_callback_if_any(const D::callback_handler& cb,
eosio::name coopname,
const eosio::checksum256& proposal_hash,
uint8_t status,
const eosio::asset& amount,
eosio::name self) {
if (cb.contract.value == 0) return;
// Payload: (coopname, proposal_hash, status, total_actual, data).
// status = ExpenseDomain::ProposalStatus — инициатор различает CLOSED vs DECLINED.
// coopname нужен инициатору для скоупа таблиц (capital::progexpenses scope=coopname).
eosio::action(
eosio::permission_level{self, "active"_n},
cb.contract,
cb.action,
std::make_tuple(coopname, proposal_hash, status, amount, cb.data)
).send();
}
} // namespace
// ── actions ──────────────────────────────────────────────────────────────────
void expense::createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<D::item> items,
D::callback_handler callback,
document2 statement) {
// Прямой вызов backend'а — авторизация кооператива; inline от контракта-инициатора
// (capital::createpgexp и т.п.) — авторизация по whitelist, как в ledger2::apply.
if (!has_auth(coopname)) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
}
verify_document_or_fail(statement, {username});
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
eosio::check(ledger2_is_known_wallet(source_wallet),
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
// Источник определяет ledger2-коды всех операций жизненного цикла —
// отказываем сразу при создании, а не в момент оплаты.
expense_ops_for(source_wallet);
auto tbl = get_proposals(get_self(), coopname);
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
"СЗ с таким proposal_hash уже существует");
// Механика оплаты задаётся на каждом item отдельно (ADVANCE | DIRECT) —
// в одном СЗ допустимо смешение: ledger2-проводка payexp идёт по-позиционно
// с кодом, выведенным из механики конкретного item'а.
for (auto& it : items) {
eosio::check(it.planned_amount.is_valid() && it.planned_amount.amount > 0,
"planned_amount каждого item должен быть положительным");
eosio::check(it.mechanics <= static_cast<uint8_t>(D::Mechanics::DIRECT),
"Неизвестная mechanics item (0 = ADVANCE, 1 = DIRECT)");
eosio::check(it.recipient_type <= static_cast<uint8_t>(D::RecipientType::ORG),
"Неизвестный recipient_type item");
// Инвариант: пайщик (SELF/MEMBER) получает только аванс под отчёт на личные
// реквизиты (расходом станет после его отчёта чеком); организация — только
// прямую оплату по выставленным реквизитам, аванс ей не выдаётся.
if (it.recipient_type == static_cast<uint8_t>(D::RecipientType::ORG)) {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::DIRECT),
"Организации/ИП доступна только оплата по счёту (DIRECT)");
} else {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::ADVANCE),
"Пайщику средства выдаются только авансом под отчёт (ADVANCE)");
eosio::check(it.recipient != name(),
"recipient обязателен для получателя-пайщика");
eosio::check(is_account(it.recipient),
"Аккаунт получателя-пайщика не существует");
}
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
it.actual_amount = zero_like(it.planned_amount);
}
const auto total = sum_planned(items);
tbl.emplace(get_self(), [&](auto& row) {
row.id = tbl.available_primary_key();
row.coopname = coopname;
row.proposal_hash = proposal_hash;
row.username = username;
row.source_wallet = source_wallet;
row.status = static_cast<uint8_t>(D::ProposalStatus::CREATED);
row.items = items;
row.total_planned = total;
row.total_actual = zero_like(total);
row.callback = callback;
row.statement_doc = statement;
row.decision_doc = document2{};
row.created_at = eosio::current_time_point();
row.updated_at = row.created_at;
});
// СЗ утверждает совет (как возвраты паевых взносов): ставим вопрос в повестку.
// Утверждение председателем после голосования → callback authexp от _soviet;
// отклонение (отрицательный консенсус / просрочка) → declexp с причиной.
::Soviet::create_agenda(
_expense,
coopname,
username,
get_valid_soviet_action("createexp"_n),
proposal_hash,
_expense,
"authexp"_n,
"declexp"_n,
statement,
std::string("")
);
}
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED),
"Авторизовать можно только СЗ в статусе CREATED");
verify_document_or_fail(decision);
idx.modify(it, get_self(), [&](auto& row) {
row.decision_doc = decision;
row.status = static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED);
row.updated_at = eosio::current_time_point();
});
}
void expense::declexp(name coopname, checksum256 proposal_hash, std::string reason) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
// Отклонение возможно только пока ни один item не оплачен: после payexp деньги
// уже ушли через ledger2, и decline разъехался бы с учётом инициатора
// (callback DECLINED возвращает инициатору весь резерв). Частично исполненный
// СЗ завершается обычным путём: reportexp / returnexp → closeexp.
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED),
"Отклонить можно только СЗ без оплат (статусы CREATED / AUTHORIZED)");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
row.updated_at = eosio::current_time_point();
});
// Callback инициатору (capital::onpgexpdone и т.п.); total_actual здесь всегда 0 —
// оплат до decline не было (см. проверку статусов выше).
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::DECLINED),
it->total_actual, get_self());
}
void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Оплата возможна только из статусов AUTHORIZED / PARTIALLY_PAID");
eosio::check(actual_amount.is_valid() && actual_amount.amount > 0,
"actual_amount должен быть положительным");
bool item_found = false;
uint8_t paid_mechanics = 0;
name paid_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::APPROVED),
"Item уже оплачен или закрыт");
eosio::check(actual_amount.symbol == i.planned_amount.symbol,
"Символ actual_amount не совпадает с планом item");
eosio::check(actual_amount.amount <= i.planned_amount.amount,
"Сумма оплаты не может превышать план item; доплата сверх плана — через overspendexp");
i.actual_amount = actual_amount;
// DIRECT не имеет фазы подотчёта (нет ADVANCE_HOLD): закрывающие документы
// прикладывает сам кассир, item считается отчитанным сразу после оплаты.
i.status = static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::DIRECT
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
: static_cast<uint8_t>(D::ItemStatus::PAID);
paid_mechanics = i.mechanics;
paid_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += actual_amount;
// DIRECT-only СЗ становится готов к закрытию сразу после последней оплаты.
row.status = all_reported
? static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED)
: static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
row.updated_at = eosio::current_time_point();
});
// ledger2-проводка по механике оплаченного item'а — выдача аванса или прямая
// оплата; коды выводятся из кошелька-источника СЗ. СЗ может смешивать механики.
// username нужен ledger2 только для USER_SHARED-стороны (кошелёк-подотчёт) —
// это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
const auto& ops = expense_ops_for(it->source_wallet);
const auto code = static_cast<D::Mechanics>(paid_mechanics) == D::Mechanics::ADVANCE
? ops.advance
: ops.direct;
Ledger2::apply(get_self(), coopname, code, actual_amount,
paid_recipient, proposal_hash, "expense:payexp");
}
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Отчёт возможен только по СЗ в статусе PARTIALLY_PAID");
asset item_amount = asset{0, it->total_actual.symbol};
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Отчёт возможен только для оплаченных items");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"reportexp применим только к ADVANCE-механике");
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
item_amount = i.actual_amount;
item_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
if (all_reported) {
row.status = static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED);
}
row.updated_at = eosio::current_time_point();
});
// ledger2: BURN кошелька-подотчёта на остаточный actual item'а (выдано
// возвращено + доплачено), без бухпроводки — проводка уже сделана при выдаче.
// Подотчёт числится на получателе аванса — его username и закрывает burn.
// Полный возврат аванса (actual == 0) — burn не нужен, подотчёт уже пуст.
if (item_amount.amount > 0) {
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).report, item_amount,
item_recipient, proposal_hash, "expense:reportexp");
}
}
void expense::closeexp(name coopname, checksum256 proposal_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED),
"Закрыть можно только СЗ в статусе REPORT_SUBMITTED");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::CLOSED);
row.updated_at = eosio::current_time_point();
});
// Callback на финализацию (capital::onpgexpdone и т.п.) — если был установлен при createexp.
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::CLOSED),
it->total_actual, get_self());
}
void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Возврат возможен только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"returnexp применим только к ADVANCE-механике");
eosio::check(return_amount.is_valid() &&
return_amount.symbol == i.actual_amount.symbol,
"Символ return_amount не совпадает с item");
eosio::check(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
"return_amount должен быть положительным и не превышать actual_amount");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — после
// возврата остатка получатель штатно отчитывается reportexp по
// фактически потраченной части (при полном возврате — actual == 0).
i.actual_amount -= return_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual -= return_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: TRANSFER подотчёт → пул-источник (зеркало выдачи аванса).
// Подотчёт снимается с получателя аванса.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).refund, return_amount,
item_recipient, proposal_hash, "expense:returnexp");
}
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount) {
require_auth(coopname);
eosio::check(overspend_amount.amount > 0, "overspend_amount должен быть положительным");
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Перерасход регистрируется только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"overspendexp применим только к ADVANCE-механике");
eosio::check(overspend_amount.is_valid() &&
overspend_amount.symbol == i.actual_amount.symbol,
"Символ overspend_amount не совпадает с item");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — подотчёт
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
// который сделает burn ADVANCE_HOLD на итоговый actual.
i.actual_amount += overspend_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += overspend_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: доплата перерасхода — TRANSFER пул-источник → подотчёт получателя
// (зеркало выдачи аванса на сумму доплаты). Закрытие на полную сумму — в reportexp.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).overspend, overspend_amount,
item_recipient, proposal_hash, "expense:overspend");
}
@@ -0,0 +1,233 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include "../lib/index.hpp"
using namespace eosio;
using std::string;
/**
* @defgroup public_expense Контракт EXPENSE
* @brief Шасси расходов цифрового кооператива (MVP — Благорост).
*
* Универсальный контракт: principal storage = СЗ-расход с массивом items;
* кошелёк-источник (пул) приходит параметром source_wallet, ledger2-коды всех
* операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
* (ledger2/operations.hpp) — подключение нового пула не меняет контракт;
* механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
* callback на финализацию (контракт+action+data) сохраняется при createexp
* как переменная — `expense` агностичен к программе-получателю.
*
* See: components/desktop/extensions/expenses/NAMING-C28-28.md (v3).
*/
namespace ExpenseDomain {
/**
* @brief Способ оплаты item-а в СЗ.
* ADVANCE — пайщик-получатель оплачивает, затем приносит чек.
* DIRECT — кассир/председатель платит организации напрямую.
*/
enum class Mechanics : uint8_t {
ADVANCE = 0,
DIRECT = 1,
};
/**
* @brief Получатель платежа в item-е.
* SELF — сам создатель СЗ.
* MEMBER — другой пайщик.
* ORG — внешняя организация.
*/
enum class RecipientType : uint8_t {
SELF = 0,
MEMBER = 1,
ORG = 2,
};
enum class ProposalStatus : uint8_t {
CREATED = 0, ///< СЗ создан, ждёт подписи signact1 создателя.
AUTHORIZED = 1, ///< СЗ подписан signact2 (совет), готов к оплате.
PARTIALLY_PAID = 2, ///< Часть item-ов оплачена.
REPORT_SUBMITTED = 3, ///< Отчёт о расходе подан, ждёт авторизации совета.
CLOSED = 4, ///< СЗ-отчёт авторизован — расход закрыт.
DECLINED = 5, ///< СЗ отклонён до или после авторизации.
};
enum class ItemStatus : uint8_t {
APPROVED = 0, ///< Запланирован, ждёт оплаты.
PAID = 1, ///< Оплачен (ADVANCE — выдан пайщику; DIRECT — оплачен организации).
REPORTED = 2, ///< Закрыт отчётом (чек приложен). DIRECT-item получает его сразу при payexp.
RETURNED = 3, ///< Зарезервировано, не используется: returnexp уменьшает actual_amount, статус остаётся PAID до отчёта.
OVERSPENT = 4, ///< Зарезервировано, не используется: overspendexp увеличивает actual_amount, статус остаётся PAID до отчёта.
};
/**
* @brief Callback на финализацию — устанавливается при createexp как переменная.
*
* Контракт expense не знает про capital/blagorost — он просто вызывает inline
* action на (contract, action) и прокидывает data наружу. Пустой contract = нет callback.
*
* Пример: для расхода в РИД-проект Благороста UI/backend заполняет
* { "capital"_n, "onexpreport"_n, packed(wip_project_hash) }.
*/
struct callback_handler {
eosio::name contract;
eosio::name action;
std::vector<char> data;
EOSLIB_SERIALIZE(callback_handler, (contract)(action)(data))
};
/**
* @brief Item — одна строка СЗ-расхода.
*/
struct item {
eosio::checksum256 item_hash;
uint8_t mechanics; ///< Mechanics
uint8_t recipient_type; ///< RecipientType
eosio::name recipient; ///< username (для SELF/MEMBER), либо name-идентификатор (для ORG)
std::string description;
eosio::asset planned_amount;
eosio::asset actual_amount; ///< 0 пока не оплачено / не отчиталось
uint8_t status; ///< ItemStatus
EOSLIB_SERIALIZE(item, (item_hash)(mechanics)(recipient_type)(recipient)(description)(planned_amount)(actual_amount)(status))
};
/**
* @brief Таблица СЗ-расходов. Scope = coopname.
*/
struct [[eosio::table, eosio::contract("expense")]] proposal {
uint64_t id;
eosio::name coopname; ///< кооператив (= scope таблицы; дублируем полем по канону)
eosio::checksum256 proposal_hash;
eosio::name username; ///< создатель
eosio::name source_wallet; ///< семантически — из какого ЦПП/фонда (w.cap.blago, w.sov.expns, …)
uint8_t status; ///< ProposalStatus
std::vector<item> items;
eosio::asset total_planned;
eosio::asset total_actual;
callback_handler callback; ///< опционально; contract.value == 0 = нет callback
document2 statement_doc; ///< type=2010, signact1 создателя
document2 decision_doc; ///< type=2011, signact2 совета
eosio::time_point_sec created_at;
eosio::time_point_sec updated_at;
uint64_t primary_key() const { return id; }
eosio::checksum256 by_hash() const { return proposal_hash; }
uint64_t by_username() const { return username.value; }
uint64_t by_status() const { return static_cast<uint64_t>(status); }
EOSLIB_SERIALIZE(proposal,
(id)(coopname)(proposal_hash)(username)(source_wallet)(status)
(items)(total_planned)(total_actual)(callback)(statement_doc)(decision_doc)
(created_at)(updated_at))
};
using proposals_index = eosio::multi_index<"proposals"_n, proposal,
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<proposal, eosio::checksum256, &proposal::by_hash>>,
eosio::indexed_by<"byusername"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_username>>,
eosio::indexed_by<"bystatus"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_status>>
>;
} // namespace ExpenseDomain
/**
* @ingroup public_contracts
* @brief Шасси расходов — 8 actions, MVP только Благорост.
*/
class [[eosio::contract("expense")]] expense : public contract {
public:
using contract::contract;
/**
* @brief Создать и подать СЗ-расход.
*
* Создание и подача — одна транзакция (slug createexp). На входе: items + источник
* (source_wallet) + опц. callback на финализацию.
* Подписывает создатель (signact1 statement_doc, type=2010).
*
* Авторизация: coopname (прямой вызов backend'а) либо контракт-инициатор из
* contracts_whitelist (inline, например capital::createpgexp).
* Механика оплаты — per-item (item.mechanics: ADVANCE | DIRECT); ledger2-код
* операции выводится из механики позиции в момент оплаты payexp.
*
* Ставит вопрос в повестку совета (soviet::createagenda, тип createexp):
* утверждение совета → callback authexp, отклонение → declexp.
*/
[[eosio::action]]
void createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<ExpenseDomain::item> items,
ExpenseDomain::callback_handler callback,
document2 statement);
/**
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
* Callback решения совета — вызывается только контрактом soviet после
* голосования и утверждения председателем. После этого расход доступен для оплаты.
*/
[[eosio::action]]
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
/**
* @brief Отклонить СЗ. Callback решения совета (отрицательный консенсус
* голосов «против» либо просрочка повестки) — вызывается только контрактом
* soviet. Возможно только до первой оплаты (CREATED / AUTHORIZED):
* после payexp средства уже ушли через ledger2, и СЗ завершается обычным
* путём reportexp / returnexp → closeexp.
*/
[[eosio::action]]
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
/**
* @brief Оплатить item — выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
* Контракт зовёт Ledger2::apply с кодом из набора операций кошелька-источника
* (EXPENSE_OPERATION_SETS: source_wallet + механика item'а → operation_code).
* actual_amount не может превышать план item (доплата — через overspendexp).
* DIRECT-item не имеет фазы подотчёта и помечается REPORTED сразу; когда все
* items REPORTED — proposal переходит в REPORT_SUBMITTED прямо из payexp.
*/
[[eosio::action]]
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount);
/**
* @brief Отчёт о расходе (ADVANCE) — пайщик закрывает item чеком.
* Зовёт Ledger2::apply(o.exp.advrpt) на остаточный actual item'а (при полном
* возврате аванса burn пропускается). Когда все items reported — статус
* proposal становится REPORT_SUBMITTED.
*/
[[eosio::action]]
void reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash);
/**
* @brief Закрытие расхода советом — финальный signact2 СЗ-отчёта.
* Если у proposal заполнен callback — отправляет inline action на (callback.contract, callback.action, callback.data).
*/
[[eosio::action]]
void closeexp(name coopname, checksum256 proposal_hash);
/**
* @brief Возврат неиспользованного аванса (ADVANCE-остаток).
* Settlement-запись: уменьшает actual_amount item'а, статус остаётся PAID —
* фактически потраченная часть закрывается затем штатным reportexp.
* Зовёт Ledger2::apply(o.exp.advret).
*/
[[eosio::action]]
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount);
/**
* @brief Доплата при перерасходе (ADVANCE).
* Settlement-запись: увеличивает actual_amount item'а, статус остаётся PAID.
* Зовёт Ledger2::apply(o.exp.over) — выдача доплаты; закрытие подотчёта на
* полную сумму (выдано + доплата) делает последующий reportexp.
*/
[[eosio::action]]
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount);
};
+9 -3
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, //решение общего собрания пайщиков
@@ -102,7 +103,10 @@ static const std::set<eosio::name> soviet_actions = {
//WALLET
"createwthd"_n, //создать заявление на возврат паевого взноса
//EXPENSE
"createexp"_n, //служебная записка-смета о расходах (шасси расходов)
//LEDGER
"ledgerwthd"_n, //заявление на списание со счета через ledger
@@ -165,6 +169,7 @@ static constexpr uint64_t _capital_program_id = 4;
static constexpr eosio::name _ledger = "ledger"_n;
static constexpr eosio::name _ledger2 = "ledger2"_n;
static constexpr eosio::name _apps = "apps"_n;
static constexpr eosio::name _expense = "expense"_n;
static constexpr eosio::name _power_account = "eosio.power"_n;
static constexpr eosio::name _saving_account = "eosio.saving"_n;
@@ -193,7 +198,8 @@ static constexpr uint64_t _capital_program_id = 4;
"ledger"_n,
"ledger2"_n,
"capital"_n,
"apps"_n
"apps"_n,
"expense"_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
@@ -85,6 +86,7 @@ namespace operations {
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name PROGRAM_EXPENSE_TOPUP = "o.cap.pgtop"_n; ///< Пополнение пула программных расходов из инвестиций программы (ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr — деньги уже на 51, выделяется кооперативный резерв под расходы; паевые L3-кошельки пайщиков не трогаются).
}
// marketplace
@@ -98,6 +100,27 @@ namespace operations {
inline constexpr eosio::name CONVERT_AXN = "o.sov.axncnv"_n; ///< Трансляция паевого взноса в членский (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → DELEGATE_FEES).
}
// expense — шасси расходов (MVP: только Благорост; хозрасходы из членских — отдельный эпик).
//
// Принципы (см. components/desktop/extensions/expenses/NAMING-C28-28.md):
// - Контракт `expense` универсальный: operation_code передаётся в payload.
// - Источник оплат — КООПЕРАТИВНЫЙ пул расходов (PROGRAM_EXPENSE_POOL),
// пополняемый o.cap.pgtop; личные L3-кошельки пайщиков (w.cap.blago)
// при оплатах СЗ не изменяются.
// - При расходе из Благороста паевой фонд (80) НЕ трогается: меняется только форма
// актива 51 → 08 (Дт 08 / Кт 51 для обеих механик).
// - ADVANCE-отчёт (`o.exp.advrpt`) НЕ создаёт новой бухпроводки: проводка уже
// сделана на `o.exp.blgadv` при выдаче.
// - Callback на финализацию — переменная (`callback{contract, action, data}`),
// заполняется при `expense::createexp`; expense ничего не знает про capital.
namespace expense {
inline constexpr eosio::name BLAGO_ADVANCE = "o.exp.blgadv"_n; ///< Выдача подотчётных из пула расходов (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51).
inline constexpr eosio::name BLAGO_DIRECT = "o.exp.blgdir"_n; ///< Прямая оплата из пула расходов (BURN PROGRAM_EXPENSE_POOL, Dr 08 / Cr 51).
inline constexpr eosio::name ADVANCE_REPORT = "o.exp.advrpt"_n; ///< Закрытие подотчёта пайщика (BURN ADVANCE_HOLD, без бухпроводки — canal 08/51 уже сделан на blgadv).
inline constexpr eosio::name ADVANCE_RETURN = "o.exp.advret"_n; ///< Возврат неиспользованного подотчёта (TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL, Dr 51 / Cr 08).
inline constexpr eosio::name OVERSPEND = "o.exp.over"_n; ///< Доплата сверх подотчёта (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51); сразу за ней expense вызывает ADVANCE_REPORT.
}
// migration (только из migrate.cpp)
//
// В OPERATION_REGISTRY включены **только** те транзиты, которые проводятся
@@ -220,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,
@@ -352,6 +384,72 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
// 19a. Пополнение пула программных расходов: ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr.
// Совет выделяет часть свободных инвестиций программы под целевые расходы:
// деньги физически на 51 с момента взносов, здесь появляется кооперативный
// резерв-кошелёк, из которого шасси expense оплачивает СЗ. Паевые L3-кошельки
// пайщиков (w.cap.blago, счёт 80) не изменяются — права требования сохраняются.
{ operations::capital::PROGRAM_EXPENSE_TOPUP, processes::capital::PGEXP, WalletOp::ISSUE,
eosio::name{}, ledger2_wallets::PROGRAM_EXPENSE_POOL,
0, 0,
"Пополнение пула программных расходов ЦПП «Благорост»" },
// ----- Шасси расходов (o.exp.*) — вызываются из контракта expense -----
//
// Базовое состояние Благороста ДО расхода:
// - Деньги физически на 51 с момента o.wal.depcpl (Dr 51 / Cr 80).
// - 80 (паевой) наполнен; кошелёк w.cap.blago.
// - 08 пустой (`o.cap.invest` — TRANSFER без проводок).
//
// Принцип: расход не уменьшает паевой фонд (80). Меняется только форма актива
// 51 → 08 (банк уходит, появляется WIP-проект).
// 20. Выдача подотчётных из пула программных расходов: Dr 08 / Cr 51,
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
// Источник — КООПЕРАТИВНЫЙ пул расходов (пополняется o.cap.pgtop); личные
// L3-кошельки пайщиков (w.cap.blago) при оплате СЗ не трогаются — их паевые
// взносы в программе не уменьшаются. Деньги физически уходят пайщику (Cr 51),
// стоимость капитализируется в WIP (Dr 08). ADVANCE_HOLD фиксирует
// ответственность получателя аванса (USER_SHARED) до отчёта.
{ operations::expense::BLAGO_ADVANCE, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Выдача подотчётных из пула расходов ЦПП «Благорост»" },
// 21. Прямая оплата из пула программных расходов (DIRECT): Dr 08 / Cr 51,
// BURN PROGRAM_EXPENSE_POOL. Оплата организации по счёту; деньги уходят с 51,
// стоимость капитализируется в 08. Кошелёк-резерв не задействован.
{ operations::expense::BLAGO_DIRECT, processes::expense::PROPOSAL, WalletOp::BURN,
ledger2_wallets::PROGRAM_EXPENSE_POOL, eosio::name{},
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Прямая оплата из пула расходов ЦПП «Благорост»" },
// 22. Закрытие подотчёта пайщика по отчёту: BURN ADVANCE_HOLD, БЕЗ бухпроводки.
// Проводка Dr 08 / Cr 51 уже сделана на BLAGO_ADVANCE при выдаче. При отчёте только
// снимается кошелёк-резерв пайщика — никакого canal 08/51 второй раз.
{ operations::expense::ADVANCE_REPORT, processes::expense::PROPOSAL, WalletOp::BURN,
ledger2_wallets::ADVANCE_HOLD, eosio::name{},
0, 0,
"Закрытие подотчёта пайщика по отчёту" },
// 23. Возврат неиспользованного подотчёта: Dr 51 / Cr 08,
// TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL.
// Зеркало BLAGO_ADVANCE: деньги возвращаются на 51, WIP-стоимость уменьшается,
// остаток снова доступен пулу расходов.
{ operations::expense::ADVANCE_RETURN, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::ADVANCE_HOLD, ledger2_wallets::PROGRAM_EXPENSE_POOL,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::NON_CURRENT_INVESTMENTS,
"Возврат неиспользованного подотчёта в пул расходов" },
// 24. Доплата сверх подотчёта (перерасход): Dr 08 / Cr 51,
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
// Зеркало BLAGO_ADVANCE на сумму перерасхода. Контракт expense сразу за OVERSPEND
// вызывает ADVANCE_REPORT — две последовательные записи в одной транзакции `expense::overspendexp`.
{ operations::expense::OVERSPEND, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Доплата сверх подотчёта (перерасход)" },
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
@@ -534,3 +632,105 @@ inline constexpr const OperationAdjustmentEntry* find_adjustment(eosio::name ope
}
return nullptr;
}
// =====================================================================
// Наборы операций шасси расходов — фабричная настройка контракта expense.
// =====================================================================
//
// Контракт `expense` агностичен к программе-источнику: кошелёк-пул приходит в
// `createexp` параметром `source_wallet`, а ledger2-коды всех пяти операций
// жизненного цикла (аванс / прямая оплата / отчёт / возврат / перерасход)
// выводятся из этого кошелька через таблицу ниже — в коде expense нет ни
// одного захардкоженного operation_code.
//
// Подключение шасси к новому пулу (например, кошельку членских взносов
// кооперативного участка) = добавить 5 операций в OPERATION_REGISTRY и одну
// строку здесь. Контракт expense при этом не меняется.
struct ExpenseOperationSet {
eosio::name source_wallet; ///< пул-источник средств (COOPERATIVE-кошелёк)
eosio::name advance; ///< выдача аванса под отчёт (TRANSFER pool → подотчёт)
eosio::name direct; ///< прямая оплата организации по счёту (BURN pool)
eosio::name report; ///< закрытие подотчёта по отчёту (BURN подотчёта)
eosio::name refund; ///< возврат неиспользованного аванса (TRANSFER подотчёт → pool)
eosio::name overspend; ///< доплата при перерасходе (TRANSFER pool → подотчёт)
};
static constexpr ExpenseOperationSet EXPENSE_OPERATION_SETS[] = {
// Пул программных расходов ЦПП «Благорост» — source_wallet заполняет
// capital::createpgexp при создании СЗ через inline expense::createexp.
{ ledger2_wallets::PROGRAM_EXPENSE_POOL,
operations::expense::BLAGO_ADVANCE,
operations::expense::BLAGO_DIRECT,
operations::expense::ADVANCE_REPORT,
operations::expense::ADVANCE_RETURN,
operations::expense::OVERSPEND },
};
static constexpr size_t EXPENSE_OPERATION_SETS_SIZE =
sizeof(EXPENSE_OPERATION_SETS) / sizeof(EXPENSE_OPERATION_SETS[0]);
// Compile-time валидация наборов: каждый код существует в OPERATION_REGISTRY,
// тип wallet-операции и привязка кошельков соответствуют роли кода в наборе.
namespace ledger2_expense_sets_detail {
constexpr const OperationRegistryEntry* find_op(eosio::name code) {
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
if (OPERATION_REGISTRY[i].code == code) return &OPERATION_REGISTRY[i];
}
return nullptr;
}
constexpr bool source_wallets_unique() {
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
for (size_t j = i + 1; j < EXPENSE_OPERATION_SETS_SIZE; ++j) {
if (EXPENSE_OPERATION_SETS[i].source_wallet == EXPENSE_OPERATION_SETS[j].source_wallet) {
return false;
}
}
}
return true;
}
constexpr bool sets_consistent() {
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
const auto& s = EXPENSE_OPERATION_SETS[i];
const auto* adv = find_op(s.advance);
const auto* dir = find_op(s.direct);
const auto* rep = find_op(s.report);
const auto* ref = find_op(s.refund);
const auto* ovr = find_op(s.overspend);
if (!adv || !dir || !rep || !ref || !ovr) return false;
// Аванс: пул → кошелёк-подотчёт.
if (adv->wallet_op != WalletOp::TRANSFER || adv->wallet_from != s.source_wallet) return false;
const eosio::name hold = adv->wallet_to;
// Прямая оплата: сжигание с пула (подотчёт не задействован).
if (dir->wallet_op != WalletOp::BURN || dir->wallet_from != s.source_wallet) return false;
// Отчёт: сжигание подотчёта.
if (rep->wallet_op != WalletOp::BURN || rep->wallet_from != hold) return false;
// Возврат: подотчёт → пул.
if (ref->wallet_op != WalletOp::TRANSFER ||
ref->wallet_from != hold || ref->wallet_to != s.source_wallet) return false;
// Перерасход: пул → подотчёт (зеркало аванса на сумму доплаты).
if (ovr->wallet_op != WalletOp::TRANSFER ||
ovr->wallet_from != s.source_wallet || ovr->wallet_to != hold) return false;
}
return true;
}
} // namespace ledger2_expense_sets_detail
static_assert(ledger2_expense_sets_detail::source_wallets_unique(),
"EXPENSE_OPERATION_SETS: source_wallet должен быть уникален");
static_assert(ledger2_expense_sets_detail::sets_consistent(),
"EXPENSE_OPERATION_SETS: набор операций не согласован с OPERATION_REGISTRY "
"(коды/типы wallet-операций/привязка кошельков)");
/**
* @brief Поиск набора операций шасси расходов по кошельку-источнику.
*/
inline const ExpenseOperationSet* find_expense_operation_set(eosio::name source_wallet) {
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
if (EXPENSE_OPERATION_SETS[i].source_wallet == source_wallet) {
return &EXPENSE_OPERATION_SETS[i];
}
}
return nullptr;
}
@@ -54,6 +54,7 @@ namespace processes {
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
inline constexpr eosio::name PGEXP = "p.cap.pgexp"_n; ///< Пул программных расходов: пополнение из инвестиций программы (o.cap.pgtop, одноактовый).
}
// marketplace
@@ -61,6 +62,11 @@ namespace processes {
inline constexpr eosio::name REQUEST = "p.mkt.reqst"_n; ///< Цикл запроса маркетплейса (o.mkt.supply + o.mkt.recv).
}
// expense — шасси расходов (СЗ → авторизация → платёж → отчёт → закрытие)
namespace expense {
inline constexpr eosio::name PROPOSAL = "p.exp.expns"_n; ///< Цикл расхода по СЗ: o.exp.blgadv|blgdir (платёж) + опц. o.exp.over (перерасход) + o.exp.advrpt (отчёт ADVANCE) + опц. o.exp.advret (возврат). Анкер процесса — proposal_hash.
}
// soviet
namespace soviet {
inline constexpr eosio::name AXN_CONVERT = "p.sov.axncnv"_n; ///< Конвертация паевого RUB → делегатский ЧВ (одноактовый).
@@ -65,9 +65,16 @@ struct ledger2_wallets {
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (COOPERATIVE — кооперативный пул, без L3-разреза по пайщику; L3-разрез из ADR-009 отменён из-за несовместимости с CRPS-перераспределением, см. wallets.hpp:107)
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
static constexpr eosio::name PROGRAM_EXPENSE_POOL = "w.cap.pgexp"_n; ///< Пул программных расходов ЦПП «Благорост» (COOPERATIVE) — кооперативный кошелёк, из которого шасси expense оплачивает СЗ; пополняется topupprogexp (o.cap.pgtop), паевые L3-кошельки пайщиков (w.cap.blago) при расходах не трогаются
// marketplace — выплаты
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
// expense — шасси расходов (подотчёт пайщика, USER_SHARED)
// Зеркало паттерна w.wal.wpend: кошелёк-резерв на пайщике-получателе ADVANCE-механики.
// На момент выдачи аванса фиксирует ответственность пайщика; на отчёте — BURN без новой бухпроводки
// (canal Дт 08 / Кт 51 уже сделан на o.exp.blgadv).
static constexpr eosio::name ADVANCE_HOLD = "w.exp.adv"_n; ///< Подотчётные средства пайщика (USER_SHARED; резерв при ADVANCE-механике шасси расходов)
};
/**
@@ -96,16 +103,17 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (7) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
{ ledger2_wallets::ADVANCE_HOLD, "Подотчётные средства пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::REGISTRATION_PENDING, "Регистрационный взнос в ожидании решения совета", WalletKind::USER_SHARED },
// COOPERATIVE (10) — единый кооперативный баланс, без L3
// COOPERATIVE (10) — единый кооперативный баланс, без L3 (Generator + 9 «единых пулов»)
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
// CRPS-распределение между сегментами проекта не поддерживает per-user
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
@@ -120,6 +128,7 @@ inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::PROGRAM_EXPENSE_POOL, "Пул программных расходов ЦПП «Благорост»", WalletKind::COOPERATIVE },
}};
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
@@ -232,16 +241,48 @@ struct Ledger2WalletProgramMapping {
uint64_t required_program_id; // 0 = исключение (без проверки)
};
inline constexpr std::array<Ledger2WalletProgramMapping, 7> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
{ ledger2_wallets::MIN_SHARE_FUND, 0 /* w.reg.minshr — без проверки */ },
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК */ },
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК */ },
{ ledger2_wallets::BLAGOROST_FUND, 4 /* Благорост */ },
{ ledger2_wallets::GENERATOR_FUND, 3 /* Генератор */ },
{ ledger2_wallets::PREIMP_FUND, 0 /* w.cap.preimp — РИД-учёт до перехода на электронный учёт, без проверки */ },
{ ledger2_wallets::ADVANCE_HOLD, 0 /* w.exp.adv — подотчёт пайщика по СЗ; программа-источник проверена контрактом expense, повторная gate не нужна */ },
{ 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-кошелька.
*
+9 -1
View File
@@ -50,6 +50,10 @@ namespace Names {
// Коллбэки для расходов
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
// Программные расходы обслуживаются шасси expense; capital видит только
// инициирование (createpgexp) и этот callback завершения flow.
constexpr eosio::name ON_PROGRAM_EXPENSE_DONE = "onpgexpdone"_n;
// Коллбэки для долгов
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
@@ -80,10 +84,13 @@ namespace Names {
// Внешние действия в других контрактах
constexpr eosio::name CREATE_OUTPAY = "createoutpay"_n;
constexpr eosio::name OPEN_PROGRAM_WALLET = "openprogwall"_n;
// Действия для отправки в soviet
constexpr eosio::name CREATE_APPROVAL = "createapprv"_n;
constexpr eosio::name CREATE_AGENDA = "createagenda"_n;
// Шасси expense
constexpr eosio::name CREATE_EXPENSE_PROPOSAL = "createexp"_n;
}
namespace SovietActions {
@@ -120,6 +127,7 @@ namespace Names {
constexpr eosio::name CREATE_INVESTMENT = "createinvest"_n; // акцепт инвестиции
constexpr eosio::name CREATE_PROGRAM_INVESTMENT = "createpinv"_n; // акцепт программной инвестиции
constexpr eosio::name CREATE_EXPENSE = "createexpnse"_n; // акцепт расхода
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n; // акцепт программного расхода
constexpr eosio::name CREATE_WITHDRAW_1 = "createwthd1"_n; // акцепт возврата из задания
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
@@ -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": {
+7
View File
@@ -34,6 +34,13 @@ SMTP_USERNAME=
SMTP_PASSWORD=
EMAIL_FROM="'КО-ПОЧТАЛЬОН' <mail@coopenomics.world>"
# Напоминатель об отчёте по авансу под отчёт (extensions/expenses).
# Интервал тика сканера зеркала расходов (мс). Дефолт 6 ч. Письмо пайщику —
# не чаще раза в неделю (троттлит идемпотентность Центра уведомлений).
EXPENSE_ADVANCE_REMINDER_INTERVAL_MS=21600000
# Сколько дней аванс может висеть без отчёта до первого напоминания. Дефолт 3.
EXPENSE_ADVANCE_REMINDER_GRACE_DAYS=3
# BLOCKHAIN
BLOCKCHAIN_RPC=http://127.0.0.1:8888
CHAIN_ID=cae86058a6d8698833afb474ab8a5ad8599c6cf54f9ebcf275dbac7055c16fe1
@@ -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,
@@ -53,3 +53,47 @@ export function createPaginationResult<T>(ItemType: new (...args: any[]) => T, n
}
return PaginationResult;
}
/**
* Сборка `PaginationResult<U>` для consumer'ов, читающих через inter-порт
* (нет своего Repository.findAndCount, доступен только raw `{items, totalCount}`).
*
* Зачем: вычисление `totalPages`/`currentPage` из `PaginationInputDTO` — общая
* детерминированная логика; дублировать её в каждом consumer'е = canon-долг.
*/
export function buildPaginationResult<T, U>(
raw: { items: T[]; totalCount: number },
options: PaginationInputDTO | undefined,
mapItem: (it: T) => U,
): PaginationResult<U> {
const limit = options?.limit;
const page = options?.page != null ? Math.max(1, options.page) : 1;
const totalPages = limit != null && limit > 0 ? Math.max(1, Math.ceil(raw.totalCount / limit)) : 1;
return {
items: raw.items.map(mapItem),
totalCount: raw.totalCount,
totalPages,
currentPage: page,
};
}
/**
* Канон-конверсия `PaginationInputDTO` → `{limit, offset, sortBy, sortOrder}`
* для адаптеров inter-портов / внешних read API, не принимающих page-form.
*/
export function paginationInputToOffset(options?: PaginationInputDTO): {
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} {
const limit = options?.limit;
const page = options?.page != null ? Math.max(1, options.page) : 1;
const offset = limit != null ? (page - 1) * limit : undefined;
return {
limit,
offset,
sortBy: options?.sortBy,
sortOrder: options?.sortOrder,
};
}
@@ -0,0 +1,133 @@
import { InputType, Field, IntersectionType, OmitType, Int } from '@nestjs/graphql';
import { IsString, IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
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';
type Action = Cooperative.Registry.ExpenseProposalDecision.Action;
type ItemAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseItem;
type HeaderAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseProposalHeader;
type DecisionBody = Cooperative.Registry.ExpenseProposalDecision.IExpenseProposalDecisionBody;
@InputType('ExpenseProposalDecisionItemInput')
class ExpenseProposalDecisionItemInputDTO implements ItemAction {
@Field()
@IsString()
number!: string;
@Field()
@IsString()
description!: string;
@Field()
@IsString()
amount!: string;
@Field()
@IsString()
recipient_type!: 'SELF' | 'MEMBER' | 'ORG';
@Field()
@IsString()
mechanics!: 'ADVANCE' | 'DIRECT';
}
@InputType('ExpenseProposalDecisionHeaderInput')
class ExpenseProposalDecisionHeaderInputDTO implements HeaderAction {
@Field()
@IsString()
description!: string;
@Field()
@IsString()
total_amount!: string;
@Field(() => Int)
@IsInt()
items_count!: number;
@Field()
@IsString()
source_wallet!: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
deadline?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
fund_name?: string;
}
@InputType('ExpenseProposalDecisionBodyInput')
class ExpenseProposalDecisionBodyInputDTO implements DecisionBody {
@Field({ description: 'Род решения (approve / decline)' })
@IsString()
kind!: 'approve' | 'decline';
@Field({ description: 'Причина отказа (для decline)', nullable: true })
@IsOptional()
@IsString()
reason?: string;
}
@InputType('BaseExpenseProposalDecisionMetaDocumentInput')
class BaseExpenseProposalDecisionMetaDocumentInputDTO implements Omit<Action, 'coopname' | 'username' | 'registry_id' | 'block_num' | 'lang' | 'title' | 'generator' | 'version' | 'created_at' | 'timezone' | 'links'> {
@Field({ description: 'Хеш сметы расхода' })
@IsString()
@IsNotEmpty()
proposal_hash!: string;
@Field(() => Int, { description: 'Идентификатор решения совета (повестка) — источник данных голосования' })
@IsInt()
decision_id!: number;
@Field(() => ExpenseProposalDecisionHeaderInputDTO, { description: 'Шапка СЗ' })
@ValidateNested()
@Type(() => ExpenseProposalDecisionHeaderInputDTO)
proposal!: ExpenseProposalDecisionHeaderInputDTO;
@Field(() => [ExpenseProposalDecisionItemInputDTO], { description: 'Позиции расхода' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseProposalDecisionItemInputDTO)
items!: ExpenseProposalDecisionItemInputDTO[];
@Field(() => ExpenseProposalDecisionBodyInputDTO, { description: 'Резолюция совета (утвердить / отказать)' })
@ValidateNested()
@Type(() => ExpenseProposalDecisionBodyInputDTO)
resolution!: ExpenseProposalDecisionBodyInputDTO;
}
/**
* Input генерации документа решения по СЗ (registry 2011).
*/
@InputType('ExpenseProposalDecisionGenerateDocumentInput')
export class ExpenseProposalDecisionGenerateDocumentInputDTO extends IntersectionType(
BaseExpenseProposalDecisionMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
}
@InputType('ExpenseProposalDecisionSignedMetaDocumentInput')
export class ExpenseProposalDecisionSignedMetaDocumentInputDTO extends IntersectionType(
BaseExpenseProposalDecisionMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
/**
* Подписанный документ-решение по СЗ (registry 2011).
*/
@InputType('ExpenseProposalDecisionSignedDocumentInput')
export class ExpenseProposalDecisionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => ExpenseProposalDecisionSignedMetaDocumentInputDTO, {
description: 'Метаинформация решения по СЗ',
})
public readonly meta!: ExpenseProposalDecisionSignedMetaDocumentInputDTO;
}
@@ -0,0 +1,212 @@
import { InputType, Field, IntersectionType, OmitType, Int } from '@nestjs/graphql';
import { IsString, IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
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';
type Action = Cooperative.Registry.ExpenseProposalStatement.Action;
type ItemAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseItem;
type HeaderAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseProposalHeader;
/**
* Позиция-вход генерации СЗ-документа. Приватные поля (имя/реквизиты/назначение)
* — это вход для фабрики: сервер сохраняет их off-chain в doc_data и публикует
* в meta только `doc_data_hash`. На on-chain эти поля НЕ попадают.
*/
@InputType('ExpenseProposalItemInput')
class ExpenseProposalItemInputDTO {
@Field({ description: 'Порядковый номер строки' })
@IsString()
number!: string;
@Field({ description: 'Описание расхода' })
@IsString()
description!: string;
@Field({ description: 'Сумма строки' })
@IsString()
amount!: string;
@Field({ description: 'Тип получателя (SELF / MEMBER / ORG)' })
@IsString()
recipient_type!: 'SELF' | 'MEMBER' | 'ORG';
@Field({ description: 'Способ оплаты (ADVANCE / DIRECT)' })
@IsString()
mechanics!: 'ADVANCE' | 'DIRECT';
@Field({ description: 'Имя получателя', nullable: true })
@IsOptional()
@IsString()
recipient_name?: string;
@Field({ description: 'Реквизиты получателя', nullable: true })
@IsOptional()
@IsString()
requisites?: string;
@Field({ description: 'Назначение платежа — отдельной строкой после реквизитов', nullable: true })
@IsOptional()
@IsString()
payment_purpose?: string;
@Field({
description:
'Идентификатор сохранённых реквизитов получателя-пайщика — сервер подставит полные реквизиты в документ.',
nullable: true,
})
@IsOptional()
@IsString()
payment_method_id?: string;
@Field({
description: 'Имя аккаунта получателя-пайщика (владелец реквизитов).',
nullable: true,
})
@IsOptional()
@IsString()
recipient_username?: string;
}
/**
* Публичная позиция подписанной meta — ровно то, что публикуется on-chain.
* Без имени/реквизитов/назначения платежа (они off-chain в doc_data).
*/
@InputType('ExpenseProposalSignedItemInput')
class ExpenseProposalSignedItemInputDTO implements ItemAction {
@Field({ description: 'Порядковый номер строки' })
@IsString()
number!: string;
@Field({ description: 'Описание расхода' })
@IsString()
description!: string;
@Field({ description: 'Сумма строки' })
@IsString()
amount!: string;
@Field({ description: 'Тип получателя (SELF / MEMBER / ORG)' })
@IsString()
recipient_type!: 'SELF' | 'MEMBER' | 'ORG';
@Field({ description: 'Способ оплаты (ADVANCE / DIRECT)' })
@IsString()
mechanics!: 'ADVANCE' | 'DIRECT';
}
@InputType('ExpenseProposalHeaderInput')
class ExpenseProposalHeaderInputDTO implements HeaderAction {
@Field({ description: 'Описание цели расходов' })
@IsString()
description!: string;
@Field({ description: 'Итоговая сумма расходов' })
@IsString()
total_amount!: string;
@Field(() => Int, { description: 'Количество позиций' })
@IsInt()
items_count!: number;
@Field({ description: 'Кошелёк-источник' })
@IsString()
source_wallet!: string;
@Field({ description: 'Срок исполнения («в срок до»), формат DD.MM.YYYY', nullable: true })
@IsOptional()
@IsString()
deadline?: string;
@Field({
description: 'Фонд списания — подставляется сервером из параметров шасси расходов, передавать не нужно',
nullable: true,
})
@IsOptional()
@IsString()
fund_name?: string;
}
/**
* База ВХОДА генерации — богатые позиции (приватные поля уйдут в doc_data на
* сервере). `doc_data_hash` здесь нет: его вычисляет сервер при генерации.
*/
@InputType('BaseExpenseProposalStatementGenerateMetaDocumentInput')
class BaseExpenseProposalStatementGenerateMetaDocumentInputDTO {
@Field({ description: 'Хеш сметы расхода (детерминированный)' })
@IsString()
@IsNotEmpty()
proposal_hash!: string;
@Field(() => ExpenseProposalHeaderInputDTO, { description: 'Шапка СЗ' })
@ValidateNested()
@Type(() => ExpenseProposalHeaderInputDTO)
proposal!: ExpenseProposalHeaderInputDTO;
@Field(() => [ExpenseProposalItemInputDTO], { description: 'Позиции расхода' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseProposalItemInputDTO)
items!: ExpenseProposalItemInputDTO[];
}
/**
* База ПОДПИСАННОЙ meta — ровно то, что подписывается и едет on-chain:
* публичные позиции + `doc_data_hash` (реквизиты off-chain).
*/
@InputType('BaseExpenseProposalStatementSignedMetaDocumentInput')
class BaseExpenseProposalStatementSignedMetaDocumentInputDTO implements Omit<Action, 'coopname' | 'username' | 'registry_id' | 'block_num' | 'lang' | 'title' | 'generator' | 'version' | 'created_at' | 'timezone' | 'links'> {
@Field({ description: 'Хеш сметы расхода (детерминированный)' })
@IsString()
@IsNotEmpty()
proposal_hash!: string;
@Field(() => ExpenseProposalHeaderInputDTO, { description: 'Шапка СЗ' })
@ValidateNested()
@Type(() => ExpenseProposalHeaderInputDTO)
proposal!: ExpenseProposalHeaderInputDTO;
@Field(() => [ExpenseProposalSignedItemInputDTO], { description: 'Публичные позиции расхода (без реквизитов)' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseProposalSignedItemInputDTO)
items!: ExpenseProposalSignedItemInputDTO[];
@Field({ description: 'Идентификатор приватных данных документа off-chain (реквизиты/имя/назначение)' })
@IsString()
@IsNotEmpty()
doc_data_hash!: string;
}
/**
* Input генерации документа СЗ-заявления (registry 2010).
* Backend через factory собирает PDF, возвращает `IGeneratedDocument` (без подписей).
*/
@InputType('ExpenseProposalStatementGenerateDocumentInput')
export class ExpenseProposalStatementGenerateDocumentInputDTO extends IntersectionType(
BaseExpenseProposalStatementGenerateMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
}
@InputType('ExpenseProposalStatementSignedMetaDocumentInput')
export class ExpenseProposalStatementSignedMetaDocumentInputDTO extends IntersectionType(
BaseExpenseProposalStatementSignedMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
/**
* Подписанный документ СЗ-заявления (registry 2010).
*/
@InputType('ExpenseProposalStatementSignedDocumentInput')
export class ExpenseProposalStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => ExpenseProposalStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация СЗ-заявления',
})
public readonly meta!: ExpenseProposalStatementSignedMetaDocumentInputDTO;
}
@@ -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;
}
}
@@ -28,4 +28,10 @@ export class PaymentFiltersInputDTO implements PaymentFiltersDomainInterface {
@Field(() => String, { nullable: true, description: 'Хэш платежа' })
hash?: string;
@Field(() => String, {
nullable: true,
description: 'Хэш расхода (служебной записки): вернёт все платежи, связанные с этим расходом',
})
proposal_hash?: string;
}
@@ -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 {}
@@ -26,6 +26,7 @@ import { AccountType } from '~/application/account/enum/account-type.enum';
import { PaymentMethodRepository, PAYMENT_METHOD_REPOSITORY } from '~/domain/common/repositories/payment-method.repository';
import type { PaymentDetailsDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
import { AccountDomainPort, ACCOUNT_DOMAIN_PORT } from '~/domain/account/ports/account-domain.port';
import { INTER_EXPENSE_CHASSIS, type InterExpenseChassisPort } from '@coopenomics/inter';
/**
* Интерактор домена gateway для управления платежами (просмотр, изменение статуса и создание)
@@ -54,7 +55,9 @@ export class GatewayInteractor {
@Inject(ACCOUNT_DOMAIN_SERVICE)
private readonly accountDomainService: AccountDomainService,
@Inject(PAYMENT_METHOD_REPOSITORY)
private readonly paymentMethodRepository: PaymentMethodRepository
private readonly paymentMethodRepository: PaymentMethodRepository,
@Inject(INTER_EXPENSE_CHASSIS)
private readonly expenseChassis: InterExpenseChassisPort
) {}
/**
@@ -185,6 +188,25 @@ export class GatewayInteractor {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Пользователь ${payment.username} внес паевой взнос на сумму ${blockchainFormattedQuantity}`);
} else if (payment.type === PaymentTypeEnum.EXPENSE_RETURN) {
// Возврат неиспользованного аванса под отчёт (недорасход): пайщик отчитался
// о факте меньше выданного аванса и вернул разницу на расчётный счёт.
// Подтверждение приёма средств кассиром проводит on-chain
// expense::returnexp (TRANSFER подотчёт → пул на сумму возврата), затем
// expense::reportexp закрывает позицию на фактически потраченную часть.
// Это НЕ паевой взнос — completeIncome здесь не вызывается (другая ledger2-
// семантика). proposal_hash и item_hash — в blockchain_data.
const bc = payment.blockchain_data as { proposal_hash?: string; item_hash?: string } | undefined;
if (!bc?.proposal_hash || !bc?.item_hash) {
throw new Error(`У платежа возврата ${payment.hash} нет proposal_hash/item_hash в blockchain_data`);
}
const returnAmount = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
await this.expenseChassis.returnItem(payment.coopname, bc.proposal_hash, bc.item_hash, returnAmount);
await this.expenseChassis.reportItem(payment.coopname, bc.proposal_hash, bc.item_hash);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Возврат недорасхода ${payment.hash} (СЗ ${bc.proposal_hash}) проведён on-chain (returnexp + reportexp)`);
}
} catch (e: any) {
if (payment.id) {
@@ -246,6 +268,62 @@ export class GatewayInteractor {
return;
}
// Оплата позиции СЗ-расхода: подтверждение кассой проводит on-chain
// expense::payexp (ledger2-проводка по механике позиции — аванс/оплата по
// счёту). Реквизиты и назначение платежа в чейн не уходят — они живут
// только в этом платеже и снимке шасси. proposal_hash — в blockchain_data
// (hash платежа = item_hash, его одного payexp недостаточно).
if (payment.type === PaymentTypeEnum.EXPENSE) {
const proposalHash = (payment.blockchain_data as { proposal_hash?: string } | undefined)?.proposal_hash;
try {
if (!proposalHash) {
throw new Error(`У платежа расхода ${payment.hash} отсутствует proposal_hash в blockchain_data`);
}
const actualAmount = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
await this.expenseChassis.payItem(payment.coopname, proposalHash, payment.hash, actualAmount);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Оплата позиции расхода ${payment.hash} (СЗ ${proposalHash}) проведена on-chain (payexp)`);
} catch (e: any) {
const message = e?.message ?? String(e);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.FAILED, message });
}
this.logger.error(`Ошибка оплаты позиции расхода ${payment.hash}: ${message}`, e);
}
return;
}
// Доплата при перерасходе аванса под отчёт: пайщик отчитался о факте больше
// выданного аванса, кооператив доплачивает разницу. Подтверждение выплаты
// кассиром проводит on-chain expense::overspendexp (TRANSFER пул → подотчёт на
// сумму доплаты), затем expense::reportexp закрывает позицию на полный факт.
// proposal_hash и item_hash — в blockchain_data (hash платежа уникальный, не
// равен item_hash, чтобы не пересекаться с платежом выдачи аванса).
if (payment.type === PaymentTypeEnum.EXPENSE_OVERSPEND) {
const bc = payment.blockchain_data as { proposal_hash?: string; item_hash?: string } | undefined;
try {
if (!bc?.proposal_hash || !bc?.item_hash) {
throw new Error(`У платежа доплаты ${payment.hash} нет proposal_hash/item_hash в blockchain_data`);
}
const overspendAmount = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
await this.expenseChassis.overspendItem(payment.coopname, bc.proposal_hash, bc.item_hash, overspendAmount);
await this.expenseChassis.reportItem(payment.coopname, bc.proposal_hash, bc.item_hash);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Доплата перерасхода ${payment.hash} (СЗ ${bc.proposal_hash}) проведена on-chain (overspendexp + reportexp)`);
} catch (e: any) {
const message = e?.message ?? String(e);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.FAILED, message });
}
this.logger.error(`Ошибка доплаты перерасхода ${payment.hash}: ${message}`, e);
}
return;
}
try {
const completeOutcomeData: CompleteOutcomeDomainInterface = {
coopname: payment.coopname,
@@ -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}`;
}
}
@@ -55,9 +55,10 @@ export class PaymentNotificationService implements OnModuleInit {
// Получаем отображаемое имя пользователя
const userName = await this.accountPort.getDisplayName(payment.username);
// Выбираем workflow по статусу И направлению платежа: исходящий PAID — это
// возврат взноса пайщику, у него своё уведомление «Возврат выполнен», а не
// «Платёж принят» (иначе пайщик при возврате получает письмо как на приём).
// Выбираем workflow по статусу И направлению — но БЕЗ привязки к типу платежа
// (мы не всегда знаем, это возврат взноса, аванс под отчёт или доплата).
// Исходящий PAID → пайщик получил деньги → «Платёж выполнен»; входящий PAID →
// кооператив принял платёж пайщика → «Платёж принят». Оба текста универсальны.
const isOutgoing = payment.direction === PaymentDirectionEnum.OUTGOING;
const workflowId =
payment.status === PaymentStatusEnum.PAID
@@ -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;
}
@@ -16,6 +16,10 @@ export interface GeneratorPort {
list<T = any>(collection: string, filter?: Record<string, any>): Promise<Cooperative.Document.IGetResponse<T>>;
getHistory<T = any>(collection: string, filter: Record<string, any>): Promise<T[]>;
search(query: string): Promise<ISearchResult[]>;
saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }>;
getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null>;
}
export const GENERATOR_PORT = Symbol('GeneratorPort');
@@ -22,9 +22,37 @@ 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);
}
public async saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }> {
return await this.generatorInfrastructureService.saveDocData(payload, registry_id);
}
public async getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null> {
return await this.generatorInfrastructureService.getDocData<P>(hash);
}
public async getDocumentByHash(hash: string): Promise<DocumentDomainEntity | null> {
const document = await this.documentRepository.findByHash(hash);
return document;
@@ -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,10 @@ export class PaymentDomainEntity implements PaymentDomainInterface {
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат взноса',
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
[PaymentTypeEnum.MEMBERSHIP_EXIT]: 'Возврат паевого взноса при выходе из кооператива',
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
};
return typeLabels[this.type] || this.type;
}
@@ -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,24 @@ export enum PaymentTypeEnum {
// Исходящий возврат вступительного и мин. паевого взносов при отказе совета
// в приёме. Отдельный тип, не WITHDRAWAL — чтобы не путать с возвратом паевого.
REGISTRATION_REFUND = 'registration_refund',
// Исходящий возврат всего паевого взноса при выходе пайщика из кооператива.
// Отдельный тип, не WITHDRAWAL — это полный выход с блокировкой аккаунта,
// а не частичный возврат паевого действующему пайщику.
MEMBERSHIP_EXIT = 'membership_exit',
// Исходящая оплата позиции служебной записки-сметы (шасси expense). Создаётся
// автоматически после авторизации СЗ советом; подтверждение кассиром проводит
// on-chain оплату expense::payexp по позиции.
EXPENSE = 'expense',
// Входящий возврат неиспользованного аванса под отчёт (недорасход). Создаётся,
// когда пайщик отчитался о факте меньше выданного аванса: он возвращает разницу
// на расчётный счёт кооператива; подтверждение кассиром (приём средств)
// проводит on-chain expense::returnexp и закрывает позицию expense::reportexp.
EXPENSE_RETURN = 'expense_return',
// Исходящая доплата при перерасходе аванса под отчёт. Создаётся, когда пайщик
// отчитался о факте больше выданного аванса: кооператив доплачивает разницу;
// подтверждение кассиром (выплата) проводит on-chain expense::overspendexp и
// закрывает позицию expense::reportexp.
EXPENSE_OVERSPEND = 'expense_overspend',
}
/**
@@ -27,6 +45,10 @@ 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]: 'Доплата по перерасходу аванса',
};
/**
@@ -49,17 +71,25 @@ export const PAYMENT_DIRECTION_LABELS: Record<PaymentDirectionEnum, string> = {
* Определяет направление платежа по его типу
*/
export function getPaymentDirection(type: PaymentTypeEnum): PaymentDirectionEnum {
const incomingTypes = [PaymentTypeEnum.REGISTRATION, PaymentTypeEnum.DEPOSIT];
return incomingTypes.includes(type) ? PaymentDirectionEnum.INCOMING : PaymentDirectionEnum.OUTGOING;
return INCOMING_PAYMENT_TYPES.includes(type) ? PaymentDirectionEnum.INCOMING : PaymentDirectionEnum.OUTGOING;
}
/**
* Входящие типы платежей
*/
export const INCOMING_PAYMENT_TYPES = [PaymentTypeEnum.REGISTRATION, PaymentTypeEnum.DEPOSIT];
export const INCOMING_PAYMENT_TYPES = [
PaymentTypeEnum.REGISTRATION,
PaymentTypeEnum.DEPOSIT,
PaymentTypeEnum.EXPENSE_RETURN,
];
/**
* Исходящие типы платежей
*/
export const OUTGOING_PAYMENT_TYPES = [PaymentTypeEnum.WITHDRAWAL, PaymentTypeEnum.REGISTRATION_REFUND];
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;
}
@@ -13,6 +13,7 @@ export interface PaymentFiltersDomainInterface {
direction?: PaymentDirectionEnum;
provider?: string;
hash?: string;
proposal_hash?: string;
}
/**
@@ -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');
@@ -119,6 +119,16 @@ export const PROCESS_HASH_LOCATOR: Readonly<Record<string, HashLocation[]>> = Ob
// Сущностная таблица — `capital::prgwithdraws.withdraw_hash`.
'p.cap.wthcap': [{ code: 'capital', table: 'prgwithdraws', field: 'withdraw_hash' }],
// p.cap.pgexp — пополнение пула программных расходов (o.cap.pgtop, ISSUE
// w.cap.pgexp). Одноактовый, process_hash синтетический (sha256 в контракте) —
// entity-таблицы нет, данные из blockchain_actions.
'p.cap.pgexp': [],
// p.exp.expns — расход по СЗ (шасси expense): o.exp.blgadv/blgdir (оплата) +
// опц. o.exp.over + o.exp.advrpt (отчёт) + опц. o.exp.advret (возврат).
// Анкер процесса — proposal_hash в `expense::proposals`.
'p.exp.expns': [{ code: 'expense', table: 'proposals', field: 'proposal_hash' }],
// marketplace::requests.hash — поле так и называется `hash`.
'p.mkt.reqst': [{ code: 'marketplace', table: 'requests', field: 'hash' }],
@@ -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;
}
/**
* Удаляет токены по критериям
*/
@@ -76,6 +76,18 @@ export class StateOutputDTO extends BaseOutputDTO {
})
global_available_invest_pool!: string;
@Field(() => String, {
nullable: true,
description: 'Пул программных расходов — средства, переведённые под целевые расходы программы',
})
program_expense_pool?: string;
@Field(() => String, {
nullable: true,
description: 'Зарезервировано в пуле программных расходов под активные расходы (служебные записки в работе)',
})
program_expense_reserved?: string;
@Field(() => String, {
description: 'Общая сумма членских взносов по программе',
})
@@ -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,47 @@
import { Field, InputType } from '@nestjs/graphql';
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ExpenseItemInputDTO } from '~/extensions/expenses/application/dto/expense-item.input';
import { ExpenseProposalStatementSignedDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-statement-document.dto';
/**
* Создание программного расхода капитала через шасси `expense`.
* Capital резервирует `program_expense_pool` на сумму items и шлёт inline
* action `expense::createexp` с callback `{capital, onpgexpdone}`. Дальше
* lifecycle обслуживает шасси; capital получает callback на финализацию.
*/
@InputType('CapitalCreateProgramExpenseInput')
export class CreateProgramExpenseInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хэш СЗ-расхода (детерминированный, из UI). Он же станет proposal_hash в шасси.' })
@IsNotEmpty()
@IsString()
expense_hash!: string;
@Field(() => String, { description: 'Имя пайщика-создателя СЗ (председатель).' })
@IsNotEmpty()
@IsString()
creator!: string;
@Field(() => [ExpenseItemInputDTO], { description: 'Строки расхода. Способ оплаты (аванс под отчёт / оплата по счёту) задаётся на каждой строке отдельно.' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseItemInputDTO)
items!: ExpenseItemInputDTO[];
@Field(() => String, { description: 'Описание программного расхода.' })
@IsString()
description!: string;
@Field(() => ExpenseProposalStatementSignedDocumentInputDTO, {
description: 'Подписанная СЗ-смета (document2, registry 2010).',
})
@ValidateNested()
@Type(() => ExpenseProposalStatementSignedDocumentInputDTO)
statement!: ExpenseProposalStatementSignedDocumentInputDTO;
}
@@ -0,0 +1,51 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ExpenseProposalStatus } from '~/extensions/expenses/domain/enums/expense-proposal-status.enum';
import { ExpenseMechanics } from '~/extensions/expenses/domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '~/extensions/expenses/domain/enums/expense-recipient-type.enum';
import { ExpenseItemStatus } from '~/extensions/expenses/domain/enums/expense-item-status.enum';
/** Owner-callback из шасси (зеркало InterExpenseCallbackHandler). */
@ObjectType('CapitalProgramExpenseCallback')
export class ProgramExpenseCallbackOutputDTO {
@Field(() => String) contract!: string;
@Field(() => String) action!: string;
@Field(() => String) data!: string;
}
/** Item в выводе (зеркало InterExpenseItem). */
@ObjectType('CapitalProgramExpenseItem')
export class ProgramExpenseItemOutputDTO {
@Field(() => String) item_hash!: string;
@Field(() => ExpenseMechanics) mechanics!: ExpenseMechanics;
@Field(() => ExpenseRecipientType) recipient_type!: ExpenseRecipientType;
@Field(() => String) recipient!: string;
@Field(() => String, { description: 'Имя получателя (ФИО пайщика или название организации)' })
recipient_name!: string;
@Field(() => String) description!: string;
@Field(() => String) planned_amount!: string;
@Field(() => String) actual_amount!: string;
@Field(() => ExpenseItemStatus) status!: ExpenseItemStatus;
}
/**
* Программный расход в проекции Капитала те же данные шасси, спроецированные
* под consumer-extension. Capital НЕ хранит свой mirror items/total берёт через
* inter-порт INTER_EXPENSE_CHASSIS.
*/
@ObjectType('CapitalProgramExpense')
export class ProgramExpenseOutputDTO {
@Field(() => String) coopname!: string;
@Field(() => String) expense_hash!: string;
@Field(() => String) creator!: string;
@Field(() => String, { description: 'Имя инициатора (ФИО пайщика или название организации)' })
creator_name!: string;
@Field(() => String) source_wallet!: string;
@Field(() => ExpenseProposalStatus) status!: ExpenseProposalStatus;
@Field(() => ProgramExpenseCallbackOutputDTO, { nullable: true })
callback?: ProgramExpenseCallbackOutputDTO;
@Field(() => [ProgramExpenseItemOutputDTO]) items!: ProgramExpenseItemOutputDTO[];
@Field(() => String) total_planned!: string;
@Field(() => String) total_actual!: string;
@Field(() => String) created_at!: string;
@Field(() => String) updated_at!: string;
}
@@ -0,0 +1,19 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Пополнение пула программных расходов кооператива (председатель).
* Перевод суммы из `global_available_invest_pool` в `program_expense_pool`.
*/
@InputType('CapitalTopupProgramExpenseInput')
export class TopupProgramExpenseInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Сумма пополнения (asset, eg "10000.0000 RUB").' })
@IsNotEmpty()
@IsString()
amount!: 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
);
}
}
}
@@ -0,0 +1,80 @@
import { Args, 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 { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import {
createPaginationResult,
PaginationInputDTO,
PaginationResult,
} from '~/application/common/dto/pagination.dto';
import { ProgramExpensesManagementService } from '../services/program-expenses-management.service';
import { CreateProgramExpenseInputDTO } from '../dto/program_expenses/create-program-expense.input';
import { TopupProgramExpenseInputDTO } from '../dto/program_expenses/topup-program-expense.input';
import { ProgramExpenseOutputDTO } from '../dto/program_expenses/program-expense.output';
const paginatedProgramExpensesResult = createPaginationResult(
ProgramExpenseOutputDTO,
'PaginatedCapitalProgramExpenses',
);
/**
* GraphQL-резолвер программных расходов капитала. Председатель/совет.
* Write on-chain capital; read шасси через inter-порт.
*/
@Resolver()
export class ProgramExpensesResolver {
constructor(private readonly service: ProgramExpensesManagementService) {}
@Mutation(() => TransactionDTO, {
name: 'capitalCreateProgramExpense',
description: 'Создание программного расхода капитала через шасси expense.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async createProgramExpense(
@Args('data', { type: () => CreateProgramExpenseInputDTO }) data: CreateProgramExpenseInputDTO,
): Promise<TransactionDTO> {
return this.service.createProgramExpense(data);
}
@Mutation(() => TransactionDTO, {
name: 'capitalTopupProgramExpensePool',
description: 'Пополнение пула программных расходов капитала из инвестиционного пула.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async topupProgramExpense(
@Args('data', { type: () => TopupProgramExpenseInputDTO }) data: TopupProgramExpenseInputDTO,
): Promise<TransactionDTO> {
return this.service.topupProgramExpense(data);
}
@Query(() => paginatedProgramExpensesResult, {
name: 'capitalProgramExpenses',
description: 'Список программных расходов капитала (через шасси expense).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async listProgramExpenses(
@Args('coopname', { type: () => String }) coopname: string,
@Args('options', { type: () => PaginationInputDTO, nullable: true }) options?: PaginationInputDTO,
): Promise<PaginationResult<ProgramExpenseOutputDTO>> {
return this.service.listProgramExpenses(coopname, options);
}
@Query(() => ProgramExpenseOutputDTO, {
name: 'capitalProgramExpense',
description: 'Программный расход по expense_hash.',
nullable: true,
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getProgramExpense(
@Args('coopname') coopname: string,
@Args('expense_hash') expenseHash: string,
): Promise<ProgramExpenseOutputDTO | null> {
return this.service.getProgramExpense(coopname, expenseHash);
}
}
@@ -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({
@@ -0,0 +1,239 @@
import { Injectable, Inject } from '@nestjs/common';
import { CapitalContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
import { config } from '~/config';
import {
INTER_EXPENSE_CHASSIS,
type InterExpenseChassisPort,
type InterExpenseItem,
type InterExpenseProposalRead,
type InterExpenseProposalStatus,
type InterExpenseRequisiteItemInput,
} from '@coopenomics/inter';
import { AccountDataPort, ACCOUNT_DATA_PORT } from '~/domain/account/ports/account-data.port';
import { CapitalBlockchainPort, CAPITAL_BLOCKCHAIN_PORT } from '../../domain/interfaces/capital-blockchain.port';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import {
buildPaginationResult,
PaginationInputDTO,
paginationInputToOffset,
type PaginationResult,
} from '~/application/common/dto/pagination.dto';
import type { CreateProgramExpenseInputDTO } from '../dto/program_expenses/create-program-expense.input';
import type { TopupProgramExpenseInputDTO } from '../dto/program_expenses/topup-program-expense.input';
import type {
ProgramExpenseCallbackOutputDTO,
ProgramExpenseItemOutputDTO,
ProgramExpenseOutputDTO,
} from '../dto/program_expenses/program-expense.output';
import { ExpenseProposalStatus } from '~/extensions/expenses/domain/enums/expense-proposal-status.enum';
import { ExpenseMechanics } from '~/extensions/expenses/domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '~/extensions/expenses/domain/enums/expense-recipient-type.enum';
import { ExpenseItemStatus } from '~/extensions/expenses/domain/enums/expense-item-status.enum';
/**
* Управление программными расходами Капитала.
*
* Тонкое расширение: write `capital::createpgexp` / `topupprogexp` через
* `CapitalBlockchainPort`; read через inter-порт `INTER_EXPENSE_CHASSIS`
* (шасси-extension отвечает за хранение proposals).
*/
@Injectable()
export class ProgramExpensesManagementService {
constructor(
@Inject(CAPITAL_BLOCKCHAIN_PORT)
private readonly capitalBlockchainPort: CapitalBlockchainPort,
@Inject(INTER_EXPENSE_CHASSIS)
private readonly expenseChassis: InterExpenseChassisPort,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountDataPort: AccountDataPort,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
) {}
async createProgramExpense(data: CreateProgramExpenseInputDTO): Promise<TransactResult> {
// Реквизиты получателей: валидация ДО on-chain заявки, снимок в шасси —
// ПОСЛЕ (фиксация «куда платить» на момент создания СЗ).
const requisiteItems: InterExpenseRequisiteItemInput[] = data.items.map((it) => ({
proposalHash: data.expense_hash,
itemHash: it.item_hash,
recipient: it.recipient,
isOrganization: it.recipient_type === ExpenseRecipientType.ORG,
mechanics: it.mechanics === ExpenseMechanics.DIRECT ? 'DIRECT' : 'ADVANCE',
paymentMethodId: it.payment_method_id,
requisites: it.requisites,
paymentPurpose: it.payment_purpose,
}));
await this.expenseChassis.validateRequisites(data.coopname, requisiteItems);
const zeroAmount = `0.0000 ${config.blockchain.root_govern_symbol}`;
const blockchainData: CapitalContract.Actions.CreateProgramExpense.ICreateProgramExpense = {
coopname: data.coopname,
expense_hash: data.expense_hash,
creator: data.creator,
items: data.items.map((it) => ({
item_hash: it.item_hash,
mechanics: it.mechanics === ExpenseMechanics.DIRECT ? 1 : 0,
recipient_type:
it.recipient_type === ExpenseRecipientType.SELF
? 0
: it.recipient_type === ExpenseRecipientType.MEMBER
? 1
: 2,
recipient: it.recipient,
description: it.description,
planned_amount: it.planned_amount,
actual_amount: zeroAmount,
status: 0,
})),
description: data.description,
statement: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.statement),
};
const result = await this.capitalBlockchainPort.createProgramExpense(blockchainData);
await this.expenseChassis.snapshotRequisites(data.coopname, requisiteItems);
return result;
}
async topupProgramExpense(data: TopupProgramExpenseInputDTO): Promise<TransactResult> {
return this.capitalBlockchainPort.topupProgramExpense({
coopname: data.coopname,
amount: data.amount,
});
}
async listProgramExpenses(
coopname: string,
options?: PaginationInputDTO,
): Promise<PaginationResult<ProgramExpenseOutputDTO>> {
const { limit, offset, sortBy, sortOrder } = paginationInputToOffset(options);
const allowedSort = sortBy === 'createdAt' || sortBy === 'updatedAt' ? sortBy : undefined;
const result = await this.expenseChassis.listProposalsByOwner(coopname, 'capital', 'onpgexpdone', {
limit,
offset,
sortBy: allowedSort,
sortOrder,
});
const names = await this.resolveDisplayNames(
result.items.flatMap((p) => [p.creator, ...this.memberRecipients(p)]),
);
return buildPaginationResult(result, options, (p) => this.toOutput(p, names));
}
async getProgramExpense(coopname: string, expenseHash: string): Promise<ProgramExpenseOutputDTO | null> {
const proposal = await this.expenseChassis.readProposalByHash(coopname, expenseHash);
if (!proposal) return null;
const names = await this.resolveDisplayNames([proposal.creator, ...this.memberRecipients(proposal)]);
return this.toOutput(proposal, names);
}
/** Получатели-пайщики СЗ (у ORG-строк recipient — уже название организации). */
private memberRecipients(p: InterExpenseProposalRead): string[] {
return p.items
.filter((it) => this.mapRecipientType(it.recipientType) !== ExpenseRecipientType.ORG)
.map((it) => it.recipient);
}
/** ФИО/название организации по username; при ошибке резолва остаётся username. */
private async resolveDisplayNames(usernames: string[]): Promise<Map<string, string>> {
const unique = [...new Set(usernames.filter(Boolean))];
const entries = await Promise.all(
unique.map(async (username): Promise<[string, string]> => {
try {
const displayName = await this.accountDataPort.getDisplayName(username);
return [username, displayName || username];
} catch {
return [username, username];
}
}),
);
return new Map(entries);
}
private toOutput(p: InterExpenseProposalRead, names: Map<string, string>): ProgramExpenseOutputDTO {
return {
coopname: p.coopname,
expense_hash: p.proposalHash,
creator: p.creator,
creator_name: names.get(p.creator) ?? p.creator,
source_wallet: p.sourceWalletCode,
status: this.mapStatus(p.status),
callback: p.callback ? this.toCallbackOutput(p.callback) : undefined,
items: p.items.map((it) => this.toItemOutput(it, names)),
total_planned: p.totalPlanned,
total_actual: p.totalActual,
created_at: p.createdAt,
updated_at: p.updatedAt,
};
}
private toItemOutput(it: InterExpenseItem, names: Map<string, string>): ProgramExpenseItemOutputDTO {
const recipientType = this.mapRecipientType(it.recipientType);
return {
item_hash: it.itemHash,
mechanics: this.mapMechanics(it.mechanics),
recipient_type: recipientType,
recipient: it.recipient,
recipient_name:
recipientType === ExpenseRecipientType.ORG
? it.recipient
: (names.get(it.recipient) ?? it.recipient),
description: it.description,
planned_amount: it.plannedAmount,
actual_amount: it.actualAmount,
status: this.mapItemStatus(it.status),
};
}
private toCallbackOutput(cb: { contract: string; action: string; data: string }): ProgramExpenseCallbackOutputDTO {
return { contract: cb.contract, action: cb.action, data: cb.data };
}
private mapStatus(status: InterExpenseProposalStatus): ExpenseProposalStatus {
switch (status) {
case 'CREATED':
return ExpenseProposalStatus.CREATED;
case 'AUTHORIZED':
return ExpenseProposalStatus.AUTHORIZED;
case 'PARTIALLY_PAID':
return ExpenseProposalStatus.PARTIALLY_PAID;
case 'REPORT_SUBMITTED':
return ExpenseProposalStatus.REPORT_SUBMITTED;
case 'CLOSED':
return ExpenseProposalStatus.CLOSED;
case 'DECLINED':
return ExpenseProposalStatus.DECLINED;
default:
return ExpenseProposalStatus.UNDEFINED;
}
}
private mapMechanics(raw: number): ExpenseMechanics {
return raw === 1 ? ExpenseMechanics.DIRECT : ExpenseMechanics.ADVANCE;
}
private mapRecipientType(raw: number): ExpenseRecipientType {
if (raw === 2) return ExpenseRecipientType.ORG;
if (raw === 1) return ExpenseRecipientType.MEMBER;
return ExpenseRecipientType.SELF;
}
private mapItemStatus(raw: number): ExpenseItemStatus {
switch (raw) {
case 0:
return ExpenseItemStatus.APPROVED;
case 1:
return ExpenseItemStatus.PAID;
case 2:
return ExpenseItemStatus.REPORTED;
case 3:
return ExpenseItemStatus.RETURNED;
case 4:
return ExpenseItemStatus.OVERSPENT;
default:
return ExpenseItemStatus.UNDEFINED;
}
}
}
@@ -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
);
}
@@ -52,6 +52,8 @@ export class ContractManagementInteractor {
return {
coopname: blockchainState.coopname,
global_available_invest_pool: blockchainState.global_available_invest_pool,
program_expense_pool: blockchainState.program_expense_pool,
program_expense_reserved: blockchainState.program_expense_reserved,
program_membership_funded: blockchainState.program_membership_funded,
program_membership_available: blockchainState.program_membership_available,
program_membership_distributed: blockchainState.program_membership_distributed,
@@ -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';
@@ -374,6 +380,7 @@ import { VotingService } from './application/services/voting.service';
import { ResultSubmissionService } from './application/services/result-submission.service';
import { DistributionManagementService } from './application/services/distribution-management.service';
import { ExpensesManagementService } from './application/services/expenses-management.service';
import { ProgramExpensesManagementService } from './application/services/program-expenses-management.service';
import { ContributorAccountSyncService } from './application/services/contributor-account-sync.service';
import { CommitSyncService } from './application/syncers/commit-sync.service';
import { CommitDeltaMapper } from './infrastructure/blockchain/mappers/commit-delta.mapper';
@@ -426,6 +433,7 @@ import { VotingResolver } from './application/resolvers/voting.resolver';
import { ResultSubmissionResolver } from './application/resolvers/result-submission.resolver';
import { DistributionManagementResolver } from './application/resolvers/distribution-management.resolver';
import { ExpensesManagementResolver } from './application/resolvers/expenses-management.resolver';
import { ProgramExpensesResolver } from './application/resolvers/program-expenses.resolver';
import { SegmentsResolver } from './application/resolvers/segments.resolver';
import { LogResolver } from './application/resolvers/log.resolver';
import { MutationLogMapperService } from './application/services/mutation-log-mapper.service';
@@ -574,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);
// Не бросаем ошибку, чтобы не блокировать запуск модуля
}
@@ -583,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
);
// Не бросаем ошибку, чтобы не блокировать запуск модуля
}
@@ -710,6 +722,7 @@ IssueIdGenerationService,
ResultSubmissionService,
DistributionManagementService,
ExpensesManagementService,
ProgramExpensesManagementService,
ContributorAccountSyncService,
SegmentsService,
SegmentMapper,
@@ -769,6 +782,7 @@ IssueIdGenerationService,
ResultSubmissionResolver,
DistributionManagementResolver,
ExpensesManagementResolver,
ProgramExpensesResolver,
SegmentsResolver,
TimeTrackerResolver,
LogResolver,
@@ -871,6 +885,7 @@ IssueIdGenerationService,
ProgramShareRegistrationService,
ProgramShareRegistrationSchedulerService,
ProgramShareRegistrationOnUserWalletDeltaListener,
ProgramShareRegistrationOnProjectDeltaListener,
GitCommitMarkersSyncService,
{
provide: ISSUE_LINKED_GIT_COMMIT_REPOSITORY,
@@ -224,6 +224,22 @@ export interface CapitalBlockchainPort {
*/
createExpense(data: CapitalContract.Actions.CreateExpense.ICreateExpense): Promise<TransactResult>;
/**
* Программный расход через шасси: capital резервирует program_expense_pool
* и шлёт inline action в expense::createexp с callback `{capital, onpgexpdone}`.
*/
createProgramExpense(
data: CapitalContract.Actions.CreateProgramExpense.ICreateProgramExpense,
): Promise<TransactResult>;
/**
* Пополнение пула программных расходов из доступного остатка
* `global_available_invest_pool` (председатель).
*/
topupProgramExpense(
data: CapitalContract.Actions.TopupProgramExpense.ITopupProgramExpense,
): Promise<TransactResult>;
/**
* Редактирование участника CAPITAL контракта
*/
@@ -746,6 +746,38 @@ export class CapitalBlockchainAdapter implements CapitalBlockchainPort {
});
}
async createProgramExpense(
data: CapitalContract.Actions.CreateProgramExpense.ICreateProgramExpense,
): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateProgramExpense.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async topupProgramExpense(
data: CapitalContract.Actions.TopupProgramExpense.ITopupProgramExpense,
): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.TopupProgramExpense.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
/**
* Редактирование участника CAPITAL контракта
*/
@@ -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 {
@@ -0,0 +1,141 @@
# Expenses Extension — шасси расходов (C28-31)
**Статус:** implemented (8 actions полного lifecycle, sync-сервис, GraphQL).
## Скоуп расширения
Backend-сторона **шасси системы расходов** (MVP-SINGLE) для Цифрового Кооператива. Расширение:
- хранит и отдаёт **проектные сущности** (`expense_proposals`, `expense_items`) — синхронизация с контрактом `expenses` (TBA в C28-29);
- хранит и отдаёт **первичные файлы** (`expense_files`) — payment proof, отчёт-чеки, return proof. Файлы — в MinIO через готовую `~/infrastructure/file-storage` инфру;
- агрегирует document2 (2010 СЗ-смета, 2011 протокол-1 — см. cooptypes registry 2010/2011) и `expense_files` в единый ответ `expenseProposalDetails` для UI;
- триггерит capital extension при переходе расхода в финальный статус (capitalization of WIP).
## Зависимости (порядок сборки)
1. **C28-28** — P0 коды в `OPERATION_REGISTRY` (`o.exp.*`) — БЛОКИРУЕТ контракт.
2. **C28-29** — контракт `expenses` + cooptypes types: `Expenses.payexp`, `Expenses.reportexp`, `Expenses.returnexp` etc.
3. **C28-30** — ✅ document2 2010/2011 (готово, commit `1fa41b2b39a`).
4. **C28-31** (этот модуль) — TypeORM-entities + sync + GraphQL.
5. **C28-32** — UI шасси (потребляет GraphQL отсюда).
## Планируемая структура файлов
```
extensions/expenses/
├── README.md ← этот файл
├── expenses-extension.module.ts ← NestJS root module (forwardRef в Capital, File-storage)
├── application/
│ ├── dto/
│ │ ├── pay-expense-item.input.ts ← { proposal_hash, item_hash, actual_amount, payment_proof: Upload }
│ │ ├── upload-report-file.input.ts ← { proposal_hash, item_hash?, kind, file: Upload }
│ │ ├── submit-report.input.ts ← { proposal_hash, total_actual_amount }
│ │ └── return-expense.input.ts ← { proposal_hash, item_hash, return_proof: Upload }
│ └── resolvers/
│ ├── expense-proposal.resolver.ts ← Query: expenseProposalDetails(proposal_hash)
│ ├── expense-files.resolver.ts ← Query: expenseFiles(proposal_hash), Mutation: uploadExpenseFile
│ └── expense-mutations.resolver.ts ← Mutation: payExpenseItem / submitReport / returnExpense / authorizeReport
├── domain/
│ ├── entities/
│ │ ├── expense-proposal.entity.ts ← domain model (proposal_hash, status, items[], document2_refs)
│ │ ├── expense-item.entity.ts ← domain model (item_hash, recipient_type, mechanics, amounts)
│ │ └── expense-file.entity.ts ← domain model (file_id, proposal_hash, item_hash?, kind, minio_key, checksum, mime, size, uploaded_at, uploaded_by)
│ ├── enums/
│ │ ├── expense-proposal-status.enum.ts ← DRAFT | APPROVED | PARTIALLY_PAID | REPORTED | AUTHORIZED | CLOSED | DECLINED
│ │ ├── expense-item-status.enum.ts ← APPROVED | PAID | REPORTED | RETURNED | OVERSPENT
│ │ ├── expense-recipient-type.enum.ts ← SELF | MEMBER | ORG (зеркалит cooptypes ExpenseItemRecipientType)
│ │ ├── expense-payment-mechanics.enum.ts ← ADVANCE | DIRECT (зеркалит cooptypes ExpenseItemPaymentMechanics)
│ │ └── expense-file-kind.enum.ts ← PAYMENT_PROOF | REPORT_FILE | RETURN_PROOF
│ ├── interfaces/
│ │ ├── expense-blockchain.interface.ts ← IExpenseProposalBlockchainData / IExpenseItemBlockchainData
│ │ └── expense-database.interface.ts ← IExpenseProposalDatabaseData / IExpenseItemDatabaseData / IExpenseFileDatabaseData
│ └── repositories/
│ ├── expense-proposal.repository.ts ← interface
│ ├── expense-item.repository.ts ← interface
│ └── expense-file.repository.ts ← interface
├── infrastructure/
│ ├── database/
│ │ └── expenses-database.module.ts ← TypeOrmModule.forFeature([...])
│ ├── entities/
│ │ ├── expense-proposal.typeorm-entity.ts ← coopname, proposal_hash, username, status, total_amount, statement_doc, decision_doc, _created_at, _updated_at
│ │ ├── expense-item.typeorm-entity.ts ← proposal_hash, item_hash, recipient_type, mechanics, planned_amount, actual_amount, recipient_account, status
│ │ └── expense-file.typeorm-entity.ts ← file_id (uuid), proposal_hash, item_hash (nullable), kind, minio_key, checksum, mime, size_bytes, uploaded_by, uploaded_at
│ ├── mappers/
│ │ ├── expense-proposal.mapper.ts ← domain <-> typeorm
│ │ ├── expense-item.mapper.ts
│ │ └── expense-file.mapper.ts
│ ├── repositories/
│ │ ├── expense-proposal.typeorm-repository.ts ← extends BaseBlockchainRepository
│ │ ├── expense-item.typeorm-repository.ts ← extends BaseBlockchainRepository
│ │ └── expense-file.typeorm-repository.ts ← plain TypeORM (нет блокчейн-якоря)
│ ├── blockchain/
│ │ └── mappers/
│ │ ├── expense-proposal-delta.mapper.ts ← parser2 delta → IExpenseProposalBlockchainData
│ │ └── expense-item-delta.mapper.ts
│ └── services/
│ ├── expense-files.service.ts ← @UseBucket('expenses:files'), put/getReadUrl/delete
│ ├── document-aggregation.service.ts ← собирает 2010+2011 document2 + expense_files в expenseProposalDetails
│ └── capital-trigger.service.ts ← вызывает capital extension при authorizeReport → capitalization
└── constants/
└── expenses-bucket.ts ← { name: 'expenses:files', maxBytes, allowedMime, metadataSchema }
```
## MinIO bucket spec
```ts
{
name: 'expenses:files',
maxBytes: 20 * 1024 * 1024, // 20 МБ на файл (чеки/PDF/фото)
allowedMime: [
'image/jpeg', 'image/png', 'image/webp', 'image/heic',
'application/pdf',
],
metadataSchema: {
proposalHash: 'required',
itemHash: 'optional',
kind: 'required', // PAYMENT_PROOF | REPORT_FILE | RETURN_PROOF
uploadedBy: 'required', // username
},
defaultUrlTtlSeconds: 600, // 10 мин на signed read URL
}
```
Ключ объекта: `{coopname}/expenses/{proposal_hash}/{item_hash|_proposal}/{kind}/{checksum}.{ext}`.
## GraphQL-схема (предварительная)
```graphql
type Query {
expenseProposalDetails(coopname: String!, proposal_hash: String!): ExpenseProposalDetails!
expenseFiles(coopname: String!, proposal_hash: String!): [ExpenseFile!]!
}
type Mutation {
payExpenseItem(input: PayExpenseItemInput!): ExpenseItem!
submitExpenseReport(input: SubmitReportInput!): ExpenseProposal!
authorizeExpenseReport(coopname: String!, proposal_hash: String!): ExpenseProposal!
declineExpenseReport(coopname: String!, proposal_hash: String!, reason: String!): ExpenseProposal!
returnExpenseItem(input: ReturnExpenseInput!): ExpenseItem!
uploadExpenseFile(input: UploadExpenseFileInput!): ExpenseFile!
}
type ExpenseProposalDetails {
proposal: ExpenseProposal!
items: [ExpenseItem!]!
files: [ExpenseFile!]!
documents: ExpenseProposalDocuments! # signed 2010 + signed 2011
events: [ExpenseProposalEvent!]! # таймлайн
}
```
## Когда расшивается C28-28
Каркас выше можно класть в код **только после** согласования имён `o.exp.*` action-codes и моделей wallets/счетов с пользователем (вопросы заданы в issue C28-28). До этого любой код в `infrastructure/blockchain/mappers/*` опирается на несуществующие cooptypes-типы.
## Ссылки
- PRD шасси: `13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/requirements/f8-prd-shassi-sistemy-raskhodov-tsifrovogo-kooperativa-v12.md`
- Issue C28-31: `13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/issues/C28-31-shassi-raskhodov-epik-3-backend-extension-expenses.md`
- File-storage инфра: `~/infrastructure/file-storage/README.md`
- Образец extension с blockchain-sync: `~/extensions/capital/`
- Document2 templates: `components/cooptypes/src/cooperative/registry/2010.ExpenseProposalStatement/`, `2011.ExpenseProposalDecision/`
@@ -0,0 +1,60 @@
import { Field, InputType } from '@nestjs/graphql';
import { ArrayMinSize, IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ExpenseItemInputDTO } from './expense-item.input';
import { ExpenseCallbackInputDTO } from './expense-callback.input';
import { ExpenseProposalStatementSignedDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-statement-document.dto';
/**
* Input создания и подачи СЗ-расхода (создатель сметы пайщик / председатель).
*
* Сабмит: `expense::createexp` через `ExpensesBlockchainPort.createExp`.
* Подпись `statement` (registry 2010) сделана на стороне UI через `Classes.Document`.
*/
@InputType('CreateExpenseProposalInput')
export class CreateExpenseProposalInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Имя пайщика-создателя СЗ.' })
@IsNotEmpty()
@IsString()
username!: string;
@Field(() => String, { description: 'Хеш сметы расхода (детерминированный, из UI).' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, {
description: 'Источник средств (eosio::name кошелька-источника, eg "w.cap.blago").',
})
@IsNotEmpty()
@IsString()
source_wallet!: string;
@Field(() => [ExpenseItemInputDTO], { description: 'Строки расхода (массив items).' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseItemInputDTO)
items!: ExpenseItemInputDTO[];
@Field(() => ExpenseCallbackInputDTO, {
nullable: true,
description: 'Callback на финализацию closeexp (опционально).',
})
@IsOptional()
@ValidateNested()
@Type(() => ExpenseCallbackInputDTO)
callback?: ExpenseCallbackInputDTO;
@Field(() => ExpenseProposalStatementSignedDocumentInputDTO, {
description: 'Подписанная СЗ-смета (document2, registry 2010).',
})
@ValidateNested()
@Type(() => ExpenseProposalStatementSignedDocumentInputDTO)
statement!: ExpenseProposalStatementSignedDocumentInputDTO;
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
/**
* Callback на финализацию `expense::closeexp`. Пустой `contract` (`""`) = нет callback.
* Зеркалит `ExpenseDomain::callback_handler` из `expense.hpp`.
*/
@InputType('ExpenseCallbackInput')
export class ExpenseCallbackInputDTO {
@Field(() => String, { nullable: true, description: 'Контракт-целевой' })
@IsOptional()
@IsString()
contract?: string;
@Field(() => String, { nullable: true, description: 'Action-метод' })
@IsOptional()
@IsString()
action?: string;
@Field(() => String, { nullable: true, description: 'Payload (hex bytes)' })
@IsOptional()
@IsString()
data?: string;
}

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