Compare commits

...

31 Commits

Author SHA1 Message Date
ant de55c3aa3e Merge pull request 'Эпик 1 (Благорост): контракт capital — займы, расходы программы, L2-роли' (#3) from feat/blagorost-E1-contract into blagorost
Reviewed-on: #3
2026-06-02 06:17:24 +00:00
coopops 32fc211006 fix(capital): стандарт debt — «подписанный акт приёма-передачи»
Поправка терминологии в p.cap.debt.standard.yaml: «вторая подпись на
акте» → «подписанный акт приёма-передачи» — корректное юридическое
название документа, который завершает приём результата работ по проекту.

Затронуты: operations.o.cap.repay.description, related.p.cap.rid.note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 06:27:59 +00:00
coopops 24663bdaac fix(capital,loan): gitea PR #3 review — 3 коммента
1. loan.debts.source_contract — убрана binary_extension-обёртка, простой
   name. Таблица в продакшене пустая, расширение не нужно.

2. p.cap.debt.standard.yaml переписан бизнес-языком для председателей /
   бухгалтеров / пайщиков. Из прозы убраны идентификаторы кошельков
   (w.cap.*), коды операций (o.cap.*), бухгалтерские проводки в шапке и
   action.purpose / state.description / scenario / alternatives. Технические
   детали остались только в structured-секциях 5 (documents) и 6 (operations).

3. «акт-2» убран как ошибочная терминология — у проекта один акт, на
   нём ставится вторая подпись. Поправлено везде в стандарте.

Compile: loan + capital собраны через cdt-cpp v4.1.0 без ошибок.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 06:25:00 +00:00
coopops c3a433b591 refactor(capital): финальный канон имён debt-операций невозврата
Утверждено автором 2026-05-19. Семантика операции через имя, группировка по
сущности (dbt для debt-операций), без аббревиатур типа seize/wroff/seizecollat.

Переименования (относительно предыдущего refactor-коммита):
  o.cap.seize       → o.cap.crtnma   (CREATE_NMA — создать НМА кооператива)
  o.cap.wroff       → o.cap.dbtwrf   (DEBT_WRITEOFF — списать долг)
  capital::seizecollat → capital::closedebt (закрыть долг)
  Status::SEIZED    → Status::WRITEOFF
  mark_seized       → mark_writeoff
  seizecollat.cpp   → closedebt.cpp

Кошелёк w.cap.nma и проводки (Дт 04 / Кт 08 + Дт 80 / Кт 58) — без изменений.

Канон утверждён ДО реализации (правило в auto-memory:
feedback-kanon-naming-soglasovyvat); предыдущий заход с собственными
именами отвергнут автором.

Compile: cdt-cpp v4.1.0 — exit 0, capital.abi содержит action closedebt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:55:05 +00:00
coopops ca877ab294 refactor(capital): канон имён ledger2 + реализован action seizecollat
Канон имён в ledger2-онтологии — самодокументирующиеся английские термины
без аббревиатур. Префикс контракта (cap) не дублировать в имени.

Переименования (старые имена — отвергнутый рабочий драфт; нигде больше
не используются):
  o.cap.dflt    → o.cap.seize    (SEIZE_COLLATERAL — изъятие обеспечения)
  o.cap.lnwoff  → o.cap.wroff    (WRITE_OFF_LOAN — списание займа)
  w.cap.coopn   → w.cap.nma      (NMA — НМА кооператива)
  capital::dfltdebt → capital::seizecollat
  state defaulted (virtual) → seized (Status::SEIZED + mark_seized)

Story 1.13 — action capital::seizecollat:
  - guard: debt.status ∈ {paid, overdue} ∧ project.status == cancelled;
  - apply o.cap.seize  (TRANSFER w.cap.gen → w.cap.nma, Дт 04 / Кт 08);
  - apply o.cap.wroff  (BURN w.cap.loan,                Дт 80 / Кт 58);
  - decrease segment.debt_amount + active_debts_count;
  - subtract project.used_for_compensation;
  - decrease contributors.debt_amount;
  - mark debt SEIZED (mark_seized);
  - inline Loan::settle_debt (loan-контракт);
  - require_recipient(username, coopname);
  - require_auth(coopname) — без подписи пайщика, без сбора совета.

Решения 2026-05-19 (Игорь Смуров / Алексей Муравьёв):
  - срок займа фикс — 1 год;
  - проводки невозврата: Дт 04 / Кт 08 + Дт 80 / Кт 58;
  - автоматизм без подписи пайщика и без сбора совета.

Compile: cdt-cpp v4.1.0 OK (warnings only Ricardian + deprecation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:30:47 +00:00
coopops 4d6a03a142 feat(capital): закрыть невозврат займа — обращение взыскания на коммиты-обеспечение
Утверждённые проводки и срок (обсуждение Matrix 2026-05-19, Игорь Смуров / Алексей Муравьев):

* Срок займа — фиксированный 1 год вместо 3 мес. (`DEFAULT_DEBT_TERM_SECONDS = 365 × 24 × 3600`).
* Новые операции ledger2:
  - `o.cap.dflt` — обращение взыскания на коммиты-обеспечение (Дт 04 / Кт 08, TRANSFER `w.cap.gen` → `w.cap.coopn`).
  - `o.cap.lnwoff` — списание займа в счёт обращения взыскания (Дт 80 / Кт 58, BURN `w.cap.loan`).
* Новый кошелёк `w.cap.coopn` (COOP_NMA, COOPERATIVE) — НМА кооператива, полученные из обеспечения.
* Кооперативный стандарт `p.cap.debt.standard.yaml` обновлён: состояние `defaulted`, шаг `dfltdebt`, две новые операции в секции 6, альтернативная ветка в сценарии.
* Тесты-заготовки в `capital-blagorost.test.ts` (Story 1.13).

Реализация самого action `capital::dfltdebt` остаётся за Story 1.13 — здесь только ontology + стандарт + проводки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:07:58 +00:00
coopops c185b0167f fix(capital+loan): review PR#402 round 2 — изоляция loan, счётчик займов в сегменте, бездокументарные роли
Round 2 ревью PR #402 — 8 замечаний:

LOAN-контракт независим от прикладных (capital/marketplace/...):
- loan.debts: убран binary_extension<checksum256> project_hash и индекс byuserproj
- loan.debts: добавлен binary_extension<name> source_contract (контракт-источник)
- loan::createdebt(): убран параметр project_hash, payer пишется в source_contract
- Loan::create_debt(): убран project_hash из сигнатуры и хелпера
- Loan::count_user_project_debts(): удалена — capital считает у себя
- Локальный контекст (project_hash и т.п.) остаётся в Capital::Debts

CAPITAL — счётчик активных займов на сегменте через binary_extension:
- Segment::active_debts_count (binary_extension<uint32_t>, хвостом)
- increase/decrease/get_active_debts_count хелперы
- debtpaycnfrm: после confirm_paid +1 счётчика сегмента
- settledebt: после mark_settled -1 счётчика
- signact2: лимит «10 займов на проект в одном паевом взносе» проверяется
  по счётчику (не через обход byprojhash); сам перебор ограничен счётчиком,
  ранний выход после нужного числа активных записей

role_requests — бездокументарная схема и строгая типизация:
- namespace Role { CREATOR, AUTHOR, MASTER, NONE } в role_requests.hpp
- удалены поля statement и master_decision из role_request struct
- create()/approve() — без параметров document2
- validate_role_or_fail и apply_role_to_project — через Role:: namespace
- requestrole/inviterole/approverole/acceptinvite — без statement/master_decision
- requestrateu — role = Role::NONE (не маркер "rate"_n), request_type = RATE_UPDATE
- decline сохраняет только reason; решения подписываются coopname-транзакцией

prerequisite fix:
- names.hpp: убран дубль Names::Capital::CREATE_PROGRAM_EXPENSE (был объявлен
  и в callback-блоке line 55, и в акцепт-блоке line 130 — оставлен один)

Замечания закрыты:
- PR comment id=3264976591 (фу-фу byuserproj) — индекс удалён
- id=3264980890 (count_user_project_debts) — функция удалена
- id=3265021918 (loan независим от capital) — изоляция выполнена
- id=3265072380 (rate как name через namespace) — RequestType::RATE_UPDATE + Role::NONE
- id=3265080990 (счётчик в binary_extension вместо перебора) — active_debts_count
- id=3265088096 (namespace типа роли) — Role::{CREATOR,AUTHOR,MASTER}
- id=3265101459 (без statement/master_decision) — поля удалены
- id=3265104196 (через Namespace проверка) — validate_role_or_fail через Role::

Сборка: cdt-cpp v4.1.0 capital.wasm + loan.wasm без ошибок.
Тесты: capital-blagorost.test.ts — формулировки A2/D обновлены под счётчик
       и бездокументарную схему.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:40:09 +00:00
coopops 07f35ea084 feat(loan): централизация учёта займов — capital вызывает loan через inline action
Ревью PR#402 (debtpaycnfrm:23): учёт займов должен быть централизован в одном контракте,
как Gateway для платежей. Существующий контракт loan/ уже задеплоен и в whitelist,
но capital его не вызывал — теперь вызывает.

loan-контракт:
— table_loan_debts: добавлено поле project_hash (binary_extension<checksum256> —
  таблица уже в продакшене, расширение схемы только хвостом) + индекс byuserproj
— Loan::create_debt: новая сигнатура с project_hash; createdebt сохраняет привязку к проекту
— Loan::count_user_project_debts: хелпер для лимита займов на проект в паевом взносе

capital:
— debtpaycnfrm: после confirm_paid дублирует запись в loan.debts через inline
  Loan::create_debt (с due_at из обновлённой capital-записи и project_hash)
— settledebt: дополнительно зовёт inline Loan::settle_debt; принимает также OVERDUE
  (не только PAID) — погашение просроченного через председателя
— signact2 (паевой взнос): собирает все PAID/OVERDUE займы пайщика на проекте
  из capital.debts, проверяет лимит 10 (transaction limit EOSIO), на каждый —
  inline Loan::settle_debt + capital.Debts::mark_settled. Никакого «боевого взноса» —
  только паевой по интеллектуальному результату

Boot/deploy не требует правок: loan/ уже в configs/contracts.ts и configs/index.ts,
аккаунт loan создаётся, контракт деплоится. capital уже в contracts_whitelist в lib/consts.

Тесты: добавлен describe «A2 — Централизованный учёт займов» с 6 it.todo
по новым сценариям (createdebt в loan, settle через capital, лимит 10, whitelist).

Полный перенос таблицы debts из capital в loan (с удалением capital.debts) —
отдельная Story 1.12, оставлена на следующую итерацию: текущий PR сохраняет
параллельный учёт в обеих таблицах для безопасной миграции прода.
2026-05-19 08:16:15 +00:00
coopops 4a13cd26c0 fix(capital/roles): review PR#402 — 3 роли, description, явная ставка, inline add author/master
role_requests:
— role строго {creator, author, master}; contributor — L1, в role_requests не попадает
— validate_role_or_fail() — валидация на уровне requestrole/inviterole
— description (std::string, может быть пустой) — текст заявки/приглашения от пайщика к мастеру и наоборот
— apply_role_to_project(): inline addauthor для role=author, setmaster для role=master,
  creator → no-op (фиксируется при первом createcmmt)

Actions:
— requestrole/inviterole/requestrateu: добавлено description в сигнатуру + проброс в RoleRequests::create
— approverole/acceptinvite: после approve/segment.set_approved_rate вызывают
  apply_role_to_project — заявленная роль немедленно отражается в реестре участников
  (раньше creator становился creator только после первого commit'а)
— inviterole: уточнено в комментарии что master-параметр может быть председателем
  при приглашении кандидата на роль master (когда мастера в проекте ещё нет)

Ставка часа всегда подаётся явно (rate_per_hour, hours_per_day) — это ставка пайщика
именно под этот проект; фронт по дефолту подставляет contributors.rate_per_hour,
но пайщик может изменить под конкретный проект. createcmmt берёт approved_rate
сегмента, не глобальную из contributors.
2026-05-19 08:09:08 +00:00
coopops 59a8b9b6ee fix(capital): review PR#402 — binary_extension + auth + лимиты + 86 счёт
— segments.approved_rate_per_hour/hours_per_day → binary_extension<> (прод-таблица)
— global_state.program_expense_pool/reserved → binary_extension<asset> (прод-таблица)
— settledebt: require_auth(coopname), убран has_auth(username) — backend подписывает
— markdebtoverd: batch_limit 100 → 25 (transaction time limit EOSIO 0.5с)
— approverole/inviterole/requestrole/requestrateu: норма часов 12 → 8 (фронт-ограничение)
— debtpayretry: новый action для повторной отправки outpay из AUTHORIZED + last_pay_error
— PAY_EXPENSE: проводка Dr 86 / Cr 51 (TARGET_RECEIPTS/BANK_ACCOUNT) — целевое финансирование, не общехоз 26
— memo.get_settle_debt_memo: "Погашение займа участником" (без "деньгами") — погашение не только наличными
2026-05-19 08:04:50 +00:00
coopops eb720a88ef test(capital): scaffold capital-blagorost.test.ts под новые ветви Эпика 1
Story 1.11 эпика «Благорост» (blago issue 51F-23).

Существующий capital.test.ts ≈2650 строк — раздувать монолит нерационально.
Новые сценарии вынесены в отдельный файл со структурой describe/it.todo() —
каркас под пошаговое наполнение реальными helper'ами по мере появления
backend-обвязки (Эпик 4: processDebtDeclinePay, processSettleDebt,
processMarkOverdue, processProgramExpense, processRequestRole, и т.д.).

Покрытые сценарии (it.todo):
- A: debtpaydcln (×2), settledebt (×3), markdebtoverd (×3) + due_at (×1)
- B: exppaycnfrm проводка, topupprogexp, createpgexp резервирование,
  полный цикл создания/одобрения/авторизации/оплаты, declpgexp на 3 фазах
- D: requestrole/approverole + approved_rate в createcmmt (1200 ≠ 1500),
  editcontrib не трогает approved, inviterole/acceptinvite/declinvite,
  declinerole, requestrateu
- E: require_recipient на всех новых actions (Story 1.10)

Существующие тесты не сломаны (отдельный файл, отдельный describe).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:07:41 +00:00
coopops 58b8654989 feat(capital): require_recipient во все новые actions эпика «Благорост»
Story 1.10 эпика «Благорост» (blago issue 51F-22).

Notification-services (Эпик 6) подписываются на chain через action traces
и триггерят Novu workflows. На каждой ветке переходов требуется явный
require_recipient, чтобы участники, не подписавшие транзакцию, всё равно
получили её в свою цепочку recipient — это и есть «event ridge» из PRD-E.

Покрыто:
- debtpaydcln  → должник + coopname
- settledebt   → должник + coopname
- createpgexp  → creator + coopname
- apprvpgexp   → expense.username + approver
- authpgexp    → expense.username + coopname
- pgexppay     → expense.username + coopname
- declpgexp    → expense.username + coopname
- requestrole  → username + master
- approverole  → req.username + req.master
- declinerole  → req.username + req.master
- inviterole   → candidate + master
- acceptinvite → req.username + req.master
- declinvite   → req.username + req.master
- requestrateu → username + master

markdebtoverd сознательно пропущен — это batch-action (до 100 записей
за вызов), массовая рассылка require_recipient ухудшит RAM-стоимость
транзакции. Novu по этому переходу подписывается на изменение статуса
debt записи через парсер.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:05:58 +00:00
coopops 138d7ab350 chore(capital): чистка debug print() в startvoting + [[deprecated]] на program_invest
Story 1.9 эпика «Благорост» (blago issue 51F-21).

PRD: «удалить отладочные print() в startvoting.cpp; пометить progrinvests
deprecated до 07.2026». Логирующие print() в returntopool.cpp и lvlnotify.cpp
оставлены — они несут смысл, не отладочные маркеры.

Изменения:
- startvoting.cpp: убраны 5 маркеров print("z"/"a"/"b"/"c"/"d")
- program_invests.hpp: атрибут [[deprecated("remove after 2026-07")]] на struct
  program_invest + расширенный doxygen-комментарий с пояснением жизненного цикла

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:04:30 +00:00
coopops b2adb56535 feat(capital/roles): L2-роли — requestrole/approverole/declinerole/inviterole/acceptinvite/declinvite/requestrateu
Story 1.7 эпика «Благорост» (blago issue 51F-19).

PRD-D1/D3/D4: «двухуровневые допуски, мастер компонента валидирует исполнителей
и ставку часа на сегменте». До этого коммита actions L2-цикла в контракте
не существовало; ставки фиксировались только глобально через editcontrib.

Изменения:
- domain/entities/role_requests.hpp: таблица rolerequests, статусы pending/approved/declined,
  направление request/invite, тип role/rateupdate, helpers create/approve/decline
- 7 новых cpp actions в app/participation_management/roles/:
  - requestrole — пайщик просит L2-роль (creator/author/contributor) + ставку
  - approverole — мастер одобряет: фиксация approved-ставки в сегменте через
    Capital::Segments::set_approved_rate (Story 1.8)
  - declinerole — мастер отклоняет с причиной (запись для аудита остаётся)
  - inviterole — мастер инициирует обратный поток, приглашает кандидата
  - acceptinvite — кандидат принимает инвайт → applied к сегменту
  - declinvite — кандидат отказывается (имя укорочено с declineinvite — лимит 12 chars)
  - requestrateu — пайщик просит обновить approved-ставку (тот же flow, RequestType::RATE_UPDATE);
    approverole с новой ставкой → новая approved в сегменте
- name declineinvite сокращена до declinvite (13 → 10 chars, eosio лимит 12)

Авторизация на текущем этапе — require_auth(coopname); backend проверяет
master-роль приглашающего/одобряющего перед отправкой action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:03:35 +00:00
coopops 1f486ce48a feat(capital/segments): approved_rate_per_hour в сегменте + createcmmt через approved-ставку
Story 1.8 эпика «Благорост» (blago issue 51F-20).

PRD-D2: «approved_rate_per_hour хранится в сегменте; createcmmt использует
approved-ставку». До этого коммита createcmmt брал ставку из глобального
contributors.rate_per_hour — мастер компонента не мог дифференцировать.

Изменения:
- segments.hpp: поля approved_rate_per_hour, approved_hours_per_day (по умолчанию 0
  — fallback на глобальную ставку из contributors)
- segments.hpp::set_approved_rate(coopname, project_hash, username, rate, hours)
  — helper будет вызван из approverole (Story 1.7)
- createcmmt.cpp: если segment.approved_rate_per_hour.amount > 0 — используется
  approved; иначе contributor->rate_per_hour. Меняется и creator_base, и
  delta_amounts (через calculate_fact_generation_amounts)
- editcontrib (без изменения) — теперь не влияет на approved-ставки на
  сегментах: контракт берёт сегментные значения вперёд глобальных

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:00:43 +00:00
coopops 7ca2695121 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-05-18 16:58:46 +00:00
coopops bbfc592357 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-05-18 16:55:44 +00:00
coopops 18646f8353 feat(capital/expense): exppaycnfrm — проводка по фонду программы
Story 1.4 эпика «Благорост» (blago issue 51F-16).

Закрывает TODO в exppaycnfrm.cpp:35 — после подтверждения оплаты проектного
расхода списываем сумму с программного фонда «Благорост» в пул хозрасходов
из целевого финансирования.

Изменения:
- processes::capital::EXPENSE (p.cap.expns) — новый process_type для цикла
  целевого расхода (создание → одобрение → авторизация → outpay → подтверждение)
- operations::capital::PAY_EXPENSE (o.cap.expns) — TRANSFER BLAGOROST_FUND
  → SOV_EXPENSES, без Dr/Cr; параллельная бухпроводка Dr 26 / Cr 51 на
  расчётном делается gateway и здесь не дублируется
- запись 20 в OPERATION_REGISTRY (lib/core/ledger2/operations.hpp)
- get_expense_pay_memo() в Capital::Memo
- exppaycnfrm.cpp теперь после complete_expense вызывает Ledger2::apply
  до delete_expense (нужны expense.amount/username для проводки)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:54:00 +00:00
coopops e932260847 feat(capital/debt): markdebtoverd + due_at в debtpaycnfrm — статус просрочки
Story 1.3 эпика «Благорост» (blago issue 51F-15).

PRD-A3, FR-A11: «срок погашения 3 месяца, дальше — overdue, без блокировки операций».
Поле due_at и статус OVERDUE добавлены, helper sweep_overdue вычитан в action.

Изменения:
- статус OVERDUE — выставляется markdebtoverd для долгов с now > due_at
- поле debt.due_at (time_point_sec) — на 3 месяца (DEFAULT_DEBT_TERM_SECONDS = 90 дней)
  от даты подтверждения выплаты gateway
- helper debts.hpp::confirm_paid — атомарный переход pay_pending → paid с
  проставлением due_at и сбросом last_pay_error
- helper debts.hpp::sweep_overdue(coopname, batch_limit) — итерация по debts
  в scope, перевод paid→overdue для просроченных, не более batch_limit записей
- markdebtoverd(coopname) action — backend-cron вызовет раз в сутки (Эпик 4),
  batch=100 чтобы уложиться в 30s транзакции; повтор до 0 изменений
- mark_settled расширен: SETTLED доступен также из OVERDUE
- debtpaycnfrm теперь использует confirm_paid (раньше update_debt_status +
  только PAID, без due_at)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:51:13 +00:00
coopops b42394bfb4 feat(capital/debt): settledebt — прямое погашение займа деньгами
Story 1.2 эпика «Благорост» (blago issue 51F-14).

settledebt был stub'ом. Реализация по PRD-A3: должник или председатель
вносит сумму, ровно равную остатку долга — заём закрывается.

Изменения:
- сигнатура: settledebt(coopname, debt_hash, amount, statement) — раньше
  принимался username, но идентифицировать долг надо по hash (у одного
  пайщика может быть несколько активных)
- статус SETTLED — финальное состояние погашенного долга (как через
  деньги settledebt, так и через результат signact2)
- helper debts.hpp::mark_settled — атомарный переход paid → settled с
  пометкой даты погашения и memo
- get_settle_debt_memo() — описание проводки в ledger2
- авторизация: has_auth(debt.username) || has_auth(coopname) — раньше
  было только coopname, что мешало пайщику самому погасить долг
- проводка REPAY (Dr 80 / Cr 58) на полную сумму долга
- contributors.decrease_debt_amount на ту же сумму

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:48:42 +00:00
coopops ff8f60e4b2 feat(capital/debt): debtpaydcln + статус PAY_PENDING — откат outpay без удаления долга
Story 1.1 эпика «Благорост» (blago issue 51F-13).

PRD-A3 говорит «отказ оплаты по реквизитам не приводит к удалению долга».
До правки debtpaydcln был stub'ом, статусы не различали «в полёте до gateway»
и «авторизован, ждёт повторной отправки».

Изменения в lifecycle долга:
- статус PAY_PENDING — долг отдан в gateway на outpay
- поле last_pay_error — текст ошибки от gateway (пусто, пока нет отказов)
- утилиты debts.hpp: start_pay() (approved/authorized → PAY_PENDING + сохранение
  решения совета + сброс last_pay_error) и mark_pay_declined() (PAY_PENDING →
  AUTHORIZED + last_pay_error)

Касается actions:
- debtauthcnfr теперь через start_pay() переводит долг в PAY_PENDING и
  параллельно фиксирует document решения совета как authorization
- debtpaycnfrm требует PAY_PENDING (а не AUTHORIZED) перед PAID
- debtpaydcln вызывает mark_pay_declined() — повторная outpay из AUTHORIZED
  доступна без новой авторизации совета

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:46:11 +00:00
Alex Ant abcd34f23d fix(capital): revert ошибочной замены GenerationConvertStatement → MoneyInvestStatement (#394) (#396)
PR #394 «починил» TS-ошибки coopback заменой типа `Cooperative.Registry.GenerationConvertStatement`
на `GenerationMoneyInvestStatement` в 3 файлах controller'а. Это семантически неверно:

- 1080 GenerationConvertStatement — заявление о трансляции паевого взноса
  (поля: project_hash, main_wallet_amount, blagorost_wallet_amount, to_wallet, to_blagorost, appendix_hash)
- 1020 GenerationMoneyInvestStatement — заявление о денежном паевом взносе по программе Генерация
  (совсем другой набор полей)

DTO BaseGenerationConvertStatementMetaDocumentInputDTO декларирует поля 1080, но `implements ExcludeCommonProps<action>`
где action = тип 1020 → TS2352 на as-cast в interactor, потому что Action'ы не пересекаются по полям.

Реальная причина исходных ошибок coopback после #392 — несвежий dist `@coopenomics/cooptypes`
на dev-узле (старое имя символа). Лечится пересборкой пакета, не переименованием ссылок.

Изменено (откат #394):
- generation-convert-statement-document.dto.ts:12 — `action = ...GenerationConvertStatement.Action`
- distribution-management.service.ts:56 — `registry_id: ...GenerationConvertStatement.registry_id`
- distribution-management.interactor.ts:48,66 — `Promise<...GenerationConvertStatement.Action>`

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:00:45 +05:00
Alex Ant 83cac58fca fix(controller): IS_UNIONED zod-парсер принимает string из .env (#395)
z.boolean().default(true) валится для переменной из .env, потому что
process.env всегда отдаёт строку: zod не приводит "true"/"false" к
boolean, в результате validateEnv падает с «IS_UNIONED: параметр не
установлен» и coopback не стартует, если в .env стоит IS_UNIONED=false
(стандартный dev-обход messenger-гейта, см. flow подключения партнёра).

Заменено на string().default('true').transform(v => v === 'true') —
сохранение прежнего default=true и поддержка string-форм из env.

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-18 12:40:57 +05:00
Alex Ant dbf46d88d0 fix(capital): подтянуть 3 ссылки на GenerationConvertStatement → MoneyInvestStatement (#394)
PR #392 переименовал Cooperative.Registry.GenerationConvertStatement в
GenerationMoneyInvestStatement в @coopenomics/document, но в controller
осталось 3 несинхронизированные ссылки:

- distribution-management.service.ts:56 — registry_id метода generation
- distribution-management.interactor.ts:48,66 — тип Action в return и as-cast
- generation-convert-statement-document.dto.ts:12 — type action в DTO

Coopback падал на ts-node compile (TS2551/2724), что блокировало старт
всего dev-stack после reboot dev-chain 2026-05-18.

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-18 12:29:32 +05:00
Alex Ant a0cb930b77 refactor(capital/convert): унифицировать шаблон 1080, убрать 1081/1082 (#392)
* refactor(capital/convert): унифицировать 1080 как универсальное заявление о конвертации, убрать 1081/1082

Шаблон 1080 (GenerationConvertStatement) уже технически универсален — содержит
обе суммы (main_wallet_amount/blagorost_wallet_amount) и условные блоки в
context. Шаблоны 1081 (GenerationToProjectConvertStatement) и 1082
(GenerationToCapitalizationConvertStatement) были недоделанными заглушками
без полей и нигде не подключены в UI.

Изменения:
- cooptypes: 1080 переименован GenerationToMainWalletConvertStatement →
  GenerationConvertStatement; title/description нейтральные. 1081/1082 удалены.
- factory: Template + Action 1080 переименованы; в Action добавлено
  super.formatAsset(...) для обеих сумм (как в Action 1020). 1081/1082 удалены.
- controller: DTO переименован; appendix_hash убран из generate-input и
  перенесён в signed-meta-input; добавлен enrich appendix_hash через
  AppendixRepository.findConfirmedByUsernameAndProjectHash в
  DistributionManagementInteractor.prepareGenerationConvertStatementData
  (по образцу InvestsManagementInteractor для 1020). Резолвер мутации
  переименован в capitalGenerateGenerationConvertStatement; убраны два
  резолвера 1081/1082. mutation-log-mapper обновлён.
- sdk: мутация переименована, две удалены, zeus regenerated.
- desktop: Distribution-фичи 1081/1082 удалены, 1080-фича переименована.
  ConvertSegment теперь шлёт project_hash + обе суммы (formatToEosioAsset) +
  to_wallet/to_blagorost; appendix_hash подтягивается на бекенде.
- controller schema.gql regenerated.

registry_id=1080 не меняется — on-chain контракт convertsegm не сверяет
registry_id, миграций БД/контракта не требуется.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(capital/convert): применить ревью — title «трансляция паевого взноса из программы Генерация»

По комментарию ревью в PR #392 (строка 37): принять доменный термин
«трансляция паевого взноса» (перенос между программами) вместо
«конвертация»; description согласован в том же стиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:29:12 +05:00
Alex Ant 4a4145c4ed feat(controller): is_server_init флаг в initSystem для provider-overwrite (#391)
* feat(docs-harness): onboarding 01..06 — визуальная цепочка регистрация → активация

Шесть сценариев visual docs-harness, покрывающих полный путь подключения
нового кооператива через провайдера Восход:

  01-register-coop                 — регистрация кооператива-клиента
  02-sign-and-submit               — подпись заявления о вступлении + PayInitial
  03-operator-approve              — chairman принимает заявку в реестре одобрений
  04-sign-connection-agreement     — Партнёр-1 видит ConnectionAgreementStepper
                                     (на текущем стенде получаем заглушку
                                     /signup, пока пайщик не принят)
  05-activate-from-registry        — оператор открывает карточку инстанса
                                     в provider-frontend, выбирает preset
  06-wait-instance-active          — pending → ACTIVE (overrideInstance
                                     для имитации финального статуса в шоте)

Обвязка:
  • lib/registrator-signup.mjs — переиспользуемый helper подписания.
  • lib/harness.mjs — расширенный dismissOnboardingDialogs (Положение ЦПП
    и связанные модалки chairman'а).
  • state/cooperatives/{,.gitkeep} — папка для фикстур; partner1.json
    (с приватным wif) игнорируется (.gitignore обновлён).

ОГРАНИЧЕНИЕ: в 06 финальный ACTIVE — это playwright-override JSON, а не
реальный POST /instances/activate с боевым Ansible до testnet300.coopenomics.world.
Реальный E2E (аренда VM на Hostkey + поднятие кооператива на домене)
будет следующим шагом Эпика 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(controller): is_server_init flag в initSystem для разблокировки provider-overwrite

Поле data.is_server_init: boolean (optional) в InitDTO + домейн-интерфейсе.
Если true (вызов от провайдера через server-secret) — coopback ставит
init_by_server=true безусловно, даже если до этого пользователь успел
заполнить визард первым (user-init). Это разблокирует ситуацию, когда
провайдер не успел вызвать initSystem до того как chairman открыл
/install — следующий callInitSystemMutation от провайдера перезапишет
organization_data и пометит её readonly для визарда.

Без флага сохраняется прежняя логика: первая инициализация — серверная,
повторная наследует флаг.

Инцидент 2026-05-18 на partner1: при первой установке provider вообще
не успел/не сходил в callInitSystemMutation, визард пользователя
проинициализировал систему как user-init (init_by_server=false), визард
2-го захода не предзаполнил форму. С этим фиксом следующий вызов
provider'а поднимет флаг и фикстура встанет на место.

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:40:49 +05:00
Alex Ant 00860b3b18 fix commit process
Build bootstrap container / build (push) Failing after 2m52s
2026-05-15 22:21:35 +05:00
Alex Ant 8fe9509e97 fix(capital/time-tracking): личные доли estimate + revert при decline (#387)
* chore(release): publish

* chore(release): publish

* ci: атомарный release.yaml, убрать workflow_run-связку (#366)

build-contracts + build-containers через workflow_run упёрлись в:
(а) default-branch caveat (новая логика не активна, пока не в main),
(б) `${{ github.event.workflow_run.head_sha }}` иногда пуст в YAML-
выражениях — описание см. в шаге Resolve tag, инцидент v2026.5.14
не дёрнул PRODUCTION_WEBHOOK_URL.

Замена — один `release.yaml` на push тэга `v*`: резолвит ветку через
`git branch --contains`, собирает контракты → пушит
`dicoop/contracts:<branch>`, собирает базу + сервисные образы →
пушит `dicoop/<svc>:<tag>`, шлёт webhook. Гонок нет by construction.

`build-contracts.yaml` остаётся только на push веток для CI-
обновления `dicoop/contracts:dev|testnet|main` без релизного тэга.
Триггер `tags: ['*']` и логика резолва ветки через --contains
оттуда удалены — это теперь забота release.yaml.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* chore(release): publish

* chore(release): publish

* ci: tag-only триггеры для build-contracts и docs (#367)

build-contracts.yaml — только workflow_dispatch (ручная пересборка
`dicoop/contracts:<branch>` для отладки на dev-ноде). Тэги обрабатывает
release.yaml атомарно (контракты + контейнеры + webhook), отдельная
сборка по push'у в ветку только давала вторую параллельную сборку.

publish-docs.yaml и build-contracts-docs.yaml — на push:tags v* с
gate-job'ом, пропускающим только продакшн-тэги (без -alpha/-beta/-rc/
-test) на main. Раньше docs пересобирались на каждый push в
main/testnet/dev/reports/marketplace2 — впустую.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(capital/time-tracking): личные доли estimate, partial-split, revert при decline

Три бага в распределении билетов времени, вскрытые на прод-инциденте voskhod
(проект CC7-1 «Концепция», estimate=15 ч, 3 creators):

БАГ #1 — recalcDoneEstimatesForContributorProject раздавал «общий остаток пула»
(estimate − committed_total) / N всем creators поровну. Закоммитивший свою долю
получал её ещё раз, остальные — урезанную (15/3=5 → после committed 5 у одного
становилось 10/3=3.33 у каждого, включая того кто уже закоммитил).

Фикс: личная доля = estimate/N − собственный committed estimate. Введён общий
helper redistributeIssueEstimateEntries, который используют и applyExplicit-
EstimateToTimeEntries (force=true), и recalcDoneEstimates (force=false с
no-op оптимизацией если раскладка уже совпадает с планом).

БАГ #2 — commitTime при partial split (entry.hours > requested) создавал новую
committed-запись без entry_type и estimate_snapshot. По default'у БД сохраняла
её как entry_type='hourly', что ломало последующий recalc (он фильтрует только
entry_type='estimate'). Фикс: явно копировать entry_type и estimate_snapshot
из оригинального entry.

БАГ #3 — declineCommit / handleDeclineCommit меняли только commit.status в БД,
но не возвращали time-entries в is_committed=false. После отказа мастера часы
оставались в total_committed_hours и не возвращались в available_hours. Фикс:
новый метод revertEntriesForDeclinedCommit в TimeTrackingInteractor + методы
findCommittedByCommitHash / revertCommittedEntriesByCommitHash в TimeEntry-
Repository. После revert делается force=true redistribute для затронутых DONE
задач, чтобы доли вернулись в норму.

Покрытие тестами: 17 unit-тестов в time-tracking.interactor.spec.ts с
регрессионными сценариями под каждый из трёх багов плюс integration-сценарий
полного lifecycle CC7-1 (estimate → коммит → decline → revert).

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:02:36 +05:00
coopops 848566bd69 fix(file-storage): MinIO стартует только при заданном MINIO_ENDPOINT
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 11s
Publish Docs / build-and-publish-docs (push) Failing after 18m20s
Раньше MINIO_ENDPOINT имел default http://minio:9000, и на проде без
minio-контейнера контроллер падал на bootstrap в HeadBucket с
getaddrinfo ENOTFOUND minio — Nest application не поднимался вообще.

Теперь MINIO_ENDPOINT optional без default; адаптер хранит enabled-флаг
по наличию endpoint и при отсутствии — onApplicationBootstrap логирует
warning и возвращается без сетевых вызовов. Любая попытка getBucket /
fetchObjectForReadProxy кидает InterFileStorageBackendUnavailableError
с понятным сообщением.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:30:48 +00:00
coopops aeb4efe2b6 fix(capital/commit): nullable description/meta в CommitOutputDTO + не дефолтить satisfaction в 5
Если syncCommit не дождался delta из блокчейна, interactor возвращает DB-only
entity, где description/meta остаются undefined — non-nullable GraphQL field
ломал ответ мутации capitalCreateCommit. Сделал оба поля nullable.

UI CreateCommitButton.vue: satisfaction_stars=0 по умолчанию, label "не указано"
пока пользователь не выбрал; блок contribution_feedback в payload только если
stars >= 1 или review_text непустой.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:43:12 +00:00
Alex Ant 7ef138ba19 feat(file-storage): универсальное файловое хранилище контура кооператива (MinIO/S3-портабельное) (#359)
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 7s
Publish Docs / build-and-publish-docs (push) Failing after 17m11s
* [E59-2][@ant] feat(inter): InterFileStorage порт — типы, токен INTER_FILE_STORAGE и типизированные ошибки для универсального файлового хранилища контура кооператива

* [E59-3][@ant] feat(controller): MinIO-адаптер InterFileStoragePort, реестр бакетов, @UseBucket/@InjectBucket декораторы и FileStorageInfrastructureModule с forRoot/forFeature; 36 unit-тестов на адаптер, реестр, декоратор и HMAC-подписание

* [E59-4][@ant] feat(controller): HTTP-ручка GET /api/storage/:bucket/:key с HMAC-валидацией подписи и стримом из MinIO; fetchObjectForReadProxy на адаптере, controller registered в FileStorageInfrastructureModule.forRoot; 9 e2e-тестов через supertest на 200/403/404/502

* [E59-5][@ant] test(controller): integration suite против реального MinIO — 8 сценариев на полный цикл put/head/getReadUrl/GET/delete + ошибки лимитов/MIME/metadata + HMAC-роут 200/403/404; docker-compose рядом с тестами, npm run test:integration:file-storage с автодетектом доступности MinIO

* [E59-6][@ant] feat(controller,compose): MinIO в dev docker-compose, env-валидация и FileStorageInfrastructureModule.forRoot в app.module — контроллер на старте идемпотентно создаёт бакет coop-<coopname>; integration-тесты проходят против MinIO из dev compose

* [E59-6][@ant] docs(file-storage): краткий README для разработчиков расширений — пример @UseBucket/@InjectBucket/forFeature, операции, ошибки, env, как запускать тесты

---------

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-14 23:14:10 +05:00
162 changed files with 7556 additions and 1667 deletions
+6
View File
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
COOPBACK_HOST_PORT=2998
DESKTOP_HOST_PORT=2999
OPENSEARCH_HOST_PORT=9200
MINIO_HOST_PORT=9000
MINIO_CONSOLE_HOST_PORT=9001
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
-223
View File
@@ -1,223 +0,0 @@
name: Build Docker Images
# Триггерится по успешному завершению `Build contracts container`. Это
# гарантирует, что к моменту webhook'а на тестнет/прод образ
# `dicoop/contracts:<branch>` уже обновлён в DockerHub.
#
# Раньше workflow висел на `push: tags`, и при релизе (`chore(release):
# publish` + git tag) запускался параллельно с build-contracts. Поскольку
# build-containers (~8 мин) короче build-contracts (~11 мин), webhook
# отлетал раньше окончания build-contracts на 2-3 минуты — ансибл на
# тестнете подтягивал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
# старым wasm. Инцидент 2026-05-13 (ledger2 фикс walletop sweep'а
# откатился ансиблом).
#
# Теперь: build-contracts триггерится и по push веток, и по push тэгов
# (см. tags в build-contracts.yaml). После его success этот workflow
# резолвит — есть ли тэг на коммите, и если есть — собирает контейнеры
# и шлёт webhook. Если тэга нет (обычный push в ветку без релиза) —
# делаем no-op.
on:
workflow_run:
workflows: ["Build contracts container"]
types: [completed]
# Ручной fallback: запустить сборку контейнеров и деплой для конкретного
# тэга, если автоматический workflow_run не сработал (типичный кейс —
# миграционное окно: на default-ветке (main) старая версия build-containers
# с `push: tags`, а на dev/testnet уже новая с `workflow_run`. До мержа
# в main новая логика не активна).
workflow_dispatch:
inputs:
tag:
description: 'Git-тэг для сборки и деплоя (например v2026.5.13-alpha-3)'
required: true
jobs:
build:
runs-on: ubuntu-latest
# Job запускается, если: (а) workflow_run от build-contracts успешен,
# либо (б) ручной запуск через workflow_dispatch.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout repository at triggering SHA
uses: actions/checkout@v3
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve tag for triggering commit
id: resolve_tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG_NAME="${{ github.event.inputs.tag }}"
HEAD_SHA=$(git rev-parse "$TAG_NAME^{commit}")
echo "Ручной запуск: tag=$TAG_NAME sha=$HEAD_SHA"
else
HEAD_SHA="${{ env.HEAD_SHA }}"
# `git describe --exact-match` отдаёт имя тэга, лежащего ровно
# на этом коммите; код 128 если тэга нет — тогда выходим без
# сборки контейнеров (push в ветку без релизного тэга).
TAG_NAME=$(git describe --tags --exact-match "$HEAD_SHA" 2>/dev/null || true)
if [ -z "$TAG_NAME" ]; then
echo "На коммите $HEAD_SHA тэга нет — релиза не было, контейнеры не собираем."
echo "has_tag=false" >> $GITHUB_OUTPUT
exit 0
fi
fi
echo "Найден тэг: $TAG_NAME"
echo "has_tag=true" >> $GITHUB_OUTPUT
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
echo "Это продакшн тег, будем добавлять latest"
else
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
echo "Это не продакшн тег, latest не добавляем"
fi
- name: Debug info
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "Триггер: ${{ github.event_name }}"
echo "Триггерящий коммит: ${{ env.HEAD_SHA }}"
echo "Резолвенный тэг: ${{ env.DOCKER_TAG }}"
echo "Последние коммиты:"
git log -n 3 --oneline
echo "Проверяем файлы в директории components/desktop/src-ssr:"
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
echo "Проверяем файлы в middlewares:"
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
- name: Login to DockerHub
if: steps.resolve_tag.outputs.has_tag == 'true'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Сначала собираем базовый образ с runtime
- name: Build base image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
# Если это продакшн тег, добавляем latest
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
# Создаем сервисные образы на основе базового
- name: Build desktop image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
docker push dicoop/desktop:latest
fi
- name: Build controller image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
docker push dicoop/coopback:latest
fi
# TEMP: парсер используется существующим деплоем как dicoop/cooparser,
# пока не мигрировали на dicoop/parser из отдельного coopenomics/parser repo.
# Оставляем сборку и пуш образа до завершения миграции потребителей.
- name: Build parser image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
docker push dicoop/cooparser:latest
fi
- name: Build notificator image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
docker push dicoop/notificator:latest
fi
- name: Build notifications image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
docker push dicoop/notifications:latest
fi
# Отправка хука для деплоя. К этому моменту build-contracts.yaml
# уже завершился успешно (мы и есть его workflow_run-зависимый
# потребитель), значит образ `dicoop/contracts:<branch>` гарантированно
# обновлён в DockerHub — гонки 2026-05-13 больше не воспроизвести.
- name: Trigger deployment webhook
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
run: |
if [[ "${{ env.DOCKER_TAG }}" == *alpha* ]]; then
# Хук для тестнета (alpha теги)
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
else
# Хук для продакшена (остальные теги)
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
fi
# Уведомление в Telegram об успехе
- name: Telegram notify success
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
run: |
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
ADDITIONAL_INFO=" (с тегом latest)"
else
ADDITIONAL_INFO=""
fi
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
# Уведомление в Telegram об ошибке (только если до этого был
# резолвен тэг — иначе no-op запуск без сборки и без алертов).
- name: Telegram notify failure
if: failure() && steps.resolve_tag.outputs.has_tag == 'true'
run: |
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]"
+33 -3
View File
@@ -1,18 +1,48 @@
# .github/workflows/trigger-coopenomics.yml
name: Trigger Contracts Docs Deploy
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
on:
push:
branches: [dev, testnet, main, capital] # или когда нужно триггерить
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
outputs:
should_trigger: ${{ steps.check.outputs.should_trigger }}
steps:
- name: Checkout (для git branch --contains)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gate — main + non-alpha
id: check
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
echo "Тэг $TAG — pre-release, docs deploy не дёргаем"
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
echo "Тэг $TAG не на main — docs deploy не дёргаем"
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Тэг $TAG — продакшн релиз на main, дёргаем coopenomics"
echo "should_trigger=true" >> "$GITHUB_OUTPUT"
trigger-coopenomics:
needs: gate
if: needs.gate.outputs.should_trigger == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Coopenomics deployment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.COOPENOMICS_PAT }}
repository: coopenomics/coopenomics # укажи правильный owner/repo
repository: coopenomics/coopenomics
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
+25 -56
View File
@@ -7,12 +7,18 @@ name: Build contracts container
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
# контракты через `cleos set contract`.
#
# Триггер — push в одну из веток-окружений (dev / testnet / main).
# Маппинг:
# Триггер — push в одну из веток-окружений (dev / testnet / main) с
# изменением .cpp/CMakeLists/build-скриптов. Маппинг:
# dev → IS_TESTNET=ON, tag=dev
# testnet → IS_TESTNET=ON, tag=testnet
# main → IS_TESTNET=OFF, tag=main + latest
#
# ВАЖНО: тэги `v*` тут НЕ обрабатываются — они уезжают в `release.yaml`,
# который делает релиз атомарно (контракты + контейнеры + webhook в
# одном job'е). См. release.yaml для контекста — там в комментарии
# описана причина (гонка build-contracts и build-containers по push'у
# тэга, инцидент 2026-05-13 с ledger2 walletop sweep'ом).
#
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
# Артефакты копируются в slim alpine-образ.
@@ -22,24 +28,12 @@ name: Build contracts container
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
on:
push:
branches: [dev, testnet, main]
paths:
- 'components/contracts/cpp/**'
- 'components/contracts/CMakeLists.txt'
- 'components/contracts/build.sh'
- 'components/contracts/build-all.sh'
- 'components/contracts/docker/**'
- '.github/workflows/build-contracts.yaml'
# Дополнительно ловим все push'и тэгов: build-containers.yaml зависит от
# успеха этой сборки через workflow_run, и без unconditional запуска по
# тегу build-containers не получит триггер для коммитов, где .cpp не
# менялся (например `chore(release): publish` правит только версии
# пакетов). См. историю гонки 2026-05-13: webhook деплоя сработал на
# 2:34 быстрее обновления `dicoop/contracts:testnet`, на чейн уехал
# старый wasm.
tags:
- '*'
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
# который собирает контракты атомарно вместе с контейнерами и
# webhook'ом — отдельная сборка по push'у в ветку только мешает
# (две параллельные сборки на каждый release-bump). Здесь оставляем
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
workflow_dispatch:
jobs:
@@ -48,55 +42,30 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# Полная история нужна, чтобы при push'е тэга резолвить ветку
# через `git branch -r --contains $SHA` (см. следующий step).
fetch-depth: 0
- name: Determine build mode and docker tag
# Источник режима — ВЕТКА, не имя тэга. Сначала пытаемся взять
# `github.ref_name` как ветку (push веток); если не подошло — это
# push тэга, и мы ищем какая из dev/testnet/main содержит коммит.
# Тэг такого окружения нести семантику не должен (например,
# `v2026.5.13-alpha-2` → ветка testnet, BUILD_MODE=test).
run: |
REF="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
BRANCH=""
if [ "$REF_TYPE" = "branch" ]; then
BRANCH="$REF"
else
SHA="${{ github.sha }}"
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
fi
case "$BRANCH" in
case "${{ github.ref_name }}" in
main)
echo "BUILD_MODE=prod" >> $GITHUB_ENV
echo "DOCKER_TAG=main" >> $GITHUB_ENV
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
echo "BUILD_MODE=prod" >> "$GITHUB_ENV"
echo "DOCKER_TAG=main" >> "$GITHUB_ENV"
echo "EXTRA_TAG=latest" >> "$GITHUB_ENV"
;;
testnet)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=testnet" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
dev)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
*)
echo "Unsupported ref: ref_name=$REF ref_type=$REF_TYPE resolved_branch='$BRANCH'" >&2
echo "Unsupported branch ${{ github.ref_name }}" >&2
exit 1
;;
esac
echo "Resolved BRANCH=$BRANCH (ref_name=$REF ref_type=$REF_TYPE)"
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
+32 -6
View File
@@ -1,16 +1,42 @@
name: Publish Docs
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
on:
push:
branches:
- main
- testnet
- dev
- reports
- marketplace2
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
outputs:
should_publish: ${{ steps.check.outputs.should_publish }}
steps:
- name: Checkout (для git branch --contains)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gate — main + non-alpha
id: check
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
echo "Тэг $TAG — pre-release, docs не публикуем"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
echo "Тэг $TAG не на main — docs не публикуем"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Тэг $TAG — продакшн релиз на main, публикуем docs"
echo "should_publish=true" >> "$GITHUB_OUTPUT"
build-and-publish-docs:
needs: gate
if: needs.gate.outputs.should_publish == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
+276
View File
@@ -0,0 +1,276 @@
name: Release
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
# `v*`. В одном job'е по порядку:
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
#
# Зачем атомарно: раньше `build-contracts` и `build-containers`
# крутились параллельно по push'у тэга, и webhook от build-containers
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
# старым wasm. Попытка связать их через workflow_run упёрлась в
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
# нет двух workflow'ов — нет гонки, нет fallback'ов.
#
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
# `build-contracts.yaml`.
#
# Резолв ветки. Тэг семантики окружения не несёт (например
# `v2026.5.13-alpha-2` может лежать на testnet или dev — определяет
# именно ветка, на которую сделан merge перед тэгом). Поэтому ищем
# первое совпадение SHA с remote-ветками main/testnet/dev.
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve branch, build mode and tags
run: |
TAG_NAME="${{ github.ref_name }}"
SHA="${{ github.sha }}"
BRANCH=""
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
if [ -z "$BRANCH" ]; then
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
exit 1
fi
case "$BRANCH" in
main)
BUILD_MODE=prod
CONTRACTS_TAG=main
CONTRACTS_EXTRA=latest
;;
testnet)
BUILD_MODE=test
CONTRACTS_TAG=testnet
CONTRACTS_EXTRA=
;;
dev)
BUILD_MODE=test
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
esac
# Прод-тэги (без -alpha/-beta/-rc/-test) уезжают на боевой
# webhook + получают тэг :latest у сервисных образов.
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
else
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
fi
{
echo "TAG_NAME=$TAG_NAME"
echo "SHA=$SHA"
echo "BRANCH=$BRANCH"
echo "BUILD_MODE=$BUILD_MODE"
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
echo "IS_PROD=$IS_PROD"
echo "WEBHOOK_URL=$WEBHOOK_URL"
} >> "$GITHUB_ENV"
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile contracts
working-directory: components/contracts
run: |
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
- name: Stage contracts docker context
working-directory: components/contracts
run: |
ROOT="docker/.context"
rm -rf "$ROOT"
mkdir -p "$ROOT/contracts"
# Имена контрактов = аргументы add_contract_build(...) только в
# незакомменченных строках (CMake-комментарий начинается с `#`).
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
echo "Contracts to package:"
echo "$NAMES"
MANIFEST="$ROOT/contracts/manifest.json"
pack_one() {
local NAME="$1" WASM="$2" ABI="$3"
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
return 1
fi
mkdir -p "$ROOT/contracts/$NAME"
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
"$NAME" "$SHA_WASM" "$SHA_ABI"
}
{
printf '{\n'
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
printf ' "git_sha": "%s",\n' "$SHA"
printf ' "tag": "%s",\n' "$TAG_NAME"
printf ' "branch": "%s",\n' "$BRANCH"
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf ' "contracts": [\n'
FIRST=1
emit() {
entry=$(pack_one "$@") || return 0
[ $FIRST -eq 0 ] && printf ',\n'
FIRST=0
printf '%s' "$entry"
}
for c in $NAMES; do
if [ "$c" = "system" ]; then
# У system многоконтрактная сборка: разносим под-контракты
# eosio.* как полноценные элементы каталога, сам "system"
# как имя пропускаем.
for sub in build/contracts/system/contracts/eosio.*/; do
[ -d "$sub" ] || continue
sub_name=$(basename "$sub")
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
done
else
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
fi
done
printf '\n ]\n'
printf '}\n'
} > "$MANIFEST"
cp docker/Dockerfile "$ROOT/Dockerfile"
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
chmod +x "$ROOT/entrypoint.sh"
echo "--- manifest.json ---"
cat "$MANIFEST"
- name: Build and push contracts image
working-directory: components/contracts
run: |
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
--label "org.opencontainers.image.revision=$SHA" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
docker push "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
if [ -n "$CONTRACTS_EXTRA" ]; then
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
docker push "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
run: |
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
echo "---"
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
# === Этап 2: контейнеры приложений ===
- name: Build and push base image
run: |
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
docker push "dicoop/mono-base:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
{
echo "FROM dicoop/mono-base:$TAG_NAME"
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
docker push "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
docker push "dicoop/$SVC:latest"
fi
}
build_service desktop '@coopenomics/desktop' start
build_service coopback '@coopenomics/controller' start
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
# из отдельного coopenomics/parser repo.
build_service cooparser '@coopenomics/parser' start
build_service notificator 'coop-notificator' start
build_service notifications '@coopenomics/notifications' sync
# === Этап 3: webhook деплоя ===
- name: Trigger deployment webhook
run: |
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "$TAG_NAME"
- name: Telegram notify success
if: success()
run: |
EXTRA=""
if [ "$IS_PROD" = "true" ]; then
EXTRA=" (с тегом latest)"
fi
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
-d "text=✅ [GITHUB MONO] Релиз $TAG_NAME ($BRANCH, contracts:$CONTRACTS_TAG, sha=${SHA::7})$EXTRA"
- name: Telegram notify failure
if: failure()
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
-d "text=❌ [GITHUB MONO] Ошибка релиза $TAG_NAME ($BRANCH, sha=${SHA::7})"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
@@ -0,0 +1,174 @@
import { describe, it } from 'vitest'
/**
* Расширение Capital Test под эпик «Благорост» (Story 1.11).
*
* Существующий capital.test.ts (~2650 строк) покрывает базовые happy-paths.
* Новые ветви Story 1.1–1.10 описаны здесь отдельно, чтобы не раздувать
* монолитный файл. it.todo() — каркас под последующее наполнение helpers'ами
* по аналогии с processDebt / processFundProgram / etc.
*
* Helpers, которые потребуются (часть из них появится в Эпиках 2–5):
* - processDebtDeclinePay (gateway → debtpaydcln)
* - processSettleDebt (settledebt деньгами)
* - processMarkOverdue (markdebtoverd batch)
* - processTopupProgramExpense (chairman → topupprogexp)
* - processProgramExpense (createpgexp → apprvpgexp → authpgexp → pgexppay)
* - processDeclineProgramExpense (declpgexp на разных фазах)
* - processRequestRole / processApproveRole / processDeclineRole
* - processInviteRole / processAcceptInvite / processDeclineInvite
* - processRequestRateUpdate
*/
describe('CAPITAL — расширения «Благорост» (Эпик 1)', () => {
describe('A — Займы', () => {
it.todo(
'Story 1.1: debtpaydcln возвращает PAY_PENDING → AUTHORIZED с last_pay_error и не удаляет запись'
)
it.todo(
'Story 1.1: debtpayretry повторно отправляет outpay из AUTHORIZED без новой авторизации совета'
)
it.todo(
'Story 1.1: debtpayretry отвергается если last_pay_error пустой (платёж ещё не пробовали)'
)
it.todo(
'Story 1.2: settledebt на полную сумму → SETTLED, проводка REPAY 30000, contributors.debt_amount уменьшается'
)
it.todo(
'Story 1.2: settledebt доступен и для OVERDUE, не только PAID'
)
it.todo(
'Story 1.2: settledebt отвергается при amount ≠ debt.amount'
)
it.todo(
'Story 1.3: debtpaycnfrm проставляет due_at = now + 1 год (фиксированный срок, решение 2026-05-19)'
)
it.todo(
'Story 1.13: closedebt — компонент cancelled → автоматическое закрытие долга: o.cap.crtnma (Дт 04 / Кт 08, TRANSFER GENERATOR_FUND → NMA, создание НМА из коммитов-обеспечения) + o.cap.dbtwrf (Дт 80 / Кт 58, BURN LOAN_ISSUED, write-off долга); сегмент уменьшен на сумму займа; долг → WRITEOFF; без подписи пайщика и без сбора совета (решение 2026-05-19)'
)
it.todo(
'Story 1.13: closedebt отвергается, если родительский компонент не в статусе cancelled'
)
it.todo(
'Story 1.13: closedebt отвергается, если долг не в статусе paid/overdue'
)
it.todo(
'Story 1.13: closedebt — глобальный contributors.debt_amount пайщика уменьшен; запись в loan.debts удалена; loan.summaries.total опущен'
)
it.todo(
'Story 1.3: markdebtoverd переводит PAID → OVERDUE для долгов с now > due_at; PAID с due_at в будущем не трогает'
)
it.todo(
'Story 1.3: markdebtoverd обрабатывает не более batch_limit (25) записей за вызов'
)
})
describe('A2 — Централизованный учёт займов (loan-контракт + счётчик сегмента)', () => {
it.todo(
'Story 1.12: loan-контракт изолирован — loan.debts хранит только (coopname, username, debt_hash, amount, repaid_at) + source_contract; project_hash отсутствует'
)
it.todo(
'Story 1.12: debtpaycnfrm создаёт запись в loan.debts через inline createdebt и поднимает loan.summaries.total пайщика'
)
it.todo(
'Story 1.12: settledebt в capital удаляет запись из loan.debts через inline settledebt и опускает loan.summaries.total'
)
it.todo(
'Story 1.12: debt в loan.debts помечается source_contract = вызвавший контракт (capital для capital→loan)'
)
it.todo(
'Story 1.12: debtpaycnfrm увеличивает active_debts_count на сегменте (binary_extension), settledebt — уменьшает'
)
it.todo(
'Story 1.12: signact2 с N≤10 займами на проекте — все погашаются inline-actions в loan; active_debts_count = 0'
)
it.todo(
'Story 1.12: signact2 с >10 займами на проекте (active_debts_count > 10) — fail сразу по счётчику, без обхода byprojhash'
)
it.todo(
'Story 1.12: loan.createdebt от аккаунта вне contracts_whitelist — fail (только capital/marketplace/...)'
)
})
describe('B — Расходы программы', () => {
it.todo(
'Story 1.4: exppaycnfrm после complete_expense делает Ledger2 PAY_EXPENSE (BLAGOROST_FUND → SOV_EXPENSES)'
)
it.todo(
'Story 1.5/1.6: topupprogexp пополняет program_expense_pool из global_available_invest_pool'
)
it.todo(
'Story 1.5: createpgexp резервирует amount; вторая createpgexp на превышение — fail'
)
it.todo(
'Story 1.5: cycle createpgexp → apprvpgexp → authpgexp → pgexppay = paid + delete + Ledger2 PAY_EXPENSE'
)
it.todo(
'Story 1.5: declpgexp на любом из {created, approved, authorized} возвращает reserved → pool, удаляет запись'
)
})
describe('D — Двухуровневые допуски и approved-ставка (бездокументарная схема)', () => {
it.todo(
'Story 1.7: requestrole без statement создаёт rolerequests со статусом PENDING; approverole без master_decision фиксирует approved_rate'
)
it.todo(
'Story 1.7: requestrole с role вне Role::{CREATOR, AUTHOR, MASTER} — fail "Допустимые роли заявки..."'
)
it.todo(
'Story 1.7: requestrateu создаёт запись с role = Role::NONE и request_type = RequestType::RATE_UPDATE'
)
it.todo(
'Story 1.8: createcmmt с approved_rate=1200 и creator_hours=4 → generation_amounts на 4800, не на 6000 (глобальная)'
)
it.todo(
'Story 1.7: editcontrib с новой глобальной ставкой не меняет approved_rate_per_hour на сегменте'
)
it.todo(
'Story 1.7: inviterole без statement → acceptinvite применяет инвайт-ставку к сегменту; declinvite оставляет сегмент без approved-rate'
)
it.todo(
'Story 1.7: declinerole оставляет запись со статусом DECLINED + reason, сегмент не трогает'
)
it.todo(
'Story 1.7: requestrateu + approverole с новой ставкой обновляет approved_rate в сегменте; role.apply_role_to_project не вызывается'
)
})
describe('E — Event ridges (Story 1.10)', () => {
it.todo(
'Все новые actions вызывают require_recipient для (автор события, обратная сторона, председатель)'
)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -0,0 +1,90 @@
/**
* @brief Закрывает долг созданием НМА кооператива из коммитов-обеспечения.
*
* Срабатывает, когда родительский компонент проекта явно перешёл в `cancelled`:
* проект до акта-2 не дойдёт, возврат через `signact2` невозможен.
* Коммиты-обеспечение, накопленные пайщиком на сегменте проекта-компонента
* (`w.cap.gen`), формируют НМА кооператива на отдельном кошельке `w.cap.nma`,
* а обязательство пайщика по займу списывается (write-off). Никаких санкций
* к пайщику не применяется, совет не собирается, подпись пайщика не требуется.
*
* Двухоперационная свёртка ledger2:
* - o.cap.crtnma — TRANSFER w.cap.gen → w.cap.nma, Дт 04 / Кт 08.
* Зеркалит o.cap.accept, но без участия пайщика: НМА из
* коммитов-обеспечения долга идут в имущество кооператива,
* а не на share программы пайщика.
* - o.cap.dbtwrf — BURN w.cap.loan, Дт 80 / Кт 58.
* Списание долга (write-off). Зеркало o.cap.repay, но share
* пайщика не пополняется — долг закрыт имущественно.
*
* Решение 2026-05-19 (Игорь Смуров / Алексей Муравьёв): срок займа фиксированный
* (1 год от выплаты), но при явной отмене компонента закрытие происходит
* досрочно — без ожидания due_at.
*
* @param coopname Наименование кооператива (scope)
* @param debt_hash Хеш закрываемого займа
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация только от @p coopname. Action идёт из backend кооператива,
* отслеживающего переход компонента в `cancelled`.
*/
void capital::closedebt(name coopname, checksum256 debt_hash) {
require_auth(coopname);
// 1. Получить долг
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
// 2. Долг должен быть активным (выплачен, не погашен)
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
"Закрытие долга допустимо для активного (paid) или просроченного (overdue) займа");
// 3. Guard на статус родительского компонента: только `cancelled` (отменён).
// Если компонент завершён нормально (`finalized`/`result`) — возврат идёт
// через signact2 → o.cap.repay, не сюда.
auto project = Capital::Projects::get_project_or_fail(coopname, exist_debt.project_hash);
eosio::check(project.status == Capital::Projects::Status::CANCELLED,
"Закрытие долга через НМА допустимо только при cancelled статусе компонента проекта");
// 4. Сегмент пайщика на компоненте
auto exist_segment = Capital::Segments::get_segment_or_fail(coopname,
exist_debt.project_hash, exist_debt.username, "Сегмент пайщика не найден");
// 5. Контрибьютор для обновления глобального долга
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
auto memo = Capital::Memo::get_debt_memo(exist_debt.username);
// 6. o.cap.crtnma: TRANSFER GENERATOR_FUND → NMA, Дт 04 / Кт 08.
// Из коммитов-обеспечения долга создаётся НМА кооператива.
Ledger2::apply(_capital, coopname, operations::capital::CREATE_NMA,
exist_debt.amount, exist_debt.username, debt_hash, memo);
// 7. o.cap.dbtwrf: BURN LOAN_ISSUED, Дт 80 / Кт 58.
// Финансовое вложение кооператива в пайщика списано (write-off); share пайщика
// не пополняется — долг закрыт имущественно.
Ledger2::apply(_capital, coopname, operations::capital::DEBT_WRITEOFF,
exist_debt.amount, exist_debt.username, debt_hash, memo);
// 8. Обновить агрегаты сегмента: -debt_amount, -active_debts_count.
Capital::Segments::decrease_debt_amount(coopname, exist_segment.id, exist_debt.amount);
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// 9. Уменьшить used_for_compensation в проекте (зеркало createdebt).
Capital::Projects::subtract_used_for_compensation(coopname, project.id, exist_debt.amount);
// 10. Глобальный долг контрибьютора уменьшаем (как в settledebt).
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, exist_debt.amount);
// 11. Запись долга → WRITEOFF (для аудита).
Capital::Debts::mark_writeoff(coopname, exist_debt.id);
// 12. Централизованный реестр в loan-контракте: уменьшаем сводную, удаляем запись.
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, exist_debt.amount);
// 13. Уведомить пайщика и кооператив.
require_recipient(exist_debt.username);
require_recipient(coopname);
}
@@ -19,11 +19,11 @@ void capital::debtauthcnfr(eosio::name coopname, checksum256 debt_hash, document
// Получаем долг
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
// Обновляем статус долга
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::AUTHORIZED,
_capital, decision);
// Фиксируем решение совета и переводим долг в pay_pending —
// outpay уходит в gateway, дальше debtpaycnfrm (успех) или debtpaydcln (отказ).
Capital::Debts::start_pay(coopname, exist_debt.id, decision, _capital);
// создаём объект исходящего платежа в gateway с коллбэком после обработки
::Gateway::create_outcome(
_capital,
@@ -18,12 +18,11 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
// Проверяем что долг в статусе 'authorized' (готов к выплате)
eosio::check(exist_debt.status == Capital::Debts::Status::AUTHORIZED,
"Долг должен быть в статусе 'authorized' для подтверждения оплаты");
// Обновляем статус долга на PAID
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::PAID, _gateway);
// Переводим долг в PAID + проставляем due_at = now + 1 год (срок погашения,
// решение 2026-05-19 — срок зафиксирован, не привязан к календарному
// сроку проекта).
// confirm_paid внутри проверяет статус pay_pending и атомарно сбрасывает last_pay_error.
Capital::Debts::confirm_paid(coopname, exist_debt.id, _gateway);
// Выдача пайщику беспроцентного займа: Dr 58 / Cr 51, ISSUE LOAN_ISSUED (4051).
// Семантика момента — деньги ушли пайщику, у кооператива появилось финансовое
@@ -34,5 +33,17 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
// Увеличиваем долг contributor (теперь долг активен и должен быть погашен через внесение результата)
Capital::Contributors::increase_debt_amount(coopname, contributor->id, exist_debt.amount);
};
// Локальный счётчик займов пайщика на проекте этого сегмента (+1).
// Используется в signact2 как лимит «10 займов на проект» без обхода byprojhash.
Capital::Segments::increase_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// Централизованный учёт займа в контракте loan — loan-контракт независим от capital,
// знает только (coopname, username, debt_hash, amount, repaid_at) + source_contract
// (заполняется самим loan::createdebt из payer-авторизации). Локальная привязка к
// проекту/программе остаётся в Capital::Debts (поле project_hash).
// due_at уже проставлен в confirm_paid, читаем обновлённое значение.
auto fresh_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
Loan::create_debt(_capital, coopname, exist_debt.username,
debt_hash, fresh_debt.due_at, exist_debt.amount);
};
@@ -1,23 +1,27 @@
/**
* @brief Отклоняет оплату долга
* Отклоняет оплату долга и удаляет его из базы данных:
* - Получает долг
* - Удаляет долг из базы данных с указанием причины
* @param coopname Наименование кооператива
* @param debt_hash Хеш долга для отклонения оплаты
* @param reason Причина отклонения оплаты долга
* @brief Отклоняет оплату долга по реквизитам (gateway возвращает ошибку перечисления).
*
* Решение совета уже принято в @ref capital::debtauthcnfr, долг НЕ удаляется и НЕ откатывается
* в approved. Запись остаётся, статус возвращается из @c pay_pending в @c authorized
* с заполненным полем @c last_pay_error = @p reason. Из @c authorized доступна повторная
* отправка в gateway без новой авторизации совета.
*
* @param coopname Наименование кооператива
* @param debt_hash Хеш долга, для которого пришёл отказ от gateway
* @param reason Текстовая причина отказа (заполняется gateway-оператором)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p _gateway
*/
void capital::debtpaydcln(name coopname, checksum256 debt_hash, std::string reason) {
require_auth(_gateway);
//TODO: отказ в оплате по реквизитам не должен приводить к удалению и откату т.к. решение о займе уже принято
// // Получаем долг
// auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
// // Удаляем долг
// Capital::Debts::delete_debt(coopname, debt_hash);
};
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
Capital::Debts::mark_pay_declined(coopname, exist_debt.id, reason, _capital);
// event ridge: должник и кооператив получают уведомление об отказе оплаты.
require_recipient(exist_debt.username);
require_recipient(coopname);
};
@@ -0,0 +1,40 @@
/**
* @brief Повторная отправка платежа по займу в gateway после предыдущего отказа.
*
* Допустимо только из AUTHORIZED с непустым last_pay_error (то есть после debtpaydcln).
* Использует уже сохранённое решение совета — новой авторизации не требуется.
* Переводит займ в PAY_PENDING, очищает last_pay_error и создаёт новый outpay.
*
* @param coopname Кооператив
* @param debt_hash Хеш займа для повторной отправки
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname (председатель/backend). Пайщик подпись не ставит.
*/
void capital::debtpayretry(eosio::name coopname, checksum256 debt_hash) {
require_auth(coopname);
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
eosio::check(exist_debt.status == Capital::Debts::Status::AUTHORIZED,
"Повторная отправка платежа возможна только из статуса authorized");
eosio::check(!exist_debt.last_pay_error.empty(),
"Повторная отправка платежа возможна только после предыдущего отказа (debtpaydcln)");
Capital::Debts::start_pay(coopname, exist_debt.id, exist_debt.authorization, _capital);
::Gateway::create_outcome(
_capital,
coopname,
exist_debt.username,
exist_debt.debt_hash,
exist_debt.amount,
_capital,
Names::Capital::CONFIRM_DEBT_PAYMENT,
Names::Capital::DECLINE_DEBT
);
// event ridge: должник и председатель видят повторную отправку.
require_recipient(exist_debt.username);
require_recipient(coopname);
}
@@ -0,0 +1,25 @@
/**
* @brief Помечает просроченные займы статусом OVERDUE.
*
* Долг считается просроченным, когда current_time_point > debt.due_at
* (поле due_at проставляется в debtpaycnfrm на 1 год от даты выплаты;
* срок зафиксирован — решение 2026-05-19).
* Из OVERDUE долг можно так же закрыть settledebt или signact2 как из PAID.
*
* Action рассчитан на запуск из backend через @Cron (см. Эпик 4) — раз в сутки.
* Один вызов помечает не более 25 записей чтобы уложиться в 0.5-секундный
* лимит транзакции EOSIO; backend повторяет до тех пор, пока action не
* помечает 0 — это сигнал, что в текущем scope больше нечего переводить.
*
* @param coopname Кооператив (scope таблицы debts)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель / системный).
*/
void capital::markdebtoverd(name coopname) {
require_auth(coopname);
constexpr uint32_t MAX_PER_BATCH = 25;
Capital::Debts::sweep_overdue(coopname, MAX_PER_BATCH);
}
@@ -1,22 +1,57 @@
/**
* @brief Погашает долг участника
* Погашает долг участника в системе кооператива:
* - Проверяет подлинность заявления о погашении
* - Обрабатывает погашение долга (TODO: реализация)
* @param coopname Наименование кооператива
* @param username Наименование пользователя-должника
* @param amount Сумма погашения долга
* @param statement Заявление о погашении долга
* @brief Погашает активный заём пайщика.
*
* Прямой канал погашения (без сдачи результата): сумма, ровно равная остатку долга,
* закрывает заём. В ledger2 фиксируется операция REPAY (Dr 80 / Cr 58),
* глобальный долг пайщика в contributors.debt_amount уменьшается, запись долга
* переходит в SETTLED.
*
* Альтернативный путь закрытия — через подписание акта-2 по результату (см. signact2.cpp),
* там та же операция REPAY вызывается на сумму result->debt_amount.
*
* @param coopname Наименование кооператива
* @param debt_hash Хеш погашаемого долга
* @param amount Сумма погашения (должна совпасть с debt.amount — полное закрытие)
* @param statement Документ-заявление на погашение (SettleDebtStatement)
* @ingroup public_actions
* @ingroup public_capital_actions
* @note Авторизация требуется от аккаунта: @p coopname
*
* @note Авторизация только от @p coopname. Пайщик транзакции не подписывает — backend кооператива
* подставляет себя.
*/
void capital::settledebt(name coopname, name username, eosio::asset amount, document2 statement){
void capital::settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement) {
require_auth(coopname);
//TODO: ?
// // Получаем долг
// auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
}
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
"Погашение допустимо для активного (paid) или просроченного (overdue) займа");
eosio::check(amount.is_valid(), "Сумма погашения некорректна");
eosio::check(amount.symbol == exist_debt.amount.symbol,
"Символ суммы погашения не совпадает с символом долга");
eosio::check(amount == exist_debt.amount,
"Сумма погашения должна полностью покрывать остаток долга");
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
// Возврат займа пайщиком: Dr 80 / Cr 58 — закрываем финансовое вложение в участника.
auto memo = Capital::Memo::get_settle_debt_memo(exist_debt.username, debt_hash);
Ledger2::apply(_capital, coopname, operations::capital::REPAY, amount,
exist_debt.username, debt_hash, memo);
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, amount);
Capital::Debts::mark_settled(coopname, exist_debt.id, memo, _capital);
// Локальный счётчик займов пайщика на сегменте этого проекта (-1).
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// Централизованный учёт: уменьшаем сводную и удаляем запись из loan.debts через inline action.
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, amount);
// event ridge: и должник, и председатель видят факт погашения.
require_recipient(exist_debt.username);
require_recipient(coopname);
}
@@ -28,9 +28,16 @@ void capital::exppaycnfrm(eosio::name coopname, checksum256 expense_hash) {
// Обновляем used_expense_pool в проекте
Capital::Projects::complete_expense(coopname, project.id, expense.amount);
// Списываем сумму расхода с программного фонда в пул хозрасходов:
// TRANSFER BLAGOROST_FUND → SOV_EXPENSES (без Dr/Cr — оба под счётом 80).
// Бухгалтерская проводка Dr 26 / Cr 51 на расчётном фиксируется gateway отдельно.
auto memo = Capital::Memo::get_expense_pay_memo(expense_hash);
Ledger2::apply(_capital, coopname,
operations::capital::PAY_EXPENSE,
expense.amount, expense.username,
expense_hash, memo);
// Удаляем запись расхода
Capital::Expenses::delete_expense(coopname, expense.id);
//TODO: здесь должна быть проводка по фонду
}
@@ -0,0 +1,41 @@
/**
* @brief Одобряет программный расход председателем и отправляет в совет.
*
* @param coopname Кооператив
* @param approver Председатель (или доверенный)
* @param expense_hash Хеш расхода
* @param approved_statement Принятая записка (с подписью председателя)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname.
*/
void capital::apprvpgexp(name coopname, name approver, checksum256 expense_hash,
document2 approved_statement) {
require_auth(coopname);
verify_document_or_fail(approved_statement);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(expense.status == Capital::Expenses::Status::CREATED,
"Программный расход должен быть в статусе 'created'");
Capital::Expenses::set_program_approved(coopname, expense.id, approved_statement);
::Soviet::create_agenda(
_capital,
coopname,
expense.username,
Names::SovietActions::CAPITAL_RESOLVE_PROGRAM_EXPENSE,
expense_hash,
_capital,
Names::Capital::AUTHORIZE_PROGRAM_EXPENSE,
Names::Capital::DECLINE_PROGRAM_EXPENSE,
expense.expense_statement,
std::string("")
);
// event ridge: автор расхода и approver (председатель/доверенный) уведомляются.
require_recipient(expense.username);
require_recipient(approver);
}
@@ -0,0 +1,37 @@
/**
* @brief Авторизует программный расход советом и создаёт исходящий платёж в gateway.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода
* @param authorization Документ-решение совета
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _soviet.
*/
void capital::authpgexp(name coopname, checksum256 expense_hash, document2 authorization) {
require_auth(_soviet);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(expense.status == Capital::Expenses::Status::APPROVED,
"Программный расход должен быть в статусе 'approved'");
Capital::Expenses::set_program_authorized(coopname, expense.id, authorization);
Action::send<createoutpay_interface>(
_gateway,
Names::External::CREATE_OUTPAY,
_capital,
coopname,
expense.username,
expense.expense_hash,
expense.amount,
_capital,
Names::Capital::CONFIRM_PROGRAM_EXPENSE_PAYMENT,
Names::Capital::DECLINE_PROGRAM_EXPENSE
);
// event ridge: автор расхода и председатель уведомляются о решении совета.
require_recipient(expense.username);
require_recipient(coopname);
}
@@ -0,0 +1,39 @@
/**
* @brief Создаёт целевой программный расход и резервирует средства в state.
*
* Программный расход — целевое списание программы «Благорост», не привязанное к проекту.
* Источник средств — пул @c program_expense_pool, наполняемый через @ref capital::topupprogexp.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (анкер процесса p.cap.expns)
* @param creator Инициатор (председатель / казначей; авторизуется через coopname)
* @param amount Сумма
* @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,
eosio::asset amount, std::string description, document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement);
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);
// event ridge: создатель расхода и председатель видят появление заявки.
require_recipient(creator);
require_recipient(coopname);
}
@@ -0,0 +1,37 @@
/**
* @brief Отклоняет программный расход на любом этапе до подтверждения оплаты.
*
* Допустимо из created (отказ председателя), approved (отказ совета через callback)
* и authorized (отказ gateway по реквизитам). Возвращает зарезервированную сумму
* обратно в program_expense_pool и удаляет запись.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname (если отказ инициирован председателем),
* @p _soviet (если совет отказал) или @p _gateway (если выпуск платежа провалился).
*/
void capital::declpgexp(name coopname, checksum256 expense_hash, std::string reason) {
eosio::check(
eosio::has_auth(coopname) || eosio::has_auth(_soviet) || eosio::has_auth(_gateway),
"Отклонить программный расход может только председатель, совет или gateway"
);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(
expense.status == Capital::Expenses::Status::CREATED ||
expense.status == Capital::Expenses::Status::APPROVED ||
expense.status == Capital::Expenses::Status::AUTHORIZED,
"Программный расход уже завершён (paid/declined)"
);
Capital::State::release_program_expense(coopname, expense.amount);
Capital::Expenses::delete_program_expense(coopname, expense.id);
// event ridge: автор расхода и председатель уведомляются об отклонении.
require_recipient(expense.username);
require_recipient(coopname);
}
@@ -0,0 +1,38 @@
/**
* @brief Подтверждает оплату программного расхода (callback от gateway).
*
* Снимает резерв с @c program_expense_reserved, делает ledger2-проводку
* @ref operations::capital::PAY_EXPENSE (TRANSFER BLAGOROST_FUND → SOV_EXPENSES)
* и удаляет запись расхода — финал процесса @c p.cap.expns.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _gateway.
*/
void capital::pgexppay(name coopname, checksum256 expense_hash) {
require_auth(_gateway);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(expense.status == Capital::Expenses::Status::AUTHORIZED,
"Программный расход должен быть в статусе 'authorized' для подтверждения оплаты");
// Окончательное списание из резерва (деньги ушли gateway).
Capital::State::consume_program_expense(coopname, expense.amount);
// Бухгалтерская/кошельковая проводка.
auto memo = Capital::Memo::get_expense_pay_memo(expense_hash);
Ledger2::apply(_capital, coopname,
operations::capital::PAY_EXPENSE,
expense.amount, expense.username,
expense_hash, memo);
// Запись больше не нужна как ожидающая — закрываем процесс.
Capital::Expenses::delete_program_expense(coopname, expense.id);
// event ridge: автор расхода и председатель уведомляются о факте оплаты.
require_recipient(expense.username);
require_recipient(coopname);
}
@@ -0,0 +1,21 @@
/**
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
*
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool — деньги остаются
* на том же ledger2-кошельке @c BLAGOROST_FUND, меняется только разбивка по назначению.
*
* @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);
}
@@ -47,11 +47,22 @@ void capital::createcmmt(eosio::name coopname, eosio::name username, checksum256
eosio::check(creator_hours > 0, "Только положительная сумма часов создателя");
eosio::check(creator_hours <= 1000000, "Превышено максимальное количество часов (1 млн)");
// Если на сегменте уже утверждена мастером approved-ставка (Story 1.7/1.8) — берём её,
// иначе fallback на глобальную rate_per_hour из contributors. Это позволяет мастеру
// компонента дифференцировать ставку по проекту без изменения договора УХД.
auto segment_opt = Capital::Segments::get_segment(coopname, project_hash, username);
eosio::asset effective_rate = contributor->rate_per_hour;
if (segment_opt.has_value()
&& segment_opt->approved_rate_per_hour.has_value()
&& segment_opt->approved_rate_per_hour->amount > 0) {
effective_rate = *segment_opt->approved_rate_per_hour;
}
// считаем сумму фактических затрат создателя на основе ставки в час и потраченного времени
eosio::asset creator_base = contributor -> rate_per_hour * creator_hours;
eosio::asset creator_base = effective_rate * creator_hours;
// Вычисляем фактическое изменение сумм генерации
auto delta_amounts = Capital::Core::Generation::calculate_fact_generation_amounts(contributor -> rate_per_hour, creator_hours);
auto delta_amounts = Capital::Core::Generation::calculate_fact_generation_amounts(effective_rate, creator_hours);
// Создаем коммит без отправки на аппрув председателю
// Одобрение осуществляется мастером проекта через approvecmmt
@@ -0,0 +1,34 @@
/**
* @brief Кандидат принимает инвайт от мастера: ставка/часы из заявки становятся
* approved-параметрами сегмента.
*
* @param coopname Кооператив
* @param request_hash Хеш инвайт-заявки
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname (backend подставляет кандидата).
*/
void capital::acceptinvite(name coopname, checksum256 request_hash) {
require_auth(coopname);
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
"Принять можно только инвайт");
Capital::RoleRequests::approve(coopname, req.id, req.rate_per_hour, req.hours_per_day);
Capital::Segments::set_approved_rate(
coopname, req.project_hash, req.username, req.rate_per_hour, req.hours_per_day
);
// Подключаем кандидата к проекту по приглашённой роли.
if (req.request_type == Capital::RoleRequests::RequestType::ROLE) {
Capital::RoleRequests::apply_role_to_project(
coopname, req.project_hash, req.username, req.role
);
}
// event ridge: кандидат и мастер видят принятие инвайта.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,46 @@
/**
* @brief Мастер компонента одобряет заявку на L2-допуск либо обновление approved-ставки.
*
* Фиксирует approved-ставку и норматив часов в сегменте через
* @ref Capital::Segments::set_approved_rate — далее `createcmmt` берёт их.
* Сегмент должен уже существовать (создаётся при подписании приложения договора УХД
* на этот проект через apprvappndx — L1-допуск).
*
* Бездокументарная схема: решение фиксируется только подписью транзакции
* (require_auth coopname), документ-решение мастера не передаётся.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (requestrole либо requestrateu)
* @param approved_rate Утверждённая мастером ставка часа (≠ запрошенной — допустимо)
* @param approved_hours Утверждённая норма часов
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::approverole(name coopname, checksum256 request_hash,
eosio::asset approved_rate, uint64_t approved_hours) {
require_auth(coopname);
Wallet::validate_asset(approved_rate);
eosio::check(approved_rate.amount > 0, "Утверждённая ставка должна быть положительной");
eosio::check(approved_hours > 0 && approved_hours <= 8, "Норма часов — от 1 до 8");
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
Capital::RoleRequests::approve(coopname, req.id, approved_rate, approved_hours);
Capital::Segments::set_approved_rate(
coopname, req.project_hash, req.username, approved_rate, approved_hours
);
// Только для ROLE-заявок: подключаем участника к проекту по заявленной роли.
// RATE_UPDATE роль не меняет, только ставку — inline action не нужен.
if (req.request_type == Capital::RoleRequests::RequestType::ROLE) {
Capital::RoleRequests::apply_role_to_project(
coopname, req.project_hash, req.username, req.role
);
}
// event ridge: заявитель и мастер компонента видят решение.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,20 @@
/**
* @brief Мастер компонента отклоняет заявку на L2-допуск / на обновление approved-ставки.
* Запись остаётся в таблице со статусом DECLINED + причиной — для аудита.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::declinerole(name coopname, checksum256 request_hash, std::string reason) {
require_auth(coopname);
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
Capital::RoleRequests::decline(coopname, req.id, reason);
// event ridge: заявитель и мастер компонента видят отказ.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,22 @@
/**
* @brief Кандидат отказывается от инвайта мастера.
*
* @param coopname Кооператив
* @param request_hash Хеш инвайт-заявки
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::declinvite(name coopname, checksum256 request_hash, std::string reason) {
require_auth(coopname);
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
"Отказаться можно только от инвайта");
Capital::RoleRequests::decline(coopname, req.id, reason);
// event ridge: кандидат и мастер видят отказ от инвайта.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,46 @@
/**
* @brief Мастер компонента (или председатель — для роли master при пустом слоте мастера)
* приглашает кандидата на роль. Обратный поток к requestrole.
* Кандидат далее acceptinvite (с применением приглашённой ставки) либо declinvite.
*
* Бездокументарная схема: приглашение и принятие/отказ оформляются только
* подписью транзакции (require_auth coopname). Юридический документ-инвайт не нужен.
*
* Ставка часа в приглашении — это ставка, на которую кандидат соглашается через
* acceptinvite. Не путать с contributors.rate_per_hour (личная глобальная ставка).
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (анкер; уникален в scope coopname)
* @param project_hash Проект / компонент
* @param candidate Приглашаемый пайщик (потенциальный участник)
* @param master Мастер-приглашающий (или координатор/председатель для роли master)
* @param role Предлагаемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
* @param rate_per_hour Предлагаемая ставка часа
* @param hours_per_day Предлагаемая норма часов в день
* @param description Текст приглашения (может быть пустым)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
name candidate, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description) {
require_auth(coopname);
Capital::RoleRequests::validate_role_or_fail(role);
Wallet::validate_asset(rate_per_hour);
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов — от 1 до 8");
Capital::RoleRequests::create(
coopname, request_hash, project_hash, candidate, master, role,
rate_per_hour, hours_per_day,
Capital::RoleRequests::Direction::INVITE,
Capital::RoleRequests::RequestType::ROLE,
description
);
// event ridge: кандидат и мастер видят инвайт.
require_recipient(candidate);
require_recipient(master);
}
@@ -0,0 +1,43 @@
/**
* @brief Заявка пайщика на обновление approved-ставки на сегменте.
* Тот же flow что и requestrole, но через RequestType::RATE_UPDATE.
* approverole с новой ставкой → обновление сегмента; declinerole — без изменений.
*
* Бездокументарная схема: заявление-документ не передаётся, утверждение
* фиксируется только транзакцией. role в записи остаётся Role::NONE
* (валидация роли для RATE_UPDATE не применяется).
*
* @param coopname Кооператив
* @param request_hash Хеш заявки
* @param project_hash Проект / компонент
* @param username Заявитель
* @param master Мастер компонента (ожидаемый одобряющий)
* @param new_rate Желаемая новая ставка
* @param new_hours Желаемая новая норма часов
* @param description Текст заявки на обновление ставки (может быть пустым)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master,
eosio::asset new_rate, uint64_t new_hours,
std::string description) {
require_auth(coopname);
Wallet::validate_asset(new_rate);
eosio::check(new_rate.amount > 0, "Новая ставка должна быть положительной");
eosio::check(new_hours > 0 && new_hours <= 8, "Норма часов — от 1 до 8");
Capital::RoleRequests::create(
coopname, request_hash, project_hash, username, master,
Capital::RoleRequests::Role::NONE,
new_rate, new_hours,
Capital::RoleRequests::Direction::REQUEST,
Capital::RoleRequests::RequestType::RATE_UPDATE,
description
);
// event ridge: заявитель и мастер компонента видят запрос на обновление ставки.
require_recipient(username);
require_recipient(master);
}
@@ -0,0 +1,49 @@
/**
* @brief Заявка пайщика на L2-допуск (роль creator/author/master) на компоненте.
*
* Создаёт запись в `rolerequests` со статусом PENDING. Дальше мастер компонента
* (или председатель — для роли master, когда мастера в проекте ещё нет)
* решает: approverole (с фиксацией approved-ставки в сегменте) или declinerole.
*
* Бездокументарная схема: подача и решение оформляются только подписью транзакции
* (require_auth coopname). Юридическое заявление при приёме на роль не требуется —
* допуск даётся подписью действия на стороне кооператива.
*
* Ставка часа подаётся явно — это ставка пайщика именно под этот проект.
* По дефолту фронт подставляет contributors.rate_per_hour, но пайщик может изменить.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (анкер для approverole/declinerole)
* @param project_hash Проект (или компонент — на уровне хэша одно)
* @param username Заявитель
* @param master Мастер компонента (ожидаемый одобряющий)
* @param role Запрашиваемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
* @param rate_per_hour Заявленная ставка часа под этот проект
* @param hours_per_day Заявленная норма часов в день
* @param description Текст заявки (может быть пустым)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description) {
require_auth(coopname);
Capital::RoleRequests::validate_role_or_fail(role);
Wallet::validate_asset(rate_per_hour);
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов в день — от 1 до 8");
Capital::RoleRequests::create(
coopname, request_hash, project_hash, username, master, role,
rate_per_hour, hours_per_day,
Capital::RoleRequests::Direction::REQUEST,
Capital::RoleRequests::RequestType::ROLE,
description
);
// event ridge: заявитель и мастер компонента видят новую заявку.
require_recipient(username);
require_recipient(master);
}
@@ -14,21 +14,20 @@
*/
void capital::startvoting(name coopname, checksum256 project_hash) {
require_auth(coopname);
print("z");
// Проверяем существование проекта и получаем его
auto project = Capital::Projects::get_project_or_fail(coopname, project_hash);
print("a");
// Проверяем что проект авторизован советом
//eosio::check(project.is_authorized, "Проект не авторизован советом");
// Проверяем, что проект в статусе "active"
eosio::check(project.status == Capital::Projects::Status::ACTIVE, "Проект должен быть в статусе 'active'");
eosio::check(project.counts.total_commits > 0, "Проект без коммитов не может быть поставлен на голосование");
print("b");
// Обновляем статус проекта на "voting"
Capital::Projects::update_status(coopname, project.id, Capital::Projects::Status::VOTING);
print("c");
// Инициализируем голосование по методу Водянова
Capital::Core::Voting::initialize_project_voting(coopname, project.id);
print("d");
}
}
@@ -61,10 +61,41 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
}
// Возврат беспроцентного займа пайщика: Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY.
// Семантика: пайщик погасил ссуду результатом (интеллектуальным взносом),
// Семантика: пайщик погасил ссуду результатом (паевой взнос интеллектуальным результатом),
// у кооперативa уменьшилось фин. вложение 58, деньги вернулись на расчётный.
if (result -> debt_amount.amount > 0){
Ledger2::apply(_capital, coopname, operations::capital::REPAY, result -> debt_amount, result -> username, result_hash, memo);
// Лимит «10 займов на проект в одном паевом взносе» проверяем по счётчику
// active_debts_count на сегменте (binary_extension) — без обхода byprojhash.
// Сам счётчик инкрементируется в debtpaycnfrm и декрементируется в mark_settled.
uint32_t active_cnt = Capital::Segments::get_active_debts_count(coopname, result->project_hash, result->username);
eosio::check(active_cnt <= 10,
"Превышен лимит займов на проект для одного паевого взноса (10)");
// Перебираем долги пайщика по проекту, но не больше известного счётчика —
// ранний выход сразу после нужного числа активных записей.
Capital::Debts::debts_index debts(_capital, coopname.value);
auto byproj = debts.get_index<"byprojhash"_n>();
std::vector<std::tuple<uint64_t, checksum256, eosio::asset>> to_settle;
to_settle.reserve(active_cnt);
for (auto it = byproj.find(result->project_hash);
it != byproj.end() && it->project_hash == result->project_hash
&& to_settle.size() < active_cnt; ++it) {
if (it->username == result->username
&& (it->status == Capital::Debts::Status::PAID
|| it->status == Capital::Debts::Status::OVERDUE)) {
to_settle.emplace_back(it->id, it->debt_hash, it->amount);
}
}
for (auto& tup : to_settle) {
auto id = std::get<0>(tup);
auto dh = std::get<1>(tup);
auto amt = std::get<2>(tup);
Loan::settle_debt(_capital, coopname, result->username, dh, amt);
Capital::Debts::mark_settled(coopname, id, memo, _capital);
Capital::Segments::decrease_active_debts_count(coopname, result->project_hash, result->username);
}
}
// Обновляем накопительные показатели контрибьютора на основе его ролей в сегменте
@@ -92,9 +92,29 @@
#include "app/debt_managment/create_debt/debtauthcnfr.cpp"
#include "app/debt_managment/create_debt/debtpaycnfrm.cpp"
#include "app/debt_managment/create_debt/debtpaydcln.cpp"
#include "app/debt_managment/create_debt/debtpayretry.cpp"
#include "app/debt_managment/create_debt/declinedebt.cpp"
#include "app/debt_managment/create_debt/markdebtoverd.cpp"
#include "app/debt_managment/create_debt/closedebt.cpp"
#include "app/debt_managment/settle_debt/settledebt.cpp"
// Управление программными расходами
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
#include "app/expense_managment/program_expenses/createpgexp.cpp"
#include "app/expense_managment/program_expenses/apprvpgexp.cpp"
#include "app/expense_managment/program_expenses/authpgexp.cpp"
#include "app/expense_managment/program_expenses/pgexppay.cpp"
#include "app/expense_managment/program_expenses/declpgexp.cpp"
// L2-роли (Story 1.7)
#include "app/participation_management/roles/requestrole.cpp"
#include "app/participation_management/roles/approverole.cpp"
#include "app/participation_management/roles/declinerole.cpp"
#include "app/participation_management/roles/inviterole.cpp"
#include "app/participation_management/roles/acceptinvite.cpp"
#include "app/participation_management/roles/declinvite.cpp"
#include "app/participation_management/roles/requestrateu.cpp"
// Управление расходами
#include "app/expense_managment/create_expense/approveexpns.cpp"
#include "app/expense_managment/create_expense/capauthexpns.cpp"
+64 -2
View File
@@ -315,11 +315,73 @@ public:
[[eosio::action]]
void debtpaydcln(name coopname, checksum256 debt_hash, std::string reason);
[[eosio::action]]
void debtpayretry(eosio::name coopname, checksum256 debt_hash);
[[eosio::action]]
void declinedebt(name coopname, eosio::name username, checksum256 debt_hash, std::string reason);
[[eosio::action]]
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
void settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement);
[[eosio::action]]
void markdebtoverd(name coopname);
[[eosio::action]]
void closedebt(name coopname, checksum256 debt_hash);
[[eosio::action]]
void topupprogexp(name coopname, eosio::asset amount);
[[eosio::action]]
void createpgexp(name coopname, checksum256 expense_hash, name creator,
eosio::asset amount, std::string description, document2 statement);
[[eosio::action]]
void apprvpgexp(name coopname, name approver, checksum256 expense_hash,
document2 approved_statement);
[[eosio::action]]
void authpgexp(name coopname, checksum256 expense_hash, document2 authorization);
[[eosio::action]]
void pgexppay(name coopname, checksum256 expense_hash);
[[eosio::action]]
void declpgexp(name coopname, checksum256 expense_hash, std::string reason);
// L2-роли и approved-ставки сегмента (Story 1.7) — бездокументарная схема,
// решения фиксируются только подписью транзакции (require_auth coopname).
[[eosio::action]]
void requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description);
[[eosio::action]]
void approverole(name coopname, checksum256 request_hash,
eosio::asset approved_rate, uint64_t approved_hours);
[[eosio::action]]
void declinerole(name coopname, checksum256 request_hash, std::string reason);
[[eosio::action]]
void inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
name candidate, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description);
[[eosio::action]]
void acceptinvite(name coopname, checksum256 request_hash);
[[eosio::action]]
void declinvite(name coopname, checksum256 request_hash, std::string reason);
[[eosio::action]]
void requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master,
eosio::asset new_rate, uint64_t new_hours,
std::string description);
};
@@ -15,10 +15,14 @@ namespace Capital::Debts {
*/
namespace Status {
constexpr name CREATED = "created"_n; ///< Долг создан
constexpr name APPROVED = "approved"_n; ///< Долг одобрен
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом
constexpr name PAID = "paid"_n; ///< Долг выплачен (готов к использованию)
constexpr name CREATED = "created"_n; ///< Долг создан
constexpr name APPROVED = "approved"_n; ///< Долг одобрен председателем
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом, ожидает повторной отправки на оплату
constexpr name PAY_PENDING = "paypending"_n; ///< Долг отправлен в gateway на оплату (исходящий платёж создан, ждём debtpaycnfrm/debtpaydcln)
constexpr name PAID = "paid"_n; ///< Долг выплачен пайщику, активный (ждёт погашения)
constexpr name OVERDUE = "overdue"_n; ///< Долг просрочен (now > due_at) — выставляется action markdebtoverd
constexpr name SETTLED = "settled"_n; ///< Долг полностью погашен (деньгами через settledebt либо результатом через signact2)
constexpr name WRITEOFF = "writeoff"_n; ///< Долг списан (write-off) за счёт созданного НМА из коммитов-обеспечения (через closedebt при отмене родительского компонента; решение 2026-05-19)
}
/**
@@ -42,7 +46,9 @@ struct [[eosio::table, eosio::contract(CAPITAL)]] debt {
document2 approved_statement; ///< Одобренное заявление
document2 authorization; ///< Авторизация совета
std::string memo; ///< Примечание
std::string last_pay_error; ///< Последняя ошибка outpay от gateway (заполняется debtpaydcln; пусто = успех или ещё не отправлен)
time_point_sec due_at; ///< Срок погашения (current_time_point + 1 год при переходе в paid; решение 2026-05-19); now > due_at → overdue
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
uint64_t by_username() const { return username.value; } ///< Индекс по имени пользователя (2)
@@ -145,6 +151,179 @@ inline void update_debt_status(
});
}
/**
* @brief Переводит долг в PAY_PENDING — фиксирует решение совета и факт отправки в gateway.
* Допустимо из APPROVED (первая отправка после авторизации совета) или из AUTHORIZED
* (повторная отправка после предыдущего отказа gateway). Очищает last_pay_error.
*/
inline void start_pay(
eosio::name coopname,
uint64_t debt_id,
const document2 &authorization,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::APPROVED || debt->status == Status::AUTHORIZED,
"Старт оплаты допустим только из approved/authorized");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::PAY_PENDING;
d.authorization = authorization;
d.last_pay_error = "";
});
}
/**
* @brief Возвращает долг из PAY_PENDING в AUTHORIZED с записью причины ошибки оплаты.
* Долг не удаляется — решение совета уже принято, доступна повторная отправка в gateway.
*/
inline void mark_pay_declined(
eosio::name coopname,
uint64_t debt_id,
const std::string &reason,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAY_PENDING,
"Отказ оплаты допустим только из статуса pay_pending");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::AUTHORIZED;
d.last_pay_error = reason;
});
}
/**
* @brief Кол-во секунд в стандартном сроке погашения займа (1 год = 365 дней).
*
* Решение 2026-05-19 (Игорь Смуров / Алексей Муравьёв): срок займа
* фиксированный — год, не привязан к календарному сроку проекта. Если
* родительский компонент явно перешёл в `cancelled` раньше — долг закрывается
* досрочно через создание НМА кооператива из коммитов-обеспечения и списание
* долга (см. action `capital::closedebt`, операции o.cap.crtnma + o.cap.dbtwrf).
*/
constexpr uint32_t DEFAULT_DEBT_TERM_SECONDS = 365 * 24 * 60 * 60;
/**
* @brief Переводит долг в PAID, фиксирует дату выплаты и проставляет due_at.
*
* Используется в debtpaycnfrm после успешной outpay из gateway. due_at рассчитывается
* от current_time_point на год вперёд; долги с now > due_at и status == PAID
* далее перейдут в OVERDUE через markdebtoverd.
*/
inline void confirm_paid(
eosio::name coopname,
uint64_t debt_id,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAY_PENDING,
"Подтверждение оплаты допустимо только из pay_pending");
auto now = eosio::current_time_point();
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::PAID;
d.due_at = eosio::time_point_sec(now.sec_since_epoch() + DEFAULT_DEBT_TERM_SECONDS);
d.last_pay_error = "";
});
}
/**
* @brief Помечает долги PAID с now > due_at как OVERDUE в пределах одного batch.
*
* @param coopname Кооператив (scope)
* @param batch_limit Максимальное число записей за вызов (защита от 30-сек лимита транзакции)
* @return Сколько долгов помечено за этот вызов; backend-cron повторяет до 0.
*/
inline uint32_t sweep_overdue(
eosio::name coopname,
uint32_t batch_limit
) {
debts_index debts(_capital, coopname.value);
auto now = eosio::current_time_point();
auto now_sec = eosio::time_point_sec(now.sec_since_epoch());
uint32_t marked = 0;
for (auto it = debts.begin(); it != debts.end() && marked < batch_limit; ++it) {
if (it->status == Status::PAID && it->due_at != time_point_sec() && now_sec > it->due_at) {
debts.modify(it, coopname, [&](auto &d) {
d.status = Status::OVERDUE;
});
++marked;
}
}
return marked;
}
/**
* @brief Закрывает долг как погашенный — переводит в SETTLED, сохраняет документ-основание
* в поле memo (если передан) и фиксирует дату погашения.
*
* Деньги и проводка REPAY к этому моменту уже должны быть применены в Ledger2,
* а contributors.debt_amount — уменьшен. Функция отвечает только за состояние записи.
*/
inline void mark_settled(
eosio::name coopname,
uint64_t debt_id,
const std::string &settle_memo = "",
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
"Погашение допустимо только из статуса paid или overdue");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::SETTLED;
d.repaid_at = eosio::current_time_point();
if (!settle_memo.empty()) {
d.memo = settle_memo;
}
});
}
/**
* @brief Закрывает долг как списанный — переводит в WRITEOFF.
*
* Применяется в closedebt после применения o.cap.crtnma + o.cap.dbtwrf и
* декремента сегмента. Семантически зеркалит mark_settled, но не пишет
* memo о погашении: долг закрыт имущественно через созданное НМА из
* коммитов-обеспечения, без зачисления на share пайщика. Решение 2026-05-19.
*/
inline void mark_writeoff(
eosio::name coopname,
uint64_t debt_id,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
"Списание долга допустимо только из статуса paid или overdue");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::WRITEOFF;
d.repaid_at = eosio::current_time_point();
});
}
/**
* @brief Удаляет долг
*/
@@ -178,8 +178,116 @@ 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 set_program_approved(eosio::name coopname, uint64_t id, const document2 &approved_statement) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.modify(itr, coopname, [&](auto &e) {
e.status = Status::APPROVED;
e.approved_statement = approved_statement;
});
}
inline void set_program_authorized(eosio::name coopname, uint64_t id, const document2 &authorization) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.modify(itr, coopname, [&](auto &e) {
e.status = Status::AUTHORIZED;
e.authorization = authorization;
});
}
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,11 @@ namespace Capital {
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
config config; ///< Управляемая конфигурация контракта
// binary_extension: таблица state уже в продакшене, новые поля только хвостом.
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)
};
@@ -78,7 +84,7 @@ inline global_state get_global_state(name coopname) {
/**
* @brief Проверяет что контракт инициализирован.
*
*
* @param coopname Имя кооператива.
*/
inline void is_initialized(name coopname) {
@@ -86,6 +92,83 @@ inline void is_initialized(name coopname) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
}
/// Lazy-default для binary_extension<asset>: пустое значение = 0 в _root_govern_symbol.
inline asset _read_pool(const eosio::binary_extension<asset> &field) {
return field.has_value() ? *field : asset(0, _root_govern_symbol);
}
/**
* @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) {
s.global_available_invest_pool -= amount;
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
});
}
/**
* @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(_read_pool(itr->program_expense_pool) >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) - amount);
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) + amount);
});
}
/**
* @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(_read_pool(itr->program_expense_reserved) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
});
}
/**
* @brief Окончательное списание программного расхода после exppaycnfrm:
* program_expense_reserved -= amount. Деньги уже ушли gateway,
* ledger2-проводка PAY_EXPENSE сделана в action подтверждения.
*/
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(_read_pool(itr->program_expense_reserved) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
});
}
}// namespace State
}// namespace Capital
@@ -17,6 +17,7 @@
#include "projects.hpp"
#include "results.hpp"
#include "debts.hpp"
#include "role_requests.hpp"
#include "wallets.hpp"
#include "plan_pool.hpp"
#include "fact_pool.hpp"
@@ -87,4 +87,12 @@ namespace Capital::Memo {
return "Внесение имущества в программу Благорост: " + checksum256_to_hex(property_hash);
}
inline std::string get_settle_debt_memo(eosio::name username, checksum256 debt_hash) {
return "Погашение займа участником " + username.to_string() + " (заём " + checksum256_to_hex(debt_hash) + ")";
}
inline std::string get_expense_pay_memo(checksum256 expense_hash) {
return "Оплата целевого расхода программы «Благорост»: " + checksum256_to_hex(expense_hash);
}
} // namespace Capital::Memo
@@ -4,17 +4,23 @@ using namespace eosio;
using std::string;
namespace Capital {
//TODO: УДАЛИТЬ после 07.2026 т.к. не используем
/**
* @brief Таблица программных инвестиций (progrinvests): ранее использовалась для заявок на зачёт с этапом одобрения советом; при текущей логике createpinv строки не создаёт, таблица сохранена для совместимости и чтения исторических записей.
* @deprecated description: не используется
* @brief Таблица программных инвестиций (progrinvests): ранее использовалась
* для заявок на зачёт с этапом одобрения советом. При текущей логике
* @ref capital::createpinv строки не создаёт, таблица сохранена для
* совместимости и чтения исторических записей.
*
* @deprecated Запланировано к удалению после 2026-07. Не использовать в новом
* коде. Чтение через scope coopname допустимо до этой даты для
* ретроспективных отчётов; писать через emplace/modify нельзя.
*
* @ingroup public_tables
* @ingroup public_capital_tables
*
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): progrinvests
* @par Имя таблицы (table): progrinvests
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] program_invest {
struct [[deprecated("remove after 2026-07"), eosio::table, eosio::contract(CAPITAL)]] program_invest {
uint64_t id; ///< ID программной инвестиции (внутренний ключ)
eosio::name coopname; ///< Имя кооператива
eosio::name username; ///< Имя инвестора
@@ -0,0 +1,211 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
using namespace eosio;
using std::string;
namespace Capital::RoleRequests {
/**
* @brief Статусы заявки на роль / обновление approved-ставки.
*/
namespace Status {
constexpr name PENDING = "pending"_n;
constexpr name APPROVED = "approved"_n;
constexpr name DECLINED = "declined"_n;
}
/**
* @brief Направление: пайщик просит сам (REQUEST) или мастер инвайтит кандидата (INVITE).
*/
namespace Direction {
constexpr name REQUEST = "request"_n;
constexpr name INVITE = "invite"_n;
}
/**
* @brief Тип заявки: «дай мне роль/допуск L2» или «обнови мне approved-ставку».
*/
namespace RequestType {
constexpr name ROLE = "role"_n;
constexpr name RATE_UPDATE = "rateupdate"_n;
}
/**
* @brief Допустимые роли заявки на L2-допуск.
* Контрибьюторы и инвесторы — L1, через role_requests не идут.
*/
namespace Role {
constexpr name CREATOR = "creator"_n;
constexpr name AUTHOR = "author"_n;
constexpr name MASTER = "master"_n;
constexpr name NONE = name{}; ///< Для RATE_UPDATE: поле role не значимо
}
/**
* @brief Очередь заявок на L2-допуск/инвайт и на обновление approved-ставки.
*
* Бездокументарная схема: подача и решение оформляются только транзакцией
* блокчейна (require_auth coopname). Юридическое заявление при приёме на роль
* не требуется — допуск даётся через подпись действия на стороне кооператива.
*
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): rolerequests
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] role_request {
uint64_t id; ///< Внутренний ключ
eosio::name coopname; ///< Кооператив
checksum256 request_hash; ///< Хеш заявки (анкер)
checksum256 project_hash; ///< Проект / компонент
eosio::name username; ///< Заявитель (REQUEST) или кандидат (INVITE)
eosio::name master; ///< Мастер компонента (одобряющий) — при invite на роль master это координатор/председатель
eosio::name role = Role::NONE; ///< Role::CREATOR | Role::AUTHOR | Role::MASTER (для RequestType::RATE_UPDATE — Role::NONE)
eosio::asset rate_per_hour = asset(0, _root_govern_symbol); ///< Заявленная пайщиком/мастером ставка часа (всегда явная, не из contributors)
uint64_t hours_per_day = 0; ///< Заявленная норма часов
eosio::asset approved_rate = asset(0, _root_govern_symbol); ///< Утверждённая ставка (≠ requested при approve)
uint64_t approved_hours = 0; ///< Утверждённая норма часов
eosio::name direction = Direction::REQUEST; ///< request | invite
eosio::name request_type = RequestType::ROLE; ///< role | rateupdate
eosio::name status = Status::PENDING; ///< pending | approved | declined
std::string description; ///< Текст заявки / приглашения (может быть пустым)
std::string decline_reason; ///< Причина отказа (если DECLINED)
time_point_sec created_at = current_time_point();
uint64_t primary_key() const { return id; }
checksum256 by_request_hash() const { return request_hash; }
uint128_t by_project_user() const {
return combine_checksum_ids(project_hash, username);
}
};
typedef eosio::multi_index<
"rolerequests"_n, role_request,
indexed_by<"byhash"_n, const_mem_fun<role_request, checksum256, &role_request::by_request_hash>>,
indexed_by<"byprojuser"_n, const_mem_fun<role_request, uint128_t, &role_request::by_project_user>>
> role_requests_index;
inline std::optional<role_request> get_role_request(eosio::name coopname, const checksum256 &request_hash) {
role_requests_index t(_capital, coopname.value);
auto idx = t.get_index<"byhash"_n>();
auto itr = idx.find(request_hash);
if (itr == idx.end()) return std::nullopt;
return role_request(*itr);
}
inline role_request get_role_request_or_fail(eosio::name coopname, const checksum256 &request_hash) {
auto v = get_role_request(coopname, request_hash);
eosio::check(v.has_value(), "Заявка на роль не найдена");
return v.value();
}
inline void create(
eosio::name coopname,
const checksum256 &request_hash,
const checksum256 &project_hash,
eosio::name username,
eosio::name master,
eosio::name role,
const eosio::asset &rate_per_hour,
uint64_t hours_per_day,
eosio::name direction,
eosio::name request_type,
const std::string &description
) {
role_requests_index t(_capital, coopname.value);
eosio::check(!get_role_request(coopname, request_hash).has_value(),
"Заявка с указанным хэшем уже существует");
uint64_t id = get_global_id_in_scope(_capital, coopname, "rolerequests"_n);
t.emplace(coopname, [&](auto &r) {
r.id = id;
r.coopname = coopname;
r.request_hash = request_hash;
r.project_hash = project_hash;
r.username = username;
r.master = master;
r.role = role;
r.rate_per_hour = rate_per_hour;
r.hours_per_day = hours_per_day;
r.direction = direction;
r.request_type = request_type;
r.status = Status::PENDING;
r.description = description;
r.created_at = current_time_point();
});
}
/**
* @brief Проверяет, что role входит в допустимый набор Role::{CREATOR, AUTHOR, MASTER}.
* Контрибьюторы и инвесторы — это L1-роли и через role_requests не идут.
* Не применяется к заявкам RequestType::RATE_UPDATE (там role = Role::NONE).
*/
inline void validate_role_or_fail(eosio::name role) {
eosio::check(
role == Role::CREATOR || role == Role::AUTHOR || role == Role::MASTER,
"Допустимые роли заявки: creator, author, master"
);
}
/**
* @brief Применяет заявленную роль к проекту через inline action:
* Role::AUTHOR → addauthor, Role::MASTER → setmaster, Role::CREATOR → no-op
* (creator фиксируется фактически при первом createcmmt).
*
* Вызывается из approverole/acceptinvite после фиксации решения, чтобы
* заявленная при подаче роль немедленно отражалась в реестре участников.
* Inline action идёт под permission coopname — председатель/backend подписывает.
*/
inline void apply_role_to_project(
eosio::name coopname,
const checksum256 &project_hash,
eosio::name username,
eosio::name role
) {
if (role == Role::AUTHOR) {
eosio::action(
eosio::permission_level{coopname, "active"_n},
_capital,
"addauthor"_n,
std::make_tuple(coopname, project_hash, username)
).send();
} else if (role == Role::MASTER) {
eosio::action(
eosio::permission_level{coopname, "active"_n},
_capital,
"setmaster"_n,
std::make_tuple(coopname, project_hash, username)
).send();
}
}
inline void approve(
eosio::name coopname,
uint64_t request_id,
const eosio::asset &approved_rate,
uint64_t approved_hours
) {
role_requests_index t(_capital, coopname.value);
auto itr = t.find(request_id);
eosio::check(itr != t.end(), "Заявка не найдена");
eosio::check(itr->status == Status::PENDING, "Заявка уже обработана");
t.modify(itr, coopname, [&](auto &r) {
r.status = Status::APPROVED;
r.approved_rate = approved_rate;
r.approved_hours = approved_hours;
});
}
inline void decline(eosio::name coopname, uint64_t request_id, const std::string &reason) {
role_requests_index t(_capital, coopname.value);
auto itr = t.find(request_id);
eosio::check(itr != t.end(), "Заявка не найдена");
eosio::check(itr->status == Status::PENDING, "Заявка уже обработана");
t.modify(itr, coopname, [&](auto &r) {
r.status = Status::DECLINED;
r.decline_reason = reason;
});
}
} // namespace Capital::RoleRequests
@@ -2,6 +2,7 @@
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
#include <eosio/binary_extension.hpp>
using namespace eosio;
@@ -112,7 +113,19 @@ namespace Capital::Segments {
eosio::asset available_for_program = asset(0, _root_govern_symbol); ///< Доступная сумма для конвертации в программу (intellectual_cost - debt_amount)
eosio::asset available_for_wallet = asset(0, _root_govern_symbol); ///< Доступная сумма для конвертации в кошелек (provisional_amount - debt_amount)
double share_percent = 0.0; ///< Доля участника в результате (intellectual_cost / fact.total * 100)
// Допуски L2 — утверждаются мастером компонента/сегмента через approverole (Story 1.7).
// Используются в createcmmt вместо глобального contributors.rate_per_hour, если значение установлено.
// binary_extension: таблица segments уже в продакшене, расширение схемы только хвостом.
eosio::binary_extension<eosio::asset> approved_rate_per_hour; ///< Утверждённая мастером ставка часа на этом сегменте
eosio::binary_extension<uint64_t> approved_hours_per_day; ///< Утверждённое мастером количество часов в день на этом сегменте
// Счётчик активных (PAID/OVERDUE) займов пайщика на проекте этого сегмента.
// Инкрементируется в Capital::Debts::confirm_paid, декрементируется в mark_settled.
// Используется как лимит «10 займов на проект в одном паевом взносе» (signact2),
// чтобы не перебирать byprojhash и не упираться в 30-сек лимит транзакции.
eosio::binary_extension<uint32_t> active_debts_count;
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
checksum256 by_project_hash() const { return project_hash; } ///< Индекс по хэшу проекта (2)
@@ -156,6 +169,30 @@ namespace Capital::Segments {
return segment(*segment_itr);
}
/**
* @brief Фиксирует утверждённую мастером ставку часа и норматив часов в день на сегменте.
* Вызывается из action approverole (Story 1.7). createcmmt далее берёт эту
* ставку для расчёта generation_amounts вместо глобальной из contributors.
*/
inline void set_approved_rate(
eosio::name coopname,
const checksum256 &project_hash,
eosio::name username,
const eosio::asset &rate_per_hour,
uint64_t hours_per_day
) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
s.approved_rate_per_hour.emplace(rate_per_hour);
s.approved_hours_per_day.emplace(hours_per_day);
});
}
/**
* @brief Подсчитывает количество авторов в проекте.
@@ -418,14 +455,57 @@ inline void increase_debt_amount(eosio::name coopname, uint64_t segment_id, eosi
inline void decrease_debt_amount(eosio::name coopname, uint64_t segment_id, eosio::asset amount) {
Capital::Segments::segments_index segments(_capital, coopname.value);
auto segment = segments.find(segment_id);
eosio::check(segment->debt_amount >= amount, "Пайщик не может погасить долг больше, чем должен");
segments.modify(segment, coopname, [&](auto &s) {
s.debt_amount -= amount;
});
}
/**
* @brief Увеличивает счётчик активных займов пайщика на сегменте на +1.
* Вызывается в Capital::Debts::confirm_paid после перехода долга в PAID.
*/
inline void increase_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
s.active_debts_count.emplace(cur + 1);
});
}
/**
* @brief Уменьшает счётчик активных займов пайщика на сегменте на -1.
* Вызывается в Capital::Debts::mark_settled (settledebt и signact2-погашение).
*/
inline void decrease_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
eosio::check(cur > 0, "Счётчик активных займов уже на нуле");
s.active_debts_count.emplace(cur - 1);
});
}
/**
* @brief Текущее значение счётчика активных займов пайщика на сегменте.
* Используется в signact2 как лимит обхода долгов проекта (≤10).
*/
inline uint32_t get_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
auto seg = get_segment(coopname, project_hash, username);
if (!seg.has_value()) return 0;
return seg->active_debts_count.has_value() ? *seg->active_debts_count : 0;
}
/**
* @brief Удаляет сегмент участника
* @param coopname Имя кооператива
@@ -1,16 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Займ пайщику» — выдача беспроцентного целевого займа из паевого
# фонда кооператива и его возврат через акт приёма-передачи проекта.
#
# Многоактовый процесс с авторизацией советом. Две операции:
# • o.cap.lend — выдача займа на момент авторизации (ISSUE → w.cap.loan, Дт 58 / Кт 51)
# • o.cap.repay — возврат займа из акта-2 (TRANSFER w.cap.loan → w.wal.share, Дт 80 / Кт 58)
#
# Источники правды в коде:
# • cpp/capital/capital.hpp — actions
# • cpp/capital/src/debt/{createdebt,approvedebt,debtauthcnfr,debtpaycnfrm,declinedebt,debtpaydcln}.cpp
# • cpp/lib/core/ledger2/operations.hpp — o.cap.lend, o.cap.repay
# • cpp/lib/core/ledger2/processes.hpp — processes::capital::DEBT
# Стандарт «Займ пайщику»
# ─────────────────────────────────────────────────────────────────────────────
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
@@ -20,103 +9,104 @@ title: Выдача займа пайщику
slug: debt
status: proposed
contract: capital
summary: >
Пайщик получает из паевого фонда кооператива беспроцентный целевой заём
на срок проекта; возврат происходит при сдаче акта-2 проекта.
purpose: >
«Заём пайщику» — кооператив выдаёт пайщику беспроцентный целевой заём
из паевого фонда на срок проекта. Заявку последовательно одобряют
председатель и совет, выплата уходит пайщику через кассира. Возврат
происходит автоматически при сдаче акта приёма-передачи проекта —
отдельная заявка на возврат не нужна.
Пайщик получает из паевого фонда кооператива беспроцентный целевой заём
сроком на один год. Решение о выдаче принимает совет кооператива. Заём
возвращается автоматически при сдаче результата работ по проекту. Если же
проект до сдачи не доведён и был отменён, долг закрывается за счёт
имущества, которое пайщик ранее передал кооперативу в обеспечение по этому
займу. Никаких санкций к пайщику в случае невозврата не применяется.
roles:
- contributor
- chairman
- soviet
- gateway_operator
- Пайщик
- Председатель
- Совет
- Кассир
# ── Секция 2. Действия контракта ────────────────────────────────────────────
actions:
- name: capital::createdebt
human: Подать заявление
actor: contributor
role: opener
actor: Пайщик
purpose: >
Пайщик создаёт заявку на беспроцентный целевой заём. Контракт фиксирует
запись долга в статусе `created`, обновляет сегмент CRPS пайщика на
сумму займа.
Пайщик подаёт заявление на беспроцентный целевой заём.
- name: capital::approvedebt
human: Одобрить решением председателя
actor: chairman
role: progress
human: Одобрить
actor: Председатель
purpose: >
Председатель добавляет к заявке документ-одобрение займа. Статус
заявки → `approved`.
Председатель одобряет заявление пайщика и выносит его на утверждение
советом.
- name: capital::debtauthcnfr
human: Авторизовать выплату
actor: soviet
role: progress
actor: Совет
purpose: >
Совет авторизует выплату займа. На этом этапе применяется ledger2-
операция o.cap.lend и Gateway получает поручение на исходящий платёж.
Статус → `authorized`.
Совет утверждает выдачу займа и поручает кассиру выполнить выплату.
- name: capital::debtpaycnfrm
human: Подтвердить выплату
actor: gateway_operator
role: closer
actor: Кассир
purpose: >
Gateway подтверждает зачисление займа на счёт пайщика. Статус → `paid`.
Это закрывающее действие: запись долга сохраняется до возврата
(операция o.cap.repay сработает уже на акте-2 в процессе РИД).
Кассир подтверждает зачисление денежных средств пайщику. С этого
момента отсчитывается срок возврата — один год.
- name: capital::declinedebt
human: Отклонить
actor: chairman
role: reject
actor: Председатель
purpose: >
Председатель или совет отклоняет заявку на заём. Сегмент CRPS откатывается,
запись долга удаляется, операции в ledger2 не создаются.
Председатель или совет отклоняет заявление о выдаче займа.
- name: capital::debtpaydcln
human: Отклонить выплату
actor: gateway_operator
role: reject
actor: Кассир
purpose: >
Gateway отклонил исходящий платёж по займу (тех. ошибка, возврат).
Запись долга удаляется.
Кассир сообщает, что выплата не прошла (технический сбой, возврат
средств банком). Заявление снимается.
- name: capital::closedebt
human: Закрыть займ
actor: Председатель совета
purpose: >
Если проект, под который был выдан заём, отменён до сдачи результата —
долг закрывается за счёт имущества, ранее переданного пайщиком
кооперативу в обеспечение по этому займу. Делается автоматически:
без сбора совета и без подписи пайщика.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: capital::debt
entity_human: Долг пайщика
entity_source: cpp/capital/src/debt/
states:
- name: created
human: Заявление подано
description: >
Заявка на заём создана. Сегмент CRPS пайщика обновлён на запрошенную сумму.
Пайщик подал заявление на заём. Документ ждёт решения председателя.
kind: normal
- name: approved
human: Председатель одобрил
human: Одобрено председателем
description: >
К заявке добавлен документ-одобрение председателя. Заявка ждёт авторизации
советом.
Председатель одобрил заявление. Документ ушёл на утверждение совету.
kind: normal
- name: authorized
human: Совет авторизовал
human: Утверждено советом
description: >
Совет авторизовал заём, в ledger2 применена операция o.cap.lend
(Дт 58 / Кт 51), Gateway получил поручение на исходящий платёж.
Совет утвердил выдачу займа. Кассиру поручено выполнить выплату.
kind: normal
- name: paid
human: Заём выплачен
description: >
Gateway подтвердил зачисление займа пайщику. Запись долга остаётся
в реестре до момента возврата через акт приёма-передачи проекта.
kind: final
- name: removed
human: Отклонено
Кассир подтвердил зачисление средств пайщику. С этого дня
отсчитывается срок возврата — один год. Возврат произойдёт
автоматически вместе со сдачей результата работ по проекту.
kind: normal
- name: writeoff
human: Списано через имущество-обеспечение
description: >
Заявка отклонена на одном из этапов; запись удалена, операции
o.cap.lend / o.cap.repay не создавались.
Проект, под который был выдан заём, отменён до сдачи результата.
Долг закрыт автоматически за счёт имущества, переданного пайщиком
кооперативу в обеспечение по этому займу. Запись о займе остаётся
в реестре с этой пометкой для последующего аудита; никаких претензий
к пайщику не предъявляется.
kind: normal
- name: removed
human: Заявление отклонено
description: >
Заявление снято на одном из этапов согласования — председателем,
советом или кассиром.
kind: virtual
virtual: true
@@ -124,129 +114,141 @@ transitions:
- from: "∅"
to: created
action: capital::createdebt
actor: contributor
actor: Пайщик
guards:
- Пайщик имеет статус active и участвует в проекте.
- Сегмент CRPS пайщика актуален.
- provisional_amount ≥ запрошенная сумма займа.
- Заявление подписано ЭЦП.
- Пайщик — активный участник кооператива.
- Пайщик участвует в проекте, под который запрашивается заём.
- На сегменте пайщика хватает доступной суммы для запроса.
- Заявление подписано пайщиком.
- from: created
to: approved
action: capital::approvedebt
actor: chairman
actor: Председатель
guards:
- Документ одобрения председателя подписан.
- Председатель подписал решение об одобрении.
- from: approved
to: authorized
action: capital::debtauthcnfr
actor: soviet
ledger_code: o.cap.lend
operations:
- o.cap.lend
actor: Совет
guards:
- Документ авторизации совета подписан.
- Совет принял решение о выдаче займа.
- from: authorized
to: paid
action: capital::debtpaycnfrm
actor: gateway_operator
actor: Кассир
guards:
- Gateway подтвердил зачисление займа пайщику.
- Кассир подтвердил зачисление средств пайщику.
- from: created
to: removed
action: capital::declinedebt
actor: chairman
actor: Председатель
guards:
- Председатель или совет отклонили заявку.
- Председатель или совет отклонили заявление.
- from: approved
to: removed
action: capital::declinedebt
actor: soviet
actor: Совет
guards:
- Совет отклонил заявку.
- Совет отклонил заявление.
- from: authorized
to: removed
action: capital::debtpaydcln
actor: gateway_operator
actor: Кассир
guards:
- Платёж не прошёл / отклонён Gateway.
- Платёж не прошёл — банк отклонил или вернул перевод.
- from: paid
to: writeoff
action: capital::closedebt
actor: Председатель совета
guards:
- Проект, под который был выдан заём, отменён.
- У пайщика на сегменте есть имущество, переданное в обеспечение.
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
steps:
- step: 1
title: Подача заявления на заём
actor: contributor
title: Подача заявления
actor: Пайщик
action: capital::createdebt
description: >
Пайщик подаёт заявление на беспроцентный целевой заём.
Контракт создаёт запись долга со статусом `created` и обновляет
сегмент CRPS на запрошенную сумму.
- step: 2
title: Одобрение председателем
actor: chairman
actor: Председатель
action: capital::approvedebt
description: >
Председатель добавляет к заявке документ-одобрение. Статус
заявки → `approved`.
Председатель рассматривает заявление и при согласии одобряет его,
вынося на утверждение совету.
- step: 3
title: Авторизация советом и выдача займа
actor: soviet
title: Утверждение советом и поручение на выплату
actor: Совет
action: capital::debtauthcnfr
description: >
Совет авторизует выплату. В ledger2 применяется операция o.cap.lend
(ISSUE → w.cap.loan, Дт 58 / Кт 51), Gateway получает поручение на
исходящий платёж. Статус → `authorized`.
Совет утверждает выдачу займа. Кассиру передаётся поручение на
перечисление средств пайщику.
- step: 4
title: Подтверждение выплаты
actor: gateway_operator
actor: Кассир
action: capital::debtpaycnfrm
description: >
Gateway подтверждает зачисление средств пайщику. Статус → `paid`.
Запись долга остаётся в реестре до момента возврата через акт-2
проекта (операция o.cap.repay сработает в процессе p.cap.rid).
Кассир выполняет перевод и подтверждает зачисление средств пайщику.
С этого дня отсчитывается срок возврата — один год.
alternatives:
- branch: Отказ председателя или совета
at_step: 2
action: capital::declinedebt
actor: chairman
actor: Председатель
description: >
Заявка отклонена. Сегмент CRPS откатывается, запись долга удаляется,
операции в ledger2 не создаются.
- branch: Отказ Gateway
Заявление отклонено на этапе согласования. Деньги пайщику не
перечисляются.
- branch: Не прошёл платёж
at_step: 4
action: capital::debtpaydcln
actor: gateway_operator
actor: Кассир
description: >
Платёж не прошёл / отклонён. Запись долга удаляется.
Банк отклонил или вернул перевод. Заявление снимается; пайщик может
подать его заново.
- branch: Невозврат — закрытие через имущество-обеспечение
at_step: 4
action: capital::closedebt
actor: Председатель совета
description: >
Если проект, под который был выдан заём, отменён до сдачи
результата — долг закрывается автоматически за счёт имущества,
переданного пайщиком кооперативу в обеспечение по этому займу.
Сбора совета не требуется, подпись пайщика не требуется,
претензий к пайщику не предъявляется.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
- step: 1
- action: capital::createdebt
title: Заявление на получение займа
registry_id: 1050
signed_by: [contributor]
signed_by: [Пайщик]
stored_in: debts.statement
- step: 2
- action: capital::approvedebt
title: Решение совета о предоставлении займа (одобрение председателем)
registry_id: 1051
signed_by: [chairman]
signed_by: [Председатель]
stored_in: debts.approved_statement
- step: 3
title: Решение совета о предоставлении займа (авторизация совета)
- action: capital::debtauthcnfr
title: Решение совета о предоставлении займа (утверждение советом)
registry_id: 1051
signed_by: [soviet]
signed_by: [Совет]
stored_in: debts.authorization
# ── Секция 6. Операции ──────────────────────────────────────────────────────
@@ -254,32 +256,65 @@ operations:
- ledger_code: o.cap.lend
human_name: Выдача пайщику беспроцентного займа
wallet_op: ISSUE
wallet_from: ''
wallet_to: w.cap.loan # Выданные пайщикам беспроцентные займы
debit: 58 # Финансовые вложения
credit: 51 # Расчётный счёт
debit: 58
credit: 51
wallet_from: null
wallet_to: w.cap.loan
amount_ref: debt.amount
triggered_by: capital::debtauthcnfr
description: >
Выдача беспроцентного займа пайщику. Деньги уходят с расчётного счёта
кооператива (Кт 51) и появляются как финансовое вложение Дт 58.
Кошелёк «Выданные пайщикам беспроцентные займы» (w.cap.loan) фиксирует
обязательство пайщика перед кооперативом.
Кооператив перечисляет пайщику беспроцентный целевой заём. На стороне
кооператива деньги уходят с расчётного счёта и фиксируются как
финансовое вложение в пайщика-должника.
- ledger_code: o.cap.repay
human_name: Возврат беспроцентного займа пайщика по акту-2
human_name: Возврат беспроцентного займа пайщика при сдаче результата
wallet_op: TRANSFER
wallet_from: w.cap.loan # Выданные пайщикам беспроцентные займы
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
debit: 80 # Паевой фонд (складочный капитал)
credit: 58 # Финансовые вложения
debit: 80
credit: 58
wallet_from: w.cap.loan
wallet_to: w.wal.share
user_wallet: w.wal.share
user_ref: debt.username
available_delta: +debt.amount
blocked_delta: null
amount_ref: debt.amount
triggered_by: capital::debtpaycnfrm
description: >
Возврат займа в момент сдачи акта-2 проекта (происходит как часть
процесса «Приём РИД» — p.cap.rid). Финансовое вложение закрывается
(Кт 58), сумма зачитывается в паевой фонд (Дт 80) и появляется как
доступный остаток на SHARE_FUND_PAY пайщика.
Происходит в момент сдачи результата работ по проекту
(подписанный акт приёма-передачи). Долг пайщика гасится, а сумма
займа зачитывается ему как паевой взнос — появляется как свободный
остаток на личном паевом кошельке пайщика.
- ledger_code: o.cap.crtnma
human_name: Создание имущества кооператива из обеспечения по займу
wallet_op: TRANSFER
debit: 4
credit: 8
wallet_from: w.cap.gen
wallet_to: w.cap.nma
amount_ref: debt.amount
triggered_by: capital::closedebt
description: >
Срабатывает при невозврате: проект, под который был выдан заём,
отменён до сдачи результата. Имущество, ранее переданное пайщиком
кооперативу в обеспечение, переходит в собственность кооператива
как нематериальный актив.
- ledger_code: o.cap.dbtwrf
human_name: Списание долга за счёт имущества-обеспечения
wallet_op: BURN
debit: 80
credit: 58
wallet_from: w.cap.loan
wallet_to: null
amount_ref: debt.amount
triggered_by: capital::closedebt
description: >
Парная операция к созданию имущества из обеспечения: обязательство
пайщика по займу закрывается. На паевой кошелёк пайщика ничего не
зачисляется — долг закрыт имущественно, через переданное в
обеспечение имущество.
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
related:
@@ -287,11 +322,20 @@ related:
id: public_capital_rid_process
relation: triggers
note: >
Возврат займа (o.cap.repay) технически происходит в процессе
«Приём РИД» при подписании акта-2 проекта.
Возврат займа физически происходит при сдаче результата работ по
проекту (подписанный акт приёма-передачи) — в процессе «Приём
результата интеллектуальной деятельности».
- process_type: p.reg.accept
id: public_registrator_accept_process
relation: provides
note: >
Заём может получить только активный пайщик кооператива.
- process_type: p.cap.rid
id: public_capital_rid_process
relation: affects
note: >
Закрытие долга через имущество-обеспечение уменьшает на сегменте
пайщика ту часть, которая раньше была передана в обеспечение и
теперь стала собственностью кооператива.
@@ -77,9 +77,12 @@ namespace operations {
inline constexpr eosio::name DROP_PREIMP = "o.cap.drppre"_n; ///< Закрытие пред-импорт-учёта при переходе на электронный учёт (Dr 80 / Cr 04, BURN PREIMP_FUND). Вызывается из capital::importcontr перед o.cap.import.
inline constexpr eosio::name LEND = "o.cap.lend"_n; ///< Выдача беспроцентного займа пайщику (Dr 58 / Cr 51, ISSUE LOAN_ISSUED).
inline constexpr eosio::name REPAY = "o.cap.repay"_n; ///< Возврат займа пайщика по акту-2 (Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY).
inline constexpr eosio::name CREATE_NMA = "o.cap.crtnma"_n; ///< Создать НМА кооператива из коммитов-обеспечения долга при невозврате займа (Dr 04 / Cr 08, TRANSFER GENERATOR_FUND → NMA). Решение 2026-05-19.
inline constexpr eosio::name DEBT_WRITEOFF = "o.cap.dbtwrf"_n; ///< Списание долга (write-off) за счёт созданного НМА (Dr 80 / Cr 58, BURN LOAN_ISSUED). Зеркало REPAY без зачисления на share пайщика — долг закрыт имущественно. Решение 2026-05-19.
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 PAY_EXPENSE = "o.cap.expns"_n; ///< Оплата целевого расхода программы (Dr 86 / Cr 51, TRANSFER BLAGOROST_FUND → SOV_EXPENSES). Списание целевого финансирования на расчётный счёт.
}
// marketplace
@@ -252,6 +255,28 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::SHARE_FUND, ledger2_accounts::FINANCIAL_INVESTMENTS,
"Возврат беспроцентного займа пайщика по акту-2" },
// 11a. Создать НМА кооператива из коммитов-обеспечения долга при невозврате:
// Dr 04 / Cr 08, TRANSFER GENERATOR_FUND → NMA.
// Срабатывает автоматически из `capital::closedebt`, когда родительский
// компонент перешёл в `cancelled` (решение 2026-05-19 — без сбора совета,
// без подписи пайщика). РИД-коммиты, накопленные на GENERATOR_FUND пайщика
// как обеспечение долга, формируют НМА кооператива на отдельном кошельке
// `w.cap.nma`. Пара по 04 зеркалит `o.cap.accept`, но без участия пайщика —
// сегмент пайщика уменьшается на сумму операции.
{ operations::capital::CREATE_NMA, processes::capital::DEBT, WalletOp::TRANSFER,
ledger2_wallets::GENERATOR_FUND, ledger2_wallets::NMA,
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::NON_CURRENT_INVESTMENTS,
"Создание НМА кооператива из коммитов-обеспечения долга" },
// 11b. Списание долга (write-off) за счёт созданного НМА: Dr 80 / Cr 58, BURN LOAN_ISSUED.
// Парная к `o.cap.crtnma` операция, закрывает обязательство пайщика по займу.
// Зеркало `o.cap.repay`, но без зачисления на share пайщика (долг закрыт
// имущественно — через НМА из коммитов-обеспечения). Решение 2026-05-19.
{ operations::capital::DEBT_WRITEOFF, processes::capital::DEBT, WalletOp::BURN,
ledger2_wallets::LOAN_ISSUED, eosio::name{},
ledger2_accounts::SHARE_FUND, ledger2_accounts::FINANCIAL_INVESTMENTS,
"Списание долга (write-off) за счёт созданного НМА" },
// 12. Подтверждение поставки: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
{ operations::marketplace::CONFIRM_SUPPLY, processes::marketplace::REQUEST, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
@@ -305,6 +330,16 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
// 20. Оплата целевого расхода программы: Dr 86 / Cr 51, TRANSFER BLAGOROST_FUND → SOV_EXPENSES.
// Закрывает TODO в exppaycnfrm.cpp: списывает сумму расхода из программного фонда
// «Благорост» в пул «Хозяйственные расходы из числа целевого финансирования»;
// одновременно фиксируется бухпроводка по уменьшению целевого финансирования (86)
// и списанию с расчётного счёта (51).
{ operations::capital::PAY_EXPENSE, processes::capital::EXPENSE, WalletOp::TRANSFER,
ledger2_wallets::BLAGOROST_FUND, ledger2_wallets::SOV_EXPENSES,
ledger2_accounts::TARGET_RECEIPTS, ledger2_accounts::BANK_ACCOUNT,
"Оплата целевого расхода по программе «Благорост»" },
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
@@ -48,11 +48,12 @@ namespace processes {
namespace capital {
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (o.cap.drppre (опц.) + o.cap.import).
inline constexpr eosio::name INVEST = "p.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (wallet-only 2001→9001).
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay).
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay; альтернативная ветка невозврата — o.cap.crtnma + o.cap.dbtwrf, решение 2026-05-19).
inline constexpr eosio::name RID = "p.cap.rid"_n; ///< Приём РИД в паевой фонд: o.cap.commit (коммиты) + o.cap.accept (акт-2) + опц. o.cap.repay + o.cap.cnvshr/o.cap.cnvbl (финальная конвертация сегмента). Анкер процесса — result_hash.
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 EXPENSE = "p.cap.expns"_n; ///< Целевой расход программы (создание → одобрение председателя → авторизация совета → outpay → подтверждение оплаты). Anchor — expense_hash.
}
// marketplace
@@ -63,6 +63,7 @@ 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; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
static constexpr eosio::name NMA = "w.cap.nma"_n; ///< НМА кооператива — собственное имущество, перешедшее из коммитов-обеспечения при невозврате займа (COOPERATIVE; решение 2026-05-19)
// marketplace — выплаты
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
@@ -94,7 +95,7 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
@@ -103,7 +104,7 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
// COOPERATIVE (8) — единый кооперативный баланс, без L3
// COOPERATIVE (9) — единый кооперативный баланс, без L3
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
@@ -112,6 +113,7 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::NMA, "НМА кооператива (из коммитов-обеспечения по займам)", WalletKind::COOPERATIVE },
}};
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
@@ -50,6 +50,12 @@ namespace Names {
// Коллбэки для расходов
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
// Коллбэки для программных расходов
// (CREATE_PROGRAM_EXPENSE объявлен ниже в блоке "акцептов" — общий для обоих контекстов)
constexpr eosio::name AUTHORIZE_PROGRAM_EXPENSE = "authpgexp"_n;
constexpr eosio::name DECLINE_PROGRAM_EXPENSE = "declpgexp"_n;
constexpr eosio::name CONFIRM_PROGRAM_EXPENSE_PAYMENT = "pgexppay"_n;
// Коллбэки для долгов
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
@@ -91,6 +97,7 @@ namespace Names {
constexpr eosio::name CAPITAL_WITHDRAW_FROM_PROGRAM = "capwthdrprog"_n;
constexpr eosio::name CAPITAL_WITHDRAW_FROM_PROJECT = "capwthdrproj"_n;
constexpr eosio::name CAPITAL_RESOLVE_EXPENSE = "capresexpns"_n;
constexpr eosio::name CAPITAL_RESOLVE_PROGRAM_EXPENSE = "caprespgexp"_n;
constexpr eosio::name CREATE_RESULT = "createresult"_n;
constexpr eosio::name CREATE_DEBT = "createdebt"_n;
constexpr eosio::name CREATE_PROJECT = "createprj"_n;
@@ -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; // акцепт возврата из программы
@@ -19,6 +19,11 @@ struct [[eosio::table, eosio::contract(LOAN)]] debt {
time_point_sec created_at;
time_point_sec repaid_at;
// Контракт-источник, инициировавший выдачу займа (capital / marketplace / ...).
// loan не знает доменного контекста (проекта, программы) — это ответственность
// вызывающего контракта; ему же принадлежит и локальная привязка debt_hash.
name source_contract;
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_debt_hash() const { return debt_hash; }
@@ -2,10 +2,13 @@ using namespace Loan;
/**
* @brief Создание долгового обязательства.
* Создает новое долговое обязательство для пользователя с указанным сроком погашения
* Создает новое долговое обязательство пайщика. loan-контракт независим от
* прикладных контрактов здесь нет привязки к проекту/программе/компоненту.
* Локальный контекст (project_hash и т.п.) хранится на стороне вызывающего
* контракта в его собственной таблице долгов.
* @param coopname Наименование кооператива
* @param username Имя пользователя-должника
* @param debt_hash Хэш долгового обязательства
* @param debt_hash Хэш долгового обязательства (идентификатор)
* @param repaid_at Срок погашения долга
* @param quantity Сумма долга
* @ingroup public_actions
@@ -33,6 +36,7 @@ void loan::createdebt(name coopname, name username, checksum256 debt_hash, time_
d.amount = quantity;
d.created_at = time_point_sec(current_time_point());
d.repaid_at = repaid_at;
d.source_contract = payer;
});
Loan::summaries_index summaries(_loan, coopname.value);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"private": true,
"type": "module",
"scripts": {
+13
View File
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
OPENAI_BASE_URL=
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ru
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
MINIO_BUCKET=
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
FILE_STORAGE_SIGNING_SECRET=
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
FILE_STORAGE_PUBLIC_BASE_URL=
+3
View File
@@ -8,6 +8,9 @@ module.exports = {
NODE_ENV: 'test',
},
restoreMocks: true,
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
// Запускаются явно через `npm run test:integration:file-storage`.
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
coverageReporters: ['text', 'lcov', 'clover', 'html'],
moduleNameMapper: {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -22,6 +22,7 @@
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
"test": "jest -i --testPathPattern=tests/unit",
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
"test:watch": "jest -i --watchAll",
"coverage": "jest -i --coverage",
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
@@ -71,6 +72,7 @@
],
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@aws-sdk/client-s3": "^3.1035.0",
"@coopenomics/factory": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/notifications": "workspace:*",
@@ -193,6 +195,7 @@
"@graphql-codegen/typescript": "^4.1.1",
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
"@graphql-codegen/typescript-operations": "^4.3.1",
"@nestjs/testing": "^11.1.19",
"@redocly/cli": "^1.18.0",
"@types/express": "^4.17.21",
"@types/node": "^20.11.26",
+56 -73
View File
@@ -1805,7 +1805,7 @@ type CapitalCommit {
data: JSON
"""Описание коммита"""
description: String!
description: String
"""Отображаемое имя пользователя"""
display_name: String
@@ -1814,7 +1814,7 @@ type CapitalCommit {
id: Int
"""Метаданные коммита"""
meta: String!
meta: String
"""Флаг присутствия записи в блокчейне"""
present: Boolean!
@@ -6161,6 +6161,53 @@ input GenerationContractSignedMetaDocumentInput {
version: String!
}
input GenerationConvertStatementGenerateDocumentInput {
"""Сумма для перевода в программу «Благорост»"""
blagorost_wallet_amount: String!
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Сумма для перевода в Цифровой Кошелёк"""
main_wallet_amount: String!
"""Хэш проекта"""
project_hash: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Признак перевода в программу «Благорост»"""
to_blagorost: Boolean!
"""Признак перевода в Цифровой Кошелёк"""
to_wallet: Boolean!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input GenerationMoneyInvestStatementGenerateDocumentInput {
"""Сумма инвестирования"""
amount: String!
@@ -6272,56 +6319,6 @@ input GenerationMoneyInvestStatementSignedMetaDocumentInput {
version: String!
}
input GenerationToMainWalletConvertStatementGenerateDocumentInput {
"""Хэш приложения"""
appendix_hash: String!
"""Сумма для перевода на благорост кошелек"""
blagorost_wallet_amount: String!
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Сумма для перевода на основной кошелек"""
main_wallet_amount: String!
"""Хэш проекта"""
project_hash: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Перевод на благорост кошелек"""
to_blagorost: Boolean!
"""Перевод на основной кошелек"""
to_wallet: Boolean!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input GetAccountInput {
"""Имя аккаунта пользователя"""
username: String!
@@ -7685,6 +7682,13 @@ type Mutation {
"""
capitalGenerateGenerationContract(data: GenerationContractGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationConvertStatement(data: GenerationConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление об инвестировании в генерацию
@@ -7713,27 +7717,6 @@ type Mutation {
"""
capitalGenerateGenerationPropertyInvestStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToCapitalizationConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToMainWalletConvertStatement(data: GenerationToMainWalletConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToProjectConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать решение о получении займа
+9
View File
@@ -16,6 +16,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
// Domain modules
import { AccountDomainModule } from './domain/account/account-domain.module';
@@ -102,6 +103,14 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
FileStorageInfrastructureModule.forRoot({
endpoint: config.file_storage.endpoint,
accessKey: config.file_storage.access_key,
secretKey: config.file_storage.secret_key,
bucket: config.file_storage.bucket,
signingSecret: config.file_storage.signing_secret,
publicBaseUrl: config.file_storage.public_base_url,
}),
// Domain modules
AuthDomainModule,
RegistrationDomainModule,
@@ -0,0 +1,64 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString, 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';
// утилита для выборки повторяющихся параметров из базовых интерфейсов
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id' | 'appendix_hash'>;
// интерфейс параметров для генерации
type action = Cooperative.Registry.GenerationConvertStatement.Action;
@InputType(`BaseGenerationConvertStatementMetaDocumentInput`)
class BaseGenerationConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Хэш проекта' })
@IsString()
project_hash!: string;
@Field({ description: 'Сумма для перевода в Цифровой Кошелёк' })
@IsString()
main_wallet_amount!: string;
@Field({ description: 'Сумма для перевода в программу «Благорост»' })
@IsString()
blagorost_wallet_amount!: string;
@Field({ description: 'Признак перевода в Цифровой Кошелёк' })
@IsBoolean()
to_wallet!: boolean;
@Field({ description: 'Признак перевода в программу «Благорост»' })
@IsBoolean()
to_blagorost!: boolean;
}
@InputType(`GenerationConvertStatementGenerateDocumentInput`)
export class GenerationConvertStatementGenerateDocumentInputDTO extends IntersectionType(
BaseGenerationConvertStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
constructor() {
super();
}
}
@InputType(`GenerationConvertStatementSignedMetaDocumentInput`)
export class GenerationConvertStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseGenerationConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {
@Field({ description: 'Хэш приложения к проекту' })
@IsString()
appendix_hash!: string;
}
@InputType(`GenerationConvertStatementSignedDocumentInput`)
export class GenerationConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => GenerationConvertStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация для документа заявления о конвертации целевого паевого взноса',
})
public readonly meta!: GenerationConvertStatementSignedMetaDocumentInputDTO;
}
@@ -1,64 +0,0 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString, 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';
// утилита для выборки повторяющихся параметров из базовых интерфейсов
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id'>;
// интерфейс параметров для генерации
type action = Cooperative.Registry.GenerationToMainWalletConvertStatement.Action;
@InputType(`BaseGenerationToMainWalletConvertStatementMetaDocumentInput`)
class BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Хэш приложения' })
@IsString()
appendix_hash!: string;
@Field({ description: 'Хэш проекта' })
@IsString()
project_hash!: string;
@Field({ description: 'Сумма для перевода на основной кошелек' })
@IsString()
main_wallet_amount!: string;
@Field({ description: 'Сумма для перевода на благорост кошелек' })
@IsString()
blagorost_wallet_amount!: string;
@Field({ description: 'Перевод на основной кошелек' })
@IsBoolean()
to_wallet!: boolean;
@Field({ description: 'Перевод на благорост кошелек' })
@IsBoolean()
to_blagorost!: boolean;
}
@InputType(`GenerationToMainWalletConvertStatementGenerateDocumentInput`)
export class GenerationToMainWalletConvertStatementGenerateDocumentInputDTO extends IntersectionType(
BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
constructor() {
super();
}
}
@InputType(`GenerationToMainWalletConvertStatementSignedMetaDocumentInput`)
export class GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {}
@InputType(`GenerationToMainWalletConvertStatementSignedDocumentInput`)
export class GenerationToMainWalletConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация для документа заявления о переводе части целевого паевого взноса',
})
public readonly meta!: GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO;
}
@@ -1,5 +1,5 @@
import { Field, InputType } from '@nestjs/graphql';
import { ValidateNested } from 'class-validator';
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
import { CreateInitOrganizationDataInputDTO } from '~/application/account/dto/create-organization-data-input.dto';
@InputType('Init')
@@ -9,4 +9,13 @@ export class InitDTO {
})
@ValidateNested()
organization_data!: CreateInitOrganizationDataInputDTO;
@Field(() => Boolean, {
nullable: true,
description:
'Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.',
})
@IsBoolean()
@IsOptional()
is_server_init?: boolean;
}
@@ -27,10 +27,14 @@ export class InitInteractor {
}
// Определяем тип инициализации:
// - Если mono не существует - это ПЕРВАЯ инициализация (может быть как серверная так и пользовательская)
// - Если existingMono.init_by_server уже установлен - сохраняем его (не меняем источник)
// - Если mono существует но init_by_server не установлен - значит это пользовательская инициализация
const isServerInit = !existingMono ? true : existingMono.init_by_server === true;
// - Если data.is_server_init === true (вызов от провайдера через server-secret) — ВСЕГДА серверная.
// Это разблокирует перезапись user-init данных провайдером при следующем initSystem.
// - Иначе сохраняем прежнюю логику: первая инициализация = серверная, повторная — наследует флаг.
const isServerInit = data.is_server_init === true
? true
: !existingMono
? true
: existingMono.init_by_server === true;
// Проверяем права на обновление:
// Пользователь не может обновлять данные, установленные сервером
@@ -58,11 +62,11 @@ export class InitInteractor {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
}
// Устанавливаем флаг источника инициализации только если это первая инициализация
// - Для серверной инициализации (первый раз): устанавливаем true
// - Для пользовательской инициализации (первый раз): устанавливаем false
// - При повторной инициализации: НЕ меняем флаг (сохраняем изначальный источник)
if (!existingMono) {
// Устанавливаем флаг источника инициализации:
// - Первая инициализация: ставим isServerInit как есть (true для server-side, true для user-side при первом init — историческое поведение).
// - Повторная: если is_server_init=true пришёл от провайдера — поднимаем флаг в true (даже если до этого было user-init).
// Это нужно чтобы провайдер мог перезаписать данные после того как пользователь успел заполнить форму первым.
if (!existingMono || data.is_server_init === true) {
await this.monoStatusRepository.setInitByServer(isServerInit);
}
+33 -2
View File
@@ -93,8 +93,9 @@ const envVarsSchema = z.object({
.default('https://союз-русь.рф/anketa')
.describe('ссылка на анкету для получения членства в союзе кооперативов'),
IS_UNIONED: z
.boolean()
.default(true)
.string()
.default('true')
.transform((v) => v === 'true')
.describe('флаг, указывающий что требуется членство в союзе для подключения к кооперативной экономике'),
MATRIX_UNION_PERSON_ID: z.string().optional().describe('Matrix userId представителя союза для связи с кооперативами'),
MATRIX_UNION_NAME: z.string().default('СПО РУСЬ').describe('Название союза для подписания комнат связи'),
@@ -170,6 +171,28 @@ const envVarsSchema = z.object({
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N).
// MINIO_ENDPOINT без default: если не задан — file storage стартует в no-op,
// HeadBucket не делается, операции get/put отдают понятную ошибку.
MINIO_ENDPOINT: z
.string()
.optional()
.describe('Endpoint S3-совместимого бэкенда; в compose — service name. Пусто = file storage отключён.'),
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
MINIO_BUCKET: z
.string()
.optional()
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
FILE_STORAGE_SIGNING_SECRET: z
.string()
.optional()
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
FILE_STORAGE_PUBLIC_BASE_URL: z
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -293,4 +316,12 @@ export default {
whisper_model: envVars.data.WHISPER_MODEL,
whisper_language: envVars.data.WHISPER_LANGUAGE,
},
file_storage: {
endpoint: envVars.data.MINIO_ENDPOINT,
access_key: envVars.data.MINIO_ACCESS_KEY,
secret_key: envVars.data.MINIO_SECRET_KEY,
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
},
};
@@ -2,4 +2,8 @@ import type { CreateOrganizationDataInputDomainInterface } from '~/domain/accoun
export interface InitInputDomainInterface {
organization_data: CreateOrganizationDataInputDomainInterface;
// Если true — инициализация инициирована провайдером (через server-secret),
// флаг init_by_server проставляется в true безусловно. Если undefined/false —
// сохраняется текущая логика: setInitByServer(true) только при первой init.
is_server_init?: boolean;
}
@@ -90,14 +90,16 @@ export class CommitOutputDTO extends BaseOutputDTO {
commit_hash!: string;
@Field(() => String, {
nullable: true,
description: 'Описание коммита',
})
description!: string;
description?: string;
@Field(() => String, {
nullable: true,
description: 'Метаданные коммита',
})
meta!: string;
meta?: string;
@Field(() => GraphQLJSON, {
nullable: true,
@@ -7,9 +7,11 @@ import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
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 { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
@@ -57,60 +59,24 @@ export class DistributionManagementResolver {
// ============ ГЕНЕРАЦИЯ ДОКУМЕНТОВ ============
/**
* Мутация для генерации заявления о конвертации из генерации в основной кошелек
* Мутация для генерации заявления о конвертации целевого паевого взноса
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToMainWalletConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в основной кошелек',
name: 'capitalGenerateGenerationConvertStatement',
description: 'Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToMainWalletConvertStatement(
@Args('data', { type: () => GenerationToMainWalletConvertStatementGenerateDocumentInputDTO })
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
async generateGenerationConvertStatement(
@Args('data', { type: () => GenerationConvertStatementGenerateDocumentInputDTO })
data: GenerationConvertStatementGenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
options: GenerateDocumentOptionsInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToMainWalletConvertStatement(data, options);
}
/**
* Мутация для генерации заявления о конвертации из генерации в проектный кошелек
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToProjectConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в проектный кошелек',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToProjectConvertStatement(
@Args('data', { type: () => GenerateDocumentInputDTO })
data: GenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToProjectConvertStatement(data, options);
}
/**
* Мутация для генерации заявления о конвертации из генерации в благорост
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToCapitalizationConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в благорост',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToCapitalizationConvertStatement(
@Args('data', { type: () => GenerateDocumentInputDTO })
data: GenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToCapitalizationConvertStatement(data, options);
return this.distributionManagementService.generateGenerationConvertStatement(data, options, currentUser);
}
/**
@@ -5,10 +5,11 @@ import type { RefreshProgramInputDTO } from '../dto/distribution_management/refr
import type { TransactResult } from '@wharfkit/session';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import { DocumentInteractor } from '~/application/document/interactors/document.interactor';
import { Cooperative } from 'cooptypes';
import type { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
/**
* Сервис уровня приложения для управления распределением в CAPITAL
@@ -40,50 +41,19 @@ export class DistributionManagementService {
// ============ МЕТОДЫ ГЕНЕРАЦИИ ДОКУМЕНТОВ ============
/**
* Генерация заявления о конвертации из генерации в основной кошелек
* Генерация заявления о конвертации целевого паевого взноса
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
*/
async generateGenerationToMainWalletConvertStatement(
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
async generateGenerationConvertStatement(
data: GenerationConvertStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO,
currentUser: MonoAccountDomainInterface
): Promise<GeneratedDocumentDTO> {
const enrichedData = await this.distributionManagementInteractor.prepareGenerationConvertStatementData(data, currentUser);
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToMainWalletConvertStatement.registry_id,
},
options,
});
return document as GeneratedDocumentDTO;
}
/**
* Генерация заявления о конвертации из генерации в проектный кошелек
*/
async generateGenerationToProjectConvertStatement(
data: GenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToProjectConvertStatement.registry_id,
},
options,
});
return document as GeneratedDocumentDTO;
}
/**
* Генерация заявления о конвертации из генерации в благорост
*/
async generateGenerationToCapitalizationConvertStatement(
data: GenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToCapitalizationConvertStatement.registry_id,
...enrichedData,
registry_id: Cooperative.Registry.GenerationConvertStatement.registry_id,
},
options,
});
@@ -125,7 +125,7 @@ export class MutationLogMapperService {
// Результаты и конвертация
capitalPushResult: LogEventType.RESULT_PUSHED,
capitalConvertSegment: LogEventType.SEGMENT_CONVERTED,
capitalGenerateGenerationToMainWalletConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
capitalGenerateGenerationConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
capitalGenerateCapitalizationToMainWalletConvertStatement: LogEventType.PROGRAM_WITHDRAWAL,
// Генерация - Stories, Issues, Cycles
@@ -41,6 +41,14 @@ export class TimeTrackingService {
await this.timeTrackingInteractor.recalcDoneEstimatesForContributorProject(contributorHash, projectHash);
}
/**
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
* раскладку estimate-долей для затронутых задач.
*/
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
await this.timeTrackingInteractor.revertEntriesForDeclinedCommit(commitHash);
}
/**
* Получить статистику времени для участника по проекту (DTO версия)
*/
@@ -3,6 +3,10 @@ import { CapitalBlockchainPort, CAPITAL_BLOCKCHAIN_PORT } from '../../domain/int
import type { TransactResult } from '@wharfkit/session';
import type { FundProgramDomainInput } from '../../domain/actions/fund-program-domain-input.interface';
import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-program-domain-input.interface';
import { APPENDIX_REPOSITORY, AppendixRepository } from '../../domain/repositories/appendix.repository';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { Cooperative } from 'cooptypes';
/**
* Интерактор домена для распределения средств в CAPITAL контракте
@@ -12,7 +16,9 @@ import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-pro
export class DistributionManagementInteractor {
constructor(
@Inject(CAPITAL_BLOCKCHAIN_PORT)
private readonly capitalBlockchainPort: CapitalBlockchainPort
private readonly capitalBlockchainPort: CapitalBlockchainPort,
@Inject(APPENDIX_REPOSITORY)
private readonly appendixRepository: AppendixRepository
) {}
/**
@@ -32,4 +38,31 @@ export class DistributionManagementInteractor {
return await this.capitalBlockchainPort.refreshProgram(data);
}
/**
* Подготавливает данные для генерации заявления о конвертации целевого паевого взноса.
* appendix_hash подтягивается по (username, project_hash) из подтверждённого приложения к проекту.
*/
async prepareGenerationConvertStatementData(
data: GenerationConvertStatementGenerateDocumentInputDTO,
currentUser: MonoAccountDomainInterface
): Promise<Cooperative.Registry.GenerationConvertStatement.Action> {
const projectHash = data.project_hash;
if (!projectHash) {
throw new Error('project_hash обязателен для генерации заявления о конвертации');
}
const userAppendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
currentUser.username,
projectHash
);
if (!userAppendix) {
throw new Error(`Не найдено подтверждённое приложение пользователя ${currentUser.username} для проекта ${projectHash}`);
}
return {
...data,
appendix_hash: userAppendix.appendix_hash,
} as Cooperative.Registry.GenerationConvertStatement.Action;
}
}
@@ -358,6 +358,11 @@ export class GenerationInteractor {
commit.status = CommitStatus.DECLINED;
await this.commitRepository.save(commit);
// Откатываем time-entries обратно в uncommitted и нормализуем раскладку долей
// по затронутым DONE-задачам. Иначе часы остаются в total_committed_hours и
// не возвращаются в доступный пул.
await this.timeTrackingService.revertEntriesForDeclinedCommit(data.commit_hash);
// Создаём данные для блокчейна
const blockchainData: CapitalContract.Actions.CommitDecline.ICommitDecline = {
coopname: data.coopname,
@@ -429,6 +434,10 @@ export class GenerationInteractor {
// Сохранить изменения
await this.commitRepository.save(commit);
// Откатить time-entries и нормализовать раскладку (идемпотентно: если decline
// уже был обработан через локальный путь, revert вернёт 0 затронутых).
await this.timeTrackingService.revertEntriesForDeclinedCommit(actionPayload.commit_hash);
this.logger.debug(`Коммит ${actionPayload.commit_hash} отклонен`);
} catch (error: any) {
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
@@ -0,0 +1,536 @@
import { TimeTrackingInteractor } from './time-tracking.interactor';
import type { TimeEntryRepository } from '../../domain/repositories/time-entry.repository';
import type { ProjectRepository } from '../../domain/repositories/project.repository';
import type { ContributorRepository } from '../../domain/repositories/contributor.repository';
import type { IssueRepository } from '../../domain/repositories/issue.repository';
import { TimeEntryDomainEntity } from '../../domain/entities/time-entry.entity';
import { IssueStatus } from '../../domain/enums/issue-status.enum';
import { IssuePriority } from '../../domain/enums/issue-priority.enum';
import { IssueDomainEntity } from '../../domain/entities/issue.entity';
import type { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
import { ContributorStatus } from '../../domain/enums/contributor-status.enum';
// Сценарии калиброваны по проду voskhod (issue CC7-1, estimate=15, 3 creators)
// чтобы локально воспроизводить найденные баги и предотвращать регрессию.
type Mocked<T> = { [K in keyof T]: jest.Mock };
function makeContributor(username: string, hash: string, coopname = 'voskhod'): ContributorDomainEntity {
return {
contributor_hash: hash,
username,
coopname,
display_name: username,
status: ContributorStatus.ACTIVE,
hours_per_day: 8,
} as unknown as ContributorDomainEntity;
}
function makeIssue(opts: {
issue_hash: string;
project_hash?: string;
coopname?: string;
estimate: number;
status: IssueStatus;
creators: string[];
id?: string;
}): IssueDomainEntity {
return {
id: opts.id ?? 'TST-1',
issue_hash: opts.issue_hash,
project_hash: opts.project_hash ?? 'project-hash-1',
coopname: opts.coopname ?? 'voskhod',
title: 'Test issue',
priority: IssuePriority.MEDIUM,
status: opts.status,
estimate: opts.estimate,
creators: opts.creators,
created_by: opts.creators[0] ?? 'ant',
sort_order: 0,
metadata: { labels: [], attachments: [] },
} as unknown as IssueDomainEntity;
}
function makeEntry(opts: {
contributor_hash: string;
issue_hash: string;
hours: number;
is_committed: boolean;
entry_type?: 'hourly' | 'estimate';
estimate_snapshot?: number;
commit_hash?: string;
_id?: string;
date?: string;
}): TimeEntryDomainEntity {
return new TimeEntryDomainEntity({
_id: opts._id ?? `e-${Math.random()}`,
contributor_hash: opts.contributor_hash,
issue_hash: opts.issue_hash,
project_hash: 'project-hash-1',
coopname: 'voskhod',
date: opts.date ?? '2026-05-15',
hours: opts.hours,
is_committed: opts.is_committed,
entry_type: opts.entry_type ?? 'estimate',
estimate_snapshot: opts.estimate_snapshot,
commit_hash: opts.commit_hash,
block_num: 0,
present: false,
status: 'active',
});
}
function buildInteractor() {
const timeEntryRepository: Mocked<TimeEntryRepository> = {
create: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
findByContributorAndDate: jest.fn().mockResolvedValue([]),
findUncommittedByContributor: jest.fn().mockResolvedValue([]),
findUncommittedByProjectAndContributor: jest.fn().mockResolvedValue([]),
update: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
updateMany: jest.fn().mockResolvedValue(undefined),
getTotalUncommittedHours: jest.fn().mockResolvedValue(0),
getContributorProjectStats: jest
.fn()
.mockResolvedValue({ total_committed_hours: 0, total_uncommitted_hours: 0 }),
commitTimeEntries: jest.fn().mockResolvedValue(undefined),
revertCommittedEntriesByCommitHash: jest.fn().mockResolvedValue(0),
findCommittedByCommitHash: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue(undefined),
deleteUncommittedByIssueHash: jest.fn().mockResolvedValue(undefined),
findProjectsByContributor: jest.fn().mockResolvedValue([]),
findContributorsByProject: jest.fn().mockResolvedValue([]),
findByProjectWithPagination: jest
.fn()
.mockResolvedValue({ items: [], totalCount: 0, currentPage: 1, totalPages: 0 }),
getAggregatedTimeEntriesByIssues: jest.fn().mockResolvedValue([]),
getAggregatedTimeEntriesCount: jest.fn().mockResolvedValue(0),
findByIssueAndType: jest.fn().mockResolvedValue([]),
getTotalEstimateHoursByIssue: jest.fn().mockResolvedValue({ total: 0, estimate_snapshot: 0 }),
hasCommittedTimeByIssueHash: jest.fn().mockResolvedValue(false),
updateProjectHashByIssueHash: jest.fn().mockResolvedValue(undefined),
getFactByIssues: jest.fn().mockResolvedValue(new Map()),
};
const contributorRepository: Partial<Mocked<ContributorRepository>> = {
findByUsernameAndCoopname: jest.fn().mockResolvedValue(null),
findOne: jest.fn().mockResolvedValue(null),
findByStatusAndCoopname: jest.fn().mockResolvedValue([]),
};
const issueRepository: Partial<Mocked<IssueRepository>> = {
findByIssueHash: jest.fn().mockResolvedValue(null),
findByStatus: jest.fn().mockResolvedValue([]),
findByStatusAndCreators: jest.fn().mockResolvedValue([]),
findCompletedByProjectAndCreators: jest.fn().mockResolvedValue([]),
};
const projectRepository: Partial<Mocked<ProjectRepository>> = {
findAll: jest.fn().mockResolvedValue([]),
findByHash: jest.fn().mockResolvedValue(null),
};
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
log: jest.fn(),
verbose: jest.fn(),
} as any;
const interactor = new TimeTrackingInteractor(
timeEntryRepository as unknown as TimeEntryRepository,
projectRepository as unknown as ProjectRepository,
contributorRepository as unknown as ContributorRepository,
issueRepository as unknown as IssueRepository,
logger
);
return { interactor, timeEntryRepository, contributorRepository, issueRepository, projectRepository };
}
function expectEntryCreate(
mock: jest.Mock,
expected: Partial<{ contributor_hash: string; hours: number; entry_type: string; is_committed: boolean }>
): void {
const calls = mock.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const match = calls.find((entry) => {
if (expected.contributor_hash && entry.contributor_hash !== expected.contributor_hash) return false;
if (expected.hours !== undefined && Math.abs(entry.hours - expected.hours) > 1e-6) return false;
if (expected.entry_type !== undefined && entry.entry_type !== expected.entry_type) return false;
if (expected.is_committed !== undefined && entry.is_committed !== expected.is_committed) return false;
return true;
});
if (!match) {
const summary = calls
.map((e) => `{contributor=${e.contributor_hash}, hours=${e.hours}, type=${e.entry_type}, committed=${e.is_committed}}`)
.join('\n ');
throw new Error(`No create() call matched ${JSON.stringify(expected)}.\nActual calls:\n ${summary}`);
}
}
describe('TimeTrackingInteractor.applyExplicitEstimateToTimeEntries', () => {
it('делит estimate поровну между creators при первой установке', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate', is_committed: false });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate', is_committed: false });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate', is_committed: false });
});
it('при estimate=0 только удаляет незакоммиченные, новых не создаёт', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
const issue = makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
it('БАГ #1: не даёт «двойную долю» creator-у, который уже закоммитил свою часть', async () => {
// Прод-сценарий CC7-1: ant закоммитил полную долю (5 ч). При recalc/applyExplicit
// его доля = 5 5 = 0 (а не 10/3 = 3.33 как было в баговой версии). Остальные
// получают свои 5 ч, без «бонусной перераздачи остатка».
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
// ant не должен получить uncommitted estimate (он уже закоммитил полную долю)
const createCalls = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const antEntries = createCalls.filter((e) => e.contributor_hash === 'ant-hash');
expect(antEntries).toHaveLength(0);
// Остальные двое получают по 5 ч каждый, а не 10/3
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
expect(timeEntryRepository.create).toHaveBeenCalledTimes(2);
});
it('частичный committed уменьшает долю только этого creator', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
// Смуров закоммитил 3 из 5 → его остаток 2, у остальных по 5
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 2 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
});
it('overcommitted (закоммичено больше доли) даёт 0, не отрицательное значение', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 8, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
const antCreates = timeEntryRepository.create.mock.calls
.map((c) => c[0] as TimeEntryDomainEntity)
.filter((e) => e.contributor_hash === 'ant-hash');
expect(antCreates).toHaveLength(0);
});
it('пустой список creators — удаляет uncommitted, не создаёт новых', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: [] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('TimeTrackingInteractor.recalcDoneEstimatesForContributorProject', () => {
it('no-op если раскладка уже совпадает с планом', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
it('лечит раскладку 10/3 (баговую) обратно к 5/5/5 когда есть закоммитившие', async () => {
// Воспроизведение прод-сценария CC7-1 после фикса: ant закоммитил 5 ч (committed),
// в БД остались баговые 3.333 uncommitted у всех. Recalc должен снести их и
// оставить ant=0, остальным по 5.
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
// ant не получает uncommitted (он уже закоммитил свою долю)
const antCreates = timeEntryRepository.create.mock.calls
.map((c) => c[0] as TimeEntryDomainEntity)
.filter((e) => e.contributor_hash === 'ant-hash');
expect(antCreates).toHaveLength(0);
});
it('пропускает задачи без estimate', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('TimeTrackingInteractor.commitTime', () => {
it('БАГ #2: при partial split сохраняет entry_type=estimate и estimate_snapshot', async () => {
// Прод-сценарий: у Смурова 3.333 ч estimate uncommitted, коммит 3 ч → split.
// До фикса новая committed-запись создавалась с entry_type='hourly' (default).
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('smr', 'smr-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
]);
const entry = makeEntry({
contributor_hash: 'smr-hash',
issue_hash: 'i1',
hours: 5,
is_committed: false,
entry_type: 'estimate',
estimate_snapshot: 15,
_id: 'orig',
});
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([entry]);
await interactor.commitTime('smr-hash', 'project-hash-1', 3, 'commit-hash-1');
expect(timeEntryRepository.create).toHaveBeenCalledTimes(1);
const created = timeEntryRepository.create.mock.calls[0][0] as TimeEntryDomainEntity;
expect(created.is_committed).toBe(true);
expect(created.hours).toBe(3);
expect(created.entry_type).toBe('estimate');
expect(created.estimate_snapshot).toBe(15);
expect(created.commit_hash).toBe('commit-hash-1');
expect(timeEntryRepository.update).toHaveBeenCalledTimes(1);
const updated = timeEntryRepository.update.mock.calls[0][0] as TimeEntryDomainEntity;
expect(updated._id).toBe('orig');
expect(updated.hours).toBeCloseTo(2, 6);
});
it('full commit (entry.hours == requested) помечает оригинал, не создаёт split', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
]);
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'orig' }),
]);
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-hash-2');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
});
it('коммит только по DONE задачам — отфильтровывает entries по активным задачам', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
// Только i1 в DONE, i2 в IN_PROGRESS
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant'] }),
]);
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate' }),
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i2', hours: 3, is_committed: false, entry_type: 'hourly' }),
]);
await interactor.commitTime('ant-hash', 'project-hash-1', 3, 'commit-hash-3');
// Должен коммитить из i1 (DONE), не из i2
const committedArgs = timeEntryRepository.commitTimeEntries.mock.calls[0]?.[0] as TimeEntryDomainEntity[] | undefined;
const splitCreate = timeEntryRepository.create.mock.calls[0]?.[0] as TimeEntryDomainEntity | undefined;
const touchedIssues = new Set<string>();
committedArgs?.forEach((e) => touchedIssues.add(e.issue_hash));
if (splitCreate) touchedIssues.add(splitCreate.issue_hash);
expect(touchedIssues.has('i2')).toBe(false);
});
});
describe('TimeTrackingInteractor.revertEntriesForDeclinedCommit', () => {
it('БАГ #3: вызывает revert + нормализует раскладку по затронутой DONE-задаче', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const reverted = makeEntry({
contributor_hash: 'ant-hash',
issue_hash: 'i1',
hours: 5,
is_committed: true,
entry_type: 'estimate',
estimate_snapshot: 15,
commit_hash: 'commit-bad',
});
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([reverted]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
issueRepository.findByIssueHash!.mockResolvedValue(
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] })
);
// После revert у ant'a 0 committed (revert убрал) — поэтому новая раскладка 5/5/5
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
await interactor.revertEntriesForDeclinedCommit('commit-bad');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalledWith('commit-bad');
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate' });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate' });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate' });
});
it('идемпотентно: повторный вызов на коммит без записей — no-op', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([]);
await interactor.revertEntriesForDeclinedCommit('commit-already-reverted');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).not.toHaveBeenCalled();
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
});
it('не пересчитывает задачи которые больше не DONE', async () => {
const { interactor, timeEntryRepository, issueRepository } = buildInteractor();
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15, commit_hash: 'c1' }),
]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
issueRepository.findByIssueHash!.mockResolvedValue(
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.IN_PROGRESS, creators: ['ant'] })
);
await interactor.revertEntriesForDeclinedCommit('c1');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalled();
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('integration scenario: CC7-1 прод-инцидент', () => {
it('после декларации estimate=15 на 3 creators даёт по 5 ч каждому, total uncommitted=15', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({
issue_hash: 'cc7-1-hash',
estimate: 15,
status: IssueStatus.DONE,
creators: ['ant', 'zxfevlujlica', 'ipesgnlxmnwx'],
});
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
await interactor.applyExplicitEstimateToTimeEntries(issue);
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
expect(creates).toHaveLength(3);
const totalHours = creates.reduce((s, e) => s + e.hours, 0);
expect(totalHours).toBeCloseTo(15, 6);
creates.forEach((e) => {
expect(e.hours).toBeCloseTo(5, 6);
expect(e.entry_type).toBe('estimate');
expect(e.is_committed).toBe(false);
expect(e.estimate_snapshot).toBe(15);
});
});
it('сценарий full lifecycle: коммит → decline → revert восстанавливает доступное время', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const doneIssue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([doneIssue]);
issueRepository.findByIssueHash!.mockResolvedValue(doneIssue);
// 1. Стартовое состояние: 5/5/5 uncommitted
const initial = [
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'a1' }),
];
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue(initial);
// 2. ant коммитит свои 5 ч
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-ant');
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
// 3. Симулируем что в БД теперь есть committed estimate 5 ч от ant'a
const committedEntry = makeEntry({
contributor_hash: 'ant-hash',
issue_hash: 'i1',
hours: 5,
is_committed: true,
entry_type: 'estimate',
estimate_snapshot: 15,
commit_hash: 'commit-ant',
});
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([committedEntry]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
// После revert у ant'а 0 committed estimate → новая раскладка 5/5/5
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
// 4. Decline → revert
timeEntryRepository.create.mockClear();
timeEntryRepository.deleteUncommittedByIssueHash.mockClear();
await interactor.revertEntriesForDeclinedCommit('commit-ant');
// 5. После revert: 5/5/5 uncommitted
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const total = creates.reduce((s, e) => s + e.hours, 0);
expect(total).toBeCloseTo(15, 6);
expect(creates.every((e) => e.entry_type === 'estimate' && !e.is_committed)).toBe(true);
});
});
@@ -49,64 +49,116 @@ export class TimeTrackingInteractor {
}
/**
* Реакция на фактическое изменение оценки (вызывать только если сохранённое и новое значение
* различаются): снимаем все незакоммиченные билеты по задаче, затем при ненулевой оценке создаём
* новые записи estimate (поровну между исполнителями). При оценке 0 только очистка.
* Реакция на фактическое изменение оценки: пересобирает незакоммиченные estimate-билеты
* задачи под текущий состав creators и новую оценку, учитывая личный committed-баланс
* каждого исполнителя. При оценке 0 только очистка незакоммиченных.
*/
async applyExplicitEstimateToTimeEntries(issue: IssueDomainEntity): Promise<void> {
const newEstimate = issue.estimate ?? 0;
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
if (isNegligibleHours(newEstimate)) {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
this.logger.debug(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), оценка снята или обнулена — незакоммиченные билеты по задаче удалены`
);
return;
}
await this.redistributeIssueEstimateEntries(issue, newEstimate, { force: true });
}
/**
* Пересобрать незакоммиченные estimate-билеты задачи: для каждого creator личная
* доля = estimate / N, минус его уже закоммиченные estimate-часы по этой же задаче.
* Закоммиченные записи не трогаются. «Остаток» одного creator не перераспределяется
* между другими каждый учитывается изолированно.
*
* @param opts.force true пересоздаёт всегда; false пропускает no-op когда раскладка
* уже совпадает с планом.
* @returns true если что-то пересоздано/удалено, false если no-op
*/
private async redistributeIssueEstimateEntries(
issue: IssueDomainEntity,
estimate: number,
opts: { force: boolean }
): Promise<boolean> {
const creators = issue.creators || [];
if (creators.length === 0) {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
this.logger.warn(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены, начисление estimate пропущено`
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены`
);
return;
return true;
}
const hoursPerCreator = newEstimate / creators.length;
const date = new Date().toISOString().split('T')[0];
const sharePerCreator = estimate / creators.length;
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
const committedByContributor = new Map<string, number>();
for (const entry of estimateEntries) {
if (!entry.is_committed) continue;
committedByContributor.set(
entry.contributor_hash,
(committedByContributor.get(entry.contributor_hash) || 0) + entry.hours
);
}
type Plan = { contributor_hash: string; uncommittedShare: number };
const plan: Plan[] = [];
for (const creatorUsername of creators) {
const contributor = await this.contributorRepository.findByUsernameAndCoopname(creatorUsername, issue.coopname);
if (!contributor) {
this.logger.warn(
`applyExplicitEstimateToTimeEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
`redistributeIssueEstimateEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
);
continue;
}
const myCommitted = committedByContributor.get(contributor.contributor_hash) || 0;
const uncommittedShare = Math.max(0, sharePerCreator - myCommitted);
plan.push({ contributor_hash: contributor.contributor_hash, uncommittedShare });
}
const estimateEntry = new TimeEntryDomainEntity({
_id: '',
contributor_hash: contributor.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: hoursPerCreator,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: newEstimate,
});
if (!opts.force) {
const currentUncommitted = estimateEntries.filter((e) => !e.is_committed);
const planNonZero = plan.filter((p) => p.uncommittedShare > HOURS_FLOAT_EPSILON);
const planByHash = new Map(planNonZero.map((p) => [p.contributor_hash, p.uncommittedShare]));
const isValid =
currentUncommitted.length === planNonZero.length &&
currentUncommitted.every((entry) => {
const expected = planByHash.get(entry.contributor_hash);
return expected !== undefined && hoursAlmostEqual(entry.hours, expected);
});
if (isValid) return false;
}
await this.timeEntryRepository.create(estimateEntry);
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
const date = new Date().toISOString().split('T')[0];
for (const item of plan) {
if (item.uncommittedShare <= HOURS_FLOAT_EPSILON) continue;
await this.timeEntryRepository.create(
new TimeEntryDomainEntity({
_id: '',
contributor_hash: item.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: item.uncommittedShare,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: estimate,
})
);
}
this.logger.debug(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), выставлена оценка ${newEstimate} ч — незакоммиченные билеты заменены`
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}), estimate=${estimate} ч, ` +
`share=${sharePerCreator} ч × ${creators.length} creators, незакоммиченные пересозданы (${plan.length} участников)`
);
return true;
}
/**
@@ -117,12 +169,38 @@ export class TimeTrackingInteractor {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issueHash);
}
/**
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
* раскладку estimate-долей для затронутых DONE-задач (если их состав creators
* актуален). Идемпотентно: вызов на коммит без time-entries no-op.
*/
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
const reverted = await this.timeEntryRepository.findCommittedByCommitHash(commitHash);
if (reverted.length === 0) return;
await this.timeEntryRepository.revertCommittedEntriesByCommitHash(commitHash);
const issueHashes = [...new Set(reverted.map((e) => e.issue_hash))];
for (const issueHash of issueHashes) {
const issue = await this.issueRepository.findByIssueHash(issueHash);
if (!issue) continue;
if (issue.status !== IssueStatus.DONE) continue;
const estimate = issue.estimate ?? 0;
if (isNegligibleHours(estimate)) continue;
await this.redistributeIssueEstimateEntries(issue, estimate, { force: true });
}
this.logger.debug(
`revertEntriesForDeclinedCommit: коммит ${commitHash}, откачено ${reverted.length} записей, ` +
`пересчитано ${issueHashes.length} задач`
);
}
/**
* Лечебный пересчёт: для всех DONE-задач проекта, где участник сейчас в creators,
* сносит незакоммиченные estimate-билеты и распределяет оставшуюся часть estimate
* (estimate уже закоммиченные часы по задаче) поровну между текущими creators.
* Идемпотентно. Вызывается, например, перед createCommit, чтобы вылечить расхождения,
* накопившиеся после смен creators/статусов, не прошедших через applyExplicitEstimate.
* пересобирает незакоммиченные estimate-билеты по личным долям creators
* (estimate / N собственный committed). Идемпотентно. Вызывается перед createCommit
* и из миграций, чтобы вылечить расхождения после смен creators/статусов.
*/
async recalcDoneEstimatesForContributorProject(contributorHash: string, projectHash: string): Promise<void> {
const contributor = await this.contributorRepository.findOne({ contributor_hash: contributorHash });
@@ -135,76 +213,7 @@ export class TimeTrackingInteractor {
for (const issue of completedIssues) {
const estimate = issue.estimate ?? 0;
if (isNegligibleHours(estimate)) continue;
const creators = issue.creators || [];
if (creators.length === 0) continue;
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
const committedTotal = estimateEntries
.filter((entry) => entry.is_committed)
.reduce((sum, entry) => sum + entry.hours, 0);
const uncommittedTotal = estimateEntries
.filter((entry) => !entry.is_committed)
.reduce((sum, entry) => sum + entry.hours, 0);
const remaining = estimate - committedTotal;
const expectedHoursPerCreator = remaining > HOURS_FLOAT_EPSILON ? remaining / creators.length : 0;
const currentCreatorHashes = new Set<string>();
for (const creatorUsername of creators) {
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
creatorUsername,
issue.coopname
);
if (creatorContributor) currentCreatorHashes.add(creatorContributor.contributor_hash);
}
const uncommittedEntries = estimateEntries.filter((entry) => !entry.is_committed);
const distributionLooksValid =
uncommittedEntries.length === currentCreatorHashes.size &&
uncommittedEntries.every(
(entry) =>
currentCreatorHashes.has(entry.contributor_hash) &&
hoursAlmostEqual(entry.hours, expectedHoursPerCreator)
) &&
hoursAlmostEqual(uncommittedTotal, remaining > HOURS_FLOAT_EPSILON ? remaining : 0);
if (distributionLooksValid) continue;
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
if (remaining <= HOURS_FLOAT_EPSILON) continue;
const date = new Date().toISOString().split('T')[0];
for (const creatorUsername of creators) {
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
creatorUsername,
issue.coopname
);
if (!creatorContributor) continue;
await this.timeEntryRepository.create(
new TimeEntryDomainEntity({
_id: '',
contributor_hash: creatorContributor.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: expectedHoursPerCreator,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: estimate,
})
);
}
this.logger.debug(
`recalcDoneEstimatesForContributorProject: задача ${issue.id} (${issue.issue_hash}) — пересчитаны estimate-билеты: ` +
`estimate=${estimate} ч, committed=${committedTotal} ч, distributed=${expectedHoursPerCreator} ч × ${creators.length}`
);
await this.redistributeIssueEstimateEntries(issue, estimate, { force: false });
}
}
@@ -801,7 +810,9 @@ export class TimeTrackingInteractor {
entriesToCommit.push(entry);
remainingHours -= entry.hours;
} else {
// Коммитим часть записи - создаём новую запись с оставшимся временем
// Коммитим часть записи - создаём новую запись с оставшимся временем.
// entry_type и estimate_snapshot копируем из оригинала — иначе recalc estimate
// не увидит закоммиченную долю (он фильтрует только entry_type='estimate').
const committedEntry = new TimeEntryDomainEntity({
_id: '',
contributor_hash: entry.contributor_hash,
@@ -812,6 +823,8 @@ export class TimeTrackingInteractor {
hours: remainingHours,
commit_hash: commitHash,
is_committed: true,
entry_type: entry.entry_type,
estimate_snapshot: entry.estimate_snapshot,
block_num: entry.block_num,
present: entry.present,
status: entry.status,
@@ -59,6 +59,21 @@ export interface TimeEntryRepository {
*/
commitTimeEntries(entries: TimeEntryDomainEntity[], commitHash: string): Promise<void>;
/**
* Найти закоммиченные time-entries по commit_hash. Используется при decline,
* чтобы понять какие задачи затронуты до отката.
*/
findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]>;
/**
* Откатить закоммиченные time-entries по commit_hash обратно в uncommitted
* (is_committed=false, commit_hash=NULL). Используется при decline коммита, чтобы
* часы вернулись в доступный пул. Идемпотентно.
*
* @returns количество затронутых записей
*/
revertCommittedEntriesByCommitHash(commitHash: string): Promise<number>;
/**
* Удалить запись времени
*/
@@ -121,6 +121,24 @@ export class TimeEntryTypeormRepository implements TimeEntryRepository {
await this.repository.update({ _id: In(ids) }, { commit_hash: commitHash, is_committed: true, _updated_at: new Date() });
}
async findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]> {
const entities = await this.repository.find({
where: { commit_hash: commitHash, is_committed: true },
});
return entities.map((entity) => this.toDomain(entity));
}
async revertCommittedEntriesByCommitHash(commitHash: string): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.update(TimeEntryEntity)
.set({ is_committed: false, commit_hash: null as unknown as undefined, _updated_at: new Date() })
.where('commit_hash = :commitHash', { commitHash })
.andWhere('is_committed = true')
.execute();
return result.affected ?? 0;
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
@@ -0,0 +1,169 @@
# FileStorage
Универсальное файловое хранилище контура кооператива (MinIO в v1, готовность к нативному S3).
Подробности и архитектура — req **«SPEC: файловое хранилище v1»** в blago.
## Когда использовать
Расширению нужно положить и потом отдать пайщику бинарный файл — изображение, PDF, в перспективе
любой другой blob. Не катить свой S3-клиент и не плодить ad-hoc эндпоинты.
## Минимальный пример
### 1. Сервис расширения декларирует свой бакет
```ts
// extensions/stol-zakazov/order-images.service.ts
import { Injectable } from '@nestjs/common';
import {
InjectBucket,
UseBucket,
type InterFileStorageBucket,
} from '~/infrastructure/file-storage';
import type { FileUpload } from 'graphql-upload';
const MB = 1024 * 1024;
@UseBucket({
name: 'stol-zakazov:images',
maxBytes: 10 * MB,
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
metadataSchema: { ownerId: 'required', orderId: 'required' },
defaultUrlTtlSeconds: 600,
})
@Injectable()
export class OrderImagesService {
constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
async attach(orderId: string, ownerId: string, upload: FileUpload): Promise<string> {
const key = `orders/${orderId}/main.${ext(upload.mimetype)}`;
await this.bucket.put(key, upload.createReadStream(), {
contentType: upload.mimetype,
metadata: { ownerId, orderId },
});
return key;
}
async displayUrl(key: string): Promise<string> {
return this.bucket.getReadUrl(key);
}
async remove(key: string): Promise<void> {
await this.bucket.delete(key); // идемпотентно
}
}
```
**Конвенция:** один бакет — один сервис. Нужен второй бакет — сделай второй сервис.
### 2. Модуль расширения регистрирует сервис в `forFeature`
```ts
// extensions/stol-zakazov/stol-zakazov.module.ts
import { Module } from '@nestjs/common';
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
import { OrderImagesService } from './order-images.service';
@Module({
imports: [FileStorageInfrastructureModule.forFeature([OrderImagesService])],
providers: [OrderImagesService],
exports: [OrderImagesService],
})
export class StolZakazovModule {}
```
`forFeature` читает `@UseBucket` метадату каждого класса и провайдит ему конкретный
`InterFileStorageBucket` (с уже подмешанным префиксом). Корневой `forRoot(...)` уже подключён
в `app.module.ts`.
### 3. GraphQL — обычная мутация с Upload и поле URL в ответе
```graphql
type Mutation {
createOrderItem(input: CreateOrderItemInput!): OrderItem!
}
input CreateOrderItemInput {
title: String!
image: Upload!
}
type OrderItem {
id: ID!
title: String!
imageUrl: String! # резолвер: bucket.getReadUrl(key) после ACL-проверки
}
```
UI ходит обычным `<img src={imageUrl}>`. Никакого нового REST-слоя для GraphQL-клиента —
служебная ручка `/api/storage/...` живёт сама и видит только подписанные URL.
## Операции
| Метод | Что делает | Гарантии |
|---|---|---|
| `put(key, body, opts)` | Загружает объект | Перезапись по тому же ключу, валидация `maxBytes`/`allowedMime`/`metadataSchema` |
| `getReadUrl(key, opts?)` | Короткоживущий URL чтения | HMAC-signed URL контроллера; TTL по умолчанию из `defaultUrlTtlSeconds` спеки |
| `delete(key)` | Удалить | Идемпотентно (нет объекта = успех) |
| `head(key)` | Метаданные без скачивания | Бросает `InterFileStorageObjectNotFoundError` если нет |
`body` принимает `Uint8Array` или `ReadableStream<Uint8Array>` (graphql-upload `createReadStream()`
также подходит — duck-typed, проверяется наличие `getReader()` или конвертируется адаптером).
## Ошибки
Всё — наследники `InterFileStorageError`. Доменный код мапит на свои коды:
- `InterFileStorageObjectNotFoundError` — нет объекта (`head`)
- `InterFileStorageObjectTooLargeError` — превышен `maxBytes`
- `InterFileStorageMimeRejectedError``contentType` не в `allowedMime`
- `InterFileStorageMetadataValidationError` — нет required-метаданного
- `InterFileStorageBackendUnavailableError` — недоступен MinIO/S3
- `InterFileStorageBucketNotConfiguredError` — невалидная спека бакета
## Конфигурация (env)
| Переменная | Default | Заметка |
|---|---|---|
| `MINIO_ENDPOINT` | `http://minio:9000` | Compose service name; для запуска контроллера на хосте — `http://127.0.0.1:9000` |
| `MINIO_ACCESS_KEY` | `minioadmin` | Только dev; на prod подменяется плейбуком |
| `MINIO_SECRET_KEY` | `minioadmin` | То же |
| `MINIO_BUCKET` | `coop-${COOPNAME}` | Физический бакет, идемпотентно создаётся на bootstrap |
| `FILE_STORAGE_SIGNING_SECRET` | `SERVER_SECRET` | HMAC-секрет; на prod — отдельный |
| `FILE_STORAGE_PUBLIC_BASE_URL` | `BACKEND_URL` | База URL для read-ссылок |
## Тесты
```bash
# Юнит (моки)
cd components/controller && npx jest src/infrastructure/file-storage/ -i
# Интеграционные против реального MinIO
docker compose up -d minio # из mono-ai-3 root
cd components/controller
npm run test:integration:file-storage
```
Тесты пропускаются автоматически (с диагностическим warning), если MinIO недоступен на
`FILE_STORAGE_TEST_MINIO_ENDPOINT` (default `http://localhost:9000`).
## Что в коробке (файлы модуля)
- `file-storage.config.ts``FileStorageInfrastructureOptions` + токен `FILE_STORAGE_OPTIONS`
- `bucket-registry.ts` — singleton, накапливает `BucketSpec` от декораторов
- `use-bucket.decorator.ts``@UseBucket`/`@InjectBucket` + `bucketTokenFor`
- `minio-file-storage.adapter.ts` — реализация `InterFileStoragePort` поверх `@aws-sdk/client-s3`,
валидация спеки, `OnApplicationBootstrap` с `ensureBucketExists`
- `signing.ts``signReadUrl` / `verifyReadUrl` (HMAC-SHA256, constant-time)
- `file-storage-http.controller.ts``GET /api/storage/:bucket/:key` с проверкой подписи
- `file-storage-infrastructure.module.ts``FileStorageModule.forRoot(...) / forFeature(consumers)`
## Не входит в v1
- `getWriteUrl()` (presigned PUT)
- `list(prefix)`, `copy(src, dst)`
- Lifecycle/retention на уровне порта
- Привязка URL к userId (отдельный режим, если появится особо приватный контент)
- Антивирусное сканирование
См. §11 спеки.
@@ -0,0 +1,42 @@
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
describe('BucketRegistry', () => {
beforeEach(() => {
BucketRegistry._resetForTests();
});
const spec = (name: string): InterFileStorageBucketSpec => ({
name,
maxBytes: 1024,
allowedMime: ['image/jpeg'],
});
it('add + get возвращает зарегистрированную спеку', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(BucketRegistry.get(A)?.name).toBe('ext-a:images');
});
it('повторная регистрация того же класса под тем же именем — no-op (идемпотентно)', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(() => BucketRegistry.add(A, spec('ext-a:images'))).not.toThrow();
});
it('повторная регистрация того же класса под другим именем — выбрасывает', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(() => BucketRegistry.add(A, spec('ext-a:other'))).toThrow(/уже зарегистрирован/);
});
it('list возвращает все зарегистрированные пары', () => {
class A {}
class B {}
BucketRegistry.add(A, spec('ext-a:images'));
BucketRegistry.add(B, spec('ext-b:receipts'));
const list = BucketRegistry.list();
expect(list).toHaveLength(2);
expect(list.map((r) => r.spec.name).sort()).toEqual(['ext-a:images', 'ext-b:receipts']);
});
});
@@ -0,0 +1,39 @@
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
/**
* Глобальный реестр бакетов, наполняется декоратором `@UseBucket` на этапе загрузки модулей.
* Используется `FileStorageInfrastructureModule.forFeature(...)` для wire-up per-class providers
* и `MinioFileStorageAdapter.onApplicationBootstrap` для логирования / диагностики.
*/
export interface RegisteredBucket {
readonly cls: { readonly name: string };
readonly spec: InterFileStorageBucketSpec;
}
const REGISTERED = new Map<{ name: string }, InterFileStorageBucketSpec>();
export const BucketRegistry = {
add(cls: { name: string }, spec: InterFileStorageBucketSpec): void {
const existing = REGISTERED.get(cls);
if (existing && existing.name !== spec.name) {
throw new Error(
`BucketRegistry: класс ${cls.name} уже зарегистрирован под бакетом '${existing.name}', ` +
`повторная регистрация под '${spec.name}' запрещена`,
);
}
REGISTERED.set(cls, spec);
},
get(cls: { name: string }): InterFileStorageBucketSpec | undefined {
return REGISTERED.get(cls);
},
list(): readonly RegisteredBucket[] {
return Array.from(REGISTERED.entries()).map(([cls, spec]) => ({ cls, spec }));
},
/** Только для тестов: очистка реестра между сценариями. */
_resetForTests(): void {
REGISTERED.clear();
},
};
@@ -0,0 +1,197 @@
import 'reflect-metadata';
import { Readable } from 'stream';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageObjectNotFoundError,
} from '@coopenomics/inter';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { FileStorageHttpController } from './file-storage-http.controller';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { signReadUrl } from './signing';
const OPTS: FileStorageInfrastructureOptions = {
endpoint: 'http://minio:9000',
accessKey: 'AKIA-test',
secretKey: 'sk-test',
bucket: 'coop-test',
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
publicBaseUrl: 'https://test.example.org',
};
interface AdapterStub {
fetchObjectForReadProxy: jest.Mock;
}
async function makeApp(stub: AdapterStub): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: OPTS },
{ provide: MinioFileStorageAdapter, useValue: stub },
],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
return app;
}
function makeStream(bytes: Buffer): Readable {
return Readable.from([bytes]);
}
function makeStub(): AdapterStub {
return { fetchObjectForReadProxy: jest.fn() };
}
function urlFor(bucket: string, key: string, ttlSeconds: number): { path: string; exp: number; sig: string } {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const sig = signReadUrl({ bucket, key, expUnix: exp, secret: OPTS.signingSecret });
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
return { path: `/api/storage/${encodeURIComponent(bucket)}/${encodedKey}`, exp, sig };
}
describe('FileStorageHttpController', () => {
let app: INestApplication;
let stub: AdapterStub;
beforeEach(() => {
stub = makeStub();
});
afterEach(async () => {
if (app) await app.close();
});
it('200 + байты при валидной подписи', async () => {
app = await makeApp(stub);
const payload = Buffer.from('hello world');
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
stream: makeStream(payload),
size: payload.length,
contentType: 'image/jpeg',
lastModified: new Date(),
});
const u = urlFor('orders-images', 'orders/42/main.jpg', 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: u.sig })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect(res.headers['content-length']).toBe(String(payload.length));
expect(res.headers['cache-control']).toMatch(/^private, max-age=\d+$/);
expect((res.body as Buffer).equals(payload)).toBe(true);
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith('orders-images/orders/42/main.jpg');
});
it('403 при неверной подписи', async () => {
app = await makeApp(stub);
const u = urlFor('orders-images', 'a.jpg', 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: 'a'.repeat(64) });
expect(res.status).toBe(403);
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
});
it('403 при истёкшем exp', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) - 1;
const sig = signReadUrl({ bucket: 'orders-images', key: 'a.jpg', expUnix: exp, secret: OPTS.signingSecret });
const res = await request(app.getHttpServer())
.get('/api/storage/orders-images/a.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
});
it('403 при отсутствии exp/sig', async () => {
app = await makeApp(stub);
const res = await request(app.getHttpServer()).get('/api/storage/orders-images/a.jpg');
expect(res.status).toBe(403);
});
it('403 при подмене bucket в URL (HMAC связан с bucket)', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) + 60;
const sig = signReadUrl({
bucket: 'orders-images',
key: 'a.jpg',
expUnix: exp,
secret: OPTS.signingSecret,
});
const res = await request(app.getHttpServer())
.get('/api/storage/expenses-receipts/a.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
});
it('403 при подмене key в URL (HMAC связан с key)', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) + 60;
const sig = signReadUrl({
bucket: 'orders-images',
key: 'a.jpg',
expUnix: exp,
secret: OPTS.signingSecret,
});
const res = await request(app.getHttpServer())
.get('/api/storage/orders-images/b.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
});
it('404 при ObjectNotFoundError от адаптера', async () => {
app = await makeApp(stub);
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
new InterFileStorageObjectNotFoundError('missing'),
);
const u = urlFor('orders-images', 'missing.jpg', 60);
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(404);
});
it('502 при BackendUnavailableError от адаптера', async () => {
app = await makeApp(stub);
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
new InterFileStorageBackendUnavailableError('backend down'),
);
const u = urlFor('orders-images', 'x.jpg', 60);
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(502);
});
it('правильно парсит составной ключ с / и спецсимволами', async () => {
app = await makeApp(stub);
const payload = Buffer.from('xy');
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
stream: makeStream(payload),
size: payload.length,
contentType: 'application/pdf',
lastModified: new Date(),
});
const key = 'reports/q1 2026/r 1.pdf';
const u = urlFor('expenses-receipts', key, 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(200);
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith(`expenses-receipts/${key}`);
});
});
@@ -0,0 +1,131 @@
import {
Controller,
Get,
HttpStatus,
Inject,
Logger,
Param,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageObjectNotFoundError,
} from '@coopenomics/inter';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { verifyReadUrl } from './signing';
/**
* Служебная ручка отдачи объектов по HMAC-signed URL.
*
* Формат URL формирует `MinioFileStorageAdapter.getReadUrl`:
* `<publicBaseUrl>/api/storage/:bucket/:key?exp=<unix-ts>&sig=<hmac-hex>`
*
* - `:bucket` публичная форма имени бакета (`<extension>-<purpose>`).
* - `:key` caller-defined ключ; может содержать `/`, кодируется посегментно.
* - `exp` unix-timestamp истечения; `now < exp` обязательно.
* - `sig` `HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>")`, hex; constant-time сверка.
*
* ACL принимается ДО получения URL на стороне доменного резолвера. Любой держатель URL до
* истечения TTL может скачать. Утечка утечка контента до `exp`.
*/
@Controller('api/storage')
export class FileStorageHttpController {
private readonly logger = new Logger(FileStorageHttpController.name);
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
private readonly adapter: MinioFileStorageAdapter,
) {}
@Get(':bucket/*')
async serve(
@Param('bucket') bucketRaw: string,
@Query('exp') expRaw: string | undefined,
@Query('sig') sig: string | undefined,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const bucket = safeDecodeURIComponent(bucketRaw);
const restRaw = (req.params as Record<string, string>)['0'] ?? '';
const key = decodeKeyFromPath(restRaw);
const exp = Number(expRaw);
if (!Number.isFinite(exp) || !sig || !bucket || !key) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const nowUnix = Math.floor(Date.now() / 1000);
if (nowUnix >= exp) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const ok = verifyReadUrl({
bucket,
key,
expUnix: exp,
sig,
secret: this.opts.signingSecret,
});
if (!ok) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const physicalKey = `${bucket}/${key}`;
let obj;
try {
obj = await this.adapter.fetchObjectForReadProxy(physicalKey);
} catch (e) {
if (e instanceof InterFileStorageObjectNotFoundError) {
res.status(HttpStatus.NOT_FOUND).end();
return;
}
if (e instanceof InterFileStorageBackendUnavailableError) {
this.logger.warn(`backend недоступен на ${physicalKey}: ${(e as Error).message}`);
res.status(HttpStatus.BAD_GATEWAY).end();
return;
}
throw e;
}
const remaining = Math.max(0, exp - nowUnix);
res.setHeader('Content-Type', obj.contentType);
if (obj.size > 0) {
res.setHeader('Content-Length', String(obj.size));
}
res.setHeader('Cache-Control', `private, max-age=${remaining}`);
res.status(HttpStatus.OK);
obj.stream.on('error', (err) => {
this.logger.warn(`stream error на ${physicalKey}: ${err.message}`);
if (!res.headersSent) {
res.status(HttpStatus.BAD_GATEWAY).end();
} else {
res.end();
}
});
obj.stream.pipe(res);
}
}
function safeDecodeURIComponent(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return '';
}
}
function decodeKeyFromPath(rest: string): string {
if (!rest) return '';
return rest.split('/').map(safeDecodeURIComponent).join('/');
}
@@ -0,0 +1,58 @@
import { DynamicModule, Global, Module, type Type } from '@nestjs/common';
import { INTER_FILE_STORAGE, type InterFileStoragePort } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { FileStorageHttpController } from './file-storage-http.controller';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { bucketTokenFor } from './use-bucket.decorator';
/**
* Динамический модуль файлового хранилища.
*
* - `forRoot(options)` провайдит адаптер `InterFileStoragePort` (токен `INTER_FILE_STORAGE`)
* и стартует `OnApplicationBootstrap` хук с `ensureBucketExists`. Глобальный токен
* доступен `forFeature`-ам без явного импорта.
* - `forFeature(consumers)` для каждого `@UseBucket`-класса регистрирует фабрику
* `bucketTokenFor(class)`, которая отдаёт `InterFileStorageBucket`. Импортируется в модуле
* расширения, где живут эти сервисы.
*/
@Global()
@Module({})
export class FileStorageInfrastructureModule {
static forRoot(options: FileStorageInfrastructureOptions): DynamicModule {
return {
module: FileStorageInfrastructureModule,
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: options },
MinioFileStorageAdapter,
{ provide: INTER_FILE_STORAGE, useExisting: MinioFileStorageAdapter },
],
exports: [INTER_FILE_STORAGE, MinioFileStorageAdapter],
};
}
static forFeature(consumers: ReadonlyArray<Type<unknown>>): DynamicModule {
const providers = consumers.map((cls) => {
const spec = BucketRegistry.get(cls);
if (!spec) {
throw new Error(
`FileStorageInfrastructureModule.forFeature: класс ${cls.name} не помечен @UseBucket`,
);
}
return {
provide: bucketTokenFor(cls),
useFactory: (port: InterFileStoragePort) => port.getBucket(spec),
inject: [INTER_FILE_STORAGE],
};
});
return {
module: FileStorageInfrastructureModule,
providers,
exports: providers.map((p) => p.provide),
};
}
}
@@ -0,0 +1,26 @@
/**
* Конфигурация подключения к MinIO/S3 и подписания read-URL.
* Передаётся в `FileStorageInfrastructureModule.forRoot(options)`.
*/
export interface FileStorageInfrastructureOptions {
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. Пусто/undefined = file storage отключён (no-op). */
endpoint?: string;
accessKey: string;
secretKey: string;
/** Имя физического бакета на сервере (например, `coop-voskhod`). */
bucket: string;
/** Секрет для HMAC-подписи read-URL служебной ручки контроллера. */
signingSecret: string;
/** База публичного URL контроллера (например, `https://voskhod.example.org`). */
publicBaseUrl: string;
/** AWS region. Для MinIO любое непустое значение. По умолчанию `us-east-1`. */
region?: string;
/** Path-style addressing (true для MinIO, false для AWS S3). По умолчанию true. */
forcePathStyle?: boolean;
/** TTL по умолчанию для read-URL (сек). Перекрывается `BucketSpec.defaultUrlTtlSeconds`. По умолчанию 600. */
defaultUrlTtlSeconds?: number;
}
export const FILE_STORAGE_OPTIONS = Symbol.for('FileStorage.Options');
export const FILE_STORAGE_DEFAULT_URL_TTL_SECONDS = 600;
@@ -0,0 +1,14 @@
export {
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
export { BucketRegistry, type RegisteredBucket } from './bucket-registry';
export { InjectBucket, UseBucket, bucketTokenFor } from './use-bucket.decorator';
export {
MinioFileStorageAdapter,
type FileStorageObjectStream,
} from './minio-file-storage.adapter';
export { FileStorageHttpController } from './file-storage-http.controller';
export { FileStorageInfrastructureModule } from './file-storage-infrastructure.module';
export { signReadUrl, verifyReadUrl } from './signing';
@@ -0,0 +1,365 @@
import 'reflect-metadata';
import {
CreateBucketCommand,
DeleteObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
} from '@aws-sdk/client-s3';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBucketSpec,
} from '@coopenomics/inter';
import type { FileStorageInfrastructureOptions } from './file-storage.config';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { verifyReadUrl } from './signing';
const BASE_OPTS: FileStorageInfrastructureOptions = {
endpoint: 'http://minio:9000',
accessKey: 'AKIA-test',
secretKey: 'sk-test',
bucket: 'coop-test',
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
publicBaseUrl: 'https://test.example.org',
};
const SPEC: InterFileStorageBucketSpec = {
name: 'orders:images',
maxBytes: 1024,
allowedMime: ['image/jpeg', 'image/png'],
metadataSchema: {
ownerId: 'required',
altText: 'optional',
},
};
function makeAdapter(opts: FileStorageInfrastructureOptions = BASE_OPTS): {
adapter: MinioFileStorageAdapter;
send: jest.Mock;
} {
const adapter = new MinioFileStorageAdapter(opts);
const send = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(adapter as any).s3 = { send };
return { adapter, send };
}
describe('MinioFileStorageAdapter — getBucket валидация спеки', () => {
it('отклоняет name без двоеточия', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, name: 'no-colon' })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
it('отклоняет неположительный maxBytes', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, maxBytes: 0 })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
it('отклоняет пустой allowedMime', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, allowedMime: [] })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
});
describe('MinioFileStorageAdapter — put', () => {
it('успешный put передаёт верные Bucket/Key/ContentType/Metadata в PutObjectCommand', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({ ETag: '"abc123"' });
const bucket = adapter.getBucket(SPEC);
const body = new Uint8Array([1, 2, 3, 4]);
const result = await bucket.put('orders/42/main.jpg', body, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1' },
});
expect(send).toHaveBeenCalledTimes(1);
const cmd = send.mock.calls[0][0] as PutObjectCommand;
expect(cmd).toBeInstanceOf(PutObjectCommand);
expect(cmd.input.Bucket).toBe('coop-test');
expect(cmd.input.Key).toBe('orders-images/orders/42/main.jpg');
expect(cmd.input.ContentType).toBe('image/jpeg');
expect(cmd.input.ContentLength).toBe(4);
expect(cmd.input.Metadata).toEqual({ ownerId: 'u-1' });
expect(result.key).toBe('orders/42/main.jpg');
expect(result.etag).toBe('abc123');
expect(result.size).toBe(4);
});
it('отклоняет put с неподходящим MIME без обращения к S3', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('orders/x/main.exe', new Uint8Array([0]), {
contentType: 'application/octet-stream',
metadata: { ownerId: 'u-1' },
}),
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put сверх maxBytes (Uint8Array) без обращения к S3', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const body = new Uint8Array(SPEC.maxBytes + 1);
await expect(
bucket.put('big.jpg', body, { contentType: 'image/jpeg', metadata: { ownerId: 'u-1' } }),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put сверх maxBytes (ReadableStream) до отправки', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const chunks: Uint8Array[] = [
new Uint8Array(SPEC.maxBytes - 100),
new Uint8Array(200), // overshoot at second chunk
];
const stream = makeWebStream(chunks) as unknown as ReadableStream<Uint8Array>;
await expect(
bucket.put('big-stream.jpg', stream, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1' },
}),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put без обязательного metadata-поля', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('x.jpg', new Uint8Array([1]), { contentType: 'image/jpeg', metadata: {} }),
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
expect(send).not.toHaveBeenCalled();
});
it('путь успешного потока — стрим читается, размер контролируется, S3 получает Buffer', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({ ETag: '"e1"' });
const bucket = adapter.getBucket(SPEC);
const stream = makeWebStream([
new Uint8Array([10, 20]),
new Uint8Array([30]),
]) as unknown as ReadableStream<Uint8Array>;
const r = await bucket.put('s.jpg', stream, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
expect(r.size).toBe(3);
const cmd = send.mock.calls[0][0] as PutObjectCommand;
const body = cmd.input.Body as Buffer;
expect(body.length).toBe(3);
expect(body[0]).toBe(10);
expect(body[2]).toBe(30);
});
it('оборачивает backend-ошибку put в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('connection refused'));
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('x.jpg', new Uint8Array([1]), {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
}),
).rejects.toBeInstanceOf(InterFileStorageBackendUnavailableError);
});
});
describe('MinioFileStorageAdapter — head', () => {
it('возвращает метаданные при успешном HEAD', async () => {
const { adapter, send } = makeAdapter();
const lastModified = new Date('2026-04-23T10:00:00Z');
send.mockResolvedValueOnce({
ContentLength: 42,
ETag: '"deadbeef"',
ContentType: 'image/png',
LastModified: lastModified,
Metadata: { ownerid: 'u-1' },
});
const bucket = adapter.getBucket(SPEC);
const r = await bucket.head('a/b.png');
expect((send.mock.calls[0][0] as HeadObjectCommand).input.Key).toBe('orders-images/a/b.png');
expect(r.size).toBe(42);
expect(r.etag).toBe('deadbeef');
expect(r.contentType).toBe('image/png');
expect(r.lastModified).toEqual(lastModified);
expect(r.metadata.ownerid).toBe('u-1');
});
it('бросает ObjectNotFoundError при S3-ошибке NotFound', async () => {
const { adapter, send } = makeAdapter();
const err = Object.assign(new Error('not found'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
});
send.mockRejectedValueOnce(err);
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
it('бросает ObjectNotFoundError при ошибке NoSuchKey', async () => {
const { adapter, send } = makeAdapter();
const err = Object.assign(new Error('no such key'), { name: 'NoSuchKey' });
send.mockRejectedValueOnce(err);
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
it('оборачивает прочие backend-ошибки в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('timeout'));
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('x.jpg')).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
describe('MinioFileStorageAdapter — delete', () => {
it('успешный delete вызывает DeleteObjectCommand с правильным Key', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({});
const bucket = adapter.getBucket(SPEC);
await bucket.delete('a/b.jpg');
const cmd = send.mock.calls[0][0] as DeleteObjectCommand;
expect(cmd).toBeInstanceOf(DeleteObjectCommand);
expect(cmd.input.Bucket).toBe('coop-test');
expect(cmd.input.Key).toBe('orders-images/a/b.jpg');
});
it('оборачивает backend-ошибку delete в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('500'));
const bucket = adapter.getBucket(SPEC);
await expect(bucket.delete('x.jpg')).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
describe('MinioFileStorageAdapter — getReadUrl', () => {
it('возвращает URL верной структуры с подписью, проходящей verifyReadUrl', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
const parsed = new URL(url);
expect(parsed.origin).toBe('https://test.example.org');
expect(parsed.pathname).toBe('/api/storage/orders-images/orders/42/main.jpg');
const exp = Number(parsed.searchParams.get('exp'));
const sig = parsed.searchParams.get('sig')!;
const now = Math.floor(Date.now() / 1000);
expect(exp).toBeGreaterThan(now);
expect(exp).toBeLessThanOrEqual(now + 61);
expect(sig).toMatch(/^[0-9a-f]{64}$/);
const ok = verifyReadUrl({
bucket: 'orders-images',
key: 'orders/42/main.jpg',
expUnix: exp,
sig,
secret: BASE_OPTS.signingSecret,
});
expect(ok).toBe(true);
});
it('берёт TTL из defaultUrlTtlSeconds бакета, если не передан', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket({ ...SPEC, defaultUrlTtlSeconds: 30 });
const url = await bucket.getReadUrl('x.jpg');
const exp = Number(new URL(url).searchParams.get('exp'));
const now = Math.floor(Date.now() / 1000);
expect(exp - now).toBeGreaterThanOrEqual(28);
expect(exp - now).toBeLessThanOrEqual(31);
});
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const url = await bucket.getReadUrl('a b/c?d=e/f.jpg');
const parsed = new URL(url);
expect(parsed.pathname).toBe('/api/storage/orders-images/a%20b/c%3Fd%3De/f.jpg');
});
});
describe('MinioFileStorageAdapter — onApplicationBootstrap', () => {
it('пропускает CreateBucket, если HeadBucket прошёл', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({}); // HeadBucket OK
await adapter.onApplicationBootstrap();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
});
it('создаёт бакет, если HeadBucket вернул 404', async () => {
const { adapter, send } = makeAdapter();
const notFound = Object.assign(new Error('not found'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
});
send.mockRejectedValueOnce(notFound).mockResolvedValueOnce({});
await adapter.onApplicationBootstrap();
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
expect(send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
});
it('бросает BackendUnavailableError, если HeadBucket упал не на 404', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('connection refused'));
await expect(adapter.onApplicationBootstrap()).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeWebStream(chunks: Uint8Array[]): {
getReader: () => { read: () => Promise<{ value?: Uint8Array; done: boolean }>; cancel: () => Promise<void> };
} {
let i = 0;
return {
getReader() {
return {
async read() {
if (i >= chunks.length) return { value: undefined, done: true };
const value = chunks[i++];
return { value, done: false };
},
async cancel() {
/* noop */
},
};
},
};
}
@@ -0,0 +1,385 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBody,
type InterFileStorageBucket,
type InterFileStorageBucketSpec,
type InterFileStorageGetReadUrlOptions,
type InterFileStorageObjectMetadata,
type InterFileStoragePort,
type InterFileStoragePutOptions,
type InterFileStoragePutResult,
} from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
import {
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { signReadUrl } from './signing';
/**
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
* физический бакет, дальше отдаёт `InterFileStorageBucket`-хэндлы по спекам.
*
* URL чтения формируется на собственный домен контроллера (служебная ручка `/api/storage/...`),
* подписывается HMAC-SHA256 секретом из конфига. При переходе на нативный S3 здесь же
* можно начать возвращать настоящие S3 presigned URL контракт строки не меняется.
*/
@Injectable()
export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicationBootstrap {
private readonly logger = new Logger(MinioFileStorageAdapter.name);
private readonly s3: S3Client | null;
private readonly enabled: boolean;
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
) {
this.enabled = typeof opts.endpoint === 'string' && opts.endpoint.length > 0;
this.s3 = this.enabled
? new S3Client({
endpoint: opts.endpoint,
region: opts.region ?? 'us-east-1',
credentials: {
accessKeyId: opts.accessKey,
secretAccessKey: opts.secretKey,
},
forcePathStyle: opts.forcePathStyle ?? true,
})
: null;
}
async onApplicationBootstrap(): Promise<void> {
if (!this.enabled || !this.s3) {
this.logger.warn(
'File storage не сконфигурирован (MINIO_ENDPOINT пуст) — модуль стартует в no-op режиме. ' +
'Загрузка/чтение файлов будут отклонены до настройки бэкенда.',
);
return;
}
await this.ensureBucketExists();
const registered = BucketRegistry.list();
this.logger.log(
`File storage готов. Физический бакет '${this.opts.bucket}', эндпоинт '${this.opts.endpoint}'. ` +
`Зарегистрировано бакетов через @UseBucket: ${registered.length}` +
(registered.length > 0
? ` (${registered.map((r) => `${r.cls.name}${r.spec.name}`).join(', ')})`
: ''),
);
}
private assertEnabled(): S3Client {
if (!this.enabled || !this.s3) {
throw new InterFileStorageBackendUnavailableError(
'File storage не сконфигурирован: MINIO_ENDPOINT пуст. Задайте переменную окружения, чтобы включить загрузку/чтение файлов.',
);
}
return this.s3;
}
private async ensureBucketExists(): Promise<void> {
const s3 = this.assertEnabled();
try {
await s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
return;
} catch (e) {
if (!isNotFound(e)) {
throw new InterFileStorageBackendUnavailableError(
`HeadBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
{ cause: asError(e) },
);
}
}
try {
await s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
this.logger.log(`Создан физический бакет '${this.opts.bucket}'`);
} catch (e) {
throw new InterFileStorageBackendUnavailableError(
`CreateBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
{ cause: asError(e) },
);
}
}
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket {
const s3 = this.assertEnabled();
validateSpec(spec);
return new MinioBucketHandle(s3, this.opts, spec);
}
/**
* Внутренний fetch для HTTP-ручки `/api/storage/:bucket/:key` (E59-4).
* `physicalKey` это уже собранный ключ S3-объекта вида `<extension>-<purpose>/<caller-key>`.
* @throws InterFileStorageObjectNotFoundError, InterFileStorageBackendUnavailableError
*/
async fetchObjectForReadProxy(physicalKey: string): Promise<FileStorageObjectStream> {
const s3 = this.assertEnabled();
try {
const r = await s3.send(
new GetObjectCommand({ Bucket: this.opts.bucket, Key: physicalKey }),
);
return {
stream: r.Body as NodeJS.ReadableStream,
size: r.ContentLength ?? 0,
contentType: r.ContentType ?? 'application/octet-stream',
lastModified: r.LastModified ?? new Date(0),
};
} catch (e) {
if (isNotFound(e)) {
throw new InterFileStorageObjectNotFoundError(`Объект '${physicalKey}' не найден`);
}
throw wrapBackendError(e, `getObject '${physicalKey}'`);
}
}
}
class MinioBucketHandle implements InterFileStorageBucket {
private readonly publicBucketName: string;
private readonly defaultTtlSeconds: number;
constructor(
private readonly s3: S3Client,
private readonly opts: FileStorageInfrastructureOptions,
private readonly spec: InterFileStorageBucketSpec,
) {
this.publicBucketName = spec.name.replace(/:/g, '-');
this.defaultTtlSeconds =
spec.defaultUrlTtlSeconds ?? opts.defaultUrlTtlSeconds ?? FILE_STORAGE_DEFAULT_URL_TTL_SECONDS;
}
private physicalKey(key: string): string {
return `${this.publicBucketName}/${key}`;
}
async put(
key: string,
body: InterFileStorageBody,
opts: InterFileStoragePutOptions,
): Promise<InterFileStoragePutResult> {
if (!this.spec.allowedMime.includes(opts.contentType)) {
throw new InterFileStorageMimeRejectedError(
`MIME '${opts.contentType}' не входит в allowedMime бакета '${this.spec.name}'`,
);
}
this.validateMetadata(opts.metadata);
const buffer = await materializeAndCheckSize(body, this.spec.maxBytes, this.spec.name);
try {
const r = await this.s3.send(
new PutObjectCommand({
Bucket: this.opts.bucket,
Key: this.physicalKey(key),
Body: buffer,
ContentType: opts.contentType,
ContentLength: buffer.byteLength,
Metadata: opts.metadata as Record<string, string> | undefined,
}),
);
return {
key,
etag: stripQuotes(r.ETag) ?? '',
size: buffer.byteLength,
};
} catch (e) {
throw wrapBackendError(e, `put '${this.spec.name}/${key}'`);
}
}
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
const exp = Math.floor(Date.now() / 1000) + ttl;
const sig = signReadUrl({
bucket: this.publicBucketName,
key,
expUnix: exp,
secret: this.opts.signingSecret,
});
const base = this.opts.publicBaseUrl.replace(/\/+$/, '');
return `${base}/api/storage/${encodeURIComponent(this.publicBucketName)}/${encodeKeyForPath(key)}?exp=${exp}&sig=${sig}`;
}
async delete(key: string): Promise<void> {
try {
await this.s3.send(
new DeleteObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
);
} catch (e) {
// S3 DeleteObject идемпотентен — отсутствие объекта не ошибка. Сюда попадают только
// реальные backend-фейлы.
throw wrapBackendError(e, `delete '${this.spec.name}/${key}'`);
}
}
async head(key: string): Promise<InterFileStorageObjectMetadata> {
try {
const r = await this.s3.send(
new HeadObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
);
return {
size: r.ContentLength ?? 0,
etag: stripQuotes(r.ETag) ?? '',
contentType: r.ContentType ?? 'application/octet-stream',
lastModified: r.LastModified ?? new Date(0),
metadata: r.Metadata ?? {},
};
} catch (e) {
if (isNotFound(e)) {
throw new InterFileStorageObjectNotFoundError(
`Объект '${this.spec.name}/${key}' не найден`,
);
}
throw wrapBackendError(e, `head '${this.spec.name}/${key}'`);
}
}
private validateMetadata(metadata?: Readonly<Record<string, string>>): void {
if (!this.spec.metadataSchema) return;
for (const [field, kind] of Object.entries(this.spec.metadataSchema)) {
if (kind !== 'required') continue;
const value = metadata?.[field];
if (!value) {
throw new InterFileStorageMetadataValidationError(
`Метаданное '${field}' обязательно для бакета '${this.spec.name}'`,
);
}
}
}
}
/**
* Результат внутреннего fetch для HTTP-ручки `/api/storage/...`.
* Не часть публичного `InterFileStoragePort` только для proxy-стрима внутри контроллера.
*/
export interface FileStorageObjectStream {
stream: NodeJS.ReadableStream;
size: number;
contentType: string;
lastModified: Date;
}
function validateSpec(spec: InterFileStorageBucketSpec): void {
if (!spec.name || !spec.name.includes(':')) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.name должен иметь формат '<extension>:<purpose>', получено '${spec.name}'`,
);
}
if (!Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.maxBytes должно быть положительным конечным числом, получено ${spec.maxBytes}`,
);
}
if (!spec.allowedMime || spec.allowedMime.length === 0) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.allowedMime должен содержать хотя бы один MIME-тип`,
);
}
}
async function materializeAndCheckSize(
body: InterFileStorageBody,
maxBytes: number,
specName: string,
): Promise<Buffer> {
if (body instanceof Uint8Array) {
if (body.byteLength > maxBytes) {
throw new InterFileStorageObjectTooLargeError(
`Размер ${body.byteLength} байт превышает лимит ${maxBytes} бакета '${specName}'`,
);
}
return Buffer.from(body);
}
// Web ReadableStream<Uint8Array> — duck-typed, чтобы не зависеть от глобального типа `ReadableStream`.
if (
typeof body === 'object' &&
body !== null &&
'getReader' in body &&
typeof (body as { getReader: unknown }).getReader === 'function'
) {
const reader = (body as { getReader: () => StreamReader }).getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (value && value.byteLength > 0) {
total += value.byteLength;
if (total > maxBytes) {
// Освобождаем reader, чтобы не оставлять стрим висящим.
try {
await reader.cancel();
} catch {
// ignore
}
throw new InterFileStorageObjectTooLargeError(
`Размер тела превышает лимит ${maxBytes} бакета '${specName}'`,
);
}
chunks.push(value);
}
}
return Buffer.concat(chunks);
}
throw new InterFileStorageBackendUnavailableError(
`Неподдерживаемый тип тела для бакета '${specName}'`,
);
}
interface StreamReader {
read(): Promise<{ value?: Uint8Array; done: boolean }>;
cancel(): Promise<void>;
}
function isNotFound(e: unknown): boolean {
if (typeof e !== 'object' || e === null) return false;
const ex = e as {
name?: string;
Code?: string;
$metadata?: { httpStatusCode?: number };
};
if (ex.name === 'NotFound' || ex.name === 'NoSuchKey' || ex.name === 'NoSuchBucket') return true;
if (ex.Code === 'NotFound' || ex.Code === 'NoSuchKey' || ex.Code === 'NoSuchBucket') return true;
if (ex.$metadata?.httpStatusCode === 404) return true;
return false;
}
function wrapBackendError(e: unknown, op: string): InterFileStorageBackendUnavailableError {
return new InterFileStorageBackendUnavailableError(`${op}: ${getMessage(e)}`, {
cause: asError(e),
});
}
function getMessage(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
return 'unknown error';
}
function asError(e: unknown): Error | undefined {
return e instanceof Error ? e : undefined;
}
function stripQuotes(s: string | undefined): string | undefined {
if (!s) return s;
return s.replace(/^"|"$/g, '');
}
function encodeKeyForPath(key: string): string {
// '/' — разделитель сегментов URL, остальное per-segment encodeURIComponent.
return key.split('/').map(encodeURIComponent).join('/');
}
@@ -0,0 +1,52 @@
import { signReadUrl, verifyReadUrl } from './signing';
describe('signing', () => {
const secret = 'test-secret-32-bytes-aaaaaaaaaaaa';
it('подпись детерминирована для одинакового набора параметров', () => {
const a = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
const b = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('verify проходит на корректную подпись', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(true);
});
it('verify отклоняет подмену bucket', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'expenses-receipts', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(false);
});
it('verify отклоняет подмену key', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/c.jpg', expUnix: 9999, sig, secret })).toBe(false);
});
it('verify отклоняет подмену exp', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 10000, sig, secret })).toBe(false);
});
it('verify отклоняет подмену секрета', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(
verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret: 'other' }),
).toBe(false);
});
it('verify отклоняет подпись неверной длины без выброса исключения', () => {
expect(
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: 'abcd', secret }),
).toBe(false);
});
it('verify отклоняет не-hex без выброса исключения', () => {
const fake = 'z'.repeat(64);
expect(
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: fake, secret }),
).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { createHmac, timingSafeEqual } from 'crypto';
/**
* HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>") в hex.
* Используется и адаптером (выдача URL), и HTTP-ручкой (валидация).
*/
export function signReadUrl(args: {
bucket: string;
key: string;
expUnix: number;
secret: string;
}): string {
const h = createHmac('sha256', args.secret);
h.update(`${args.bucket}\n${args.key}\n${args.expUnix}`);
return h.digest('hex');
}
/**
* Constant-time сверка подписи. Возвращает false на любую структурную ошибку
* (несовпадение длин, не-hex), а не выбрасывает чтобы не различать каналы side-channel-ом.
*/
export function verifyReadUrl(args: {
bucket: string;
key: string;
expUnix: number;
sig: string;
secret: string;
}): boolean {
const expected = signReadUrl(args);
if (expected.length !== args.sig.length) return false;
let expectedBuf: Buffer;
let actualBuf: Buffer;
try {
expectedBuf = Buffer.from(expected, 'hex');
actualBuf = Buffer.from(args.sig, 'hex');
} catch {
return false;
}
if (expectedBuf.length !== actualBuf.length) return false;
return timingSafeEqual(expectedBuf, actualBuf);
}
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import { BucketRegistry } from './bucket-registry';
import { UseBucket, bucketTokenFor } from './use-bucket.decorator';
describe('UseBucket decorator', () => {
beforeEach(() => {
BucketRegistry._resetForTests();
});
it('регистрирует спеку класса в BucketRegistry', () => {
@UseBucket({ name: 'demo:images', maxBytes: 1024, allowedMime: ['image/png'] })
class DemoService {}
const got = BucketRegistry.get(DemoService);
expect(got).toBeDefined();
expect(got?.name).toBe('demo:images');
expect(got?.maxBytes).toBe(1024);
expect(got?.allowedMime).toEqual(['image/png']);
});
it('bucketTokenFor стабилен и зависит только от имени класса', () => {
class FooService {}
expect(bucketTokenFor(FooService)).toBe('__InterFileStorageBucket:FooService');
expect(bucketTokenFor(FooService)).toBe(bucketTokenFor(FooService));
});
});
@@ -0,0 +1,42 @@
import { Inject } from '@nestjs/common';
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
/**
* DI-токен per-class бакета, под которым `forFeature` регистрирует фабрику `BucketHandle`.
* Конструктор `@InjectBucket()` инжектится по этому токену.
*/
export function bucketTokenFor(cls: { name: string }): string {
return `__InterFileStorageBucket:${cls.name}`;
}
/**
* Класс-декоратор: декларирует бакет, в который пишет данный сервис.
* Конвенция один бакет на один сервис; повторная регистрация под другим именем запрещена.
*
* @example
* ```ts
* @UseBucket({ name: 'stol-zakazov:images', maxBytes: 10*MB, allowedMime: ['image/jpeg'] })
* @Injectable()
* export class OrderImagesService {
* constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
* }
* ```
*/
export function UseBucket(spec: InterFileStorageBucketSpec): ClassDecorator {
return (target) => {
BucketRegistry.add(target as unknown as { name: string }, spec);
};
}
/**
* Параметр-декоратор: инжектит `InterFileStorageBucket` для текущего класса.
* Резолвится в DI-токен `bucketTokenFor(<ClassName>)`, который провайдится через
* `FileStorageInfrastructureModule.forFeature([ThisClass])`.
*/
export function InjectBucket(): ParameterDecorator {
return (target, propertyKey, parameterIndex) => {
const cls = target as unknown as { name: string };
Inject(bucketTokenFor(cls))(target, propertyKey, parameterIndex);
};
}
@@ -0,0 +1,30 @@
# File-storage integration tests
Тесты против реального MinIO. По умолчанию ожидают MinIO на `http://localhost:9100` с
кредами `testuser` / `testpassword` (см. `docker-compose.yml` рядом).
## Как запустить
```bash
# 1. Поднять MinIO
docker compose -f components/controller/tests/file-storage/docker-compose.yml up -d
# дождаться healthy
# 2. Запустить интеграционные тесты
cd components/controller
npm run test:integration:file-storage
# 3. После работы
docker compose -f components/controller/tests/file-storage/docker-compose.yml down -v
```
## Переопределение через env
| Переменная | Default | Что делает |
|---|---|---|
| `FILE_STORAGE_TEST_MINIO_ENDPOINT` | `http://localhost:9100` | URL MinIO |
| `FILE_STORAGE_TEST_MINIO_ACCESS_KEY` | `testuser` | Access key |
| `FILE_STORAGE_TEST_MINIO_SECRET_KEY` | `testpassword` | Secret key |
| `FILE_STORAGE_TEST_BUCKET` | `coop-test-{timestamp}` | Имя физического бакета (уникально per-run) |
Если MinIO недоступен на `endpoint`, suite пропускает тесты (`it.skip`) с диагностическим сообщением.
@@ -0,0 +1,19 @@
version: '3'
services:
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: file-storage-test-minio
command: server /data --console-address ":9101"
environment:
MINIO_ROOT_USER: testuser
MINIO_ROOT_PASSWORD: testpassword
ports:
# Уникальные порты, чтобы не конфликтовать с prod-MinIO (9000) и другими инстансами.
- '9100:9000'
- '9101:9101'
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 5s
timeout: 3s
retries: 10
@@ -0,0 +1,280 @@
import 'reflect-metadata';
import { execSync } from 'child_process';
import { Readable } from 'stream';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBucketSpec,
} from '@coopenomics/inter';
import {
FileStorageHttpController,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
MinioFileStorageAdapter,
signReadUrl,
} from '../../src/infrastructure/file-storage';
const ENDPOINT = process.env.FILE_STORAGE_TEST_MINIO_ENDPOINT ?? 'http://localhost:9100';
const ACCESS_KEY = process.env.FILE_STORAGE_TEST_MINIO_ACCESS_KEY ?? 'testuser';
const SECRET_KEY = process.env.FILE_STORAGE_TEST_MINIO_SECRET_KEY ?? 'testpassword';
const BUCKET = process.env.FILE_STORAGE_TEST_BUCKET ?? `coop-test-${Date.now()}`;
const SIGNING_SECRET = 'integration-signing-secret-aaaaaaaaaaaaaaaa';
const PUBLIC_BASE_URL = 'https://test.example.org';
const SPEC: InterFileStorageBucketSpec = {
name: 'orders:images',
maxBytes: 10 * 1024,
allowedMime: ['image/jpeg', 'image/png', 'application/pdf'],
metadataSchema: {
ownerId: 'required',
altText: 'optional',
},
defaultUrlTtlSeconds: 60,
};
/** Sync-проверка доступности MinIO. Решение `it` vs `it.skip` принимается до describe(). */
function probeMinioSync(): boolean {
try {
execSync(`curl -fsS --max-time 2 ${ENDPOINT}/minio/health/live`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const minioReachable = probeMinioSync();
if (!minioReachable) {
// eslint-disable-next-line no-console
console.warn(
`[integration] MinIO недоступен на ${ENDPOINT} — тесты пропущены. ` +
`Подними: docker compose -f tests/file-storage/docker-compose.yml up -d`,
);
}
const itLive = (name: string, fn: jest.ProvidesCallback, timeout?: number) =>
(minioReachable ? it : it.skip)(name, fn, timeout);
describe('file-storage integration (real MinIO)', () => {
let adapter: MinioFileStorageAdapter;
let app: INestApplication;
const opts: FileStorageInfrastructureOptions = {
endpoint: ENDPOINT,
accessKey: ACCESS_KEY,
secretKey: SECRET_KEY,
bucket: BUCKET,
signingSecret: SIGNING_SECRET,
publicBaseUrl: PUBLIC_BASE_URL,
forcePathStyle: true,
};
beforeAll(async () => {
if (!minioReachable) return;
adapter = new MinioFileStorageAdapter(opts);
await adapter.onApplicationBootstrap();
const moduleRef = await Test.createTestingModule({
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: opts },
{ provide: MinioFileStorageAdapter, useValue: adapter },
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
}, 60_000);
afterAll(async () => {
if (app) await app.close();
});
itLive('put → head → getReadUrl → GET → bytes равны (image)', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.from(jpegFixture());
await bucket.put('orders/42/main.jpg', payload, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1', altText: 'demo' },
});
const meta = await bucket.head('orders/42/main.jpg');
expect(meta.size).toBe(payload.length);
expect(meta.contentType).toBe('image/jpeg');
expect(meta.metadata.ownerid ?? meta.metadata.ownerId).toBe('u-1');
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 30 });
const parsed = new URL(url);
const res = await request(app.getHttpServer())
.get(parsed.pathname)
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect((res.body as Buffer).equals(payload)).toBe(true);
}, 30_000);
itLive('put PDF среднего размера через стрим, читается обратно идентично', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.alloc(8 * 1024); // 8 KiB
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
const stream = bufferToWebStream(payload);
await bucket.put('reports/r-1/r1.pdf', stream, {
contentType: 'application/pdf',
metadata: { ownerId: 'u-2' },
});
const meta = await bucket.head('reports/r-1/r1.pdf');
expect(meta.size).toBe(payload.length);
const url = await bucket.getReadUrl('reports/r-1/r1.pdf', { ttlSeconds: 30 });
const parsed = new URL(url);
const res = await request(app.getHttpServer())
.get(parsed.pathname)
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect((res.body as Buffer).equals(payload)).toBe(true);
}, 30_000);
itLive('delete идемпотентен (повтор на отсутствующий — успех)', async () => {
const bucket = adapter.getBucket(SPEC);
await bucket.put('to-delete.jpg', Buffer.from([1, 2, 3]), {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
await bucket.delete('to-delete.jpg');
await expect(bucket.delete('to-delete.jpg')).resolves.toBeUndefined();
await expect(bucket.delete('never-existed.jpg')).resolves.toBeUndefined();
});
itLive('head отсутствующего → ObjectNotFoundError', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('does-not-exist.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('превышение maxBytes → ObjectTooLargeError, объект не появился', async () => {
const bucket = adapter.getBucket(SPEC);
const tooBig = Buffer.alloc(SPEC.maxBytes + 1);
await expect(
bucket.put('too-big.jpg', tooBig, { contentType: 'image/jpeg', metadata: { ownerId: 'u' } }),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
await expect(bucket.head('too-big.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('запрещённый MIME → MimeRejectedError, объект не появился', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('bad.exe', Buffer.from([0]), {
contentType: 'application/octet-stream',
metadata: { ownerId: 'u' },
}),
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
await expect(bucket.head('bad.exe')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('отсутствие required-метаданного → MetadataValidationError', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('no-meta.jpg', Buffer.from([0]), { contentType: 'image/jpeg', metadata: {} }),
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
});
itLive('HMAC-роут: 200 → 403 (exp) → 403 (sig) → 404', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.from('end-to-end');
await bucket.put('hmac-test/x.jpg', payload, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
// 200
const okUrl = await bucket.getReadUrl('hmac-test/x.jpg', { ttlSeconds: 30 });
const okParsed = new URL(okUrl);
const ok = await request(app.getHttpServer())
.get(okParsed.pathname)
.query({ exp: okParsed.searchParams.get('exp')!, sig: okParsed.searchParams.get('sig')! });
expect(ok.status).toBe(200);
// 403 — истёкший exp
const pastExp = Math.floor(Date.now() / 1000) - 1;
const pastSig = signReadUrl({
bucket: 'orders-images',
key: 'hmac-test/x.jpg',
expUnix: pastExp,
secret: SIGNING_SECRET,
});
const expired = await request(app.getHttpServer())
.get('/api/storage/orders-images/hmac-test/x.jpg')
.query({ exp: pastExp, sig: pastSig });
expect(expired.status).toBe(403);
// 403 — чужой sig
const futureExp = Math.floor(Date.now() / 1000) + 60;
const wrongSig = await request(app.getHttpServer())
.get('/api/storage/orders-images/hmac-test/x.jpg')
.query({ exp: futureExp, sig: 'a'.repeat(64) });
expect(wrongSig.status).toBe(403);
// 404 — несуществующий ключ, но валидная подпись
const missingKey = 'hmac-test/missing.jpg';
const missingSig = signReadUrl({
bucket: 'orders-images',
key: missingKey,
expUnix: futureExp,
secret: SIGNING_SECRET,
});
const notFound = await request(app.getHttpServer())
.get(`/api/storage/orders-images/${missingKey}`)
.query({ exp: futureExp, sig: missingSig });
expect(notFound.status).toBe(404);
}, 30_000);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
function jpegFixture(): Uint8Array {
// Минимальный валидный JPEG header + EOI; для проверки трактовать как опаковые байты.
return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0xff, 0xd9]);
}
function bufferToWebStream(buf: Buffer): ReadableStream<Uint8Array> {
// Простая реализация без зависимости от node:stream/web.
let sent = false;
return {
getReader() {
return {
async read() {
if (sent) return { value: undefined, done: true };
sent = true;
return { value: new Uint8Array(buf), done: false };
},
async cancel() {
/* noop */
},
};
},
} as unknown as ReadableStream<Uint8Array>;
}
// suppress unused-import warning for Readable in case parser flags
void Readable;
+8 -18
View File
@@ -460,6 +460,9 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationContractSignedMetaDocumentInput:{
},
GenerationConvertStatementGenerateDocumentInput:{
},
GenerationMoneyInvestStatementGenerateDocumentInput:{
@@ -470,9 +473,6 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationMoneyInvestStatementSignedMetaDocumentInput:{
},
GenerationToMainWalletConvertStatementGenerateDocumentInput:{
},
GetAccountInput:{
@@ -770,6 +770,10 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerationContractGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationConvertStatement:{
data:"GenerationConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationMoneyInvestStatement:{
data:"GenerationMoneyInvestStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -786,18 +790,6 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToCapitalizationConvertStatement:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToMainWalletConvertStatement:{
data:"GenerationToMainWalletConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToProjectConvertStatement:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGetLoanDecision:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -3526,13 +3518,11 @@ export const ReturnTypes: Record<string,any> = {
capitalGenerateExpenseDecision:"GeneratedDocument",
capitalGenerateExpenseStatement:"GeneratedDocument",
capitalGenerateGenerationContract:"GeneratedDocument",
capitalGenerateGenerationConvertStatement:"GeneratedDocument",
capitalGenerateGenerationMoneyInvestStatement:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestAct:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestDecision:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestStatement:"GeneratedDocument",
capitalGenerateGenerationToCapitalizationConvertStatement:"GeneratedDocument",
capitalGenerateGenerationToMainWalletConvertStatement:"GeneratedDocument",
capitalGenerateGenerationToProjectConvertStatement:"GeneratedDocument",
capitalGenerateGetLoanDecision:"GeneratedDocument",
capitalGenerateGetLoanStatement:"GeneratedDocument",
capitalGenerateProgramMoneyInvestStatement:"GeneratedDocument",
+143 -171
View File
@@ -5521,6 +5521,38 @@ export type ValueTypes = {
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string | Variable<any, string>,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string | Variable<any, string>,
/** Хэш проекта */
project_hash: string | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean | Variable<any, string>,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -5597,40 +5629,6 @@ export type ValueTypes = {
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string | Variable<any, string>,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string | Variable<any, string>,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string | Variable<any, string>,
/** Хэш проекта */
project_hash: string | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Перевод на благорост кошелек */
to_blagorost: boolean | Variable<any, string>,
/** Перевод на основной кошелек */
to_wallet: boolean | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -6421,13 +6419,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ValueTypes["ComponentGener
capitalGenerateExpenseDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateExpenseStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationContract?: [{ data: ValueTypes["GenerationContractGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationConvertStatement?: [{ data: ValueTypes["GenerationConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestAct?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGetLoanDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGetLoanStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateProgramMoneyInvestStatement?: [{ data: ValueTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
@@ -13829,6 +13825,38 @@ export type ResolverInputTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -13905,40 +13933,6 @@ export type ResolverInputTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -14706,13 +14700,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ResolverInputTypes["Compon
capitalGenerateExpenseDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateExpenseStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationContract?: [{ data: ResolverInputTypes["GenerationContractGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationConvertStatement?: [{ data: ResolverInputTypes["GenerationConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ResolverInputTypes["GenerationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestAct?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ResolverInputTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGetLoanDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGetLoanStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateProgramMoneyInvestStatement?: [{ data: ResolverInputTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
@@ -18965,13 +18957,13 @@ export type ModelTypes = {
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
data?: ModelTypes["JSON"] | undefined | null,
/** Описание коммита */
description: string,
description?: string | undefined | null,
/** Отображаемое имя пользователя */
display_name?: string | undefined | null,
/** ID в блокчейне */
id?: number | undefined | null,
/** Метаданные коммита */
meta: string,
meta?: string | undefined | null,
/** Флаг присутствия записи в блокчейне */
present: boolean,
/** Проект, к которому относится коммит */
@@ -21895,6 +21887,38 @@ export type ModelTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -21971,40 +21995,6 @@ export type ModelTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -22869,6 +22859,10 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -22885,18 +22879,6 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationPropertyInvestStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member. */
capitalGenerateGenerationToCapitalizationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToMainWalletConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToProjectConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать решение о получении займа
Требуемые роли: chairman, member. */
@@ -27626,13 +27608,13 @@ export type GraphQLTypes = {
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
data?: GraphQLTypes["JSON"] | undefined | null,
/** Описание коммита */
description: string,
description?: string | undefined | null,
/** Отображаемое имя пользователя */
display_name?: string | undefined | null,
/** ID в блокчейне */
id?: number | undefined | null,
/** Метаданные коммита */
meta: string,
meta?: string | undefined | null,
/** Флаг присутствия записи в блокчейне */
present: boolean,
/** Проект, к которому относится коммит */
@@ -30712,6 +30694,38 @@ export type GraphQLTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -30788,40 +30802,6 @@ export type GraphQLTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -31739,6 +31719,10 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -31755,18 +31739,6 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationPropertyInvestStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member. */
capitalGenerateGenerationToCapitalizationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToMainWalletConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToProjectConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать решение о получении займа
Требуемые роли: chairman, member. */
@@ -35917,10 +35889,10 @@ type ZEUS_VARIABLES = {
["GenerationContractGenerateDocumentInput"]: ValueTypes["GenerationContractGenerateDocumentInput"];
["GenerationContractSignedDocumentInput"]: ValueTypes["GenerationContractSignedDocumentInput"];
["GenerationContractSignedMetaDocumentInput"]: ValueTypes["GenerationContractSignedMetaDocumentInput"];
["GenerationConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationConvertStatementGenerateDocumentInput"];
["GenerationMoneyInvestStatementGenerateDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"];
["GenerationMoneyInvestStatementSignedDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedDocumentInput"];
["GenerationMoneyInvestStatementSignedMetaDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedMetaDocumentInput"];
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"];
["GetAccountInput"]: ValueTypes["GetAccountInput"];
["GetAccountsInput"]: ValueTypes["GetAccountsInput"];
["GetBranchesInput"]: ValueTypes["GetBranchesInput"];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
@@ -34,8 +34,8 @@ export interface Model {
to_blagorost: boolean
}
export const title = 'Заявление о переводе части целевого паевого взноса'
export const description = 'Форма заявления о переводе части целевого паевого взноса'
export const title = 'Заявление о трансляции паевого взноса из программы "Генерация"'
export const description = 'Форма заявления о трансляции паевого взноса из программы "Генерация" в Цифровой Кошелёк и/или в программу «Благорост»'
export const context = `<div class="digital-document"><div style="text-align: right"><p>{% trans 'to_council' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p><p>{% trans 'from_shareholder' %} {{ common_user.full_name_or_short_name }}</p></div><div style="text-align: center"><h2>{% trans 'statement_title' %}</h2><h3>{% trans 'statement_subtitle' %}</h3></div><p>{% trans 'statement_text' %} № {{ contributor_contract_number }} {% trans 'from_date' %} {{ contributor_contract_created_at }} {% trans 'and_appendix' %} № {{ appendix_short_hash }}, {% trans 'request_text' %} №{{ project_short_hash }} {% trans 'following_way' %}:</p><ol>{% if to_wallet %}<li>{% trans 'wallet_conversion_text', main_wallet_amount %}</li>{% endif %}{% if to_blagorost %}<li>{% trans 'blagorost_conversion_text', blagorost_wallet_amount %}</li>{% endif %}</ol><div style="margin-top: 40px;"><p>{% trans 'signed_by_digital_signature' %}</p><p style="text-align: right">{{ meta.created_at }}</p></div></div><style>.digital-document {padding: 20px;white-space: pre-wrap;}</style>`
@@ -1,22 +0,0 @@
import type { IGenerate, IMetaDocument } from '../../document'
export const registry_id = 1081
// Модель действия для генерации
export interface Action extends IGenerate {
registry_id: number
}
export type Meta = IMetaDocument & Action
// Модель данных документа
export interface Model {
meta: IMetaDocument
}
export const title = 'Заявление о конвертации средств генерации в проект'
export const description = 'Форма заявления о конвертации средств из генерации в конкретный проект'
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В ПРОЕКТ</h2></div><p>Прошу конвертировать средства из генерации для инвестирования в проект.</p><p>Подпись: Иван Иванович</p></div>'
export const translations = {}
export const exampleData = {}
@@ -1,22 +0,0 @@
import type { IGenerate, IMetaDocument } from '../../document'
export const registry_id = 1082
// Модель действия для генерации
export interface Action extends IGenerate {
registry_id: number
}
export type Meta = IMetaDocument & Action
// Модель данных документа
export interface Model {
meta: IMetaDocument
}
export const title = 'Заявление о конвертации средств генерации в Благорост'
export const description = 'Форма заявления о конвертации средств из генерации в программу Благорост'
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В благорост</h2></div><p>Прошу конвертировать средства из генерации для участия в программе благороста.</p><p>Подпись: Иван Иванович</p></div>'
export const translations = {}
export const exampleData = {}
@@ -61,9 +61,7 @@ export * as CapitalizationPropertyInvestStatement from './1070.CapitalizationPro
export * as CapitalizationPropertyInvestDecision from './1071.CapitalizationPropertyInvestDecision'
export * as CapitalizationPropertyInvestAct from './1072.CapitalizationPropertyInvestAct'
export * as GenerationToMainWalletConvertStatement from './1080.GenerationToMainWalletConvertStatement'
export * as GenerationToProjectConvertStatement from './1081.GenerationToProjectConvertStatement'
export * as GenerationToCapitalizationConvertStatement from './1082.GenerationToCapitalizationConvertStatement'
export * as GenerationConvertStatement from './1080.GenerationConvertStatement'
export * as CapitalizationToMainWalletConvertStatement from './1090.CapitalizationToMainWalletConvertStatement'

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