Compare commits

..

21 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
1499 changed files with 39198 additions and 75236 deletions
-3
View File
@@ -1,3 +0,0 @@
Trial run of Typecheck workflow on PR → dev.
Created 2026-05-23 to measure end-to-end duration of vue-tsc (desktop) and tsc (controller).
Do not merge; close after measurement.
+15
View File
@@ -18,6 +18,7 @@ name: Build bootstrap container
#
# Секреты:
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
on:
push:
@@ -84,3 +85,17 @@ jobs:
SHORT_SHA="${GITHUB_SHA::7}"
docker tag "$IMAGE:${{ env.DOCKER_TAG }}" "$IMAGE:${{ env.DOCKER_TAG }}-$SHORT_SHA"
docker push "$IMAGE:${{ env.DOCKER_TAG }}-$SHORT_SHA"
- name: Telegram notify success
if: ${{ success() }}
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] dicoop/bootcoop:${{ env.DOCKER_TAG }} (sha=${GITHUB_SHA::7})"
- 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] Ошибка сборки dicoop/bootcoop на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
@@ -0,0 +1,48 @@
name: Trigger Contracts Docs Deploy
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
on:
push:
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
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
+209
View File
@@ -0,0 +1,209 @@
name: Build contracts container
# Сборка и публикация docker-образа `dicoop/contracts`, который содержит
# wasm/abi всех контрактов кооперативной экономики (apps, marketplace,
# ledger, meet, loan, wallet, capital, branch, contributor, fund, draft,
# soviet, registrator, gateway, system, test). Образ потребляется
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
# контракты через `cleos set contract`.
#
# Триггер — 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-образ.
#
# Секреты:
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN — публикация в dicoop/*
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
on:
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
# который собирает контракты атомарно вместе с контейнерами и
# webhook'ом — отдельная сборка по push'у в ветку только мешает
# (две параллельные сборки на каждый release-bump). Здесь оставляем
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Determine build mode and docker tag
run: |
case "${{ github.ref_name }}" in
main)
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"
;;
dev)
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
*)
echo "Unsupported branch ${{ github.ref_name }}" >&2
exit 1
;;
esac
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile all contracts
working-directory: components/contracts
run: |
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
- name: Stage docker context
working-directory: components/contracts
run: |
ROOT="docker/.context"
rm -rf "$ROOT"
mkdir -p "$ROOT/contracts"
# Имена контрактов берём из CMakeLists (`add_contract_build(<name>)`)
# — единая точка истины. Любое изменение списка там автоматически
# подтянется в образ.
# Имена контрактов = аргументы 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"
# Хелпер: упаковывает одну пару (wasm, abi) в /contracts/<name>/
# и добавляет JSON-запись в STDOUT (caller склеивает в массив).
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' "$GITHUB_SHA"
printf ' "ref": "%s",\n' "$GITHUB_REF_NAME"
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"
# как имя пропускаем. test_contracts/* не упаковываем —
# это тестовые stub'ы, не нужны на проде.
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"
echo "--- context tree ---"
find "$ROOT" -maxdepth 3 -type f | sort
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image
working-directory: components/contracts
run: |
IMAGE="dicoop/contracts"
docker build \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$DOCKER_TAG" \
./docker/.context
docker push "$IMAGE:$DOCKER_TAG"
if [ -n "$EXTRA_TAG" ]; then
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$EXTRA_TAG"
docker push "$IMAGE:$EXTRA_TAG"
fi
# SHA-tag для воспроизводимости (если нужно запинить конкретный
# коммит, а не двигающийся branch-tag).
SHORT_SHA="${GITHUB_SHA::7}"
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
docker push "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
- name: Verify pushed image
run: |
docker run --rm dicoop/contracts:$DOCKER_TAG list
echo "---"
docker run --rm dicoop/contracts:$DOCKER_TAG sha256
- name: Telegram notify success
if: ${{ success() }}
run: |
COUNT=$(docker run --rm dicoop/contracts:$DOCKER_TAG list | wc -l)
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] dicoop/contracts:$DOCKER_TAG ($COUNT контрактов, mode=$BUILD_MODE, sha=${GITHUB_SHA::7})"
- 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] Ошибка сборки dicoop/contracts на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
+161
View File
@@ -0,0 +1,161 @@
name: Publish Docs
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
on:
push:
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
uses: actions/checkout@v3
# Версия pnpm берётся из `packageManager` корневого package.json,
# синхронно с publish-packages.yaml и Dockerfile'ами.
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python requirements
run: |
python -m venv venv
source venv/bin/activate
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
working-directory: ./components/docs
- name: Install Node.js dependencies
run: pnpm install
working-directory: ./components/docs
- name: Patch spectaql-config.yml for CI
run: |
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
working-directory: ./components/controller
- name: Show patched spectaql-config.yml
run: cat spectaql-config.yml
working-directory: ./components/controller
- name: Build cooptypes
run: pnpm run build
working-directory: ./components/cooptypes
- name: Generate controller docs
run: pnpm run docs
working-directory: ./components/controller
- name: Copy controller docs
run: |
mkdir -p ./components/docs/docs/graphql
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
- name: Generate sdk docs
run: pnpm run docs
working-directory: ./components/sdk
- name: Copy sdk docs
run: |
mkdir -p ./components/docs/docs/sdk
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
- name: Generate cooptypes docs
run: pnpm run docs
working-directory: ./components/cooptypes
- name: Copy cooptypes docs
run: |
mkdir -p ./components/docs/docs/cooptypes
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
# публикуется как поддиректория на том же домене. Vite собирает
# с base='/standards/' (см. vite.config.ts), относительные пути
# внутри dist/ и hash-router работают корректно.
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
- name: Build standards-site
run: pnpm run build
working-directory: ./components/contracts/standards-site
- name: Copy standards-site into docs/standards
run: |
mkdir -p ./components/docs/docs/standards
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
- name: Build docs (mkdocs)
run: |
source venv/bin/activate
mkdocs build
working-directory: ./components/docs
- name: Remove specific large file before publishing
run: |
# Удаляем конкретный большой файл sdk/typedoc.json
rm -f ./components/docs/site/sdk/typedoc.json
# Проверяем, что файл удален
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
echo "ERROR: typedoc.json still exists!"
exit 1
else
echo "SUCCESS: typedoc.json removed successfully"
fi
- name: Publish to GitHub Pages
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
working-directory: ./components/docs
env:
GIT_AUTHOR_NAME: github-actions
GIT_AUTHOR_EMAIL: github-actions@github.com
GIT_COMMITTER_NAME: github-actions
GIT_COMMITTER_EMAIL: github-actions@github.com
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger docs deployment webhook
if: ${{ success() }}
run: |
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
+37
View File
@@ -0,0 +1,37 @@
name: Publish Packages
on:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
build-and-publish:
if: |
startsWith(github.ref, 'refs/tags/v') &&
!contains(github.ref, '-alpha')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0 = pnpm 9/10).
# Иначе action-setup может подтянуть другую версию и переписать pnpm-lock.yaml → Lerna EUNCOMMIT.
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+63 -237
View File
@@ -1,96 +1,57 @@
name: Release
# Релизный workflow по push'у в testnet/main (FF-промоушн-модель, см.
# scripts/RELEASE.md).
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
# `v*`. В одном job'е по порядку:
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
#
# Порядок (через jobs.needs):
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
# 2) publish-packages — npm publish через lerna (только ветка main)
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
# Gitea (только ветка main); он сам тянет mono и
# пересобирает доки.
# Зачем атомарно: раньше `build-contracts` и `build-containers`
# крутились параллельно по push'у тэга, и webhook от build-containers
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
# старым wasm. Попытка связать их через workflow_run упёрлась в
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
# нет двух workflow'ов — нет гонки, нет fallback'ов.
#
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
# `build-contracts.yaml`.
#
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
# если релиз контрактов или контейнеров провалился.
#
# Триггер — push в testnet/main с изменением lerna.json. Версию бампает
# `lerna version` ОДИН раз на dev (scripts/cut-release.sh); тот же коммит едет
# вверх по fast-forward (scripts/promote.sh). lerna.json меняется только на
# релизном бампе, поэтому обычные feature-пуши деплой не триггерят. dev в
# триггере НЕТ: cut на dev только бампает версию, деплой начинается с testnet
# (staging) и завершается main (production).
#
# Окружение определяет ВЕТКА (main → production, testnet → staging), а не
# суффикс версии: единая версия едет во все среды, поэтому source of truth —
# ветка, на которую сделан промоушн. Версия читается из закоммиченного
# lerna.json. Гейты npm-publish/доки — `branch == main`.
# Резолв ветки. Тэг семантики окружения не несёт (например
# `v2026.5.13-alpha-2` может лежать на testnet или dev — определяет
# именно ветка, на которую сделан merge перед тэгом). Поэтому ищем
# первое совпадение SHA с remote-ветками main/testnet/dev.
on:
push:
branches: [testnet, main]
paths:
- 'lerna.json'
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
workflow_dispatch:
inputs:
environment:
description: 'Куда деплоить'
type: choice
options:
- testnet
- main
required: true
ref:
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
required: false
default: ''
permissions:
contents: write
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.resolve.outputs.tag_name }}
branch: ${{ steps.resolve.outputs.branch }}
is_prod: ${{ steps.resolve.outputs.is_prod }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
# (пусто = верхушка ветки окружения, на которой запущен workflow).
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
- name: Resolve branch, build mode and version
id: resolve
- name: Resolve branch, build mode and tags
run: |
# Окружение: при ручном workflow_dispatch — из inputs.environment;
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
# из реально вычекнутого дерева, не из ветки-триггера.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BRANCH="${{ github.event.inputs.environment }}"
else
BRANCH="${{ github.ref_name }}"
fi
SHA="$(git rev-parse HEAD)"
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
# testnet/main. Тэг как триггер больше не используется.
RAW_VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' lerna.json | head -1)"
if [ -z "$RAW_VERSION" ]; then
echo "::error::Не удалось прочитать version из lerna.json"
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
TAG_NAME="v${RAW_VERSION}"
case "$BRANCH" in
main)
@@ -108,22 +69,16 @@ jobs:
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
*)
echo "::error::Неподдерживаемая ветка $BRANCH (ожидались testnet/main/dev)"
exit 1
;;
esac
# Окружение деплоя определяет ВЕТКА: main → production, иначе → testnet.
# IS_PROD управляет деплой-webhook (PRODUCTION vs TESTNET) и тэгом :latest
# у образов. Раньше критерием был суффикс -alpha у тэга; теперь единая
# версия едет во все среды, поэтому источник истины — ветка промоушна.
if [ "$BRANCH" = "main" ]; then
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
else
# Прод-тэги (без -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
{
@@ -137,12 +92,6 @@ jobs:
echo "WEBHOOK_URL=$WEBHOOK_URL"
} >> "$GITHUB_ENV"
{
echo "tag_name=$TAG_NAME"
echo "branch=$BRANCH"
echo "is_prod=$IS_PROD"
} >> "$GITHUB_OUTPUT"
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
- name: Login to DockerHub
@@ -152,41 +101,14 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
# act_runner сам job исполняется в контейнере → хостовый демон не видит
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
# build-all.sh остаётся для локальной сборки (оборачивает тот же
# build_contracts_cdt.sh в docker).
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
# без правки CMakeLists.
- name: Install CDT 4.2.0 toolchain (.deb)
run: |
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# build-essential — для host-компилятора (project() в CMakeLists);
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
# были из сборки исходников, из .deb не тянутся.
$SUDO apt-get install -y --no-install-recommends \
curl ca-certificates cmake make build-essential \
libz3-4 libtinfo6 libxml2 zlib1g
curl -fsSL -o /tmp/cdt.deb \
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
$SUDO apt-get install -y /tmp/cdt.deb
$SUDO mkdir -p /cdt
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
cdt-cpp --version || true
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile contracts
working-directory: components/contracts
run: |
./build_contracts_cdt.sh "$BUILD_MODE"
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
@@ -267,17 +189,6 @@ jobs:
- name: Build and push contracts image
working-directory: components/contracts
run: |
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
@@ -286,14 +197,14 @@ jobs:
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
dpush "$IMAGE:$CONTRACTS_TAG"
docker push "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
dpush "$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"
dpush "$IMAGE:$CONTRACTS_EXTRA"
docker push "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
@@ -306,31 +217,15 @@ jobs:
- name: Build and push base image
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
dpush "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
dpush dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
@@ -339,10 +234,10 @@ jobs:
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
dpush "dicoop/$SVC:$TAG_NAME"
docker push "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
dpush "dicoop/$SVC:latest"
docker push "dicoop/$SVC:latest"
fi
}
@@ -362,89 +257,20 @@ jobs:
-H 'Content-Type: application/json' \
-d "$TAG_NAME"
# ============================================================================
# publish-packages — npm publish (бывший publish-packages.yaml).
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
# доезжает до main по FF.
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
# pnpm-lock.yaml → Lerna EUNCOMMIT.
# ============================================================================
publish-packages:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
- name: Generate controller schema.gql (required by sdk prepublishOnly)
working-directory: components/controller
run: pnpm run generate-schema
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ============================================================================
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
# Гейт: ветка main.
#
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
# Целевой workflow слушает workflow_dispatch с входами
# mono_sha/mono_ref.
# ============================================================================
trigger-coopenomics-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
- name: Telegram notify success
if: success()
run: |
curl -fsSL -X POST \
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
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"
# ============================================================================
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
# (приёмник деплоит доки на своей стороне). Гейт: ветка main.
# ============================================================================
trigger-mono-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger docs deployment webhook
- name: Telegram notify failure
if: failure()
run: |
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
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})"
-104
View File
@@ -1,104 +0,0 @@
name: Typecheck
# Валидация TypeScript-типов для desktop (Vue 3 + Quasar) и controller (NestJS).
#
# Зачем это отдельный workflow:
# 1. `quasar build --mode ssr` в release.yaml использует Vite/esbuild и
# vue-tsc отключён через vite-plugin-checker (см. quasar.config.cjs):
# TS-ошибки в .vue/.ts НЕ ловятся при docker build образа desktop.
# 2. У `@coopenomics/controller` нет build-скрипта вовсе — `lerna run build`
# его молча пропускает; в проде `ts-node` стартует и валится на типах
# только в рантайме при загрузке модуля. Кейс PR #392 (rename 1080→1020
# в cooptypes) проскочил именно так.
#
# Триггеры:
# - pull_request на dev — гейт перед мерджем в основную ветку;
# - workflow_call — оставлен для переиспользования, но release.yaml его
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
# проверяются на PR в dev до того, как код доедет до тэга).
#
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
on:
pull_request:
branches: [dev]
workflow_call:
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
# прежних прогонов, разные PR друг друга не трогают.
concurrency:
group: typecheck-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
desktop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install workspace
run: pnpm install --frozen-lockfile
# Полный lerna build по всему workspace — как в корневом Dockerfile.
# Не --scope, потому что workspace-пакеты unbuild-типа (sdk, inter,
# notifications, factory, cooptypes, …) экспортируют типы только из
# сгенерированного dist/. Без dist/ tsc/vue-tsc валятся на TS2307
# «Cannot find module». Локально это маскируется ts-node-ом, но в
# CI пути резолвятся через package.json → types → dist/.
# См. Graphiti / run #148 root cause 2026-05-23.
- name: Build all workspace packages
run: pnpm lerna run build
# quasar prepare генерирует .quasar/* (auto-imports d.ts, типы для
# quasar плагинов). Без него vue-tsc ругается на отсутствующие
# глобальные компоненты QBtn/QCard/etc.
- name: Quasar prepare
run: pnpm --filter @coopenomics/desktop run prepare
# vue-tsc проверяет и .ts, и <script lang="ts"> в .vue.
# --skipLibCheck — иначе валится на типах из транзитивных deps
# (Quasar/Vue/Vite сами по себе чистые, но в графе есть legacy).
# Из памяти: на dev-сервере висло ~час из-за конкуренции с quasar dev;
# на чистом ubuntu-latest без живого стека ожидаемо в разы быстрее.
# Если упрётся в OOM/таймаут — fallback на `pnpm typecheck`
# (tsc --noEmit --skipLibCheck без vue-tsc, без SFC шаблонов).
- name: vue-tsc (desktop)
run: pnpm --filter @coopenomics/desktop exec vue-tsc --noEmit --skipLibCheck
controller:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install workspace
run: pnpm install --frozen-lockfile
# Полный lerna build по всему workspace — controller импортирует
# @coopenomics/sdk, @coopenomics/inter, @coopenomics/notifications
# помимо cooptypes/factory; их dist/ генерируется unbuild и без
# этого шага tsc валится на 37 TS2307. См. root cause выше.
- name: Build all workspace packages
run: pnpm lerna run build
- name: tsc --noEmit (controller)
run: pnpm --filter @coopenomics/controller run typecheck
-4
View File
@@ -1,5 +1,4 @@
node_modules/
node_modules
lerna-debug.log
components/controller/graph.png
blockchain-data/
@@ -22,6 +21,3 @@ components/reports-standarts/**/СФР_1118018397_*.xml
components/contracts/cpp/ledger2/scripts/out/
.env.testnet
.pnpm-store
schema.gql
.claude
-7
View File
@@ -1,7 +0,0 @@
store-dir=./.pnpm-store
supported-architectures.os[]=darwin
supported-architectures.os[]=linux
supported-architectures.cpu[]=x64
supported-architectures.cpu[]=arm64
supported-architectures.libc[]=glibc
-332
View File
@@ -1,332 +0,0 @@
# mono (coopenomics/mono) — рабочие заметки
Этот файл — общая память агента для пяти чекаутов: `~/mono-ai-1`..`~/mono-ai-5`. Реальный файл лежит в `mono-ai-1/CLAUDE.md`, остальные четыре — симлинки сюда; правки коммитим из mono-ai-1 в ветку `dev`.
## Стек монорепы
| Слой | Где | Технология |
|------|-----|------------|
| Backend (coopback) | `components/controller/` | NestJS 10, TypeScript, TypeORM, GraphQL, EventEmitter2 |
| Frontend (UI пайщика) | **`components/desktop/`** | **Vue 3 + Quasar** в SSR-режиме (`quasar dev --mode ssr`) |
| Контракты | `components/contracts/` | EOSIO/CDT, C++ |
| SDK для frontend | `components/sdk/` | TypeScript, авто-генерится из controller GraphQL schema |
| Parser blockchain | `components/parser/` | TypeScript |
| Boot/orchestration | `components/boot/` | TypeScript |
**Frontend (`components/desktop/`) — FSD структура:** `src/{app,pages,processes,widgets,features,entities,shared,stores,desktops,boot}/`.
Когда план говорит «UI компонент» / «frontend integration» — путь `components/desktop/src/{features|widgets|pages|processes}/<name>/`, расширение `.vue` (composition API + `<script setup lang="ts">`), стили Quasar (QChip/QBtn/QCard/QDialog…), GraphQL через Apollo Client + сгенерированные типы из `components/sdk/`. Dev — `pnpm --filter @coopenomics/desktop run dev` или `devnet` (без SSR).
**НЕ путать:** НЕТ `components/app-cooperative/` — не предлагать. Все frontend-сессии работают в той же монорепе, что и backend. В стеке **никакого React нигде нет**.
## Worktree-политика
**Для mono-ai-4 (базовая ветка `marketplace2`, Стол заказов):** **worktree приветствуется** — изоляция работ + параллельные ветки. Если worktree пуст от `node_modules` и `.env` (pnpm их не дублирует):
```bash
WT=/home/admin/mono-ai-4/.claude/worktrees/<name>
ln -s /home/admin/mono-ai-4/node_modules $WT/node_modules
ln -s /home/admin/mono-ai-4/components/controller/node_modules $WT/components/controller/node_modules
ln -s /home/admin/mono-ai-4/components/controller/.env $WT/components/controller/.env
```
Аналогично для других пакетов, чьи тесты будут запускаться (`components/desktop/node_modules`, `components/sdk/node_modules`). `jest` из bin: `cd $WT/components/controller && ./node_modules/.bin/jest -i <test>` — корректно резолвит ts-jest и подхватывает `.env`.
**Подвох cooptypes:** когда `controller/node_modules` — симлинк на main, пакет внутри `node_modules/cooptypes -> ../../cooptypes` раскрывается **относительно main checkout**. `import { MarketContract } from 'cooptypes'` тянет d.ts из main, не из worktree. Если worktree обновлён, а main позади — TSC падает на отсутствующих типах. **Фикс:** перед TSC в worktree controller'а`git pull` в main checkout до того же коммита (или хотя бы где cooptypes/src синхронен) + `pnpm build` в `main/components/cooptypes/`.
## PR-flow
**Базовая ветка mono-ai-1 — `dev`.** Прямые коммиты в `dev` для мелких фиксов разрешены и не требуют feature-ветки/PR (отменено 2026-05-22 пользователем). Для крупных задач — feature-ветка (`feat/...` или `fix/...`) от dev и PR в dev; merge делает пользователь на GitHub. Push в `main` / `mvp` — по-прежнему только через PR.
**Релиз/деплой — FF-промоушн `dev → testnet → main`** (см. `scripts/RELEASE.md`). Версию бампает `lerna` ОДИН раз на dev (`scripts/cut-release.sh`), тот же коммит едет вверх по fast-forward (`scripts/promote.sh testnet|main`). `testnet`/`main` не несут своих коммитов — только FF-указатели, поэтому merge-конфликтов нет. **В `testnet`/`main` прямых коммитов и повторных бампов версии быть не должно** — это ломает FF. Деплой триггерит push в testnet/main с изменением `lerna.json`; окружение определяет ветка (main → prod). Старые `publish-alpha.sh`/`publish-prod.sh` (merge `-X theirs` + per-branch бамп + back-merge) удалены — они и плодили 20-package.json конфликты.
**Не stash'ить `-u` при unstaged WIP пользователя.** Это создаёт окно для потери при drop/конфликте. Если нужно временно убрать unstaged — `git stash push -- <конкретные-paths>` либо коммит-в-feature, и только свои файлы. Кейс 2026-05-18: после `git stash -u`+`drop` при конфликте потерял WIP пользователя (infra.ts/config.ts/quasar.config.cjs и др.).
### Story-by-story для проекта «Стол заказов» (mono-ai-4 на marketplace2)
Каждая story из MVP-эпиков «Стол заказов» (`coopenomics/mono`, базовая ветка `marketplace2`, чекаут `~/mono-ai-4`, BMad-spec'и `_blago/.../components/3-minimalnyy-produkt/_bmad-output/`) идёт через PR.
Workflow:
1. Worktree от `marketplace2` на feature-ветке `feat/<E>-<S>-<slug>`.
2. Edits + unit-тесты; tsc + jest должны быть зелёные.
3. Commit + push feature-ветку.
4. `gh pr create --base marketplace2 --head feat/<E>-<S>-<slug>`.
5. Merge **пользователем на GitHub** (не вызывать `gh pr merge` без явной просьбы).
6. После merge — fetch + checkout marketplace2 в основной чекаут, удалить feature-ветку + worktree, запустить e2e / blockchain тесты против обновлённого marketplace2.
### Umbrella-PR на эпик vs цепочка PR
При работе story-by-story в одной feature-area **не плодить отдельные PR на каждую story с одинаковой целевой веткой**. Каждый последующий PR показывает кумулятивный diff (всё, что в head минус то, что уже мёрджнуто в base). Если предыдущие PR ещё не смерджены — в diff вылазят дубликаты файлов всех предыдущих stories.
**Default — umbrella-PR на эпик** (для крупных эпиков с >3 stories): одна `feat/<E>-epic` ветка + один PR; каждая story — отдельный коммит. Worktree последовательно, push после каждой story, PR обновляется. Так закрыт Эпик 1 Стола заказов (umbrella #380).
**Альтернатива — stacked-PRs с `base:<prev-feat>`:** каждый PR таргетится на предыдущую feature-ветку. После merge нижнего GitHub автоматически перетарджетит верхние. Требует дисциплины и тулинга.
**Анти-паттерн:** worktree от `feat/1-2-...`, потом от `feat/1-3-...`, и каждый PR в `marketplace2`. Цепочка branches правильная (изоляция), но цепочка PR — нет. Кейс Эпика 1 Стола заказов 2026-05-14: 11 PR `#370-#380` подряд от `marketplace2`, каждый +N stories назад. Пользователь дошёл до review #372 и обнаружил дубли. Закрыл #372-#379, оставил только #380.
## Локальные тесты
**Не запускать полный jest локально** ни в mono-ai-1, ни в mono-ai-4: живой dev-стек в docker (`nodeos`, `controller dev` nodemon, `parser dev`, `n8n`) вешает CPU/RAM и блокирует chain. Полный suite — задача CI после push'а PR.
В mono-ai-4 **запрещён параллельный режим jest** (worker-pool по умолчанию) — это вешает сервер. Если нужен unit-тест — точечный с `--runInBand`:
```bash
pnpm jest tests/unit/marketplace/marketplace-onboarding-service.test.ts --runInBand
```
`pnpm generate-schema` / `pnpm generate-client`**не запускать локально**; та же memory/CPU полка вешает контейнер controller'а. Либо CI, либо пользователь сам когда контейнер остановлен.
Перед коммитом достаточно `tsc --noEmit` (быстрый, не блокирует).
## SDK login canon
`@coopenomics/sdk` экспортирует `Client.create({api_url, chain_url, chain_id})` + метод `client.login(email, wif)`. Он сам:
1. Генерит `now` (ISO timestamp).
2. Подписывает приватным ключом (WIF) через eosjs.
3. Зовёт `Mutations.Auth.Login.mutation` с `{email, now, signature}`.
4. Возвращает `{tokens: {access: {token}, refresh: {token}}, account: {username}}`.
**Не дёргать `Mutations.Auth.Login` напрямую**`LoginInput` ждёт `{email, now, signature}`, генерация подписи внутри SDK Client. Refresh: `Mutations.Auth.Refresh.mutation` с `{access_token, refresh_token}`. Канон используется в `blago-cli/src/session/index.ts` (loginInteractive) и в EMP-коннекторе `connectors/cooperative-tsk-login-connector` (Story 11.5).
## Backend (controller) каноны
### 3 базовые роли — User / Member / Chairman
- **User** — обычный пайщик. Базовые потребительские права (заказывать, публиковать оферту, видеть свои данные).
- **Member** — **член совета** (не «член кооператива»!). User-права + read-only admin (видит склад, поток заказов, повестку — но не модерирует и не подписывает финальные действия).
- **Chairman** — председатель. User + admin (модерация, KU/whitelist/витрины, closing signature АПП-приёмки/выдачи, повестка совета на write).
Маппинг core-роли на extension-роль явно: User → orderer + опционально offerer/operator; Chairman → admin (полный write); Member → read-only admin (board_readonly). Пайщик может быть одновременно в нескольких extension-ролях — массив, не enum. Guard'ы в расширениях — локальные сейчас, в Phase 2 переключатся на платформенный CASL.
### `agreements` ссылается на существующий document registry_id
Когда расширение controller'а (marketplace, blagorost, любое следующее) хранит факт подписи документа пайщиком в глобальной on-chain таблице `agreements`, ссылка идёт через **существующий `registry_id` из платформенного реестра документов**, не через отдельный type-string типа `marketplace.cpp.stol-zakazov-v1`.
Поле `document_id` в `agreements` — FK на существующий platform registry. Extension-таблицы `*_onboarding_requirement``document_registry_id` ссылается на существующий ID. API запросов вида «какие документы подписаны member'ом» — `agreementsByMember(member_id, document_id_filter=[...])`.
**Технический долг платформы:** «Договор УХД сейчас не проходит через `agreements`» — отдельная задача core controller'а, вне scope конкретных расширений.
### Трёхуровневый онбординг расширений
Платформенный паттерн, обязателен для всех новых расширений controller'а.
**L1 — Кооператив (one-time):** председатель/совет принимает решение совета о подключении ЦПП, принимается положение ЦПП (статический документ из platform registry, через document factory с подстановкой параметров кооператива), оферта регистрируется в `coop_registration_offers_registry`.
**L2 — Пайщик при вступлении (per-membership):** в registration-flow появляется **выбор** ЦПП. Пайщик отмечает интересующие, document factory рендерит оферту с `{cooperative_params, member_params, agreement_date}`. Подпись пишется в глобальную on-chain `agreements`. Эта подпись **нивелирует** gate на столе расширения.
**L3 — Пайщик на рабочем столе (per-extension first visit):** backend проверяет через `agreementsByMember` — подписана ли оферта ЦПП. ДА → gate не показывается. НЕТ → gate показывается как explicit consent.
При проектировании любого нового расширения — обязательно три истории под три уровня. На MVP допустимо упростить: одинаковый `document_registry_id` для положения и оферты (физически разные документы могут быть в Phase 2).
### Marketplace asset через DI
В сервисах `components/controller/src/extensions/marketplace/**/*.service.ts` запрещён хардкод вида `const ASSET_DECIMALS = 4` / `const ASSET_SYMBOL = 'RUB'`. Decimals и symbol — через DI `MARKETPLACE_ASSET_CONFIG`:
```ts
@Inject(MARKETPLACE_ASSET_CONFIG) private readonly assetConfig: MarketplaceAssetConfig
// assetConfig.symbol, assetConfig.decimals
```
Канон-пример: `marketplace-order-create.service.ts`. См. `marketplace-asset.config.provider.ts` — мапит `config.blockchain.root_govern_symbol` / `root_govern_precision`. Разные среды (mainnet/testnet/dev) имеют разный symbol+precision.
### registry_id=800 (ReturnByAssetStatement) — клиринг, не членские взносы
Marketplace в монорепе живёт в **двух контурах**:
1. **Система клиринга** (старый, не используется) — registry_id=800. Не использовать в новых фичах.
2. **Система членских взносов** (текущий MVP) — документы лежат **рядом** с актами приёма-передачи и ТТН; другая группа registry_id.
Для нового документа Marketplace-членские-взносы: завести новый registry_id рядом с актами/ТТН и проложить цепочку `cooptypes → factory → controller → desktop`:
1. `@coopenomics/cooptypes`: новый тип документа + регистрация в registry.
2. `@coopenomics/factory`: generator (preview-структура + meta).
3. `@coopenomics/controller`: signed-document DTO + verify в сервисе.
4. `@coopenomics/desktop`: подпись через `Classes.Document` с новым registry_id.
## GraphQL каноны (controller + desktop)
### Описания @Field — бизнес-языком
В `@Field({ description })`, `@InputType`, `@ObjectType` нельзя писать «Story 4.1», «Эпик 3», «FR11a», «composite-entity», «dispatch pipeline», «tx_snapshot», «backend deterministic order_hash». Только пользовательский язык: «Идентификатор заказа», «ПВЗ получения», «Кол-во единиц товара». Story-ссылки и инвариант-комменты — только в inline-комментариях внутри сервиса.
### Enum вместо строковых литералов
Запрещены `if (offer.cycle_type === 'volume_based')`, `status: 'ACTIVE'`, `'PENDING_MODERATION'`. Любое поле с фиксированным набором (cycle_type, status, type, kind, role) — TypeScript `enum`, при необходимости `registerEnumType` для GraphQL. В тестах константы — тоже из enum, не дублирующие строки. Перед добавлением сравнения по строке — `enum MarketplaceOfferCycleType` / `MarketplaceOrderStatus` в `domain/entities/*.types.ts`.
### Пагинация — единый паттерн
В controller-resolver'ах пагинация делается единым каноническим паттерном:
- Вход: `@Args('options', { nullable: true }) options?: PaginationInputDTO` (импорт из `~/application/common/dto/pagination.dto.ts`, поля page/limit/sortBy/sortOrder).
- Выход: `createPaginationResult(ItemDTO, 'PaginatedXxx')` + сигнатура `Promise<PaginationResult<T>>` (items / totalCount / totalPages / currentPage).
- Repository принимает `PaginationInputDTO`, сам считает offset/limit/sort через TypeORM `findAndCount`.
Канон: `time-tracker.resolver.ts`, `expenses-management.resolver.ts`, `generation.resolver.ts`. Никаких локальных `{ limit, offset }`.
### Никаких raw GraphQL-строк в desktop
В `components/desktop/src/pages/**/api/index.ts` и аналогах запрещены конструкции:
```ts
const QUERY = `query Foo { ... }`;
await sendPOST('/v1/graphql', { query: QUERY, variables });
```
Даже с комментарием «техдолг до Zeus regen».
Обязательная процедура перед UI-кодом под новую GraphQL-операцию:
1. Добавить/изменить DTO/resolver в `components/controller/src/...` (code-first).
2. `pnpm run generate-schema` в `components/controller/` → пересоздать `controller/schema.gql`.
3. `pnpm run generate-client` в `components/controller/` → graphql-zeus кладёт клиент в `components/sdk/src/zeus/`.
4. `pnpm run build` в `components/sdk/` → unbuild собирает `dist/`.
5. В desktop импортировать `Mutations.<Domain>.<Name>` / `Queries.<Domain>.<Name>` из `@coopenomics/sdk` — типизировано end-to-end.
Если на шаге 1 не хватает поля — добавить и запустить весь цикл; не оставлять заглушку «пока».
### Строгая типизация desktop API из SDK через IInput['data']
Каждый вызов `client.Mutation` / `client.Query` в `components/desktop/src/**/api/index.ts`:
1. Принимает аргументом объект `data: IXxxInput`, где `IXxxInput = Mutations.<Domain>.<Action>.IInput['data']` (или `Queries...`) — тип берётся **прямо из @coopenomics/sdk**, не переописывается.
2. Передаёт в `variables` объект `data` целиком: `{ variables: { data } }`. Запрещено разворачивать поля: `{ variables: { data: { a, b, c } } }`.
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
| Что | Путь |
|---|---|
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
**Запреты:**
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
## Frontend desktop — English имена
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
- Имена Vue-компонентов: `BaseInput`, `BaseDialog`, `WalletCard`, `IdentityPanel`.
- Имена файлов и директорий: `shared/ui/BaseInput/BaseInput.vue`.
- CSS-классы: `.base-card`, `.wallet-card__icon`, `.id-panel__hero`.
- SCSS-переменные: `$primary`, `$prog-blagorost`, `$separator-color`.
- TS-типы, интерфейсы, переменные, функции: `interface WalletCardProps`, `function useWalletData()`.
**Why:** Unicode-имена ломают тулчейн (Vite/Webpack резолверы и aliases часто на ASCII-only regex; TypeScript symbol-resolution на не-ASCII нестабильно; ESLint `vue/component-name-in-template-casing` ждёт PascalCase ASCII; импорты `import БазоваяКнопка from '@/shared/ui/БазоваяКнопка'` невыносимы при review).
**Заголовки в .vue, label-ы кнопок, тексты в UI — по-русски** (это user-facing strings). Не путать с правилом «онтологические class_id по-русски» — то про EMP/ТЭМ и blago-документы, не про frontend.
**Кейс 2026-05-18:** при подготовке UX-спецификации для components/desktop ошибочно применил правило русских имён к Vue-компонентам (`БазоваяКнопка`, `БазовоеПолеВвода`); пользователь поправил.
## Стандарты процессов (.standard.yaml) — бизнес-языком
`components/contracts/cpp/**/*.standard.yaml` — документация для методолога/бухгалтера, не для разработчиков контракта. В `purpose`, `description`, `note`, `human` запрещены технические термины:
- никаких «callback», «soviet::exec», «soviet::createagenda», «AUTHORIZE_CALLBACK_SIGNATURE», «type-string», «registry N», «proposed расширение enum'а»;
- никаких «backend formирует», «controller вызывает», «contract отдаёт» — пишем кто что делает на уровне бизнеса (председатель / совет / заказчик / поставщик);
- технические `marketplace::propwroff`-имена в полях `action`/`name`/`triggered_by` оставляем как identifier'ы, но всё человеко-читаемое в `human`/`purpose`/`description` — на бизнес-словаре;
- бухгалтерские проводки `Дт 91 / Кт 10` — допустимы (бухгалтер их понимает);
- если процесс встроен в более общий — ссылаемся на стандарт по имени-человеку («типовой процесс решения совета»), не на техническую реализацию повестки.
Эталоны: `p.mkt.return.standard.yaml`, `p.cap.rid.standard.yaml`, `reg.coop.standard.yaml`. Антипример — `p.mkt.wroff.standard.yaml` в PR #399 review 2026-05-18 (был забит callback-описаниями и «type=mktwroff»).
## Vault & SERVER_SECRET
WIF админ-аккаунта (например `voskhod`) хранится в `vaults` PostgreSQL зашифрованным AES-256-CBC с ключом `sha256(SERVER_SECRET)`. Если `SERVER_SECRET` потом меняли — **старые записи разрушаются**, `decipher.final()` бросает `error:1C800064:Provider routines::bad decrypt`.
**Симптомы:**
- В логе coopback: `[VaultDomainService] Ошибка при получении WIF ключа для пользователя voskhod: bad decrypt`.
- В UI каскад `SignAgreementDialog` не закрывается; SPA отправляет `sendAgreement` корректно, но `wallet::signagree` on-chain не происходит.
- Следствие — `wallet::users[<coop>]` пуст, и любой последующий `is_can_transfer`/трансфер AXON падает на `Отправитель не является участником ЦПП кошелька`.
**Фикс:**
1. Достать живой WIF (для voskhod в dev — `5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3` из boot config.ini, signature-provider).
2. Шифрануть им current SERVER_SECRET (см. `controller/src/utils/aes.ts` — sha256(SECRET) → AES-256-CBC, IV 16 байт, формат `ivHex:cipherHex`).
3. `UPDATE vaults SET wif='...', updated_at=now() WHERE username='voskhod' AND permission='active';`.
4. Перезапуск coopback **не нужен** — он читает каждый раз.
**Дефолт всех mono-репозиториев — `SECRET`.** Если в каком-то `.env` стоит другое — девиация, не норма. Перед re-encrypt'ом vault'а ВСЕГДА сначала смотреть `~/mono-ai-1/components/controller/.env` (или соседнего) — это origin truth для SERVER_SECRET.
**Кейс 2026-05-18 (Эпик 0):** в `mono-ai-5/components/controller/.env` стоял `e2e-fixture-secret-DO-NOT-USE-IN-PROD`, а WIF voskhod в vaults был зашифрован оригиналом — `SECRET`. Фикс — откатить SERVER_SECRET к `SECRET` и подровнять provider'а.
## Capital — фиксы билетов времени (PR #387 merged 2026-05-15)
В `components/controller/src/extensions/capital/` была серия багов в распределении билетов времени, закрытая PR #387 в dev. Три бага:
1. **`recalcDoneEstimatesForContributorProject` / `applyExplicitEstimateToTimeEntries`** раздавал «общий остаток пула» `(estimate total_committed) / N` всем creators, включая того, кто уже закоммитил. Фикс: личная доля `max(0, estimate/N own_committed_estimate)` через общий helper `redistributeIssueEstimateEntries`.
2. **`commitTime` partial split** создавал committed-запись без `entry_type` и `estimate_snapshot` — БД по default'у писала `entry_type='hourly'`, что ломало последующий recalc (фильтрует только `entry_type='estimate'`). Фикс: явно копировать оба поля.
3. **`declineCommit` / `handleDeclineCommit`** меняли только `commit.status='declined'`, но не возвращали `time-entries` в `is_committed=false`. Часы оставались в `total_committed_hours`. Фикс: `revertEntriesForDeclinedCommit` через новые `findCommittedByCommitHash` / `revertCommittedEntriesByCommitHash`.
**При симптомах** «25 ч подтверждено непонятно откуда» / «доступные часы не вернулись после decline» / «парные коммиты на одну работу» — первый чек: `grep -c redistributeIssueEstimateEntries time-tracking.interactor.ts` в контейнере > 5. Если нет — деплой устарел. Если есть, но баг — посмотреть `entry_type` у committed-записей по issue: legacy записи из БАГ #2 могут до сих пор быть с `entry_type='hourly'` для split-результатов от estimate (видны по `commit_hash IS NOT NULL AND entry_type='hourly' AND estimate_snapshot IS NULL` на DONE-задаче с estimate>0). Чинятся UPDATE'ом `entry_type → estimate` с правильным `estimate_snapshot`.
**Legacy data caveat:** `capital_time_entries` с `entry_type='hourly'` + `commit_hash != NULL` могут быть как (а) настоящей hourly работой до установки estimate, так и (б) split-наследием БАГ #2. Различить: если у задачи `estimate>0` и `DONE`, и записи hourly от тех же creators что в estimate-долях — это (б), чинить.
**Edge-case дробных остатков:** при `estimate=2.5` и одном creator, после commit'а 2 ч (`Math.floor < 1` для остатка) остаётся 0.5 ч uncommitted estimate, который никогда не закоммитится. Либо ручной DELETE uncommitted остатка, либо изменить estimate на целое (но estimate в `capital_issues` — on-chain, через UI mutation, не DELETE'ом в БД).
## Стол Заказов MVP — текущее состояние
Проект `1-prilozhenie-stol-zakazov` в blago (coopname voskhod, hash `feabc749…3841f73`). Кооперативная закупка/распределение имущества участка (продукты, товары, услуги); пилот — Красногорск; цель 6 мес: 10 кооперативов / 1200+ пайщиков.
**Локация:** `~/blago/production/1-prilozhenie-stol-zakazov/`.
**Структура артефактов:**
- `_bmad-output/planning-artifacts/` (project-уровень): `prd.md`, `prd-validation-report.md`, `ux-design-specification.md`, `architecture.md` (status:complete 2026-05-12).
- **`components/3-minimalnyy-produkt/_bmad-output/planning-artifacts/epics.md`** (SoT для MVP) — `stepsCompleted=[1,2,3,4]`, `status: complete 2026-05-12`, 65 FRs → 11 эпиков → 57 stories. **НЕ создавать дубль на верхнем уровне `_bmad-output/planning-artifacts/`** — MVP-компонент имеет собственный bmad-output.
- `requirements/` (5 файлов): `04-brif`, `0b-protsessy`, `0f-prd`, `7e-uxui`, `d6-arkhitektura` — дубли _bmad-output (намеренно, см. правило о дублях BMad-артефактов в global memory).
**Locked Decisions L1L9:**
- **L1:** порядок подписей АПП (приёмка поставщик→председатель финально / выдача председатель→заказчик финально).
- **L2:** `o.mkt.payout` baseline `Дт 86 / Кт 51` без счёта 60.
- **L3:** реверты исключены — compensating forward с собственным `operation_id`.
- **L4:** геокарта КУ обязательна на трёх столах; lat/lng в `PlaceDomainEntity`.
- **L5 / L9:** «Системный конструктор ЦПП» → реализован как трёхуровневый онбординг расширений (см. выше).
- **L6:** без отрицательного баланса в MVP (отменяет NFR-R5 PRD).
- **L7:** enum 10 кооп-категорий (овощи/фрукты, молочные, мясо, рыба, хлеб, бакалея, напитки, готовая еда, услуги, прочие).
- **L8:** двухслойная схема подписания — глобальная on-chain `agreements` (SoT) + локальные `*_onboarding_requirement`/`*_onboarding_state` per-extension.
- **L10/L11/L13:** consolidated request backend-only / cycle_type backend variation / pull-модель отчётности.
**Прогресс по эпикам (на 2026-05-15):**
- **Эпик 1** — MERGED в `marketplace2`: PR #368/#370/#371/umbrella #380.
- **Эпик 2 «Сеть ПВЗ»** — MERGED PR #381 (после rebase). Workspace `market-pvz`, KU details + Yandex geocoder, Zeus SDK.
- **Эпик 11 Story 11.1 (Ledger2 canonical actions)** — MERGED PR #375 на C++ стороне. **TS-сторона cooptypes НЕ закрыта**: `cooptypes/src/contracts/marketplace/actions/index.ts` экспортирует только LEGACY клиринговые actions, `interfaces/marketplace.ts` auto-generated из устаревшего ABI. **Блокер Эпика 4** — нужен отдельный pre-эпик PR `feat/S11-1-cooptypes-canonical`: ABI regen через `eosio-abi2ts` из новой `marketplace.abi.json` после `coopcontracts` build, либо ручное добавление 18 canonical actions + canonical tables + canonical interfaces.
- **Эпик 3 «Витрина»** — IN REVIEW. PR #382 (`feat/E3-vitrina`) OPEN MERGEABLE. 6 коммитов (Stories 3.13.5 + BC-sync seam), 67 unit-тестов. Stories 3.4 (CAS counters) — callback-target для Эпика 4 + scaffolding `marketplace-order-sync.service.ts` (skeleton, throw NOT_IMPLEMENTED).
- **Эпики 4-10** — BACKLOG. Issues 598-7 … 598-13 разложены; story-requirements в `598-N-…-requirements/`.
**Открытые фоллоуапы Эпика 1:**
- L3 mutation `marketplaceSignOnboardingOffer` (write-mutation pool + `sndagreement`).
- Source-маркер `'registration_flow'` vs `'extension_gate'` в DTO.
- `marketplaceAcceptCpp` валидация повестки совета — после Эпика 8 (FR40).
- Финальный юридический текст оферты — `todo-tspp-templates.md` (блокирует Stories 1.7/1.9/1.11 в продовой подаче).
- Story 1.5 интеграционный тест против тестовой ноды ЦК — после Эпика 11.
**Реальное состояние brownfield (на marketplace2 @ d98041d):**
| Слой | Готово (membership) | Donor-клиринг (нужно решить судьбу) |
|---|---|---|
| ledger2 C++/TS реестры | 12 операций `o.mkt.*`, счета 10/91, кошельки `w.mkt.member`/`w.mkt.payout`/`w.wal.member`, WalletOp `REVOKE` | — (legacy `o.mkt.supply/recv` удалены) |
| YAML-стандарты `p.mkt.{supply,return,wroff}` | canonical имена actions | — |
| C++ actions (`marketplace.hpp` + 30 .cpp) | **ничего** из canonical | `orderoffer/accept/authcontrib/authreturn/supply/...` ~1660 LOC; legacy Wallet, не ledger2 |
| process-registry в controller | заглушки под membership (`p.mkt.reqst` убран) | — |
| cooptypes/contracts/marketplace TS-actions | нет canonical | 24 клиринговых обёртки |
| controller/application + domain/marketplace | нет canonical | 23 DTO + interactor + resolver + 6 interfaces |
| controller/extensions/marketplace | — | для категорий Ozon (clean arch, 7 TypeORM entities); может пригодиться для каталога Offer'ов |
| controller/extensions/marketplace-cards | — | минимальный каркас карточек |
| desktop/pages/Marketplace | нет canonical | 11 страниц под клиринг; `desktop/extensions/market` пуст |
**Решение 2026-05-12:** стратегии миграции не делаем — donor уже не работает по старой модели; собираем новую membership-модель в существующем контуре, donor-код переписывается / удаляется напрямую без переходников.
В `/bmad-create-architecture` MVP **не вводить adapter-слой и не описывать миграционные пути**. Прямо фиксировать: какие C++ actions/DTO/Vue-страницы из donor-листа удаляются, какие переписываются под canonical (`signsupp/signchair/signiss1/signiss2/acceptbatch/declinebatch/expirecycle/prepship/createorder(новая сигнатура)/cancelorder`), какие сохраняются (shipment/coopstock — если попадают в MVP scope, отдельно проверить).
**Обязательная enforcement-база для backend**`mono-ai-4/components/controller/CLAUDE.md` (Composite-Entity `db/bc/derived`, Write-mutation pool с placeholder/sync_key dedup, ParserClient + Redis Streams, ForkRegistry, ADR-002/008/009/011/012; параметры через `config/blockchain.config.ts`, не magic numbers).
-139
View File
@@ -1,139 +0,0 @@
# Запуск mono dev-стека на macOS
Полная пошаговая инструкция, как поднять backend (`coopback`) + parser (`cooparser`) + блокчейн-ноду + БД на macOS через Docker Desktop. Прошёл — отметь, ниже разобраны типичные грабли.
## TL;DR
```bash
cd ~/dacom-code/foundation/monocoop
docker compose up -d # 1. поднять базу
./scripts/dev-setup-macos.sh # 2. одной командой (см. ниже скрипт)
```
Если скрипта ещё нет — выполняй шаги ниже руками.
## Шаги
### 1. Поднять контейнеры
```bash
docker compose up -d
```
Поднимутся: `node` (NodeOS), `mongo`, `monoredis`, `postgres`, `coopback`, `cooparser`. **MinIO** входит в дефолт. **OpenSearch** — нет (тяжёлый, см. опц. сервисы).
### 2. Проверить чтоб .env'ы указывали на service-имена, а не localhost
`components/controller/.env`:
- `MONGODB_URL=mongodb://mongo:27017/cooperative-x`
- `REDIS_HOST=monoredis`
- `POSTGRES_HOST=postgres`, `POSTGRES_PASSWORD=postgres!23!23`
- `BLOCKCHAIN_RPC=http://node:8888`
- `SIMPLE_EXPLORER_API=http://cooparser:4000`
- `MINIO_ENDPOINT=minio:9000`
- `CHAIN_ID=<реальный chain_id, см. ниже>`
`components/parser/.env`:
- `MONGO_EXPLORER_URI=mongodb://mongo:27017/cooperative-x`
- `REDIS_HOST=monoredis`
- `API=http://node:8888`
- `SHIP=ws://node:8080`
**Почему service-имена:** все контейнеры в bridge-сети `monocoop_default`. Внутри контейнера `127.0.0.1` = сам контейнер, не host. На macOS `network_mode: host` в Docker Desktop работает плохо — используем bridge + service names.
### 3. Узнать живой chain_id
```bash
curl -s http://localhost:8888/v1/chain/get_info | jq -r .chain_id
```
И вписать в `components/controller/.env``CHAIN_ID=...`. **Если chain_id в .env не совпадает с живым — on-chain транзакции упадут на verify, хотя приложение запустится.**
### 4. Если правил .env — пересоздать контейнер (не restart!)
```bash
docker compose up -d --force-recreate --no-deps coopback cooparser
```
`docker restart` **не перечитывает** `env_file`. `--no-deps` — чтобы не пересоздавать БД (потеряются данные).
### 5. Native binding libxmljs2 — пересборка под Linux
На свежем чекауте `pnpm install` запускается на macOS, и `libxmljs2` собирает Mach-O бинарник. В Linux-контейнере он падает:
```
Error: .../xmljs.node: invalid ELF header ERR_DLOPEN_FAILED
```
**Фикс** (один раз после `pnpm install`):
```bash
docker exec monocoop-coopback-1 sh -c '
cd /app/node_modules/.pnpm/libxmljs2@0.37.0/node_modules/libxmljs2 \
&& rm -rf build \
&& PATH="$PWD/node_modules/.bin:$PATH" npm run install
'
docker restart monocoop-coopback-1
```
Проверка — magic bytes Linux ELF:
```bash
docker exec monocoop-coopback-1 sh -c \
'head -c 4 /app/node_modules/.pnpm/libxmljs2@0.37.0/node_modules/libxmljs2/build/Release/xmljs.node | od -c -N 4'
# Должно быть: 177 E L F
# Если cf fa ed fe — это macOS Mach-O, фикс не применился.
```
### 6. Дождаться cold-start coopback (~56 минут)
`ts-node` без cache + DI tree NestJS = долго. Первые логи появляются спустя ~5 минут после старта контейнера.
```bash
docker logs -f monocoop-coopback-1 | grep -E 'NestJS app|Nest application|Error'
```
Готово, когда видишь:
```
Nest application successfully started
NestJS app with Express routes running on port 2998
```
Проверка:
```bash
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"query":"{__typename}"}' http://localhost:2998/v1/graphql
# {"data":{"__typename":"Query"}}
```
## Опциональные сервисы
### OpenSearch (поиск)
```bash
docker compose --profile search up -d opensearch
```
Затем в `components/controller/.env`:
```
OPENSEARCH_ENABLED=true
```
И пересоздать coopback:
```bash
docker compose up -d --force-recreate --no-deps coopback
```
## Грабли (если опять провозился пол-дня)
| Симптом | Причина | Фикс |
|---|---|---|
| `invalid ELF header` | host'овый `pnpm install` положил Mach-O | См. п. 5 — пересборка libxmljs2 в контейнере |
| `MongooseServerSelectionError: ECONNREFUSED 127.0.0.1:27017` | .env на localhost вместо service-имени | См. п. 2 |
| `password authentication failed for user "postgres"` | в .env `POSTGRES_PASSWORD=postgres` вместо `postgres!23!23` | См. п. 2 |
| Coopback ушёл в restart-loop, но рядом есть другой `monocoop-coopback-1` Up | Случайно запустился старый `components/controller/docker-compose.yml` (network_mode: host, без bind-mount, без CMD) | `docker stop coopback && docker rm coopback` |
| `docker restart` не подхватил новые переменные .env | `restart` не перечитывает `env_file` | `docker compose up -d --force-recreate --no-deps <svc>` |
| Coopback зависает на 5+ минут, лога нет | Это нормально для cold-start ts-node | Подождать. CPU должен крутиться ≥50%. Если CPU = 0% — другая проблема |
| `[VaultDomainService] Ошибка при получении WIF... bad decrypt` | SERVER_SECRET не совпадает с тем, чем шифровали WIF в БД | См. CLAUDE.md → раздел Vault & SERVER_SECRET. Дефолт — `SECRET` |
| Транзакции падают на on-chain verify | CHAIN_ID в .env не совпадает с живым | См. п. 3 |
| `Custom endpoint \`minio://9000\` was not a valid URI` на bootstrap | `MINIO_ENDPOINT` без схемы | `MINIO_ENDPOINT=http://minio:9000` (со схемой!) |
| nodeos в restart-loop с `Database dirty flag set` после sleep/crash Docker Desktop | unclean shutdown chain state | `docker stop monocoop-node-1`; `mv blockchain-data/state-history{,.broken-$(date +%s)}`; `docker compose up -d node` — nodeos сам сделает replay |
| Cold-start coopback 10+ минут (вместо обычных 3-5) | ts-node без cache + bind-mount через osxfs + параллельная нагрузка (nodeos replay) | Подождать; в будущем — `tsx` / `ts-node --swc` для dev-режима |
+1 -20
View File
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
COPY . .
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
# Отключаем сами загрузки, пакеты всё равно ставятся.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
PUPPETEER_SKIP_DOWNLOAD=true
# Используем существующий lockfile. Если он не совпадает с workspace —
# падать сразу, не дрейфовать незаметно.
RUN pnpm install --frozen-lockfile
@@ -91,23 +83,12 @@ WORKDIR /app
# ссылка на системный интерпретатор; без него `weasyprint` падает с
# `python: not found`. `procps`/`wget` — для дебага/healthcheck'ов
# из docker-compose.
#
# `ca-certificates` обязателен: node:22-slim его не содержит, а
# native-биндинги типа @livekit/rtc-node (Rust + rustls + rustls-native-certs)
# читают корни ТОЛЬКО из системного store. Без файла
# /etc/ssl/certs/ca-certificates.crt любой TLS handshake из такого
# биндинга падает с "invalid peer certificate: UnknownIssuer" —
# даже для валидной LE-цепочки (Node-стек при этом работает,
# у него свои вшитые корни). Инцидент 2026-05-21: secretary в
# coopback не мог подключиться к wss://chatcooprtc.coopenomics.world.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 libcairo2 \
libffi8 libjpeg62-turbo libopenjp2-7 zlib1g shared-mime-info \
procps wget \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates
&& rm -rf /var/lib/apt/lists/*
# Python venv с WeasyPrint, готовый к использованию.
COPY --from=builder /venv /venv
@@ -1,55 +0,0 @@
### Blago Manage Transcription Memo
Назначение скилла — собрать краткое содержание (memo) встречи по транскрипции звонка, согласовать его с пользователем и опубликовать в backend через `blago` так, чтобы оно появилось в desktop UI на странице транскрипции звонка (поле «Заметка о звонке»).
Право редактировать memo на сервере есть только у председателя и членов совета — убедись, что активная сессия `blago login` принадлежит подходящей роли.
Перед началом прочитай: `~/.claude/config/blago/config.yaml`. Запомни оттуда `workspace_base`, `active_workspace_env` и путь из `workspaces`, соответствующий `active_workspace_env` — назовём его `workspace_path`.
#### Входной артефакт
Транскрипции звонков лежат в `<workspace_path>/<project-slug>/meetings/<stem>.md`. Имя файла `<stem>` — UTC-метка окончания звонка (`YYYY-MM-DD_HHMMSS_mmm`). Этот файл — pull-only: его текст приходит с сервера и в `blago push` не уходит.
Связь meeting-файла с UUID транскрипции хранится в `.blago/index.json` рабочей копии: запись с `entity_type=call_transcription`, `relative_path` равен пути к meeting-файлу, `entity_hash` — UUID транскрипции. Сам `blago transcription memo` этот UUID резолвит автоматически по пути.
`blago pull` всегда кладёт рядом с meeting-файлом `meetings/<stem>.memo.md` (тип `call_transcription_memo`, `entity_hash = "<uuid>:memo"`). Если на сервере memo пустой — файл создаётся пустым. Это сделано специально, чтобы редактирование и публикация шли в одно действие: открыть существующий sibling, дописать, опубликовать. Никаких отдельных шагов «создать файл» делать не нужно.
#### Формат содержимого `.memo.md`
Жёсткие требования к телу memo (применяй всегда, даже без напоминания пользователя):
1. **Первая строка — резюме одним предложением, длиной ≤150 символов.** Это «о чём вообще был звонок». Без markdown-форматирования (без `#`, `*`, `-`, ссылок, цитат), без переноса строки внутри. Считай длину в символах (Unicode codepoints), а не в словах. Если не помещается — переформулируй до сути, не сокращай прилагательными.
2. Следующая пустая строка-разделитель, затем — развёртка по сути. Сжимай до минимума:
- убирай этикет, разогрев, повторы, «слышно? — слышно», частицы;
- объединяй похожие реплики в один пункт;
- оставляй: о чём договорились, что обсудили, какие решения, какие открытые вопросы и кто за что взялся;
- короткие маркированные списки лучше абзацев; имена/числа/даты сохраняй точно.
3. Markdown отображается в desktop UI редактором (Milkdown через `WrappedEditor`). Можно: заголовки `##`/`###`, списки `-`, инлайн `**жирный**`, ссылки. Не используй декоративный markdown, который ничего не добавляет к смыслу.
#### Процедура
1. Получи от пользователя путь к meeting-файлу (или скажи ему: «дай путь к нужному meeting-файлу»). Прочитай meeting-файл целиком.
2. Открой sibling-файл `meetings/<stem>.memo.md` (он уже создан `blago pull`, возможно пустой). Если файла нет — попроси пользователя выполнить `blago pull` для нужного проекта и повтори.
3. Собери summary по правилам выше: первая строка — одно предложение до 150 символов, дальше пустая строка и сжатая суть. По прямому промпту пользователя адаптируй акценты (что выделить, какие выводы). Если промпта нет — попроси его. Запиши результат в существующий sibling.
4. Покажи пользователю обновлённое содержимое `.memo.md` целиком (или дай явную ссылку на файл). Проверь сам: первая строка — одно предложение, ≤150 символов, без markdown. Если нет — переписывай, пока не выполнится. Спроси, всё ли устраивает. Не публикуй без явного «ок / публикуй / поехали».
5. После подтверждения опубликуй memo на сервер:
```bash
blago transcription memo <workspace_path>/<project-slug>/meetings/<stem>.md
```
По умолчанию команда читает `.memo.md` рядом с указанным meeting-файлом. Альтернативы:
- `blago transcription memo <stem>.md --file path/to/draft.md` — взять текст из произвольного файла;
- `blago transcription memo <UUID> --text "<строка>"` — передать текст строкой и адресовать транскрипцию напрямую по UUID.
6. Команда вернёт строку вида `Memo обновлено на сервере: транскрипция <uuid> (символов: N; источник: …)`. Если сервер ответил ошибкой (например, прав не хватает или сессия истекла) — останови работу, сообщи пользователю текст ошибки и предложи `blago login`.
#### Замечания
- `blago pull` всегда создаёт `meetings/<stem>.memo.md` рядом с каждой meeting-транскрипцией — пустой, если на сервере memo пустой. Файл сразу проиндексирован (`entity_type=call_transcription_memo`), поэтому редактирование→`blago transcription memo` идёт без warning'ов.
- Файл `.memo.md` в `blago push` не уходит (артефакты `meetings/` исключены из staging) — это «локальный канал» вокруг серверного поля `memo`.
- Если ты редактировал sibling локально и не публиковал, а на сервере появилась новая версия — повторный `blago pull` запишет git-style маркеры конфликта (`<<<<<<< blago/local``>>>>>>> blago/remote`); разрулите вручную и опубликуй через `blago transcription memo`.
- Если на сервере memo всё ещё пустой, а у тебя локальный черновик не публиковался — `blago pull` сохранит локальный текст и проиндексирует его как baseline; никакие правки не пропадают.
- Чтобы перезаписать опубликованный memo — повтори ту же команду; сервер хранит одно текущее значение поля `memo`.
- Ограничения на длину больше нет (ни на фронте, ни на бэкенде) — можно публиковать развёрнутые саммари с выводами и пунктами.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.21-1",
"version": "2026.5.14-2",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+2 -45
View File
@@ -41,7 +41,6 @@ import { runPush } from '../sync/push.js'
import { runClearStaging, runRemove } from '../sync/remove.js'
import { restoreAllFromServer, RESTORE_ALL_PATH_SENTINELS, runRestore } from '../sync/restore.js'
import { runStatus } from '../sync/status.js'
import { runUpdateTranscriptionMemo } from '../sync/update-transcription-memo.js'
import { writeWorkspaceIndexMarkdown } from '../sync/workspace-index.js'
import { error, formatThrownValue, info, success, warn } from '../ui/output.js'
@@ -238,48 +237,6 @@ export async function runCli(argv: string[]): Promise<void> {
},
)
const transcriptionCmd = program
.command('transcription')
.description(
'Управление транскрипциями звонков: публикация краткого содержания (memo) на сервер (отображается в desktop на странице транскрипции).',
)
transcriptionCmd
.command('memo')
.description(
'Опубликовать краткое содержание (memo) для транскрипции звонка через GraphQL-мутацию chatcoopUpdateTranscriptionMemo. <pathOrId>: путь к `meetings/<stem>.md` (id берётся из .blago/index.json, entityType=call_transcription) или UUID транскрипции. Без --text/--file читает sibling-файл `<stem>.memo.md` рядом с meeting. Доступно председателю и членам совета.',
)
.argument(
'<pathOrId>',
'путь к meeting-файлу или UUID транскрипции',
)
.option('--file <path>', 'путь к файлу с текстом memo (markdown)')
.option('--text <inline>', 'текст memo строкой (приоритет над --file и sibling)')
.action(
async (
pathOrId: string,
opts: { file?: string, text?: string },
) => {
const root = requireRoot()
const cfg = await loadConfig(root)
const ctx = await ensureAuthenticatedContext(root, cfg)
const result = await runUpdateTranscriptionMemo(
ctx,
pathOrId,
{ inlineText: opts.text, filePath: opts.file },
startDir(),
)
const sourceLabel = result.memoSource === 'inline'
? '--text'
: result.memoSource === 'file'
? `--file ${result.memoFile ?? ''}`
: `sibling ${result.memoFile ?? ''}`
success(
`Memo обновлено на сервере: транскрипция ${result.transcriptionId} (символов: ${result.memoLength}; источник: ${sourceLabel}).`,
)
},
)
const delCmd = program
.command('del')
.alias('delete')
@@ -362,9 +319,9 @@ export async function runCli(argv: string[]): Promise<void> {
)
.action(async (paths: string[]) => {
const root = requireRoot()
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
success(
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
)
})
-16
View File
@@ -38,22 +38,6 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
}
}
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
let file: ReturnType<typeof matter>
try {
file = matter(raw)
}
catch {
return undefined
}
const t = file.data?.type
if (t === 'project' || t === 'issue' || t === 'story') {
return t
}
return undefined
}
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
const trimmed = body.endsWith('\n') ? body : `${body}\n`
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
+12 -41
View File
@@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { peekBlagoEntityType } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
return out
}
type FileCheck =
| { kind: 'unreadable' }
| { kind: 'non-entity' }
| { kind: 'clean' }
| { kind: 'dirty' }
/**
* Решение по одному файлу для staging:
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
* — есть в индексе и sha256 совпал → clean,
* — иначе dirty.
*/
async function classifyFileForAdd(
root: string,
rel: string,
index: Awaited<ReturnType<typeof loadIndex>>,
): Promise<FileCheck> {
let raw: string
try {
raw = await fs.readFile(path.join(root, rel), 'utf8')
}
catch {
return { kind: 'unreadable' }
}
if (peekBlagoEntityType(raw) === undefined) {
return { kind: 'non-entity' }
}
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
const entry = findByRelativePath(index, rel)
if (!entry) {
return { kind: 'dirty' }
return true
}
try {
const raw = await fs.readFile(path.join(root, rel), 'utf8')
return sha256Hex(raw) !== entry.content_etag_local
}
catch {
return true
}
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
}
export interface RunAddResult {
@@ -71,8 +50,6 @@ export interface RunAddResult {
skippedIgnored: number
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
skippedPullOnlyArtifacts: number
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
skippedNonEntity: number
}
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
let skippedUnchanged = 0
let skippedIgnored = 0
let skippedPullOnlyArtifacts = 0
let skippedNonEntity = 0
for (const t of expanded) {
const abs = path.resolve(root, t)
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
skippedPullOnlyArtifacts += 1
continue
}
const check = await classifyFileForAdd(root, rel, index)
if (check.kind === 'non-entity') {
skippedNonEntity += 1
continue
}
if (check.kind === 'clean') {
if (!(await isDirtyVsIndex(root, rel, index))) {
skippedUnchanged += 1
continue
}
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
const stagedPaths = [...set].sort()
await saveStaging(root, { paths: stagedPaths })
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
}
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
messageLastTsByRoom: Record<string, number>
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByProject: Record<string, string>
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByRoom: Record<string, string>
}
function empty(): CommunicationCursorsFile {
return {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByProject }
: {},
transcriptionLastEndedExclusiveByRoom:
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
: {},
}
}
catch {
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
}
export interface TranscriptionHeaderModel {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
const header = [
`# Транскрипция звонка`,
``,
`- Matrix room: \`${transcription.matrixRoomId}\``,
`- LiveKit room: \`${transcription.roomId}\``,
`- Начало: ${start.toISOString()}`,
`- Окончание: ${end ? end.toISOString() : '—'}`,
@@ -14,8 +14,6 @@ export type BlagoEntityType =
| 'room_message_day'
/** Файл `meetings/*.md` (транскрипция звонка), только pull с сервера */
| 'call_transcription'
/** Файл `meetings/<stem>.memo.md` — краткое содержание звонка; pull с сервера + публикация через `blago transcription memo` */
| 'call_transcription_memo'
export interface IndexEntry {
entity_type: BlagoEntityType
@@ -3,16 +3,11 @@
import type { AuthenticatedContext } from '../session/index.js'
import type { IndexFile } from './index-store.js'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { createHash } from 'node:crypto'
import { Queries, Zeus } from '@coopenomics/sdk'
import { sha256Hex } from '../lib/hash.js'
import { formatThrownValue, warn } from '../ui/output.js'
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
import {
loadCommunicationCursors,
@@ -25,7 +20,7 @@ import {
transcriptionMeetingFileStemUtc,
type CommunicationDayLine,
} from './communication-markdown.js'
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
import { syncEntityFile } from './sync-entity-file.js'
interface ProjectRowLite {
@@ -47,121 +42,6 @@ function messageDayEntityHash(projectHash: string, utcDate: string): string {
return createHash('sha256').update(`${projectHash}:${utcDate}`, 'utf8').digest('hex')
}
function transcriptionMemoEntityHash(transcriptionId: string): string {
return `${transcriptionId.toLowerCase()}:memo`
}
async function readFileIfExists(abs: string): Promise<string | null> {
try {
return await fs.readFile(abs, 'utf8')
}
catch {
return null
}
}
function normalizeMemoContent(memo: string): string {
if (memo.length === 0) {
return ''
}
return memo.endsWith('\n') ? memo : `${memo}\n`
}
/**
* Гарантирует наличие файла `meetings/<stem>.memo.md` после pull.
*
* Файл создаётся **всегда** (даже если на сервере memo пуст), чтобы пользователь мог редактировать
* sibling «в одно касание», а команда `blago transcription memo` всегда находила его. Конфликты с
* локальными правками разруливаются:
*
* - индекс есть → штатный `syncEntityFile` (он умеет merge-markers «<<<<<<< blago/local …»);
* - индекса нет, файла нет → пишем серверный memo и индексируем (baseline = сервер);
* - индекса нет, файл есть, содержимое совпадает с сервером → просто индексируем (baseline = совпавший контент);
* - индекса нет, файл есть, серверный memo пустой → оставляем локальный черновик и индексируем его (baseline = локальный);
* - индекса нет, файл есть, оба непустые и различаются → пишем merge-markers, индексируем merged.
*/
async function syncTranscriptionMemoFile(params: {
root: string
index: IndexFile
transcriptionId: string
relativePath: string
serverMemo: string
remoteUpdatedAtIso: string
}): Promise<void> {
const { root, index, transcriptionId, relativePath, serverMemo, remoteUpdatedAtIso } = params
const rel = normalizeRelativePath(relativePath)
const abs = path.join(root, rel)
const entityHash = transcriptionMemoEntityHash(transcriptionId)
const serverContent = normalizeMemoContent(serverMemo)
const prev = findByHash(index, 'call_transcription_memo', entityHash)
if (prev) {
await syncEntityFile({
root,
index,
entityType: 'call_transcription_memo',
entityHash,
relativePath: rel,
content: serverContent,
remoteUpdatedAt: remoteUpdatedAtIso,
label: `memo транскрипции ${transcriptionId}`,
})
return
}
await fs.mkdir(path.dirname(abs), { recursive: true })
const local = await readFileIfExists(abs)
if (local === null) {
await fs.writeFile(abs, serverContent, 'utf8')
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(serverContent),
})
return
}
if (local === serverContent) {
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(local),
})
return
}
if (serverContent.trim().length === 0) {
// Сервер ничего не знает — берём локальный черновик как baseline. Опубликовать его можно
// через `blago transcription memo <meeting-path>`.
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(local),
})
return
}
const merged = `<<<<<<< blago/local\n${local}\n=======\n${serverContent}\n>>>>>>> blago/remote\n`
await fs.writeFile(abs, merged, 'utf8')
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(merged),
})
warn(
`Конфликт memo транскрипции ${transcriptionId}: локальный черновик и серверная версия различаются. В «${rel}» записаны маркеры слияния («<<<<<<< blago/local» … «>>>>>>> blago/remote»). Оставьте одну версию текста и опубликуйте через «blago transcription memo».`,
)
}
function dateFromUnknown(value: unknown): Date | undefined {
if (value === null || value === undefined) {
return undefined
@@ -183,19 +63,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
}
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
if (kind === 'MEMBERS') {
return 'komnata-paishchikov'
}
if (kind === 'COUNCIL') {
return 'komnata-soveta'
}
const slug = generateSlug(displayLabel) || 'komnata'
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
return `${slug}-${shortId}`
}
export async function pullProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
@@ -211,7 +78,6 @@ export async function pullProjectCommunicationArtifacts(
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -318,16 +184,13 @@ export async function pullProjectCommunicationArtifacts(
try {
const tKey = row.project_hash
const tExIso = cursors.transcriptionLastEndedExclusiveByProject[tKey]
// Курсор `transcriptionLastEndedExclusiveByProject` влияет ТОЛЬКО на скачивание meeting.md
// (тяжёлый GetTranscription с сегментами). Sibling-файл `.memo.md` синхронизируется для всех
// COMPLETED-транскрипций каждый pull — поле `memo` приходит в лёгком GetTranscriptions.
// Как сообщения с after=0: без курсора — полная выгрузка завершённых транскрипций в meetings/.
// (GitHub-синк при первом запуске только ставит курсор без файлов — для локального зеркала так не делаем.)
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
interface TranscriptionCandidate {
id: string
endedAt: Date
memo: string
updatedAt: Date | undefined
}
const byId = new Map<string, TranscriptionCandidate>()
for (const roomId of matrixIds) {
@@ -340,23 +203,18 @@ export async function pullProjectCommunicationArtifacts(
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
continue
}
if (!(end.getTime() > lowerBoundExclusive.getTime())) {
continue
}
const prev = byId.get(t.id)
if (!prev || end > prev.endedAt) {
byId.set(t.id, {
id: t.id,
endedAt: end,
memo: typeof t.memo === 'string' ? t.memo : '',
updatedAt: dateFromUnknown(t.updatedAt),
})
byId.set(t.id, { id: t.id, endedAt: end })
}
}
}
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
// 1) meeting.md — только новые после курсора (тяжёлый GetTranscription с сегментами).
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
const candidates = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
let maxEnded: Date | null = null
for (const c of newMeetings) {
for (const c of candidates) {
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
variables: { data: { id: c.id } },
})
@@ -370,6 +228,7 @@ export async function pullProjectCommunicationArtifacts(
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
matrixRoomId: String(tr.matrixRoomId),
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
@@ -394,28 +253,10 @@ export async function pullProjectCommunicationArtifacts(
remoteUpdatedAt: toUpdatedIso(c.endedAt),
label: `транскрипция ${c.id}`,
})
if (!maxEnded || c.endedAt > maxEnded) {
maxEnded = c.endedAt
}
}
// 2) sibling .memo.md — для ВСЕХ COMPLETED-транскрипций (бэкфил независимо от курсора).
// Поле tr.memo пришло в лёгком GetTranscriptions, повторных запросов не делаем.
for (const c of allCompleted) {
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const memoRel = `${basePath}/meetings/${stem}.memo.md`
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
await syncTranscriptionMemoFile({
root: ctx.root,
index,
transcriptionId: c.id,
relativePath: memoRel,
serverMemo: c.memo,
remoteUpdatedAtIso: memoRemoteUpdatedAt,
})
}
if (maxEnded) {
cursors.transcriptionLastEndedExclusiveByProject[tKey] = maxEnded.toISOString()
}
@@ -435,207 +276,3 @@ export async function pullProjectCommunicationArtifacts(
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
}
}
/**
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
*/
export async function pullNonProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
): Promise<void> {
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
try {
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
}
catch (e) {
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
return
}
if (rooms.length === 0) {
return
}
let cursors: CommunicationCursorsFile
try {
cursors = await loadCommunicationCursors(ctx.root)
}
catch (e) {
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
for (const room of rooms) {
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
const basePath = `rooms/${folder}`
const roomTitle = room.displayLabel || room.matrixRoomId
// Сообщения комнаты — по календарным суткам UTC новее курсора.
try {
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
const afterTs = last ?? 0
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
})
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
for (const utcDate of dates) {
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
})
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
originServerTs: m.originServerTs,
authorLabel: m.authorLabel,
coopUsername: m.coopUsername,
kind: String(m.kind),
bodyText: m.bodyText,
}))
if (lines.length === 0) {
continue
}
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
])
const rel = `${basePath}/messages/${utcDate}.md`
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
await syncEntityFile({
root: ctx.root,
index,
entityType: 'room_message_day',
entityHash,
relativePath: rel,
content,
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
label: `переписка ${utcDate} (${room.matrixRoomId})`,
})
}
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
variables: { data: { matrixRoomId: room.matrixRoomId } },
})
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
}
}
catch (e) {
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
}
// Транскрипции звонков комнаты + sibling memo.
try {
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
interface TranscriptionCandidate {
id: string
endedAt: Date
memo: string
updatedAt: Date | undefined
}
const byId = new Map<string, TranscriptionCandidate>()
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
})
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
for (const t of list) {
const end = dateFromUnknown(t.endedAt)
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
continue
}
const prev = byId.get(t.id)
if (!prev || end > prev.endedAt) {
byId.set(t.id, {
id: t.id,
endedAt: end,
memo: typeof t.memo === 'string' ? t.memo : '',
updatedAt: dateFromUnknown(t.updatedAt),
})
}
}
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
let maxEnded: Date | null = null
for (const c of newMeetings) {
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
variables: { data: { id: c.id } },
})
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
continue
}
const tr = pack.transcription
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
const endedAtTr: Date | string | null | undefined
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
},
pack.segments.map(s => ({
speakerName: s.speakerName,
text: s.text,
startOffset: s.startOffset,
endOffset: s.endOffset,
})),
)
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const rel = `${basePath}/meetings/${stem}.md`
const entityHash = c.id.toLowerCase()
await syncEntityFile({
root: ctx.root,
index,
entityType: 'call_transcription',
entityHash,
relativePath: rel,
content: md,
remoteUpdatedAt: toUpdatedIso(c.endedAt),
label: `транскрипция ${c.id}`,
})
if (!maxEnded || c.endedAt > maxEnded) {
maxEnded = c.endedAt
}
}
for (const c of allCompleted) {
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const memoRel = `${basePath}/meetings/${stem}.memo.md`
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
await syncTranscriptionMemoFile({
root: ctx.root,
index,
transcriptionId: c.id,
relativePath: memoRel,
serverMemo: c.memo,
remoteUpdatedAtIso: memoRemoteUpdatedAt,
})
}
if (maxEnded) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
}
else if (tExIso === undefined) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
}
}
catch (e) {
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
}
}
try {
await saveCommunicationCursors(ctx.root, cursors)
}
catch (e) {
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
}
}
+1 -3
View File
@@ -28,7 +28,7 @@ import {
storyFileRelativePath,
workspaceBasePath,
} from './layout.js'
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
import { syncEntityFile } from './sync-entity-file.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
@@ -329,8 +329,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
await pullNonProjectCommunicationArtifacts(ctx, index)
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
await saveIndex(ctx.root, index)
+3 -43
View File
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations, Queries } from '@coopenomics/sdk'
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
import { parseBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import { warn } from '../ui/output.js'
@@ -58,30 +58,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
staging = await loadStaging(ctx.root)
}
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
const nonEntityInStaging: string[] = []
for (const p of staging.paths) {
try {
const raw = await fs.readFile(stagingAbs(p), 'utf8')
if (peekBlagoEntityType(raw) === undefined) {
nonEntityInStaging.push(normalizeRelativePath(p))
}
}
catch {
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
}
}
if (nonEntityInStaging.length > 0) {
const drop = new Set(nonEntityInStaging)
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
for (const p of nonEntityInStaging) {
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
}
staging = await loadStaging(ctx.root)
}
if (staging.paths.length === 0) {
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
}
@@ -99,24 +75,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
const abs = path.join(ctx.root, n)
const raw = await fs.readFile(abs, 'utf8')
let parsed: ReturnType<typeof parseBlagoMarkdown>
try {
parsed = parseBlagoMarkdown(raw)
}
catch (err) {
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
let type: ReturnType<typeof validateParsedForPush>['type']
let hash: string
try {
({ type, hash } = validateParsedForPush(parsed))
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
const parsed = parseBlagoMarkdown(raw)
const { type, hash } = validateParsedForPush(parsed)
const entry = findByHash(index, type, hash)
const pKind = pendingKindForEntityType(type)
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
+2
View File
@@ -367,6 +367,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
if (entry.entity_type === 'call_transcription') {
interface TranscriptionRestorePack {
transcription: {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -393,6 +394,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
}
const content = renderCallTranscriptionMarkdown(
{
matrixRoomId: tr.matrixRoomId,
roomId: tr.roomId,
startedAt: tr.startedAt,
endedAt: tr.endedAt,
@@ -4,34 +4,11 @@ import type { BlagoEntityType, IndexFile } from './index-store.js'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { parseBlagoMarkdown, serializeBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { warn } from '../ui/output.js'
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
/**
* Канонический вид для сравнения «есть ли реальный конфликт», без волатильных
* серверных меток времени. updated_at/created_at сервер бьёт при дочерних мутациях
* (создание/удаление issue/story у родителя), и они попадают и во frontmatter, и в
* content_etag_local. Из-за этого raw-sha файла расходится с etag, локальный файл
* считается «грязным», а pull при изменившемся remote_updated_at пишет маркеры слияния —
* хотя содержательно текст совпадает. Сравнение в каноне (без updated_at/created_at)
* отличает настоящую правку от чистого bump'а времени.
*/
function canonicalForCompare(raw: string): string {
try {
const parsed = parseBlagoMarkdown(raw)
const data = { ...parsed.data }
delete data.updated_at
delete data.created_at
return serializeBlagoMarkdown(data, parsed.body)
}
catch {
return raw
}
}
async function ensureDirForFile(absFile: string): Promise<void> {
await fs.mkdir(path.dirname(absFile), { recursive: true })
}
@@ -162,21 +139,6 @@ export async function syncEntityFile(params: {
const dirty
= current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local
if (dirty && remoteUpdatedAt !== prev.remote_updated_at) {
// Реальный конфликт — только если содержимое расходится вне волатильных меток времени.
// Если локальный и серверный тексты совпадают по канону (отличие лишь в updated_at/created_at),
// это не конфликт: принимаем серверную версию и лечим etag, без маркеров слияния.
if (current !== null && current !== undefined && canonicalForCompare(current) === canonicalForCompare(content)) {
await ensureDirForFile(absNew)
await fs.writeFile(absNew, content, 'utf8')
upsertEntry(index, {
entity_type: entityType,
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAt,
content_etag_local: sha256Hex(await fs.readFile(absNew, 'utf8')),
})
return
}
const merged = wrapMergeConflictMarkers(current ?? '', content)
await ensureDirForFile(absNew)
await fs.writeFile(absNew, merged, 'utf8')
@@ -1,195 +0,0 @@
// Публикация поля memo транскрипции на сервер (мутация chatcoopUpdateTranscriptionMemo).
//
// Связь meeting-файла с UUID транскрипции: `.blago/index.json` хранит entity_type=call_transcription
// и entity_hash=<uuid в lower-case> для каждого `meetings/<stem>.md` (см. pull-communication.ts).
//
// Текст memo берётся:
// 1) --text "<inline>" — приоритет;
// 2) --file <path> — читать markdown из файла;
// 3) sibling .memo.md — если <pathOrId> это meeting-файл, берём `<meetings>/<stem>.memo.md` рядом.
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations } from '@coopenomics/sdk'
import type { AuthenticatedContext } from '../session/index.js'
import { sha256Hex } from '../lib/hash.js'
import {
findByRelativePath,
type IndexFile,
loadIndex,
normalizeRelativePath,
saveIndex,
upsertEntry,
} from './index-store.js'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export interface UpdateTranscriptionMemoOptions {
readonly inlineText?: string
readonly filePath?: string
}
export interface UpdateTranscriptionMemoResult {
readonly transcriptionId: string
readonly memoLength: number
readonly memoSource: 'inline' | 'file' | 'sibling'
readonly memoFile?: string
}
/** Пара значений: id транскрипции для мутации + опциональный «meeting абсолютный путь» для sibling-резолва. */
interface ResolvedTranscription {
readonly id: string
readonly meetingAbsPath?: string
}
function resolveTranscription(
ctxRoot: string,
cwd: string,
pathOrId: string,
index: IndexFile,
): ResolvedTranscription {
const trimmed = pathOrId.trim()
if (UUID_RE.test(trimmed)) {
return { id: trimmed.toLowerCase() }
}
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed)
const rel = path.relative(ctxRoot, abs)
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(
`Путь «${pathOrId}» вне рабочей копии blago (${ctxRoot}). Передайте путь относительно корня копии или UUID транскрипции.`,
)
}
const entry = findByRelativePath(index, normalizeRelativePath(rel))
if (!entry) {
throw new Error(
`В .blago/index.json нет записи для «${rel}». Выполните «blago pull», чтобы подтянуть транскрипции.`,
)
}
if (entry.entity_type !== 'call_transcription') {
throw new Error(
`Файл «${rel}» имеет тип «${entry.entity_type}», а не «call_transcription». Передайте путь к meeting-файлу или UUID.`,
)
}
return { id: entry.entity_hash, meetingAbsPath: abs }
}
function buildSiblingMemoPath(meetingAbsPath: string): string {
const dir = path.dirname(meetingAbsPath)
const ext = path.extname(meetingAbsPath)
const stem = path.basename(meetingAbsPath, ext)
return path.join(dir, `${stem}.memo.md`)
}
async function readMemoText(
cwd: string,
resolved: ResolvedTranscription,
options: UpdateTranscriptionMemoOptions,
): Promise<{ text: string, source: UpdateTranscriptionMemoResult['memoSource'], file?: string }> {
if (typeof options.inlineText === 'string') {
return { text: options.inlineText, source: 'inline' }
}
if (options.filePath !== undefined && options.filePath.length > 0) {
const abs = path.isAbsolute(options.filePath) ? options.filePath : path.resolve(cwd, options.filePath)
const text = await fs.readFile(abs, 'utf8')
return { text, source: 'file', file: abs }
}
if (!resolved.meetingAbsPath) {
throw new Error(
'Не указан текст memo. Передайте --text "<строка>" или --file <путь>, либо первым аргументом — путь к meeting-файлу (тогда читается `<stem>.memo.md` рядом).',
)
}
const siblingAbs = buildSiblingMemoPath(resolved.meetingAbsPath)
try {
const text = await fs.readFile(siblingAbs, 'utf8')
return { text, source: 'sibling', file: siblingAbs }
}
catch {
throw new Error(
`Рядом с «${path.basename(resolved.meetingAbsPath)}» нет файла «${path.basename(siblingAbs)}». Выполните «blago pull» (он создаст sibling-файл; пустой, если на сервере memo пустой), затем отредактируйте и повторите команду — либо передайте --file / --text.`,
)
}
}
function toIso(v: unknown, fallback: Date): string {
if (v instanceof Date) {
return v.toISOString()
}
if (typeof v === 'string' || typeof v === 'number') {
const d = new Date(v)
if (!Number.isNaN(d.getTime())) {
return d.toISOString()
}
}
return fallback.toISOString()
}
/**
* После успешной мутации записываем `meetings/<stem>.memo.md` рядом с meeting-файлом и
* заносим запись в `.blago/index.json` с типом `call_transcription_memo`, чтобы:
* - следующий `blago pull` видел индексированный файл и шёл по штатной ветке syncEntityFile;
* - `blago push` его не подхватывал — он внутри `meetings/`, исключённого через `pull-only-paths`.
* Это шаг только при наличии meeting-пути (sibling-резолв из <pathOrId>); UUID-режим без файла — пропускаем.
*/
async function persistSiblingAndIndex(
ctx: AuthenticatedContext,
resolved: ResolvedTranscription,
memoText: string,
remoteUpdatedAtIso: string,
): Promise<{ siblingPath: string } | null> {
if (!resolved.meetingAbsPath) {
return null
}
const ext = path.extname(resolved.meetingAbsPath)
const stem = path.basename(resolved.meetingAbsPath, ext)
const siblingAbs = path.join(path.dirname(resolved.meetingAbsPath), `${stem}.memo.md`)
const content = memoText.endsWith('\n') ? memoText : `${memoText}\n`
await fs.mkdir(path.dirname(siblingAbs), { recursive: true })
await fs.writeFile(siblingAbs, content, 'utf8')
const rel = path.relative(ctx.root, siblingAbs)
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return { siblingPath: siblingAbs }
}
const index = await loadIndex(ctx.root)
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: `${resolved.id}:memo`,
relative_path: normalizeRelativePath(rel),
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(content),
})
await saveIndex(ctx.root, index)
return { siblingPath: siblingAbs }
}
export async function runUpdateTranscriptionMemo(
ctx: AuthenticatedContext,
pathOrId: string,
options: UpdateTranscriptionMemoOptions,
cwd: string,
): Promise<UpdateTranscriptionMemoResult> {
const index = await loadIndex(ctx.root)
const resolved = resolveTranscription(ctx.root, cwd, pathOrId, index)
const memo = await readMemoText(cwd, resolved, options)
const mutationName = Mutations.ChatCoop.UpdateTranscriptionMemo.name
const response = await ctx.client.Mutation(Mutations.ChatCoop.UpdateTranscriptionMemo.mutation, {
variables: { data: { id: resolved.id, memo: memo.text } },
})
const row = response[mutationName] as { updatedAt?: unknown } | undefined
if (!row) {
throw new Error('Сервер вернул пустой ответ на chatcoopUpdateTranscriptionMemo.')
}
const remoteUpdatedAtIso = toIso(row.updatedAt, new Date())
const persisted = await persistSiblingAndIndex(ctx, resolved, memo.text, remoteUpdatedAtIso)
return {
transcriptionId: resolved.id,
memoLength: memo.text.length,
memoSource: memo.source,
memoFile: persisted?.siblingPath ?? memo.file,
}
}
+1 -8
View File
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
COPY . .
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
&& pnpm rebuild --filter "@coopenomics/boot..."
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
# свой bundle с inlined workspace-deps.
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.7.21-1",
"version": "2026.5.14-2",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
@@ -26,7 +26,6 @@
"boot:remote": "esno src/index.ts boot:remote",
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
"create-coop": "esno src/index.ts create-coop",
"clear": "./scripts/clear.sh",
"start": "./scripts/restart.sh",
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Полный перезапуск dev-среды для теста Благороста одной командой:
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
# 2) ожидание готовности controller (GraphQL отвечает);
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
#
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
bash "$SCRIPT_DIR/extra_reboot.sh"
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
echo "Ждём готовности controller ($GRAPHQL_URL)..."
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
sleep 3
done
echo "Controller готов."
UP_TO="${SEED_UP_TO:-04-contributor}"
echo "Сидируем capital до фазы $UP_TO..."
cd "$SCRIPT_DIR/.."
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
+13 -22
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
# Загружаем per-instance конфиг из корня репо.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -10,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
echo "Останавливаем и удаляем контейнеры..."
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
echo "Удаляем тома баз данных..."
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
# Удаляем blockchain data через контейнер (alpine под root стирает данные
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
# Удаляем blockchain data
echo "Удаляем blockchain data..."
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -51,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
# Запускаем boot процесс
echo "Запускаем boot процесс..."
pnpm run boot:clean
@@ -60,6 +51,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose restart coopback || true
echo "Перезапуск завершен!"
+14 -17
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
# Загружаем per-instance конфиг из корня репо.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем контроллер перед очисткой данных
echo "Останавливаем контроллер..."
docker compose down coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres monoredis cooparser coopback || true
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем blockchain data.
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
# Удаляем blockchain data
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
# Запускаем boot процесс
echo "Запускаем boot процесс..."
pnpm run boot:extra
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose up -d coopback
echo "Перезапуск завершен!"
+3 -3
View File
@@ -21,15 +21,15 @@ docker compose stop node || true
# Удаляем blockchain data
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
+22 -37
View File
@@ -291,46 +291,31 @@ export default class Blockchain {
async activateFeature(feature: Feature) {
await this.update_pass_instance()
try {
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
console.log('Фича активирована: ', feature.name)
}
catch (e: any) {
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
const errName = e?.json?.error?.name
const errCode = e?.json?.error?.code
const msg = e instanceof Error ? e.message : String(e)
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
return
}
throw e
}
console.log('Фича активирована: ', feature.name)
}
async createToken(params: TokenContract.Interfaces.ICreate) {
-5
View File
@@ -128,9 +128,4 @@ export default [
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'expense',
path: path.join(userBase, 'expense'),
target: 'expense',
},
]
-4
View File
@@ -154,10 +154,6 @@ export default {
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'expense',
code_permissions_to: ['expense'],
},
// {
// name: provider_chairman,
// },
@@ -1,11 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [],
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
}
@@ -1,33 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
],
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "SAVANNA"
}
+9 -19
View File
@@ -1,6 +1,7 @@
import config from '../configs'
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
import { installExtraData, installInitialData, startInfra } from './infra'
import { startCoop } from './cooperative'
import { CooperativeClass, startCoop } from './cooperative'
export async function boot() {
const blockchain = await startInfra()
@@ -13,29 +14,18 @@ export async function boot() {
}
export async function bootClean() {
// Только инфраструктура: контракты, фичи, токен, системные параметры.
// Ни совета, ни программ — программы (createPrograms) требуют существующий
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
await startInfra()
const blockchain = await startInfra()
console.log('Создаём программы (Благорост и маркетплейс)')
const cooperative = new CooperativeClass(blockchain)
await cooperative.createPrograms(config.provider)
}
export async function bootExtra() {
const blockchain = await startInfra()
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
// это и есть программная on-chain активация, которая триггерит аренду VM в
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
// сервера (например, просто пересев совета/чейна для других задач), поэтому
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
if (process.env.EXTRA_RENT === '1') {
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
}
else {
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
}
await installExtraData(blockchain) // Добавить дополнительных пайщиков
console.log('Инициализируем статус системы в PostgreSQL')
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
+9 -15
View File
@@ -4,8 +4,6 @@ import config, { GOVERN_SYMBOL, SYMBOL } from '../configs'
import Blockchain from '../blockchain'
import { generateRandomSHA256 } from '../utils/randomHash'
import { processLastDecision } from '../tests/soviet/processLastDecision'
import { signProgramAgreement } from './sign-program-agreement'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
const test_hash
= '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
@@ -76,7 +74,7 @@ export class CooperativeClass {
await this.blockchain.createProgram({
coopname,
username: coopname,
type: 'capital',
type: 'blagorost',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
@@ -158,17 +156,13 @@ export class CooperativeClass {
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
// program_id > 0; программные соглашения подписываются через
// wallet::signagree (auth: coopname@active).
await signProgramAgreement(
this.blockchain,
config.provider,
username!,
walletProgramId,
walletDraftId,
await this.blockchain.sendAgreement({
coopname: config.provider,
administrator: config.provider,
username: username!,
agreement_type: 'wallet',
document,
)
})
console.log('Переводим аккаунт в кооператив')
@@ -178,8 +172,8 @@ export class CooperativeClass {
params: {
is_cooperative: true,
coop_type: 'conscoop',
announce: 'voskhod-dev.coopenomics.world',
description: 'Восход — тестовый кооператив-оператор (dev-стенд)',
announce: 'Тестовый кооператив',
description: 'Тестовое описание',
initial: `100.0000 ${config.token.govern_symbol}`,
minimum: `300.0000 ${config.token.govern_symbol}`,
org_initial: `1000.0000 ${config.token.govern_symbol}`,
+4 -195
View File
@@ -1,9 +1,5 @@
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import axios from 'axios'
import ecc from 'eosjs-ecc'
import { Generator, Registry } from '@coopenomics/factory'
import type { Cooperative } from 'cooptypes'
import { DraftContract } from 'cooptypes'
@@ -15,12 +11,7 @@ import { sleep } from '../utils'
import { generateRandomSHA256 } from '../utils/randomHash'
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
import { CooperativeClass } from './cooperative'
import { signProgramAgreement } from './sign-program-agreement'
import { fakeDocument } from '../tests/shared/fakeDocument'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
import { generateRandomSHA256 } from '../utils/randomHash'
export async function startInfra() {
// инициализируем инстанс с ключами
@@ -581,189 +572,7 @@ export async function installInitialData(blockchain: Blockchain, isExtended = fa
}
export async function installExtraData(blockchain: Blockchain) {
// Регистрируем partner1 как coop с авто-approve от провайдера (Восхода).
// Без этого реальный UI flow требует ручной активации председателем —
// провайдер не подтянет partner1 в свою DB пока в registrator.coops его
// нет. На dev-стенде хотим иметь готовый partner1 active сразу после
// `reboot:extra`, чтобы прыгать из 0 → 8 (Hostkey rent → install → ACTIVE)
// одним нажатием.
//
// Упрощённый flow (без sendAgreement через soviet — он отказывается на
// program_id > 0 с проверкой «подписывается через wallet::signagree»).
// Минимум для попадания в registrator.coops со status=active:
// newaccount → reguser(type=organization) → regcoop → stcoopstatus(active)
console.log('\n=== installExtraData: регистрируем partner1 как coop (active) ===')
const username = 'partner1'
// Берём WIF из docs-harness/state/cooperatives/partner1.json, если он там есть.
// Без этого boot:extra на каждом запуске генерирует случайный privateKey,
// а harness 08 (chairman install wizard /partner1/install) подаёт WIF из
// фикстуры на шаге 1 — они расходятся, startInstall mutation не принимает
// ключ, wizard зависает на шаге 1 без видимой ошибки. На репозиторий
// фикстура коммит'ится разработчиком вручную; здесь только читаем.
let seededKeys: { privateKey: string; publicKey: string } | undefined
try {
const fixturePath = resolve(
__dirname,
'../../../docs-harness/state/cooperatives/partner1.json',
)
if (existsSync(fixturePath)) {
const fx = JSON.parse(readFileSync(fixturePath, 'utf-8'))
if (fx?.wif) {
// publicKey не хранится в фикстуре — деривим из приватного.
const pub = fx.publicKey || await ecc.privateToPublic(fx.wif)
seededKeys = { privateKey: fx.wif, publicKey: pub }
console.log(`✓ partner1: используем seeded WIF из фикстуры (pub=${pub.slice(0, 12)}…)`)
}
}
} catch (e: any) {
console.warn(`⚠ partner1 fixture read failed (${e.message}) — генерируем новый ключ`)
}
const account = await blockchain.generateKeypair(username, seededKeys, 'Аккаунт partner1')
await blockchain.createAccount({
coopname: config.provider,
referer: '',
username: account.username,
public_key: account.publicKey,
meta: '',
})
const registration_hash = generateRandomSHA256()
await blockchain.registerUser({
coopname: config.provider,
braname: '',
username: account.username,
type: 'organization',
statement: {
hash: registration_hash,
signatures: [],
meta: '{}',
version: '1.0.0',
doc_hash: registration_hash,
meta_hash: registration_hash,
} as any,
registration_hash,
})
await blockchain.registerCooperative({
username: account.username,
coopname: account.username,
params: {
is_cooperative: true,
coop_type: 'conscoop',
announce: 'partner-dev.coopenomics.world',
description: 'Партнёрский кооператив (тестовый dev-стенд)',
initial: `100.0000 ${config.token.govern_symbol}`,
minimum: `300.0000 ${config.token.govern_symbol}`,
org_initial: `1000.0000 ${config.token.govern_symbol}`,
org_minimum: `3000.0000 ${config.token.govern_symbol}`,
},
document: {
hash: registration_hash,
signatures: [],
meta: '{}',
version: '1.0.0',
doc_hash: registration_hash,
meta_hash: registration_hash,
} as any,
})
await blockchain.preInit({
coopname: account.username,
username: config.provider,
status: 'active',
})
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
// eosio.token::is_can_transfer «Получатель не является участником целевой
// потребительской программы кошелька» — и инстанс навсегда застревает в
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
// Ресурсы для партнёра: powerup CPU/NET.
await blockchain.powerup({
payer: 'eosio',
receiver: account.username,
days: config.powerup.days,
payment: `100.0000 ${config.token.symbol}`,
transfer: true,
})
// === Засев partner1 в воскход-coopback (Mongo organizations + paymentMethods + Postgres users) ===
//
// Provider при initSystem-инициализации partner1 запрашивает у воскход-coopback'а
// приватные данные организации через GraphQL `getAccount(partner1).private_account`.
// Resolver идёт по ветке `username != config.coopname` → userRepository.findByUsername
// (Postgres users), затем organizationRepository.findByUsername (Mongo organizations).
// Без этих записей resolver вернёт private_account=null → провайдер упадёт
// «Unsupported account type: undefined» при подготовке organization_data.
console.log('Засеваем partner1 как пайщика-organization в воскход-coopback')
const partnerEmail = `chairman.${account.username}@example.com`
const generator = new Generator()
await generator.connect(process.env.MONGO_URI as string)
await generator.save('organization', {
username: account.username,
type: 'coop',
short_name: 'ПК "Партнёр-1"',
full_name: 'Потребительский Кооператив "Партнёр-1"',
represented_by: {
first_name: 'Иван',
last_name: 'Иванов',
middle_name: 'Иванович',
position: 'Председатель',
based_on: 'Решение общего собрания №1',
},
country: 'Российская Федерация',
city: 'Москва',
fact_address: '117593 г. Москва, ул. Тестовая, дом 1',
full_address: '117593 г. Москва, ул. Тестовая, дом 1',
email: partnerEmail,
phone: '+71234567891',
details: {
inn: '7728130612',
ogrn: '1247700283347',
kpp: '772801002',
},
} as any)
await generator.save('paymentMethod', {
is_default: true,
method_id: randomUUID(),
method_type: 'bank_transfer',
username: account.username,
data: {
account_number: '40703810038000110118',
currency: 'RUB',
card_number: '',
bank_name: 'ПАО Сбербанк',
details: {
bik: '044525225',
corr: '30101810400000000225',
kpp: '772801002',
},
},
})
await mongoose.disconnect().catch(() => {})
await initUsersInPostgres([{
username: account.username,
email: partnerEmail,
type: 'organization',
role: 'user',
status: 'joined',
is_registered: true,
}])
console.log(`=== partner1 (${account.username}) засеян в воскход-coopback (Mongo+Postgres) ===\n`)
// В расширенном режиме пайщики уже добавлены в installInitialData
// Здесь можно добавить дополнительную логику инициализации если потребуется
console.log('Дополнительная инициализация для расширенного режима выполнена')
}
+14 -16
View File
@@ -1,11 +1,13 @@
import config from '../configs'
import axios from 'axios'
import { describe, expect, it } from 'vitest'
import { Registry } from '@coopenomics/factory'
import { RegistratorContract, Cooperative as TCooperative } from 'cooptypes'
import type { Account, Contract, Keys } from '../types'
import config, { GOVERN_SYMBOL, SYMBOL } from '../configs'
import Blockchain from '../blockchain'
import { sendPostToCoopbackWithSecret, sleep } from '../utils'
import { generateRandomSHA256 } from '../utils/randomHash'
import { fakeDocument } from '../tests/shared/fakeDocument'
import { signProgramAgreement } from './sign-program-agreement'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
import { RegistratorContract } from 'cooptypes'
import type { Keys } from '../types'
export class ParticipantsClass {
public blockchain: Blockchain
@@ -155,17 +157,13 @@ export class ParticipantsClass {
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
// program_id > 0; программные соглашения подписываются через
// wallet::signagree (auth: coopname@active).
await signProgramAgreement(
this.blockchain,
config.provider,
username!,
walletProgramId,
walletDraftId,
fakeDocument,
)
await this.blockchain.sendAgreement({
coopname: config.provider,
administrator: config.provider,
username: username!,
agreement_type: 'wallet',
document: fakeDocument,
})
console.log('создаём кошелёк')
@@ -1,99 +0,0 @@
import { SovietContract, WalletContract } from 'cooptypes'
import { getTotalRamUsage } from '../utils/getTotalRamUsage'
async function getCoopProgramWallet(blockchain: any, coopname: string, program_id: number) {
const program = await blockchain.getTableRows(
SovietContract.contractName.production,
coopname,
'programs',
1000,
program_id.toString(),
program_id.toString(),
)
return program[0]
}
interface UserProgramAgreement {
program_id: number | string
doc_hash: string
version: number | string
draft_id: number | string
signed_at: string
}
interface WalletUserRecord {
username: string
programs: UserProgramAgreement[]
}
// Подписание программного соглашения через `wallet::signagree` (ADR-008, Эпик 2).
// После компонента 48 `soviet::sndagreement` отказывается на program_id > 0 —
// программные соглашения переехали в контракт wallet, источник правды
// «участник ЦПП X» = `wallet::users[username].programs[]`.
//
// Auth: coopname@active (или любой системный контракт из contracts_whitelist
// контракта wallet). В boot-flow ключ кооператива всегда есть в keychain,
// подписываем coopname'ом.
//
// Живёт в init/, а не в tests/, чтобы не тянуть vitest в production-цепочке
// `boot → init/cooperative → init/participant`. Тесты реэкспортируют отсюда.
export async function signProgramAgreement(
blockchain: any,
coopname: string,
username: string,
program_id: number,
draft_id: number,
document: any,
) {
const data: WalletContract.Actions.SignAgreement.ISignAgreement = {
coopname,
username,
program_id,
document,
draft_id,
}
const result = await blockchain.api.transact(
{
actions: [
{
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data,
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
getTotalRamUsage(result)
if (!result.transaction_id)
throw new Error('wallet::signagree не вернул transaction_id')
const userRows = await blockchain.getTableRows(
WalletContract.contractName.production,
coopname,
WalletContract.Tables.Users.tableName,
1000,
) as WalletUserRecord[]
const userRecord = userRows.find(r => r.username === username)
if (!userRecord)
throw new Error(`wallet::users[${coopname}, ${username}] должен быть создан после signagree`)
const signedProgram = userRecord.programs.find(p => Number(p.program_id) === program_id)
if (!signedProgram)
throw new Error(`wallet::users[${username}].programs[] должен содержать program_id=${program_id}`)
const program = await getCoopProgramWallet(blockchain, coopname, program_id)
if (!program)
throw new Error(`soviet::programs[${coopname}, ${program_id}] должна существовать`)
return {
txId: result.transaction_id,
wallet: { username, ...signedProgram },
program,
}
}
@@ -1,6 +1,6 @@
/**
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
* (id=4, type='capital') + capital::setconfig.
* (id=4, type='blagorost') + capital::setconfig.
*
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
* setconfig — только если `state` пуст.
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
await blockchain.api.transact({
actions: [{
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
@@ -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 для (автор события, обратная сторона, председатель)'
)
})
})
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -10
View File
@@ -1,17 +1,8 @@
export const sourceProgramId = 3
export const capitalProgramId = 4
export const walletProgramId = 1
// draft_id для программных соглашений — соответствует мапе `program_map` из
// `lib/core/programs.hpp` (wallet→1, source/generator→0, capital/blagorost→1000).
// Используются при `wallet::signagree` (program_id > 0 после Эпика 2 / компонента 48
// ушло из soviet::sndagreement и подписывается через wallet).
export const walletDraftId = 1
export const sourceDraftId = 0
export const capitalDraftId = 1000
export const ratePerHour = '1000.0000 RUB'
export const sourceProgramName = 'generator'
export const capitalProgramName = 'capital'
export const capitalProgramName = 'blagorost'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
@@ -1,20 +1,28 @@
import { signProgramAgreement } from '../wallet/signProgramAgreement'
import { capitalDraftId, capitalProgramId } from './consts'
import { expect } from 'vitest'
import { signAgreement } from '../soviet/signAgreement'
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
import { capitalProgramName, sourceProgramId } from './consts'
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Благорост»
// (program_id=4) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
export async function signCapitalAgreement(
blockchain: any,
coopname: string,
username: string,
fakeDocument: any,
) {
return signProgramAgreement(
blockchain,
const txId = await signAgreement(blockchain, coopname, username, capitalProgramName, fakeDocument)
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
expect(wallet).toEqual(expect.objectContaining({
coopname,
username,
capitalProgramId,
capitalDraftId,
fakeDocument,
)
available: expect.any(String),
blocked: expect.any(String),
program_id: sourceProgramId,
membership_contribution: expect.any(String),
}))
const program = await getCoopProgramWallet(blockchain, coopname, sourceProgramId)
return { wallet, program, txId }
}
@@ -1,20 +1,28 @@
import { signProgramAgreement } from '../wallet/signProgramAgreement'
import { sourceDraftId, sourceProgramId } from './consts'
import { expect } from 'vitest'
import { signAgreement } from '../soviet/signAgreement'
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
import { sourceProgramId, sourceProgramName } from './consts'
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Генератор»
// (program_id=3) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
export async function signGenerationContract(
blockchain: any,
coopname: string,
username: string,
fakeDocument: any,
) {
return signProgramAgreement(
blockchain,
const txId = await signAgreement(blockchain, coopname, username, sourceProgramName, fakeDocument)
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
expect(wallet).toEqual(expect.objectContaining({
coopname,
username,
sourceProgramId,
sourceDraftId,
fakeDocument,
)
available: expect.any(String),
blocked: expect.any(String),
program_id: sourceProgramId,
membership_contribution: expect.any(String),
}))
const program = await getCoopProgramWallet(blockchain, coopname, sourceProgramId)
return { wallet, program, txId }
}
@@ -1,5 +0,0 @@
// Реэкспорт из init/, чтобы существующие импорты в тестах продолжали работать.
// Сама реализация переехала в src/init/sign-program-agreement.ts — иначе
// production-цепочка `boot → init/cooperative → tests/wallet/signProgramAgreement`
// затягивала vitest при загрузке модуля и валила esno-запуск boot'а.
export { signProgramAgreement } from '../../init/sign-program-agreement'
@@ -1,20 +1,26 @@
import { walletDraftId, walletProgramId } from '../capital/consts'
import { signProgramAgreement } from './signProgramAgreement'
import { expect } from 'vitest'
import { signAgreement } from '../soviet/signAgreement'
import { getCoopProgramWallet, getUserProgramWallet } from './walletUtils'
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Цифровой Кошелёк»
// (program_id=1) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
export async function signWalletAgreement(
blockchain: any,
coopname: string,
username: string,
fakeDocument: any,
) {
return signProgramAgreement(
blockchain,
const txId = await signAgreement(blockchain, coopname, username, 'wallet', fakeDocument)
const wallet = await getUserProgramWallet(blockchain, coopname, username, 1)
expect(wallet).toEqual(expect.objectContaining({
coopname,
username,
walletProgramId,
walletDraftId,
fakeDocument,
)
available: expect.any(String),
blocked: expect.any(String),
program_id: 1,
membership_contribution: expect.any(String),
}))
const program = await getCoopProgramWallet(blockchain, coopname, 1)
return { wallet, program, txId }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.7.21-1",
"version": "2026.5.14-2",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+6 -4
View File
@@ -1,8 +1,10 @@
cpp/**/*.wasm
cpp/**/*.abi
!cpp/tests/test_contracts/**/*.wasm
!cpp/tests/test_contracts/**/*.abi
soviet/soviet.abi
soviet/soviet.wasm
registrator/registrator.abi
registrator/registrator.wasm
networks.sh
ano/ano.wasm
ano/ano.abi
Testing/
docs/html/*
docs/xml/*
-1
View File
@@ -58,7 +58,6 @@ add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(expense)
add_contract_build(system)
# Опции для тестов
+7 -5
View File
@@ -1,13 +1,15 @@
#!/usr/bin/env bash
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
# используется в CI (там CDT ставится из .deb, без docker), поэтому
# флаги сборки в одном месте и не разъезжаются.
mode="${1:-prod}"
if [ "$mode" = "test" ]; then
is_testnet="ON"
else
is_testnet="OFF"
fi
docker run --rm --name cdt \
--volume "$(pwd):/project" \
-w /project \
dicoop/blockchain:latest \
/bin/bash -c "./build_contracts_cdt.sh $mode"
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Компиляция всех контрактов кооперативной экономики через CDT.
#
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
#
# Два способа подготовить это окружение:
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
# (в образе CDT уже лежит в /cdt/build);
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
#
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
# не разъезжались.
set -euo pipefail
mode="${1:-prod}"
if [ "$mode" = "test" ]; then
is_testnet="ON"
else
is_testnet="OFF"
fi
mkdir -p build
cd build
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
make
@@ -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);
}
@@ -1,65 +1,39 @@
/**
* @brief Инициировать программный расход через шасси expense.
* @brief Создаёт целевой программный расход и резервирует средства в state.
*
* capital — только инициатор. Резервирует средства в @c program_expense_pool,
* создаёт скелет записи progexpense (status=CREATED, как маркер pending), затем
* шлёт inline action `expense::createexp(...)` с callback handler `{capital, onpgexpdone}`.
* Шасси expense обслуживает весь дальнейший flow (авторизация совета, выдача аванса,
* отчёт, закрытие либо отклонение) и на терминальном переходе шлёт inline `onpgexpdone`.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (= proposal_hash в шасси)
* @param creator Инициатор расхода
* @param items Позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* @param description Описание для отчётности
* @param statement Документ-заявление (ProgramExpenseStatement)
* Программный расход — целевое списание программы «Благорост», не привязанное к проекту.
* Источник средств — пул @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,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement) {
eosio::asset amount, std::string description, document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement, {creator});
eosio::check(!items.empty(), "Программный расход должен содержать хотя бы один item");
// Сумма расхода — сумма planned_amount всех items.
asset amount = items.front().planned_amount;
amount.amount = 0;
for (const auto &it : items) {
amount += it.planned_amount;
}
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);
// Inline action в шасси expense с callback handler на capital::onpgexpdone.
// data пустой — capital резолвит запись по expense_hash, дополнительный payload не нужен.
ExpenseDomain::callback_handler callback{
.contract = _capital,
.action = Names::Capital::ON_PROGRAM_EXPENSE_DONE,
.data = std::vector<char>{}
};
// Authority — _capital: у capital@eosio.code нет coopname@active, а
// expense::createexp принимает контракты-инициаторы по contracts_whitelist.
eosio::action(
eosio::permission_level{_capital, "active"_n},
_expense,
Names::External::CREATE_EXPENSE_PROPOSAL,
std::make_tuple(coopname, creator, expense_hash,
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
).send();
// 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);
}
@@ -1,65 +0,0 @@
/**
* @brief Callback от шасси expense на финализацию program-расхода.
*
* Шасси expense шлёт его inline (authority — _expense@active) когда закончило
* flow (closeexp или declexp). capital не делает ledger2-проводок —
* они уже сделаны шасси на payexp/reportexp/returnexp/overspendexp. capital
* только обновляет @c program_expense_pool и удаляет запись из @c progexpenses.
*
* status == ExpenseDomain::ProposalStatus::CLOSED (4):
* total_actual <= reserved → consume(total_actual) + release(остаток);
* total_actual > reserved (был overspendexp) → consume(reserved) +
* прямое списание превышения из program_expense_pool.
* status == ExpenseDomain::ProposalStatus::DECLINED (5):
* release(reserved); decline возможен только до оплат (total_actual == 0).
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (ключ в progexpenses)
* @param status ExpenseDomain::ProposalStatus
* @param total_actual Фактически потраченная сумма
* @param data Произвольный payload от инициатора (не используется на MVP)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _expense — inline-callback шасси.
*/
void capital::onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data) {
require_auth(_expense);
auto pgexp = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
const auto reserved = pgexp.amount;
if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::CLOSED)) {
eosio::check(total_actual.symbol == reserved.symbol,
"Символ total_actual не совпадает с зарезервированной суммой");
eosio::check(total_actual.amount >= 0,
"total_actual не может быть отрицательным");
if (total_actual.amount <= reserved.amount) {
if (total_actual.amount > 0) {
Capital::State::consume_program_expense(coopname, total_actual);
}
const asset leftover = reserved - total_actual;
if (leftover.amount > 0) {
Capital::State::release_program_expense(coopname, leftover);
}
} else {
// Перерасход (overspendexp в шасси): резерв списываем полностью,
// превышение — напрямую из свободного пула программных расходов.
Capital::State::consume_program_expense(coopname, reserved);
const asset excess = total_actual - reserved;
Capital::State::spend_program_expense_pool(coopname, excess);
}
} else if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::DECLINED)) {
// Шасси отклоняет СЗ только до первой оплаты — резерв возвращается целиком.
eosio::check(total_actual.amount == 0,
"DECLINED с ненулевым total_actual — нарушение инварианта шасси");
Capital::State::release_program_expense(coopname, reserved);
} else {
eosio::check(false, "onpgexpdone принимает только статусы CLOSED либо DECLINED");
}
Capital::Expenses::delete_program_expense(coopname, pgexp.id);
}
@@ -0,0 +1,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);
}
@@ -4,10 +4,8 @@
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp) —
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
* при расходах не изменяются.
* @c program_expense_pool из общего @c global_available_invest_pool — деньги остаются
* на том же ledger2-кошельке @c BLAGOROST_FUND, меняется только разбивка по назначению.
*
* @param coopname Кооператив (scope state)
* @param amount Сумма пополнения (символ строго совпадает с программой)
@@ -20,14 +18,4 @@ void capital::topupprogexp(name coopname, eosio::asset amount) {
require_auth(coopname);
Capital::State::topup_program_expense_pool(coopname, amount);
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
// на 51 с момента взносов — выделяется только назначение под расходы).
// Синтетический process_hash — у пополнения нет естественного анкера.
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
name{}, process_hash, "capital:topupprogexp");
}
@@ -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
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
auto idx = user_wallets.get_index<"byuserwallet"_n>();
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
// больше не используется, проверка preimp.blocked == 0 излишня.
eosio::check(preimp_it->blocked.amount == 0,
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
Ledger2::apply(
_capital, coopname, operations::capital::DROP_PREIMP,
preimp_it->available, username, contributor_hash,
@@ -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");
}
}
@@ -34,10 +34,6 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
document2 convert_statement) {
require_auth(coopname);
// Проверяем подпись заявления о трансляции паевого взноса (шаблон 1080).
// Без этого on-chain принял бы любую сконструированную document2 без валидной подписи.
verify_document_or_fail(convert_statement, {username});
// Получаем сегмент пайщика
auto segment = Capital::Segments::get_segment_or_fail(coopname, project_hash, username,
"Сегмент пайщика не найден");
@@ -112,23 +108,6 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
// Инкрементируем счётчик сконвертированных сегментов
Capital::Projects::increment_converted_segments(coopname, current_project.id);
// Линкуем заявление о трансляции (1080) к пакету процесса p.cap.rid
// (package = result_hash, ведущий документ пакета — заявление о внесении РИД, 1040).
// Не make_complete_document: тот вытаскивает 1080 на верхний уровень реестра
// как самостоятельный документ. Нужен newlink — тот же канон, что в signact1/signact2:
// off-chain controller добавляет документ в группу пакета по action+package, не как top-level.
// Делаем ДО delete_result, чтобы линковка прошла, пока result_hash ещё анкер процесса.
Action::send<newlink_interface>(
_soviet,
"newlink"_n,
_capital,
coopname,
username,
Names::Capital::CONVERT_SEGMENT,
result_hash,
convert_statement
);
// Удаляем сегмент после конвертации
Capital::Segments::remove_segment(coopname, segment.id);
@@ -48,26 +48,54 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
// Приём результата интеллектуальной деятельности (РИД) в паевой фонд.
// Схема ревью 2026-04-20 (Ангелина Matrix 2026-04-19): две раздельные проводки
// в разных action'ах по фазам жизненного цикла РИД.
// 1) COMMIT_RID (Dr 08 / Cr 80) — в `approvecmmt` на каждом одобрении
// мастером конкретного коммита, на полный `commit.amounts.total_contribution`.
// 1) COMMIT_RID (Dr 08 / Cr 80) — теперь в `approvecmmt` на каждом одобрении
// мастером конкретного коммита, на дельту `available_for_program`.
// «Собираем на 08 частями по мере накопления коммитов».
// 2) ACCEPT_RID (Dr 04 / Cr 08) — здесь, на полный накопленный
// `segment.available_for_program`. «Переносим с 08 на 04 когда РИД
// собран и подписан акт-2».
// Инвариант (программный уровень, НЕ сегмент): Σ COMMIT_RID по всем коммитам
// программы == Σ ACCEPT_RID по всем сегментам — `wallets2[w.cap.gen]` (единый
// кооперативный пул, COOPERATIVE) закрывается в ноль после конвертации всех
// сегментов программы. На уровне отдельного сегмента инвариант НЕ выполняется
// (CRPS перераспределяет доли между сегментами без compensating TRANSFER) —
// именно поэтому L3-разрез по пайщику снят (см. wallets.hpp:103).
// Инвариант: Σ COMMIT_RID (по коммитам сегмента) == ACCEPT_RID → GENERATOR_FUND
// (w.cap.gen) закрывается в ноль, 08-й счёт закрывается в ноль по этому сегменту.
if (segment.available_for_program.amount > 0) {
Ledger2::apply(_capital, coopname, operations::capital::ACCEPT_RID, segment.available_for_program, result -> username, result_hash, memo);
}
// Возврат беспроцентного займа пайщика: 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);
}
}
// Обновляем накопительные показатели контрибьютора на основе его ролей в сегменте
+17 -3
View File
@@ -92,14 +92,28 @@
#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"
// Управление программными расходами через шасси expense.
// capital — инициатор + получатель callback, шасси обслуживает весь flow.
// Управление программными расходами
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
#include "app/expense_managment/program_expenses/createpgexp.cpp"
#include "app/expense_managment/program_expenses/onpgexpdone.cpp"
#include "app/expense_managment/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"
+55 -28
View File
@@ -48,7 +48,6 @@ constexpr uint128_t CRPS_PRECISION_FACTOR = 100000000000000ULL; ///< Масшт
#include <eosio/asset.hpp>
#include "../lib/index.hpp"
#include "domain/index.hpp"
#include "../expense/expense.hpp" // ExpenseDomain::item / callback_handler — для inline-action в шасси
using namespace eosio;
@@ -316,45 +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);
// Программные расходы через шасси expense.
// capital — инициатор + получатель callback, шасси держит весь flow расхода.
[[eosio::action]]
void markdebtoverd(name coopname);
[[eosio::action]]
void closedebt(name coopname, checksum256 debt_hash);
[[eosio::action]]
void topupprogexp(name coopname, eosio::asset amount);
/**
* @brief Инициировать программный расход через шасси expense.
* Резервирует amount в program_expense_pool, создаёт запись progexpense,
* шлёт inline action expense::createexp с callback={capital, onpgexpdone}.
* Шасси обслуживает весь flow (auth/pay/report/close|decline) и шлёт callback.
*
* items[] позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* (item.mechanics: ADVANCE аванс, DIRECT прямая оплата).
*/
[[eosio::action]]
void createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement);
eosio::asset amount, std::string description, document2 statement);
/**
* @brief Callback от шасси expense на финализацию program-расхода.
* Шасси шлёт его inline (authority _expense@active, require_auth(_expense))
* после терминального перехода flow (CLOSED либо DECLINED).
*
* status == CLOSED consume(total_actual) + release(остаток резерва);
* при total_actual > reserved (перерасход) превышение
* списывается напрямую из program_expense_pool.
* status == DECLINED release(reserved); decline возможен только до оплат.
*/
[[eosio::action]]
void onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data);
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);
};
@@ -9,26 +9,23 @@ namespace Capital::Core {
* @brief Совокупный баланс программы Благорост на уровне кооператива.
*
* Источник ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
* поэтому L2 авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
* создан (никто не инвестировал) нулевой баланс.
*
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
* используется, баланс кошелька = available.
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
* (Эпик 3 invariant), поэтому L2 авторитетная сумма для CRPS-знаменателя.
* Если кошелёк ещё не создан (никто не инвестировал) нулевой баланс.
*/
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
wallets2_index wallets(_ledger2, coopname.value);
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
return it->available;
return it->available + it->blocked;
}
/**
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
*
* Источник `userwallets[(w.cap.blago, username)]`. Запись существует
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
* поэтому отсутствие = нулевой баланс.
*/
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
userwallets_index user_wallets(_ledger2, coopname.value);
@@ -36,6 +33,6 @@ namespace Capital::Core {
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
auto it = idx.find(key);
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
return it->available;
return it->available + it->blocked;
}
}
@@ -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 Удаляет долг
*/
@@ -263,6 +263,26 @@ namespace Capital::Expenses {
});
}
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);
@@ -43,14 +43,7 @@ namespace Capital {
config config; ///< Управляемая конфигурация контракта
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
// struct и только через binary_extension — иначе ломается десериализация
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
// перед config напрямую (без extension) и задеплоены на прод — таблица state
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
// пустые), чтобы порядок хвостовых extension оставался консистентным.
// binary_extension: таблица state уже в продакшене, новые поля только хвостом.
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
@@ -63,16 +56,6 @@ namespace Capital {
namespace State {
/**
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
* ещё не материализовано (старая запись, созданная до добавления
* program_expense_* через binary_extension).
*/
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
return v.has_value() ? v.value() : asset(0, sym);
}
/**
* @brief Обновляет глобальное состояние новыми значениями.
*
@@ -110,6 +93,11 @@ inline void is_initialized(name coopname) {
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 (председатель).
@@ -123,12 +111,8 @@ inline void topup_program_expense_pool(eosio::name coopname, const asset &amount
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
s.global_available_invest_pool -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
});
}
@@ -141,16 +125,12 @@ inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
eosio::check(_read_pool(itr->program_expense_pool) >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
reserved += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) - amount);
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) + amount);
});
}
@@ -163,60 +143,30 @@ inline void release_program_expense(eosio::name coopname, const asset &amount) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
eosio::check(_read_pool(itr->program_expense_reserved) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
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 Окончательное списание программного расхода (callback onpgexpdone, CLOSED):
* program_expense_reserved -= amount. Деньги уже ушли через шасси expense,
* ledger2-проводки сделаны на payexp/reportexp/returnexp.
* @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(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
eosio::check(_read_pool(itr->program_expense_reserved) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Прямое списание из свободного пула программных расходов превышение
* факта над резервом при закрытии расхода с перерасходом (overspendexp
* в шасси выдал доплату из BLAGOROST_FUND сверх зарезервированного).
*/
inline void spend_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
});
}
}// namespace State
@@ -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: >
Закрытие долга через имущество-обеспечение уменьшает на сегменте
пайщика ту часть, которая раньше была передана в обеспечение и
теперь стала собственностью кооператива.
@@ -1,10 +0,0 @@
cmake_minimum_required(VERSION 3.5)
project(expense)
find_package(cdt)
add_contract(expense expense expense.cpp)
if(IS_TESTNET)
target_compile_definitions(expense PUBLIC IS_TESTNET=1)
endif()
@@ -1,425 +0,0 @@
// expense.cpp — шасси расходов (первый потребитель: Благорост).
//
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
// Кошелёк-пул приходит в createexp параметром source_wallet; ledger2-коды всех
// операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
// (фабричная настройка — подключение нового пула не меняет этот контракт).
// Механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
// callback-handler сохраняется как переменная.
#include "expense.hpp"
namespace D = ExpenseDomain;
// ── helpers ──────────────────────────────────────────────────────────────────
namespace {
asset zero_like(const asset& a) { return asset{0, a.symbol}; }
asset sum_planned(const std::vector<D::item>& items) {
if (items.empty()) return asset{0, eosio::symbol{"RUB", 4}};
asset total = zero_like(items.front().planned_amount);
for (const auto& it : items) total += it.planned_amount;
return total;
}
// Набор ledger2-кодов операций шасси по кошельку-источнику СЗ. Один источник =
// один набор (EXPENSE_OPERATION_SETS в ledger2) — в коде expense нет
// захардкоженных operation_code, контракт настраивается на любой пул.
const ExpenseOperationSet& expense_ops_for(eosio::name source_wallet) {
const auto* set = find_expense_operation_set(source_wallet);
eosio::check(set != nullptr,
"Для кошелька-источника не настроен набор операций шасси расходов (EXPENSE_OPERATION_SETS)");
return *set;
}
D::proposals_index get_proposals(eosio::name self, eosio::name coopname) {
return D::proposals_index(self, coopname.value);
}
auto find_proposal(D::proposals_index& tbl, const eosio::checksum256& proposal_hash) {
auto idx = tbl.get_index<"byhash"_n>();
return idx.find(proposal_hash);
}
void send_callback_if_any(const D::callback_handler& cb,
eosio::name coopname,
const eosio::checksum256& proposal_hash,
uint8_t status,
const eosio::asset& amount,
eosio::name self) {
if (cb.contract.value == 0) return;
// Payload: (coopname, proposal_hash, status, total_actual, data).
// status = ExpenseDomain::ProposalStatus — инициатор различает CLOSED vs DECLINED.
// coopname нужен инициатору для скоупа таблиц (capital::progexpenses scope=coopname).
eosio::action(
eosio::permission_level{self, "active"_n},
cb.contract,
cb.action,
std::make_tuple(coopname, proposal_hash, status, amount, cb.data)
).send();
}
} // namespace
// ── actions ──────────────────────────────────────────────────────────────────
void expense::createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<D::item> items,
D::callback_handler callback,
document2 statement) {
// Прямой вызов backend'а — авторизация кооператива; inline от контракта-инициатора
// (capital::createpgexp и т.п.) — авторизация по whitelist, как в ledger2::apply.
if (!has_auth(coopname)) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
}
verify_document_or_fail(statement, {username});
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
eosio::check(ledger2_is_known_wallet(source_wallet),
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
// Источник определяет ledger2-коды всех операций жизненного цикла —
// отказываем сразу при создании, а не в момент оплаты.
expense_ops_for(source_wallet);
auto tbl = get_proposals(get_self(), coopname);
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
"СЗ с таким proposal_hash уже существует");
// Механика оплаты задаётся на каждом item отдельно (ADVANCE | DIRECT) —
// в одном СЗ допустимо смешение: ledger2-проводка payexp идёт по-позиционно
// с кодом, выведенным из механики конкретного item'а.
for (auto& it : items) {
eosio::check(it.planned_amount.is_valid() && it.planned_amount.amount > 0,
"planned_amount каждого item должен быть положительным");
eosio::check(it.mechanics <= static_cast<uint8_t>(D::Mechanics::DIRECT),
"Неизвестная mechanics item (0 = ADVANCE, 1 = DIRECT)");
eosio::check(it.recipient_type <= static_cast<uint8_t>(D::RecipientType::ORG),
"Неизвестный recipient_type item");
// Инвариант: пайщик (SELF/MEMBER) получает только аванс под отчёт на личные
// реквизиты (расходом станет после его отчёта чеком); организация — только
// прямую оплату по выставленным реквизитам, аванс ей не выдаётся.
if (it.recipient_type == static_cast<uint8_t>(D::RecipientType::ORG)) {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::DIRECT),
"Организации/ИП доступна только оплата по счёту (DIRECT)");
} else {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::ADVANCE),
"Пайщику средства выдаются только авансом под отчёт (ADVANCE)");
eosio::check(it.recipient != name(),
"recipient обязателен для получателя-пайщика");
eosio::check(is_account(it.recipient),
"Аккаунт получателя-пайщика не существует");
}
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
it.actual_amount = zero_like(it.planned_amount);
}
const auto total = sum_planned(items);
tbl.emplace(get_self(), [&](auto& row) {
row.id = tbl.available_primary_key();
row.coopname = coopname;
row.proposal_hash = proposal_hash;
row.username = username;
row.source_wallet = source_wallet;
row.status = static_cast<uint8_t>(D::ProposalStatus::CREATED);
row.items = items;
row.total_planned = total;
row.total_actual = zero_like(total);
row.callback = callback;
row.statement_doc = statement;
row.decision_doc = document2{};
row.created_at = eosio::current_time_point();
row.updated_at = row.created_at;
});
// СЗ утверждает совет (как возвраты паевых взносов): ставим вопрос в повестку.
// Утверждение председателем после голосования → callback authexp от _soviet;
// отклонение (отрицательный консенсус / просрочка) → declexp с причиной.
::Soviet::create_agenda(
_expense,
coopname,
username,
get_valid_soviet_action("createexp"_n),
proposal_hash,
_expense,
"authexp"_n,
"declexp"_n,
statement,
std::string("")
);
}
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED),
"Авторизовать можно только СЗ в статусе CREATED");
verify_document_or_fail(decision);
idx.modify(it, get_self(), [&](auto& row) {
row.decision_doc = decision;
row.status = static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED);
row.updated_at = eosio::current_time_point();
});
}
void expense::declexp(name coopname, checksum256 proposal_hash, std::string reason) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
// Отклонение возможно только пока ни один item не оплачен: после payexp деньги
// уже ушли через ledger2, и decline разъехался бы с учётом инициатора
// (callback DECLINED возвращает инициатору весь резерв). Частично исполненный
// СЗ завершается обычным путём: reportexp / returnexp → closeexp.
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED),
"Отклонить можно только СЗ без оплат (статусы CREATED / AUTHORIZED)");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
row.updated_at = eosio::current_time_point();
});
// Callback инициатору (capital::onpgexpdone и т.п.); total_actual здесь всегда 0 —
// оплат до decline не было (см. проверку статусов выше).
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::DECLINED),
it->total_actual, get_self());
}
void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Оплата возможна только из статусов AUTHORIZED / PARTIALLY_PAID");
eosio::check(actual_amount.is_valid() && actual_amount.amount > 0,
"actual_amount должен быть положительным");
bool item_found = false;
uint8_t paid_mechanics = 0;
name paid_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::APPROVED),
"Item уже оплачен или закрыт");
eosio::check(actual_amount.symbol == i.planned_amount.symbol,
"Символ actual_amount не совпадает с планом item");
eosio::check(actual_amount.amount <= i.planned_amount.amount,
"Сумма оплаты не может превышать план item; доплата сверх плана — через overspendexp");
i.actual_amount = actual_amount;
// DIRECT не имеет фазы подотчёта (нет ADVANCE_HOLD): закрывающие документы
// прикладывает сам кассир, item считается отчитанным сразу после оплаты.
i.status = static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::DIRECT
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
: static_cast<uint8_t>(D::ItemStatus::PAID);
paid_mechanics = i.mechanics;
paid_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += actual_amount;
// DIRECT-only СЗ становится готов к закрытию сразу после последней оплаты.
row.status = all_reported
? static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED)
: static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
row.updated_at = eosio::current_time_point();
});
// ledger2-проводка по механике оплаченного item'а — выдача аванса или прямая
// оплата; коды выводятся из кошелька-источника СЗ. СЗ может смешивать механики.
// username нужен ledger2 только для USER_SHARED-стороны (кошелёк-подотчёт) —
// это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
const auto& ops = expense_ops_for(it->source_wallet);
const auto code = static_cast<D::Mechanics>(paid_mechanics) == D::Mechanics::ADVANCE
? ops.advance
: ops.direct;
Ledger2::apply(get_self(), coopname, code, actual_amount,
paid_recipient, proposal_hash, "expense:payexp");
}
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Отчёт возможен только по СЗ в статусе PARTIALLY_PAID");
asset item_amount = asset{0, it->total_actual.symbol};
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Отчёт возможен только для оплаченных items");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"reportexp применим только к ADVANCE-механике");
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
item_amount = i.actual_amount;
item_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
if (all_reported) {
row.status = static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED);
}
row.updated_at = eosio::current_time_point();
});
// ledger2: BURN кошелька-подотчёта на остаточный actual item'а (выдано
// возвращено + доплачено), без бухпроводки — проводка уже сделана при выдаче.
// Подотчёт числится на получателе аванса — его username и закрывает burn.
// Полный возврат аванса (actual == 0) — burn не нужен, подотчёт уже пуст.
if (item_amount.amount > 0) {
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).report, item_amount,
item_recipient, proposal_hash, "expense:reportexp");
}
}
void expense::closeexp(name coopname, checksum256 proposal_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED),
"Закрыть можно только СЗ в статусе REPORT_SUBMITTED");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::CLOSED);
row.updated_at = eosio::current_time_point();
});
// Callback на финализацию (capital::onpgexpdone и т.п.) — если был установлен при createexp.
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::CLOSED),
it->total_actual, get_self());
}
void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Возврат возможен только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"returnexp применим только к ADVANCE-механике");
eosio::check(return_amount.is_valid() &&
return_amount.symbol == i.actual_amount.symbol,
"Символ return_amount не совпадает с item");
eosio::check(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
"return_amount должен быть положительным и не превышать actual_amount");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — после
// возврата остатка получатель штатно отчитывается reportexp по
// фактически потраченной части (при полном возврате — actual == 0).
i.actual_amount -= return_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual -= return_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: TRANSFER подотчёт → пул-источник (зеркало выдачи аванса).
// Подотчёт снимается с получателя аванса.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).refund, return_amount,
item_recipient, proposal_hash, "expense:returnexp");
}
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount) {
require_auth(coopname);
eosio::check(overspend_amount.amount > 0, "overspend_amount должен быть положительным");
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Перерасход регистрируется только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"overspendexp применим только к ADVANCE-механике");
eosio::check(overspend_amount.is_valid() &&
overspend_amount.symbol == i.actual_amount.symbol,
"Символ overspend_amount не совпадает с item");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — подотчёт
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
// который сделает burn ADVANCE_HOLD на итоговый actual.
i.actual_amount += overspend_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += overspend_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: доплата перерасхода — TRANSFER пул-источник → подотчёт получателя
// (зеркало выдачи аванса на сумму доплаты). Закрытие на полную сумму — в reportexp.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).overspend, overspend_amount,
item_recipient, proposal_hash, "expense:overspend");
}
@@ -1,233 +0,0 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include "../lib/index.hpp"
using namespace eosio;
using std::string;
/**
* @defgroup public_expense Контракт EXPENSE
* @brief Шасси расходов цифрового кооператива (MVP Благорост).
*
* Универсальный контракт: principal storage = СЗ-расход с массивом items;
* кошелёк-источник (пул) приходит параметром source_wallet, ledger2-коды всех
* операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
* (ledger2/operations.hpp) подключение нового пула не меняет контракт;
* механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
* callback на финализацию (контракт+action+data) сохраняется при createexp
* как переменная `expense` агностичен к программе-получателю.
*
* See: components/desktop/extensions/expenses/NAMING-C28-28.md (v3).
*/
namespace ExpenseDomain {
/**
* @brief Способ оплаты item-а в СЗ.
* ADVANCE пайщик-получатель оплачивает, затем приносит чек.
* DIRECT кассир/председатель платит организации напрямую.
*/
enum class Mechanics : uint8_t {
ADVANCE = 0,
DIRECT = 1,
};
/**
* @brief Получатель платежа в item-е.
* SELF сам создатель СЗ.
* MEMBER другой пайщик.
* ORG внешняя организация.
*/
enum class RecipientType : uint8_t {
SELF = 0,
MEMBER = 1,
ORG = 2,
};
enum class ProposalStatus : uint8_t {
CREATED = 0, ///< СЗ создан, ждёт подписи signact1 создателя.
AUTHORIZED = 1, ///< СЗ подписан signact2 (совет), готов к оплате.
PARTIALLY_PAID = 2, ///< Часть item-ов оплачена.
REPORT_SUBMITTED = 3, ///< Отчёт о расходе подан, ждёт авторизации совета.
CLOSED = 4, ///< СЗ-отчёт авторизован — расход закрыт.
DECLINED = 5, ///< СЗ отклонён до или после авторизации.
};
enum class ItemStatus : uint8_t {
APPROVED = 0, ///< Запланирован, ждёт оплаты.
PAID = 1, ///< Оплачен (ADVANCE — выдан пайщику; DIRECT — оплачен организации).
REPORTED = 2, ///< Закрыт отчётом (чек приложен). DIRECT-item получает его сразу при payexp.
RETURNED = 3, ///< Зарезервировано, не используется: returnexp уменьшает actual_amount, статус остаётся PAID до отчёта.
OVERSPENT = 4, ///< Зарезервировано, не используется: overspendexp увеличивает actual_amount, статус остаётся PAID до отчёта.
};
/**
* @brief Callback на финализацию устанавливается при createexp как переменная.
*
* Контракт expense не знает про capital/blagorost он просто вызывает inline
* action на (contract, action) и прокидывает data наружу. Пустой contract = нет callback.
*
* Пример: для расхода в РИД-проект Благороста UI/backend заполняет
* { "capital"_n, "onexpreport"_n, packed(wip_project_hash) }.
*/
struct callback_handler {
eosio::name contract;
eosio::name action;
std::vector<char> data;
EOSLIB_SERIALIZE(callback_handler, (contract)(action)(data))
};
/**
* @brief Item одна строка СЗ-расхода.
*/
struct item {
eosio::checksum256 item_hash;
uint8_t mechanics; ///< Mechanics
uint8_t recipient_type; ///< RecipientType
eosio::name recipient; ///< username (для SELF/MEMBER), либо name-идентификатор (для ORG)
std::string description;
eosio::asset planned_amount;
eosio::asset actual_amount; ///< 0 пока не оплачено / не отчиталось
uint8_t status; ///< ItemStatus
EOSLIB_SERIALIZE(item, (item_hash)(mechanics)(recipient_type)(recipient)(description)(planned_amount)(actual_amount)(status))
};
/**
* @brief Таблица СЗ-расходов. Scope = coopname.
*/
struct [[eosio::table, eosio::contract("expense")]] proposal {
uint64_t id;
eosio::name coopname; ///< кооператив (= scope таблицы; дублируем полем по канону)
eosio::checksum256 proposal_hash;
eosio::name username; ///< создатель
eosio::name source_wallet; ///< семантически — из какого ЦПП/фонда (w.cap.blago, w.sov.expns, …)
uint8_t status; ///< ProposalStatus
std::vector<item> items;
eosio::asset total_planned;
eosio::asset total_actual;
callback_handler callback; ///< опционально; contract.value == 0 = нет callback
document2 statement_doc; ///< type=2010, signact1 создателя
document2 decision_doc; ///< type=2011, signact2 совета
eosio::time_point_sec created_at;
eosio::time_point_sec updated_at;
uint64_t primary_key() const { return id; }
eosio::checksum256 by_hash() const { return proposal_hash; }
uint64_t by_username() const { return username.value; }
uint64_t by_status() const { return static_cast<uint64_t>(status); }
EOSLIB_SERIALIZE(proposal,
(id)(coopname)(proposal_hash)(username)(source_wallet)(status)
(items)(total_planned)(total_actual)(callback)(statement_doc)(decision_doc)
(created_at)(updated_at))
};
using proposals_index = eosio::multi_index<"proposals"_n, proposal,
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<proposal, eosio::checksum256, &proposal::by_hash>>,
eosio::indexed_by<"byusername"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_username>>,
eosio::indexed_by<"bystatus"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_status>>
>;
} // namespace ExpenseDomain
/**
* @ingroup public_contracts
* @brief Шасси расходов 8 actions, MVP только Благорост.
*/
class [[eosio::contract("expense")]] expense : public contract {
public:
using contract::contract;
/**
* @brief Создать и подать СЗ-расход.
*
* Создание и подача одна транзакция (slug createexp). На входе: items + источник
* (source_wallet) + опц. callback на финализацию.
* Подписывает создатель (signact1 statement_doc, type=2010).
*
* Авторизация: coopname (прямой вызов backend'а) либо контракт-инициатор из
* contracts_whitelist (inline, например capital::createpgexp).
* Механика оплаты per-item (item.mechanics: ADVANCE | DIRECT); ledger2-код
* операции выводится из механики позиции в момент оплаты payexp.
*
* Ставит вопрос в повестку совета (soviet::createagenda, тип createexp):
* утверждение совета callback authexp, отклонение declexp.
*/
[[eosio::action]]
void createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<ExpenseDomain::item> items,
ExpenseDomain::callback_handler callback,
document2 statement);
/**
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
* Callback решения совета вызывается только контрактом soviet после
* голосования и утверждения председателем. После этого расход доступен для оплаты.
*/
[[eosio::action]]
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
/**
* @brief Отклонить СЗ. Callback решения совета (отрицательный консенсус
* голосов «против» либо просрочка повестки) вызывается только контрактом
* soviet. Возможно только до первой оплаты (CREATED / AUTHORIZED):
* после payexp средства уже ушли через ledger2, и СЗ завершается обычным
* путём reportexp / returnexp closeexp.
*/
[[eosio::action]]
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
/**
* @brief Оплатить item выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
* Контракт зовёт Ledger2::apply с кодом из набора операций кошелька-источника
* (EXPENSE_OPERATION_SETS: source_wallet + механика item'а operation_code).
* actual_amount не может превышать план item (доплата через overspendexp).
* DIRECT-item не имеет фазы подотчёта и помечается REPORTED сразу; когда все
* items REPORTED proposal переходит в REPORT_SUBMITTED прямо из payexp.
*/
[[eosio::action]]
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount);
/**
* @brief Отчёт о расходе (ADVANCE) пайщик закрывает item чеком.
* Зовёт Ledger2::apply(o.exp.advrpt) на остаточный actual item'а (при полном
* возврате аванса burn пропускается). Когда все items reported статус
* proposal становится REPORT_SUBMITTED.
*/
[[eosio::action]]
void reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash);
/**
* @brief Закрытие расхода советом финальный signact2 СЗ-отчёта.
* Если у proposal заполнен callback отправляет inline action на (callback.contract, callback.action, callback.data).
*/
[[eosio::action]]
void closeexp(name coopname, checksum256 proposal_hash);
/**
* @brief Возврат неиспользованного аванса (ADVANCE-остаток).
* Settlement-запись: уменьшает actual_amount item'а, статус остаётся PAID
* фактически потраченная часть закрывается затем штатным reportexp.
* Зовёт Ledger2::apply(o.exp.advret).
*/
[[eosio::action]]
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount);
/**
* @brief Доплата при перерасходе (ADVANCE).
* Settlement-запись: увеличивает actual_amount item'а, статус остаётся PAID.
* Зовёт Ledger2::apply(o.exp.over) выдача доплаты; закрытие подотчёта на
* полную сумму (выдано + доплата) делает последующий reportexp.
*/
[[eosio::action]]
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount);
};

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