Compare commits

...

36 Commits

Author SHA1 Message Date
coopops 8e263b7424 Merge remote-tracking branch 'origin/reports' into reports 2026-05-11 04:52:50 +00:00
coopops 876d06046b Merge remote-tracking branch 'origin/dev' into reports
# Conflicts:
#	components/contracts/cpp/soviet/src/wallet/addbal.cpp
2026-05-11 04:51:43 +00:00
coopops c5e23f15f9 [@ant] feat(ledger2/capital/voskhod): preimp-учёт + voskhod L2/L3 хардкод-миграция
- ledger2 wallets +`w.cap.preimp` (USER_SHARED, 5 пайщиков-преимп) и `w.sov.expns`
  (COOPERATIVE, хоз.расходы из числа целевого финансирования). Реестр 11→13.
- operations: `o.cap.import` и `o.cap.actprp` Dr 51→**Dr 04** (РИД-имущество, не
  деньги). Новые `o.cap.preimp` (ISSUE Dr 04/Cr 80) и `o.cap.drppre` (BURN Dr 80/
  Cr 04 — закрытие пред-импорт-учёта при переходе на электронный учёт).
- processes: +`p.cap.preimp` (одноактовый).
- migrate_voskhod_facts полностью переписан: вместо send_transit-apply'ев —
  прямой emplace `accounts2` (51=176 800, 04=62 353 311, 08=543 400, 80=
  62 946 011, 86=127 500; Σ Dr=Σ Cr=63 073 511) + прямой emplace `wallets2`
  (5 кошельков) + L3 для 5 преимп-пайщиков с `participants.find()`-guard'ом
  (тестнет-safe). L3 для остальных USER_SHARED-кошельков заводят migrator-048/049.
- capital::importcontrib: перед `o.cap.import` проверяем `userwallets[w.cap.preimp,
  username]` и при наличии вызываем `o.cap.drppre` на полный preimp.available
  (один process_hash на цепочку IMPORT).
- cooptypes mirror (operations.ts/processes.ts) + generated wallets из C++.
- controller process-hash-locator: +`p.cap.preimp` (entity-таблицы пока нет,
  process_hash берётся из blockchain_actions).

Терминология: «до перехода на электронный учёт» (договор УХД с 5 пайщиками
voskhod подписан был задолго до миграции; они не успели в электронный учёт).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:27:00 +00:00
Alex Ant 996012399e chore(release): publish 2026-05-10 14:39:10 +05:00
Alex Ant c4ffdae7ef chore(release): publish 2026-05-10 14:36:47 +05:00
Alex Ant e60d339d2b Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-10 14:36:25 +05:00
coopops e7241d8a91 [@ant] feat(soviet/setminamt): action для правки minimum_amount у пайщика
Технический фикс: для уже-accepted пайщиков нет публичного action для
коррекции minimum_amount, апдейт делался только через addpartcpnt (создание)
и unblock (восстановление). Когда поле рассинхронизировано с кооп-минимумом
(как у voskhod::ant — 1 RUB вместо 300 RUB), править нечем.

Action setminamt(coopname, username, minimum):
  require_auth(coopname); проверяет символ; modify только minimum_amount.

Why: блокирует чистый расчёт Σ minimum_amount при миграции voskhod→ledger2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:33:04 +00:00
coopops 64c11820b0 [@ant] refactor(soviet/converttoaxn): убрать legacy-зеркало soviet.programs/progwallets
Удалён `Wallet::sub_available_funds(_soviet, _provider, ...)` — единственный
канал, через который converttoaxn ещё двигал legacy soviet::progwallets и
counter в soviet::programs (pid=1). Теперь весь учёт идёт только через
ledger2::apply CONVERT_AXN (TRANSFER SHARE_FUND_PAY → DELEGATE_FEES,
Dr 80 / Cr 86), который уже стоял рядом.

Why: converttoaxn — действие только voskhod (у других коопов нет AXN);
включается одновременно с его миграцией в ledger2, поэтому legacy-зеркало
больше не нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:16:08 +00:00
Alex Ant bfe5592ae9 chore(release): publish 2026-05-10 12:01:35 +05:00
Alex Ant 9e94510981 chore(release): publish 2026-05-10 12:01:08 +05:00
Alex Ant b432ad155d Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-10 12:00:19 +05:00
coopops eea5652d1f [@ant] fix(soviet/addbal): upsert progwallets — создавать кошелёк если отсутствует
Раньше addbal падал с "Кошелёк не найден" для пайщика без progwallets-записи
по программе. Теперь, если записи нет — создаём её с нулевыми blocked/membership
и сразу зачисляем quantity в available. Если есть — прежняя логика available += quantity.

Why: на mainnet voskhod у части пайщиков нет progwallet pid=1, и ручные начисления
паевого взноса по УХД через addbal падали ассертом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 06:59:36 +00:00
coopops 31da96dceb [598-3][@ant] chore(standards): удалить устаревший p.mkt.reqst.standard.yaml
Стандарт описывал клиринговую модель donor'а (паевый взнос имуществом
+ возврат паевого взноса имуществом). На ветке marketplace2 заменён
тремя новыми стандартами под членскую модель Стол заказов MVP:
  • p.mkt.supply.standard.yaml   — Прямая поставка-приобретение имущества
  • p.mkt.return.standard.yaml   — Гарантийный возврат имущества
  • p.mkt.wroff.standard.yaml    — Утилизация скоропорта

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 06:08:07 +00:00
Alex Ant 57cb8f4302 Merge pull request #364 from coopenomics/ledger3
ledger3 → reports: Эпики 2-3 + фиксы соглашений и L3-миграции
2026-05-10 11:05:59 +05:00
coopops f051196754 feat(apps): pricing validation + clients table + D4 regsub extension (stories v2.1.2, v2.4.1, v2.5.0)
Батчем все контрактные сторы эпиков v2.1/v2.4 + implicit D4-расширение
из эпика v2.5, чтобы CA-сторона могла строить write-port'а против
финальных on-chain сигнатур, не возвращаясь в Mono.

Story v2.1.2 — setpricing валидация:
  - hourly_rate.amount > 0 (eosio_assert)
  - package_id и plan непустые
  - snapshot policy: НЕ трогаем subs (оплаченные периоды не пересчитываются
    при смене тарифа), audit-snapshot — off-chain (journal-less invariant)

Story v2.4.1 — clients table (D3) + regclient/delclient:
  - scope = catalog_operator, PK = client_coopname
  - regclient: строгий insert (eosio_assert на дубль), RAM payer = operator
  - delclient: erase с eosio_assert на отсутствие
  - в MVP всегда voskhod-as-operator, но scope-based не блокирует replica

Story v2.5.0 (implicit, D4) — regsub extension + idempotent extend:
  - sub.attempt: uint8 = 0 — billing-retry counter
  - sub.last_charge_intent_id: checksum256 = 0 — UUIDv5 последнего charge
  - extendsub(operator, subscriber, package_id, period_seconds, intent_id):
    идемпотентный extend, eosio_assert("already extended") при дубле intent_id;
    end_at += period_seconds, attempt → 0, обновляет updated_at
  - setattempt(operator, subscriber, package_id, attempt) — для retry-watcher
  - migration safe (subs пустая в dev/MVP; pre-deploy guard
    scripts/v2-migration-guard.ts блокирует прод при count > 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:04:43 +00:00
coopops 1c573bf131 feat(apps): on-chain skeleton — таблицы pricings/globals и actions setpricing/setglobals (story v2.1.1)
Каркас для v2 каталога приложений: scope-per-package таблица `pricings`
(D1) + singleton `globals` (D2) + два action'а с `require_auth(get_self())`
без бизнес-валидации. Unblock'ает CA-команду для написания TS write-port'а
против стабильных on-chain сигнатур; полная валидация и snapshot policy
выезжают в story v2.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:41:19 +00:00
Alex Ant f35a13a382 chore(release): publish 2026-05-08 10:56:03 +05:00
Alex Ant aced4966c8 chore(release): publish 2026-05-08 10:37:35 +05:00
Alex Ant 978d94f6b7 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-08 10:37:06 +05:00
coopops e5613405f4 fix(ci): запинить pnpm через packageManager + allowlist build-скриптов
Без пина CI каждый раз тянул latest pnpm; на 10.4+ ignored builds
из warning стали ERR_PNPM_IGNORED_BUILDS, и `pnpm install --frozen-lockfile`
валился на electron/esbuild/@parcel/watcher и пр.

- packageManager=pnpm@10.33.0 в корневом package.json (синхронно с
  publish-packages.yaml и components/boot/Dockerfile)
- pnpm.onlyBuiltDependencies — 18 пакетов из лога фейла
- Dockerfile: corepack enable вместо `npm install -g pnpm` (обе стадии)
- publish-docs.yaml: pnpm/action-setup@v4 без version + cache: pnpm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:35:36 +00:00
Alex Ant b41a42ad0a chore(release): publish 2026-05-07 22:09:20 +05:00
Alex Ant 16ee154424 chore(release): publish 2026-05-07 22:09:01 +05:00
Alex Ant a15432233e Merge branch 'testnet' of github.com:coopenomics/mono into testnet 2026-05-07 22:08:48 +05:00
Alex Ant ba96a650b8 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-07 22:08:01 +05:00
coopops 4011628e2a Merge branch 'dev' into testnet 2026-05-07 16:53:40 +00:00
coopops 1f9753cf8d [@ant] fix(capital/permissions): UNION-роли — соавтор-член-совета может править артефакты
Регрессия: getProjectUserRole коротко замыкался на BOARD_MEMBER при userRole='member' и не доходил до segment.is_author. В матрице BOARD_MEMBER.EDIT_REQUIREMENT=false → permissions.can_edit_requirement=false → редактор открывался read-only у соавтора, который одновременно член совета.

Фикс — UNION-семантика: пользователь может одновременно нести несколько ролей (member + author + master + …), итоговое право — OR по матрицам всех его ролей. Симметрично для issue-уровня (submaster + author и т.п.). Чистые роли работают как раньше.

12 unit-тестов на ключевые комбинации (BOARD_MEMBER+AUTHOR, CHAIRMAN+MASTER, переходы статусов).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:52:30 +00:00
Alex Ant edd9f3ff91 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-04 20:48:17 +05:00
Alex Ant 8539b1e6fd chore(release): publish 2026-04-30 22:09:52 +05:00
Alex Ant 477779c984 chore(release): publish 2026-04-30 21:45:21 +05:00
Alex Ant 9491b6356d Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-04-30 21:45:02 +05:00
Alex Ant 0a95748580 chore(release): publish 2026-04-30 20:41:03 +05:00
Alex Ant cab6be45b3 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-04-30 20:40:41 +05:00
Alex Ant 59b25c2995 chore(release): publish 2026-04-30 20:28:51 +05:00
Alex Ant adc8790c3e Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-04-30 20:28:32 +05:00
Alex Ant 6796ed4109 chore(release): publish 2026-04-30 14:10:28 +05:00
Alex Ant 4f9ff0c1d3 chore(release): publish 2026-04-30 14:10:04 +05:00
54 changed files with 1236 additions and 755 deletions
+6 -3
View File
@@ -16,19 +16,22 @@ jobs:
- 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 pnpm
run: npm install -g pnpm
- name: Install Python requirements
run: |
python -m venv venv
+8 -2
View File
@@ -35,7 +35,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN python3 -m venv /venv \
&& /venv/bin/pip install --no-cache-dir WeasyPrint==67
RUN npm install -g pnpm lerna --no-fund --no-audit
# pnpm пинится через `packageManager` поле в корневом package.json
# (corepack-стандарт). Без пина CI каждый раз тянет latest, а pnpm 10
# ужесточил политику build-скриптов и валит `--frozen-lockfile` с
# ERR_PNPM_IGNORED_BUILDS на native-пакетах (electron / esbuild / …).
RUN corepack enable && npm install -g lerna --no-fund --no-audit
# Сначала манифесты (для кэш-friendly install). `.dockerignore` уже
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
@@ -95,7 +99,9 @@ COPY --from=builder /app /app
# Глобальные pnpm/lerna — нужны чтобы потребители mono-base могли
# делать `CMD ["pnpm","-F","<pkg>","run","start"]` в production.
RUN npm install -g pnpm lerna --no-fund --no-audit
# pnpm активируется через corepack (версия из `packageManager` корневого
# package.json — синхронно со builder-стадией).
RUN corepack enable && npm install -g lerna --no-fund --no-audit
# Sanity-check: WeasyPrint работает и виден через PATH.
RUN weasyprint --version
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+6
View File
@@ -11,6 +11,12 @@
#include "src/expsub.cpp"
#include "src/regcoop.cpp"
#include "src/setcoop.cpp"
#include "src/setpricing.cpp"
#include "src/setglobals.cpp"
#include "src/regclient.cpp"
#include "src/delclient.cpp"
#include "src/extendsub.cpp"
#include "src/setattempt.cpp"
/**
* \brief Миграция контракта.
+123
View File
@@ -247,6 +247,129 @@ public:
std::optional<eosio::public_key> signing_key,
std::optional<bool> active);
// ─── pricing & globals (v2.1) ───────────────────────────────────────
/**
* \brief Установить ставку плана `plan` для пакета `package_id` (FR1, D1).
* \details Per-plan upsert в таблице `pricings` (scope = `package_id`):
* row нет → `emplace`; row есть → `modify` с обновлённым
* `hourly_rate` и `updated_at` = текущее время блока.
* RAM payer — `get_self()` (`apps`-контракт; ВОСХОД как owner
* аккаунта оплачивает RAM координационной плоскости).
*
* Story v2.1.1 — каркас без бизнес-валидации. Полная валидация
* (`hourly_rate.amount > 0`, символ против `_root_govern_symbol`,
* plan-whitelist, snapshot предыдущей ставки в audit_log_admin)
* добавляется в Story v2.1.2.
*
* \param package_id scope таблицы `pricings` (Antelope `name`).
* \param plan имя плана, PK (в MVP всегда `default`).
* \param hourly_rate ставка за час (общая валюта экосистемы — RUB,4).
* \note Авторизация: `require_auth(get_self())` — действие выполняется
* контрактом от своего имени по запросу из `CA-admin` через
* `KE write-adapter` (Story v2.1.3).
*/
[[eosio::action]] void setpricing(eosio::name package_id,
eosio::name plan,
eosio::asset hourly_rate);
/**
* \brief Установить общесистемные параметры каталога (FR2, D2).
* \details Полная замена singleton `globals` (scope = `get_self()`):
* row нет → создаём, есть → перезаписываем все четыре поля.
* RAM payer — `get_self()`. Story v2.1.1 — каркас без
* валидации диапазонов; валидация и snapshot предыдущего
* state'а добавляются в Story v2.1.2.
*
* \note Авторизация: `require_auth(get_self())`.
*/
[[eosio::action]] void setglobals(uint32_t min_payment_period_seconds,
uint32_t free_trial_period_seconds,
uint32_t lead_time_seconds,
uint8_t retry_max);
// ─── clients (multi-tenant onboarding, v2.4) ────────────────────────
/**
* \brief Зарегистрировать кооператив-клиента каталога (FR7, D3).
* \details `clients`-row {scope=catalog_operator, PK=client_coopname}
* с `registered_at = now`. Идемпотентность строгая: повторный
* вызов на ту же пару → `eosio_assert("client already registered")`.
* RAM payer — `catalog_operator` (ВОСХОД оплачивает onboarding).
* \param catalog_operator оператор каталога, подписывает транзакцию
* и платит RAM (в MVP всегда `voskhod`).
* \param client_coopname кооператив, который подключается к каталогу.
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void regclient(eosio::name catalog_operator,
eosio::name client_coopname);
/**
* \brief Отозвать кооператив-клиента каталога (FR8, D3).
* \details Удаляет row по PK; row нет → `eosio_assert("client not found")`
* (не идемпотентно сознательно — отличаем «уже отзывали» от
* «никогда не регистрировали»). Эффекты на CA-стороне:
* инвалидация membership-cache (D9 hybrid TTL) + добавление
* активных JWT в `JwtRevokeList` (FR15) живут off-chain.
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void delclient(eosio::name catalog_operator,
eosio::name client_coopname);
// ─── subscription extend / retry-counter (D4, v2.5/v2.6) ────────────
/**
* \brief Идемпотентно продлить подписку через `charge_intent_id` (FR24, D4, D5).
* \details Находит `regsub` по `(subscriber, package_id)`; проверяет,
* что `last_charge_intent_id != charge_intent_id`. Иначе —
* `eosio_assert("already extended")` (это **ok-сигнал** для
* recovery-worker'а: дубль ack'а из кабинета или повтор после
* CA-crash). На успешном пути:
* - `end_at += period_seconds` (extend от текущей границы,
* не от now — important для непрерывности подписки),
* - `last_charge_intent_id = charge_intent_id`,
* - `attempt = 0` (счётчик retry'ев сбрасывается на удачном
* charge'е),
* - `updated_at = now`.
*
* Story v2.5.2 (TS write-port `extendByChargeIntent`) ожидает
* именно эту семантику; `AlreadyExtendedError` маппится с
* on-chain assert'а.
*
* \param catalog_operator оператор каталога (платит RAM, подписывает).
* \param subscriber кооператив-подписчик.
* \param package_id пакет.
* \param period_seconds на сколько продлить (в секундах). Должен быть > 0.
* \param charge_intent_id UUIDv5 от `(coopname, package_id, period_start_at)`.
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void extendsub(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint32_t period_seconds,
eosio::checksum256 charge_intent_id);
/**
* \brief Установить `attempt`-счётчик для подписки (D4 retry-management).
* \details Простая запись uint8 в существующую `regsub`-row. Используется
* pricing-watcher'ом CA, когда `ack=declined` или charge провалился
* (типичный путь: `setattempt(coopname, package_id, current+1)`).
* Каскад с `extendsub` не нужен — последний сам сбросит attempt
* в 0 при успехе.
*
* Альтернатива (on-chain incrattempt без передачи нового значения)
* могла бы избежать сравнения counter'ов на гонках, но усложняет
* контракт и редко полезна: pricing-watcher tick — единственный
* путь, и он сериализован per-(coopname, package_id) на стороне
* watcher-state в Postgres.
*
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void setattempt(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint8_t attempt);
// ─── service tables ─────────────────────────────────────────────────
struct [[eosio::table, eosio::contract(APPS)]] counts : counts_base {};
@@ -0,0 +1,38 @@
/**
* \brief Отозвать кооператив-клиента каталога (Story v2.4.1, FR8).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует `catalog_operator @ active` — `require_auth(catalog_operator)`.
* 2. Валидирует, что `client_coopname` непустое.
* 3. Удаляет row по primary key. Если row нет → `eosio_assert("client not found")`.
* Идемпотентность сознательно НЕ соблюдается: повторный `delclient`
* должен вернуть явную ошибку, чтобы вышестоящие watcher'ы
* (cache-invalidation, JWT-revocation в CA) могли отличить «уже
* отзывали» от «никогда не было».
*
* **Эффекты на TS-стороне** (живут в `apps-catalog`, не на цепи):
* - membership-cache для `client_coopname` инвалидируется через
* hybrid-TTL (D9): критический путь забирает уже актуальный ответ
* из chain'а, read-API получает stale-ответ ≤ 60s.
* - JWT с `coopname=client_coopname` помещаются в `JwtRevokeList`
* (FR15): пока не истечёт TTL токена — он отозван по jti.
*
* RAM payer возвращается `catalog_operator`. `subs` записи кооператива
* НЕ удаляются автоматически — это решение оператора, и история
* подписок остаётся в `subs` для аудита (флаг `active=false`
* выставляется отдельным `expsub`-вызовом).
*/
void apps::delclient(eosio::name catalog_operator,
eosio::name client_coopname) {
require_auth(catalog_operator);
eosio::check(catalog_operator.value != 0, "catalog_operator must not be empty");
eosio::check(client_coopname.value != 0, "client_coopname must not be empty");
clients_index clients(get_self(), catalog_operator.value);
auto it = clients.find(client_coopname.value);
eosio::check(it != clients.end(), "client not found");
clients.erase(it);
}
@@ -0,0 +1,57 @@
/**
* \brief Идемпотентное продление подписки через `charge_intent_id`
* (D4, D5; story v2.5/v2.6, FR24).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует подпись `catalog_operator @ active`.
* 2. Валидирует входы: `subscriber`, `package_id` непустые,
* `period_seconds > 0`.
* 3. Находит `regsub` по `(subscriber, package_id)` через secondary
* index `bycooppkg`. Если нет — `eosio_assert("subscription not found")`
* (вызов до `regsub` — программная ошибка CA-стороны, а не штатный
* control-flow).
* 4. Сверяет `last_charge_intent_id != charge_intent_id`. Если уже
* этот intent был применён — `eosio_assert("already extended")`.
* Это и есть double-emit prevention уровня цепи (D5):
* UUIDv5-детерминированный id + on-chain unique-by-last проверка
* закрывают двойное продление при race'ах watcher-recovery
* или повторных webhook'ах из кабинета.
* 5. На успешном пути модифицирует `regsub`:
* - `end_at += period_seconds` (extend ОТ текущей границы, не от now —
* это важно для непрерывности оплаченных интервалов; см. AR12).
* - `last_charge_intent_id = charge_intent_id`.
* - `attempt = 0` (счётчик retry'ев сбрасывается на удачном charge'е).
* - `updated_at = now`.
*
* RAM payer — `catalog_operator` (оператор оплачивает координацию;
* клиент-кооператив за `regsub`-row не платит).
*/
void apps::extendsub(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint32_t period_seconds,
eosio::checksum256 charge_intent_id) {
require_auth(catalog_operator);
eosio::check(subscriber.value != 0, "subscriber must not be empty");
eosio::check(package_id.value != 0, "package_id must not be empty");
eosio::check(period_seconds > 0, "period_seconds must be positive");
subs_index subs(_apps, _apps.value);
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
auto it = by_cooppkg.find(key);
eosio::check(it != by_cooppkg.end(), "subscription not found");
eosio::check(it->last_charge_intent_id != charge_intent_id,
"already extended");
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
by_cooppkg.modify(it, catalog_operator, [&](auto &s) {
s.end_at = eosio::time_point_sec(s.end_at.sec_since_epoch() + period_seconds);
s.last_charge_intent_id = charge_intent_id;
s.attempt = 0;
s.updated_at = now;
});
}
@@ -0,0 +1,45 @@
/**
* \brief Зарегистрировать кооператив-клиента каталога (Story v2.4.1).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует подпись `catalog_operator @ active` —
* `require_auth(catalog_operator)`. В MVP это всегда `voskhod`,
* но контракт не хардкодит имя — на проверке authority единственное
* допустимое подписавшее лицо это и есть scope записи.
* 2. Валидирует, что `client_coopname` непустое (eosio::name(0) —
* особое значение «отсутствие», не валидное имя).
* 3. Идемпотентно с проверкой: row нет → `emplace`. Row есть →
* `eosio_assert("client already registered")`. Это намеренный
* «строгий» upsert — повторный `regclient` без явного `delclient`
* должен ловить ошибки оператора (двойной вызов из-за UI-сбоя).
*
* RAM payer — `catalog_operator`. ВОСХОД оплачивает RAM записи как
* часть стоимости подключения кооператива; клиент за хранение `clients`-row
* не платит.
*
* Что **не делает** (выносится в TS-сторону, Story v2.4.5):
* - Не валидирует наличие `coops`-записи у `client_coopname`. Кооператив
* может быть подключён к каталогу до того, как оформлен в `coops`
* (signing_key, chain_id — это уровень subnet-операций). Каталог
* проверит это сам перед issue'ем JWT.
* - Не выпускает события / webhook'и. Audit-trail off-chain в
* `audit_log_admin` через `regsub`-watcher CA.
*/
void apps::regclient(eosio::name catalog_operator,
eosio::name client_coopname) {
require_auth(catalog_operator);
eosio::check(catalog_operator.value != 0, "catalog_operator must not be empty");
eosio::check(client_coopname.value != 0, "client_coopname must not be empty");
clients_index clients(get_self(), catalog_operator.value);
auto it = clients.find(client_coopname.value);
eosio::check(it == clients.end(), "client already registered");
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
clients.emplace(catalog_operator, [&](auto &c) {
c.client_coopname = client_coopname;
c.registered_at = now;
});
}
@@ -0,0 +1,39 @@
/**
* \brief Установить `attempt`-счётчик в `regsub` (D4 retry-management).
* \ingroup public_apps_actions
*
* Pricing-watcher CA вызывает это действие, когда `ack=declined` или
* charge не дошёл, для отметки факта повторной попытки. Контракт
* не вычисляет `current+1` сам — watcher держит state в Postgres
* (charge_intents.attempt) и передаёт нужное значение явно. Так проще:
* любая ошибочная гонка фиксируется на уровне Postgres-инкремента,
* а контракт остаётся stateless относительно retry-логики.
*
* - `extendsub` сам сбрасывает `attempt=0` при удачном charge'е, поэтому
* pricing-watcher не должен дёргать `setattempt(0)` после успеха.
* - При `attempt > globals.retry_max` watcher эмитит
* `SubscriptionExpiredError` (story v2.6.9) и прекращает попытки;
* контракт это знание не дублирует — `retry_max` хранится в
* `globals` singleton и читается только off-chain.
*/
void apps::setattempt(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint8_t attempt) {
require_auth(catalog_operator);
eosio::check(subscriber.value != 0, "subscriber must not be empty");
eosio::check(package_id.value != 0, "package_id must not be empty");
subs_index subs(_apps, _apps.value);
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
auto it = by_cooppkg.find(key);
eosio::check(it != by_cooppkg.end(), "subscription not found");
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
by_cooppkg.modify(it, catalog_operator, [&](auto &s) {
s.attempt = attempt;
s.updated_at = now;
});
}
@@ -0,0 +1,33 @@
/**
* \brief Установка общесистемных параметров каталога (singleton-перезапись).
* \ingroup public_apps_actions
*
* Story v2.1.1: каркас без валидации диапазонов. Что делает действие:
* 1. Требует `apps@active` — `require_auth(get_self())`.
* 2. Полная перезапись `globals` singleton (scope = `get_self()`,
* PK у singleton нет): четыре поля заполняются переданными значениями.
*
* Что **не делает** (выносится в Story v2.1.2):
* - Проверка диапазонов: `min_payment_period_seconds >= 60`,
* `free_trial_period_seconds <= 31 * 86400`, `retry_max <= 8` и т.п.
* - Snapshot предыдущего state'а в audit_log_admin — off-chain
* (journal-less invariant).
*
* RAM payer — `get_self()`. ВОСХОД как owner аккаунта `apps` оплачивает
* хранение singleton'а (одна запись, минимальный RAM).
*/
void apps::setglobals(uint32_t min_payment_period_seconds,
uint32_t free_trial_period_seconds,
uint32_t lead_time_seconds,
uint8_t retry_max) {
require_auth(get_self());
globals_singleton globals(get_self(), get_self().value);
globals.set(globals_row{
.min_payment_period_seconds = min_payment_period_seconds,
.free_trial_period_seconds = free_trial_period_seconds,
.lead_time_seconds = lead_time_seconds,
.retry_max = retry_max,
},
get_self());
}
@@ -0,0 +1,65 @@
/**
* \brief Установка ставки плана для пакета (per-plan upsert) с валидацией (Story v2.1.2).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует `apps@active` — `require_auth(get_self())`.
* 2. Валидирует входы: `package_id` и `plan` непустые (FR4 — сама длина
* ≤ 13 уже гарантирована типом `eosio::name`, но `name(0)` — особое
* значение «отсутствие», его явно отсекаем для удобной ошибки).
* 3. Валидирует `hourly_rate.amount > 0` (FR1) — отрицательная или
* нулевая ставка не имеет экономического смысла и блокирует
* корректный расчёт `charge.required` в watcher'е.
* 4. В таблице `pricings` (scope = `package_id`) находит запись по `plan`:
* - row нет → `emplace` с переданными `hourly_rate` и `updated_at = now`.
* - row есть → `modify`: обновляет `hourly_rate` и `updated_at`.
*
* **Snapshot policy (FR2, journal-less invariant).** Действие НИКОГДА не
* трогает таблицу `subs`: поле `regsub.end_at` существующих подписок
* остаётся как есть — перерасчёта прошлого нет. Текущий оплаченный
* период подписок зафиксирован в момент `regsub`/`extendsub` по тогдашней
* ставке, и ретро-изменения тарифа на него не влияют. Snapshot для
* audit-журнала живёт off-chain в `audit_log_admin` (журнал-less
* инвариант — на цепи нет log-таблиц, история восстанавливается из
* trace'ов Antelope).
*
* Что **намеренно не валидируется здесь:**
* - Символ `hourly_rate.symbol` против `_root_govern_symbol` (`RUB,4`).
* Cross-currency расчёт не предусмотрен MVP, но жёсткая привязка
* к одному символу даст ложноотрицательные ошибки при будущих
* multi-currency сценариях. Адаптер CA-стороны нормализует символ
* до отправки.
* - Whitelist имён `plan`. В MVP только `default`, но on-chain
* хранилище должно оставаться открытым для будущих tier-планов
* без миграции контракта.
*
* RAM payer — `get_self()` (`apps`-контракт). Фактически платит ВОСХОД
* как owner аккаунта `apps`; pricing-таблицы — часть координационной
* плоскости, оплачиваемой оператором каталога.
*/
void apps::setpricing(eosio::name package_id,
eosio::name plan,
eosio::asset hourly_rate) {
require_auth(get_self());
eosio::check(package_id.value != 0, "package_id must not be empty");
eosio::check(plan.value != 0, "plan must not be empty");
eosio::check(hourly_rate.amount > 0, "hourly_rate must be positive");
pricings_index pricings(get_self(), package_id.value);
auto it = pricings.find(plan.value);
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
if (it == pricings.end()) {
pricings.emplace(get_self(), [&](auto &p) {
p.plan = plan;
p.hourly_rate = hourly_rate;
p.updated_at = now;
});
} else {
pricings.modify(it, get_self(), [&](auto &p) {
p.hourly_rate = hourly_rate;
p.updated_at = now;
});
}
}
@@ -62,7 +62,27 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
// Пополнение кошелька программы благороста
std::string internal_memo = Capital::Memo::get_import_contributor_memo(contributor_hash, contribution_amount);
// Увеличиваем паевой фонд через ledger2: ISSUE BLAGOROST_FUND, Dr 51 / Cr 80.
// Если у пайщика существует пред-импорт-учёт РИД (`w.cap.preimp`) — сжигаем
// его полностью через `o.cap.drppre` (Dr 80 / Cr 04, BURN PREIMP_FUND) ДО
// основного импорта. После drppre основной `o.cap.import` переоткрывает
// учёт на полный `contribution_amount` (включая возможную доплату/списание).
// Один process_hash на обе операции — бэкенд видит цельную цепочку IMPORT.
{
userwallets_index user_wallets(_ledger2, coopname.value);
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) {
eosio::check(preimp_it->blocked.amount == 0,
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
Ledger2::apply(
_capital, coopname, operations::capital::DROP_PREIMP,
preimp_it->available, username, contributor_hash,
std::string{"Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт"}
);
}
}
// Увеличиваем паевой фонд через ledger2: ISSUE BLAGOROST_FUND, Dr 04 / Cr 80.
Ledger2::apply(_capital, coopname, operations::capital::IMPORT, contribution_amount, username, contributor_hash, internal_memo);
}
@@ -232,67 +232,220 @@ inline void emplace_wallet_only(eosio::name self_name,
#ifndef IS_TESTNET
/**
* Спец-ветка миграции для voskhod — РУЧНОЙ ХАРДКОД сумм по кошелькам.
* Прямой emplace в wallets2 с произвольным available/blocked. Используется
* только из voskhod-спец-ветки: там L2-агрегаты нужно завести строго по
* фактическому распределению chain (Благорост — blocked, не available).
*/
inline void emplace_wallet_balance(eosio::name self_name,
eosio::name coopname,
eosio::name wallet_id,
const eosio::asset& available,
const eosio::asset& blocked) {
if (available.amount == 0 && blocked.amount == 0) return;
wallets2_index wallets(self_name, coopname.value);
const auto human_view = ledger2_get_wallet_human_name(wallet_id);
eosio::check(!human_view.empty(),
std::string{"migrate: unknown wallet "} + wallet_id.to_string());
auto it = wallets.find(wallet_id.value);
eosio::check(it == wallets.end(),
std::string{"migrate: wallet "} + wallet_id.to_string() + " уже создан");
wallets.emplace(self_name, [&](auto& w) {
w.id = wallet_id;
w.name = std::string(human_view);
w.available = available;
w.blocked = blocked;
});
}
/**
* Прямой emplace в accounts2 БЕЗ бух-проводок. Используется только из
* voskhod-спец-ветки: после хардкода в кошельках замыкаем баланс прямой
* записью в accounts2 (отдельные «миграционные» операции под Dr 04 / Dr 08
* усложнили бы общий реестр).
*
* Тип счёта определяется планом (LEDGER2_ACCOUNT_MAP):
* - ACTIVE — записываем `balance` в `debit_balance`, `credit_balance = 0`;
* - PASSIVE — записываем `balance` в `credit_balance`, `debit_balance = 0`.
*/
inline void emplace_account_balance(eosio::name self_name,
eosio::name coopname,
uint64_t account_id,
const eosio::asset& balance) {
if (balance.amount == 0) return;
const auto* meta = ledger2_find_account_meta(account_id);
eosio::check(meta != nullptr,
std::string{"migrate: unknown account_id "} + std::to_string(account_id));
accounts2_index accounts(self_name, coopname.value);
auto it = accounts.find(account_id);
eosio::check(it == accounts.end(),
std::string{"migrate: account "} + std::to_string(account_id) + " уже создан");
const eosio::asset zero(0, balance.symbol);
const uint8_t type_u8 = static_cast<uint8_t>(meta->type);
accounts.emplace(self_name, [&](auto& a) {
a.id = account_id;
a.name = std::string(meta->name);
a.account_type = type_u8;
if (meta->type == AccountType::PASSIVE) {
a.debit_balance = zero;
a.credit_balance = balance;
} else { // ACTIVE / ACTIVE_PASSIVE
a.debit_balance = balance;
a.credit_balance = zero;
}
a.balance = account2::compute_balance(type_u8, a.debit_balance, a.credit_balance);
});
}
/**
* Прямой emplace L3-записи в userwallets БЕЗ бух-проводок. Только для
* `kind == USER_SHARED`. Записи с (0,0) — пропускаются.
*/
inline void emplace_userwallet_only(eosio::name self_name,
eosio::name coopname,
eosio::name wallet_id,
eosio::name username,
const eosio::asset& available,
const eosio::asset& blocked) {
if (available.amount == 0 && blocked.amount == 0) return;
const WalletKind kind = ledger2_get_wallet_kind(wallet_id);
eosio::check(kind == WalletKind::USER_SHARED,
std::string{"migrate: userwallets допустим только для USER_SHARED, wallet="} +
wallet_id.to_string());
userwallets_index user_wallets(self_name, coopname.value);
auto idx = user_wallets.get_index<"byuserwallet"_n>();
const auto key = combine_ids(wallet_id.value, username.value);
auto it = idx.find(key);
eosio::check(it == idx.end(),
std::string{"migrate: userwallet (wallet="} + wallet_id.to_string() +
", user=" + username.to_string() + ") уже существует");
user_wallets.emplace(self_name, [&](auto& uw) {
uw.id = user_wallets.available_primary_key();
uw.wallet_name = wallet_id;
uw.username = username;
uw.available = available;
uw.blocked = blocked;
});
}
/**
* Заводит L3-запись `w.cap.preimp` для пайщика, если он существует в
* `soviet::participants` коопа. Если нет (тестнет/чужой контекст) — silently skip.
*
* Использовать ИСКЛЮЧИТЕЛЬНО для воскходных пред-импорт-РИД-учётов (5 пайщиков
* по договору УХД, заведённых до перехода на электронный учёт: ЭГОЛ/Анацко/
* Кочетков/Мильшин/Манакин). Их деньги списаны с `progwallets[pid=1]` cleos-
* командами; здесь фиксируем их как РИД-взносы на отдельном кошельке для
* последующего drppre при importcontr.
*/
inline void emplace_preimp_if_present(eosio::name self_name,
eosio::name coopname,
eosio::name username,
int64_t amount_raw) {
participants_index parts(_soviet, coopname.value);
if (parts.find(username.value) == parts.end()) {
return; // тестнет / иной контекст — пропускаем без падения
}
const eosio::asset amt(amount_raw, _root_govern_symbol);
const eosio::asset zero(0, _root_govern_symbol);
emplace_userwallet_only(self_name, coopname, ledger2_wallets::PREIMP_FUND, username, amt, zero);
}
#endif // !IS_TESTNET
#ifndef IS_TESTNET
/**
* Спец-ветка миграции для voskhod — РУЧНОЙ ХАРДКОД балансов (accounts2 +
* wallets2 + L3 для пред-импорт-учётов).
*
* Включена только в prod-сборке (без IS_TESTNET). На тестнете voskhod идёт
* по стандартному арифметическому пути наравне с остальными кооперативами,
* чтобы любые invariant-несостыковки давали внятную ошибку, а не маскировались
* заранее заведёнными суммами.
* заранее заведёнными суммами. Дополнительная защита внутри ветки —
* `participants.find()`-guard в emplace_preimp_if_present, чтобы случайный
* запуск voskhod-веточки в чужом окружении не падал.
*
* Цифры заведены вручную после сверки с фактическими данными mainnet
* (snapshot 2026-04-29). При необходимости править — править прямо здесь,
* пересобирать ledger2.wasm и катить.
* (snapshot 2026-05-10) и согласованы с председателем (см. project-memory
* `voskhod migrate2 — финальный план миграции`).
*
* Причина хардкода: legacy::accounts и soviet::progwallets рассинхронизированы
* (деньги Благороста, прошедшие через 51, разошлись 80 vs progwallets,
* есть бумажный шлейф на 51, и т.п.). Вместо арифметической сшивки
* заводим суммы вручную по тому, как кооператив должен выглядеть в ledger2.
* (имущ. Благорост ~57M на legacy 80 без отражения в 04, бумажный шлейф на 51,
* непокрытые хоз.расходы на 86). Арифметической сшивки нет — заводим суммы
* напрямую по согласованной с бухгалтером картине.
*
* Имущественная часть на legacy 80 (РИД ~56.8M) сюда НЕ заводится:
* она остаётся осадком на legacy 80 до Phase 2 (ADR-009), когда Благорост
* схлопнется в w.cap.blago и заберёт имущество.
* ┌────────────────────────────────────── accounts2 ──────────────────────────────────────┐
* │ 51 (BANK_ACCOUNT, А) = 176 800 — банк (хардкод 145 000) + 31 800 минП │
* │ 04 (INTANGIBLE_ASSETS, А) = 62 353 311 — имущ. Благорост 56 903 311 + preimp 5М4 │
* │ 08 (NON_CURRENT_INVESTMENTS) = 543 400 — балансировка (свод НМА → 80) │
* │ 80 (SHARE_FUND, П) = 62 946 011 — 419 900 ЦК + 57 044 311 Благорост │
* │ + 31 800 минП + 5 450 000 preimp Cr-side │
* │ 86 (TARGET_RECEIPTS, П) = 127 500 — хоз.расходы 115К + legacy 861 12 500 │
* │ │
* │ Σ Dr = 176 800 + 62 353 311 + 543 400 = 63 073 511 │
* │ Σ Cr = 62 946 011 + 127 500 = 63 073 511 ✓ │
* └─────────────────────────────────────────────────────────────────────────────────────────┘
*
* Бух-баланс по этой таблице:
* Dr 51 = w.reg.minshr + w.wal.share + w.reg.entry = 31800 + 569900 + 12500 = 614200
* Cr 80 = w.reg.minshr + w.wal.share = 31800 + 569900 = 601700
* Cr 86 = w.reg.entry = 12500
* Σ Dr = Σ Cr = 614200 ✓
* ┌────────────────────────────── wallets2 (L2-агрегаты) ─────────────────────────────┐
* w.reg.minshr available = 31 800 (34 пайщика; minimum_amount; L3 → migrator-048)│
* │ w.wal.share available = 419 900 (5 пайщиков ЦК-остаток; L3 → migrator-049) │
* │ w.cap.blago blocked = 57 044 311 (12 пайщиков pid=4; L3 → migrator-049) │
* │ w.cap.preimp available = 5 450 000 (5 пайщиков; L3 — ниже, прямой emplace) │
* │ w.sov.expns available = 127 500 (хоз.расходы из числа целевого; COOPERATIVE) │
* └────────────────────────────────────────────────────────────────────────────────────┘
*
* Σ Благороста на w.cap.blago (57 044 311) и Генератора на w.cap.gen (0) идут
* прямым emplace без бух-проводок (параллельный progwallets-учёт).
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 1 = 048
* для w.reg.minshr; Phase 2 = 049 для w.wal.share / w.wal.member / w.cap.blago).
* Здесь же — только 5 пред-импорт-преимп-записей, выпавших из progwallets
* после ручных subbal'ов (см. ~/cleos.md, 5 пайщиков с РИД-взносами по
* договорам УХД, не успевшим попасть в электронный учёт до миграции).
*/
inline void migrate_voskhod_facts(eosio::name self_name, const cooperative2& coop) {
const eosio::name coopname = coop.username;
const eosio::symbol sym = _root_govern_symbol;
const eosio::name coopname = coop.username;
const eosio::symbol sym = _root_govern_symbol;
const eosio::asset zero(0, sym);
// ┌────────────────────────────────────────────────────────────────────┐
// │ ТАБЛИЦА МИГРАЦИИ ВОСХОДА — править здесь │
// │ amount задаётся в "копейках" (× 10000), т.к. RUB = 4 знака │
// │ 31 800.0000 RUB → 318'000'000 │
// └────────────────────────────────────────────────────────────────────┘
// ───────── accounts2 ─────────
emplace_account_balance(self_name, coopname, ledger2_accounts::BANK_ACCOUNT,
eosio::asset( 1'768'000'000LL, sym)); // 176 800.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::INTANGIBLE_ASSETS,
eosio::asset( 623'533'110'000LL, sym)); // 62 353 311.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::NON_CURRENT_INVESTMENTS,
eosio::asset( 5'434'000'000LL, sym)); // 543 400.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::SHARE_FUND,
eosio::asset( 629'460'110'000LL, sym)); // 62 946 011.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::TARGET_RECEIPTS,
eosio::asset( 1'275'000'000LL, sym)); // 127 500.0000 RUB
// 1. w.reg.minshr — Минимальные паевые взносы (Dr 51 / Cr 80)
const eosio::asset W_REG_MINSHR = eosio::asset( 318'000'000LL, sym); // 31 800.0000 RUB
// ───────── wallets2 (L2) ─────────
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_FUND,
eosio::asset( 318'000'000LL, sym), // 31 800.0000 RUB available
zero);
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SHARE_FUND_PAY,
eosio::asset( 4'199'000'000LL, sym), // 419 900.0000 RUB available
zero);
emplace_wallet_balance(self_name, coopname, ledger2_wallets::BLAGOROST_FUND,
zero,
eosio::asset(570'443'110'000LL, sym)); // 57 044 311.0000 RUB blocked
emplace_wallet_balance(self_name, coopname, ledger2_wallets::PREIMP_FUND,
eosio::asset(54'500'000'000LL, sym), // 5 450 000.0000 RUB available
zero);
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SOV_EXPENSES,
eosio::asset( 1'275'000'000LL, sym), // 127 500.0000 RUB available
zero);
// 2. w.wal.share — Цифровой Кошелёк, паевая часть (Dr 51 / Cr 80)
const eosio::asset W_WAL_SHARE = eosio::asset( 5'699'000'000LL, sym); // 569 900.0000 RUB
// 3. w.reg.entry — Вступительные взносы (Dr 51 / Cr 86)
const eosio::asset W_REG_ENTRY = eosio::asset( 125'000'000LL, sym); // 12 500.0000 RUB
// 4. w.cap.blago — ЦПП «Благорост» — единый кошелёк программы (emplace, БЕЗ проводок); ADR-009
const eosio::asset W_CAP_BLAGO = eosio::asset(570'443'110'000LL, sym); // 57 044 311.0000 RUB
// 5. w.cap.gen — ЦПП «Генератор» — единый кошелёк программы (emplace, БЕЗ проводок); 0 → не создаётся
const eosio::asset W_CAP_GEN = eosio::asset( 0LL, sym); // 0.0000 RUB
// ── применяем таблицу ───────────────────────────────────────────────
send_transit(self_name, coopname, operations::migration::MIN_SHARE, W_REG_MINSHR);
send_transit(self_name, coopname, operations::migration::SHARE, W_WAL_SHARE);
send_transit(self_name, coopname, operations::migration::ENTRY, W_REG_ENTRY);
emplace_wallet_only(self_name, coopname, ledger2_wallets::BLAGOROST_FUND, W_CAP_BLAGO);
emplace_wallet_only(self_name, coopname, ledger2_wallets::GENERATOR_FUND, W_CAP_GEN);
// ───────── L3: преимп-учёты (5 пайщиков с РИД-взносами по договорам УХД) ─────────
// Сумма по таблице = 5 450 000 RUB = wallets2[w.cap.preimp].available ✓
emplace_preimp_if_present(self_name, coopname, "honruwpdxtty"_n, 500'000'000LL); // 50 000.0000 ЭГОЛ
emplace_preimp_if_present(self_name, coopname, "zlvsujtoctal"_n, 50'000'000'000LL); // 5 000 000.0000 Анацко
emplace_preimp_if_present(self_name, coopname, "yxkjufikzxri"_n, 1'000'000'000LL); // 100 000.0000 Кочетков
emplace_preimp_if_present(self_name, coopname, "vvqamckynxod"_n, 1'000'000'000LL); // 100 000.0000 Мильшин
emplace_preimp_if_present(self_name, coopname, "hntppjjknmsu"_n, 2'000'000'000LL); // 200 000.0000 Манакин
}
#endif // !IS_TESTNET
@@ -68,11 +68,13 @@ namespace operations {
// capital
namespace capital {
inline constexpr eosio::name IMPORT = "o.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (Dr 51 / Cr 80, ISSUE BLAGOROST_FUND).
inline constexpr eosio::name IMPORT = "o.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (Dr 04 / Cr 80, ISSUE BLAGOROST_FUND). Только РИД-имущество — деньги через INVEST.
inline constexpr eosio::name INVEST = "o.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (TRANSFER SHARE_FUND_PAY → BLAGOROST_FUND, без Dr/Cr).
inline constexpr eosio::name COMMIT_RID = "o.cap.commit"_n; ///< Коммит РИД (Dr 08 / Cr 80, ISSUE GENERATOR_FUND).
inline constexpr eosio::name ACCEPT_RID = "o.cap.accept"_n; ///< Приём РИД в НМА (Dr 04 / Cr 08, NONE — только бухпроводка, кошелёк остаётся на GENERATOR_FUND до конвертации сегмента).
inline constexpr eosio::name ACCEPT_PROPERTY = "o.cap.actprp"_n; ///< Акт-2 имущественный паевой взнос (Dr 51 / Cr 80, ISSUE BLAGOROST_FUND).
inline constexpr eosio::name ACCEPT_PROPERTY = "o.cap.actprp"_n; ///< Акт-2 имущественный паевой взнос (Dr 04 / Cr 80, ISSUE BLAGOROST_FUND).
inline constexpr eosio::name PREIMP = "o.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (Dr 04 / Cr 80, ISSUE PREIMP_FUND).
inline constexpr eosio::name DROP_PREIMP = "o.cap.drppre"_n; ///< Закрытие пред-импорт-учёта при переходе на электронный учёт (Dr 80 / Cr 04, BURN PREIMP_FUND). Вызывается из capital::importcontr перед o.cap.import.
inline constexpr eosio::name LEND = "o.cap.lend"_n; ///< Выдача беспроцентного займа пайщику (Dr 58 / Cr 51, ISSUE LOAN_ISSUED).
inline constexpr eosio::name REPAY = "o.cap.repay"_n; ///< Возврат займа пайщика по акту-2 (Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY).
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
@@ -187,9 +189,11 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
"Возврат паевого взноса пайщику" },
// 5. Импорт пайщика Благорост (offline): Dr 51 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009: единый кошелёк программы)
// 5. Импорт пайщика Благорост (offline): Dr 04 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009: единый кошелёк программы).
// Импорт фиксирует РИД-имущество пайщика как НМА — поэтому Dr 04, не Dr 51.
// Денежные взносы в Благорост идут через `o.cap.invest` (TRANSFER SHARE_FUND_PAY → BLAGOROST_FUND).
{ operations::capital::IMPORT, processes::capital::IMPORT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::BLAGOROST_FUND,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::SHARE_FUND,
"Паевой взнос по целевой потребительской программе «Благорост» (офлайн-импорт)" },
// 6. Инвестиция из Цифрового Кошелька в Благорост: TRANSFER SHARE_FUND_PAY → BLAGOROST_FUND (без Dr/Cr — оба счёта 80)
@@ -212,11 +216,31 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::NON_CURRENT_INVESTMENTS,
"Приём результата интеллектуальной деятельности в паевой фонд" },
// 9. Акт-2 имущественный паевой взнос: Dr 51 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009)
// 9. Акт-2 имущественный паевой взнос: Dr 04 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009).
// Имущественный (РИД) — Dr 04 (НМА), не Dr 51 (банк). Денежный паевой —
// через o.wal.depcpl или o.cap.invest.
{ operations::capital::ACCEPT_PROPERTY, processes::capital::PROPERTY, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::BLAGOROST_FUND,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::SHARE_FUND,
"Паевой взнос (не денежный) по программе «Благорост»" },
// 9a. Первичный учёт РИД-взноса: Dr 04 / Cr 80, ISSUE PREIMP_FUND.
// Пайщик внёс РИД-имущество ДО перехода кооператива на электронный учёт.
// Балансы фиксируются на отдельном кошельке `w.cap.preimp`, чтобы при
// `capital::importcontr` их можно было обнулить через `o.cap.drppre` и
// переоткрыть на полный объём через `o.cap.import` под единый Благорост-фонд.
{ operations::capital::PREIMP, processes::capital::PREIMP, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::PREIMP_FUND,
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::SHARE_FUND,
"Первичный учёт РИД-взноса до перехода на электронный учёт" },
// 9b. Закрытие пред-импорт-учёта: Dr 80 / Cr 04, BURN PREIMP_FUND.
// Вызывается из `capital::importcontr` ДО `o.cap.import`, если у пайщика
// есть запись в `userwallets[w.cap.preimp]`. После закрытия `o.cap.import`
// переоткрывает учёт на полный объём (включая возможную доплату).
{ operations::capital::DROP_PREIMP, processes::capital::IMPORT, WalletOp::BURN,
ledger2_wallets::PREIMP_FUND, eosio::name{},
ledger2_accounts::SHARE_FUND, ledger2_accounts::INTANGIBLE_ASSETS,
"Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт" },
// 10. Выдача беспроцентного займа пайщику: Dr 58 / Cr 51, ISSUE LOAN_ISSUED
{ operations::capital::LEND, processes::capital::DEBT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::LOAN_ISSUED,
ledger2_accounts::FINANCIAL_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
@@ -46,11 +46,12 @@ namespace processes {
// capital
namespace capital {
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (одноактовый).
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (o.cap.drppre (опц.) + o.cap.import).
inline constexpr eosio::name INVEST = "p.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (wallet-only 2001→9001).
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay).
inline constexpr eosio::name RID = "p.cap.rid"_n; ///< Приём РИД в паевой фонд: o.cap.commit (коммиты) + o.cap.accept (акт-2) + опц. o.cap.repay + o.cap.cnvshr/o.cap.cnvbl (финальная конвертация сегмента). Анкер процесса — result_hash.
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
}
@@ -52,14 +52,16 @@ struct ledger2_wallets {
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
static constexpr eosio::name ENTRANCE_FEES = "w.reg.entry"_n; ///< Вступительные взносы (Cr 86, COOPERATIVE)
// soviet — членские (инфраструктура) + делегатские
// soviet — членские (инфраструктура) + делегатские + хоз.расходы
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
static constexpr eosio::name DELEGATE_FEES = "w.sov.delgte"_n; ///< Делегатские членские взносы (цель CONVERT_TO_AXN, COOPERATIVE)
static constexpr eosio::name SOV_EXPENSES = "w.sov.expns"_n; ///< Хозяйственные расходы из числа целевого финансирования (COOPERATIVE)
// capital — единые программные кошельки + займы
// capital — единые программные кошельки + займы + пред-импорт
static constexpr eosio::name LOAN_ISSUED = "w.cap.loan"_n; ///< Выданные пайщикам беспроцентные займы (COOPERATIVE; Dr 58 / Cr 51)
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
// marketplace — выплаты
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
@@ -91,19 +93,21 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 11> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (5) — L3-разрез по пайщику
inline constexpr std::array<Ledger2WalletMeta, 13> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
// COOPERATIVE (6) — единый кооперативный баланс, без L3
// COOPERATIVE (7) — единый кооперативный баланс, без L3
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
}};
@@ -218,12 +222,13 @@ struct Ledger2WalletProgramMapping {
uint64_t required_program_id; // 0 = исключение (без проверки)
};
inline constexpr std::array<Ledger2WalletProgramMapping, 5> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
inline constexpr std::array<Ledger2WalletProgramMapping, 6> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
{ ledger2_wallets::MIN_SHARE_FUND, 0 /* w.reg.minshr — без проверки */ },
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК */ },
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК */ },
{ ledger2_wallets::BLAGOROST_FUND, 4 /* Благорост */ },
{ ledger2_wallets::GENERATOR_FUND, 3 /* Генератор */ },
{ ledger2_wallets::PREIMP_FUND, 0 /* w.cap.preimp — РИД-учёт до перехода на электронный учёт, без проверки */ },
}};
/**
@@ -74,3 +74,6 @@
#include "table_apps_releases.hpp"
#include "table_apps_subs.hpp"
#include "table_apps_coops.hpp"
#include "table_apps_pricings.hpp"
#include "table_apps_globals.hpp"
#include "table_apps_clients.hpp"
@@ -0,0 +1,50 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/time.hpp>
#include "../consts.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Реестр кооперативов-клиентов каталога приложений (D3, FR7, FR8, AR3).
*
* Хранит факт «оператор каталога подключил кооператив `client_coopname`
* к каталогу». Сама подписка пакета хранится в `subs`; `clients` —
* это уровень onboarding'а, отвечающий на вопрос «может ли вообще
* этот кооператив пользоваться каталогом и его API».
*
* Идентификация:
* - scope = `catalog_operator` (`eosio::name`). В MVP всегда `voskhod`
* (single-operator forever по решению brief'а), но scope-based
* layout не блокирует replica-режим: другой operator → другой scope,
* миграция данных не нужна.
* - PK = `client_coopname.value` (один кооператив = одна запись
* в scope; повторный `regclient` → `eosio_assert("client already registered")`).
*
* RAM payer — `catalog_operator` (тот, кто подписал транзакцию).
* Это ВОСХОД в MVP — он оплачивает «координацию», а не клиенты.
* `delclient` освобождает RAM назад оператору.
*
* Зачем отдельная таблица, а не флаг в общей `coops`. `coops` —
* не «общая таблица», а сущность из системы регистратора и совет-плоскости
* (subnet-signing-keys, chain_id подсетей). Класть туда catalog-specific
* флаги — ломать инкапсуляцию контракта; будущая ротация владельца коопа
* не должна зависеть от состояния каталога приложений.
*
* \see architecture-v2 D3 — выбор scope-per-operator vs single-table.
* \see architecture-v2 D4 — interaction с `regsub` (membership check).
*/
struct [[eosio::table, eosio::contract(APPS)]] client {
name client_coopname; ///< primary key — кооператив-клиент
time_point_sec registered_at; ///< таймштамп `regclient`-вызова
uint64_t primary_key() const { return client_coopname.value; }
};
typedef eosio::multi_index<"clients"_n, client> clients_index;
} // namespace Apps
@@ -0,0 +1,43 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/singleton.hpp>
#include "../consts.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Общесистемные параметры каталога приложений (D2, FR2).
*
* Singleton — одна запись на контракт. Хранит четыре параметра pull-биллинга
* и retry-каденса:
*
* - `min_payment_period_seconds` — минимальный шаг pull-биллинга
* (обычно 1 час; используется при квантизации `charge.required`-периодов).
* - `free_trial_period_seconds` — период бесплатного использования
* после `regclient` до первого регулярного charge'а.
* - `lead_time_seconds` — за какое время до окончания текущего
* оплаченного интервала CA-watcher эмитит `charge.required` (D6).
* - `retry_max` — максимум transport-retry'ев при недоступности кабинета;
* после исчерпания CA переключается на суточный billing-retry с
* `attempt`-инкрементом (two-level retry pattern).
*
* Scope = `apps`-контракт (`get_self()`); singleton без PK.
* RAM payer — `get_self()`. Изменения только через action `setglobals`.
*
* \see architecture-v2 D2 — почему singleton, а не поле в общей `coops`.
* \see Story v2.1.1 — каркас (полная замена); v2.1.2 — валидация диапазонов.
*/
struct [[eosio::table, eosio::contract(APPS)]] globals_row {
uint32_t min_payment_period_seconds = 0;
uint32_t free_trial_period_seconds = 0;
uint32_t lead_time_seconds = 0;
uint8_t retry_max = 0;
};
typedef eosio::singleton<"globals"_n, globals_row> globals_singleton;
} // namespace Apps
@@ -0,0 +1,45 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/eosio.hpp>
#include <eosio/time.hpp>
#include "../consts.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Прайс-лист пакета: `{plan -> hourly_rate}` (D1, FR1, AR5).
*
* Pricing-as-source on-chain: ставка плана хранится в блокчейне и читается
* каталогом через parser2 read-adapter без локального shadow-state.
* Изменения операторских цен через action `setpricing` атомарно
* фиксируют `hourly_rate` и `updated_at`-снапшот для аудита.
*
* Идентификация:
* - scope = `package_id` (один shard на пакет).
* - PK = `plan.value` (в MVP всегда `default`; tier-планы добавляются
* записями в тот же scope без миграции).
*
* RAM payer — `apps`-контракт (`get_self()`). Фактически оплачивается
* ВОСХОДом как owner аккаунта `apps` (catalog-operator). Кооперативы-клиенты
* за хранение прайс-листа не платят — это часть инварианта
* «координацию оплачивает оператор каталога».
*
* \see /home/admin/apps-catalog/docs/architecture-v2-pricing-multitenancy.md
* D1 (Layout `pricings`-таблицы — scope-based per package).
* \see Story v2.1.1 — каркас; v2.1.2 — валидация и snapshot policy.
*/
struct [[eosio::table, eosio::contract(APPS)]] pricing {
name plan; ///< primary key (Antelope name, ≤12 chars)
asset hourly_rate; ///< ставка за час (общая валюта экосистемы)
time_point_sec updated_at; ///< таймштамп последнего `setpricing`
uint64_t primary_key() const { return plan.value; }
};
typedef eosio::multi_index<"pricings"_n, pricing> pricings_index;
} // namespace Apps
@@ -35,6 +35,24 @@ using namespace eosio;
* для аудита истечения подписки. Cleanup просроченных записей — задача
* отдельного периодического действия (вне MVP).
*
* **D4 v2-extension** — два новых поля для координации pull-биллинга
* (Story v2.6 / D4 / D5):
* - `attempt` — billing-retry counter уровня `regsub`. Инкрементируется
* pricing-watcher'ом CA через `setattempt`, когда `ack=declined`
* или charge провалился. Сбрасывается в `0` при успешном `extendsub`.
* - `last_charge_intent_id` — UUIDv5 последнего применённого
* charge.intent. Используется `extendsub` для double-emit prevention:
* повторный вызов с тем же id отсекается `eosio_assert("already extended")`.
* Дефолт — нулевой checksum256 (т.е. ещё ни одного extend не было).
*
* Поля добавлены в конец struct'а как nullable-by-default — старые
* MVP-row'ы (если бы они были) интерпретировались бы с `attempt=0` и
* нулевым `last_charge_intent_id`. Pre-deploy guard
* `scripts/v2-migration-guard.ts` (Story v2.7.5) блокирует прод-деплой
* при count > 0 production-scope записей, чтобы исключить
* deserialization-сюрпризы; в MVP/dev таблица пустая и миграция
* безопасна.
*
* Indexing:
* - PK `id` — auto-inc.
* - `bycooppkg` — composite (coopname, package_id) для idempotent upsert
@@ -43,6 +61,8 @@ using namespace eosio;
* - `byexpires` — sorted by `end_at` для будущего auto-cleanup.
*
* \see lib/domain/table_apps_coops.hpp — chain_id ↔ кооператив.
* \see architecture-v2 D4 — runtime-nullable migration policy.
* \see architecture-v2 D5 — UUIDv5-based double-emit prevention.
*/
struct [[eosio::table, eosio::contract(APPS)]] sub {
uint64_t id;
@@ -55,6 +75,8 @@ struct [[eosio::table, eosio::contract(APPS)]] sub {
time_point_sec end_at;
time_point_sec created_at;
time_point_sec updated_at;
uint8_t attempt = 0; ///< D4: billing-retry counter
checksum256 last_charge_intent_id = {}; ///< D4: UUIDv5 последнего extendsub
uint64_t primary_key() const { return id; }
uint128_t by_cooppkg() const { return ((uint128_t)coopname.value << 64) | package_id.value; }
@@ -1,462 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Запрос маркетплейса» — обмен имуществом между пайщиками через
# кооператив с двойной конвертацией (деньги → имущество и обратно).
#
# Многоступенчатый процесс (10+ узлов состояний). Две ledger2-операции:
# • o.mkt.supply — подтверждение поставки на КУ (ISSUE → w.wal.share, Дт 51 / Кт 80)
# • o.mkt.recv — выплата поставщику после получения (TRANSFER w.wal.share → w.mkt.payout, Дт 80 / Кт 51)
#
# Источники правды в коде:
# • cpp/marketplace/marketplace.hpp — actions
# • cpp/marketplace/src/deliver_on_offer/ — реализация
# • cpp/lib/domain/table_marketplace_requests.hpp — entity requests
# • cpp/lib/core/ledger2/operations.hpp — o.mkt.supply, o.mkt.recv
# • cpp/lib/core/ledger2/processes.hpp — processes::marketplace::REQUEST
# ─────────────────────────────────────────────────────────────────────────────
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
process_type: p.mkt.reqst
id: public_marketplace_request_process
title: Исполнение заказа на поставку имущества
slug: request
status: proposed
contract: marketplace
summary: >
Пайщик-заказчик размещает заявку на товар, поставщик принимает её, совет
авторизует обе стороны (взнос и возврат), товар поставляется и принимается
с гарантийным периодом. После завершения поставщик получает выплату.
purpose: >
«Исполнение заказа на поставку имущества» — кооператив принимает
имущество у одного пайщика и передаёт его другому. Контрагентом
для обеих сторон выступает сам кооператив: совет одновременно
авторизует приём от поставщика и выдачу заказчику, кооперативный
участок физически принимает имущество и передаёт его заказчику,
поставщику начисляется выплата после подтверждения получения
и истечения гарантийного срока.
roles:
- contributor # заказчик (money-contributor) и поставщик (product-contributor)
- soviet
- chairman # представитель кооперативного участка (КУ)
# ── Секция 2. Действия контракта ────────────────────────────────────────────
actions:
- name: marketplace::orderoffer
human: Создать заявку
actor: contributor
role: opener
purpose: >
Заказчик размещает заявку на товар: блокирует денежные средства,
подписывает документы конвертации входящей и возврата товара.
- name: marketplace::accept
human: Принять заявку
actor: contributor
role: progress
purpose: >
Поставщик принимает заявку: подписывает встречную конвертацию и
заявление о внесении имущественного паевого взноса. В совет
отправляются две повестки.
- name: marketplace::authcontrib
human: Авторизовать взнос имуществом
actor: soviet
role: progress
purpose: Совет авторизует имущественный взнос поставщика.
- name: marketplace::authreturn
human: Авторизовать возврат
actor: soviet
role: progress
purpose: >
Совет авторизует возврат имущества (если придётся). Когда обе
авторизации получены, статус заявки → `authorized`.
- name: marketplace::supply
human: Поставить имущество
actor: contributor
role: progress
purpose: >
Поставщик поставляет имущество на кооперативный участок и подписывает
акт поставки.
- name: marketplace::supplcnf
human: Подтвердить поставку
actor: chairman
role: progress
purpose: >
Представитель кооперативного участка подтверждает приём имущества.
Применяется ledger2-операция o.mkt.supply — имущество поступает в
SHARE_FUND с проводкой Дт 51 / Кт 80.
- name: marketplace::delivered
human: Подготовить к выдаче
actor: chairman
role: progress
purpose: >
Кооперативный участок переводит имущество в статус «готово к выдаче».
- name: marketplace::receive
human: Получить имущество
actor: contributor
role: progress
purpose: >
Заказчик получает имущество на КУ и подписывает первый акт получения.
- name: marketplace::receivecnf
human: Подтвердить получение
actor: contributor
role: progress
purpose: >
Заказчик подписывает второй акт — приём имущества подтверждён.
Применяется ledger2-операция o.mkt.recv — выплата поставщику
(TRANSFER w.wal.share → w.mkt.payout, Дт 80 / Кт 51), устанавливается гарантийный период.
- name: marketplace::complete
human: Завершить
actor: chairman
role: closer
purpose: >
После истечения гарантийного периода заявка завершается, запись удаляется.
- name: marketplace::declineacc
human: Отклонить советом
actor: soviet
role: reject
purpose: >
Совет отклонил приём заявки. Заблокированные средства возвращаются
заказчику, запись удаляется.
- name: marketplace::decline
human: Отказ на этапе принятия
actor: contributor
role: reject
purpose: >
Заявка отменена в статусе `active` или `accepted` (до авторизации).
Заблокированные средства возвращаются заказчику.
- name: marketplace::cancel
human: Отменить с комиссией
actor: contributor
role: reject
purpose: >
Отмена инициатором (заказчик/поставщик) до подтверждения поставки.
Удерживается комиссия за отмену, остаток возвращается.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: marketplace::request
entity_human: Заявка маркетплейса
entity_source: cpp/marketplace/src/deliver_on_offer/
states:
- name: active
human: Заявка размещена
description: >
Заказчик создал заявку, средства заблокированы на его кошельке.
kind: normal
- name: accepted
human: Поставщик принял
description: >
Поставщик принял заявку, в совете созданы две повестки авторизации.
kind: normal
- name: authorized
human: Авторизовано советом
description: Получены обе авторизации совета — заявка готова к поставке.
kind: normal
- name: supplied1
human: Имущество поставлено
description: Поставщик передал имущество на кооперативный участок.
kind: normal
- name: supplied2
human: Поставка подтверждена
description: >
КУ подтвердил приём имущества. Применена o.mkt.supply — имущество
в SHARE_FUND.
kind: normal
- name: delivered
human: Готово к выдаче
description: КУ подготовил имущество к выдаче заказчику.
kind: normal
- name: received1
human: Заказчик получил
description: Заказчик получил имущество и подписал акт-1.
kind: normal
- name: received2
human: Получение подтверждено
description: >
Заказчик подписал акт-2. Применена o.mkt.recv — поставщик получил
выплату. Установлен гарантийный период.
kind: normal
- name: completed
human: Завершено
description: >
Гарантийный период истёк, заявка закрыта, запись удалена.
kind: final
- name: removed
human: Отклонено / отменено
description: >
Заявка прервана: средства возвращены, запись удалена. Операции
ledger2 не создавались (или возвращены).
kind: virtual
virtual: true
transitions:
- from: "∅"
to: active
action: marketplace::orderoffer
actor: contributor
guards:
- Заказчик активен. Достаточный остаток на SHARE_FUND_PAY.
- Подписаны RETURN_STMT и CONVERT_FROM.
- from: active
to: accepted
action: marketplace::accept
actor: contributor
guards:
- Поставщик активен.
- Подписаны CONVERT_TO и CONTRIB_STMT.
- from: accepted
to: authorized
action: marketplace::authcontrib
actor: soviet
guards:
- Подписана CONTRIB_AUTH.
- Также получена RETURN_AUTH.
- from: accepted
to: authorized
action: marketplace::authreturn
actor: soviet
guards:
- Подписана RETURN_AUTH.
- Также получена CONTRIB_AUTH.
- from: authorized
to: supplied1
action: marketplace::supply
actor: contributor
guards:
- Поставщик подписал акт поставки.
- from: supplied1
to: supplied2
action: marketplace::supplcnf
actor: chairman
ledger_code: o.mkt.supply
operations:
- o.mkt.supply
guards:
- Представитель КУ подтвердил приём имущества.
- from: supplied2
to: delivered
action: marketplace::delivered
actor: chairman
guards:
- КУ подготовил имущество к выдаче.
- from: delivered
to: received1
action: marketplace::receive
actor: contributor
guards:
- Заказчик получил имущество.
- from: received1
to: received2
action: marketplace::receivecnf
actor: contributor
ledger_code: o.mkt.recv
operations:
- o.mkt.recv
guards:
- Заказчик подписал акт-2 получения.
- from: received2
to: completed
action: marketplace::complete
actor: chairman
guards:
- Гарантийный период истёк.
- from: active
to: removed
action: marketplace::decline
actor: contributor
guards:
- Заявка отменена до принятия.
- from: accepted
to: removed
action: marketplace::declineacc
actor: soviet
guards:
- Совет отклонил заявку до авторизации.
- from: accepted
to: removed
action: marketplace::cancel
actor: contributor
guards:
- Отмена с удержанием комиссии до supplied2.
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
steps:
- step: 1
title: Создание заявки
actor: contributor
action: marketplace::orderoffer
description: >
Заказчик размещает заявку на товар: блокирует средства, подписывает
RETURN_STMT (возврат) и CONVERT_FROM (входящая конвертация).
- step: 2
title: Принятие поставщиком
actor: contributor
action: marketplace::accept
description: >
Поставщик подписывает CONVERT_TO и CONTRIB_STMT, в совете создаются
две повестки.
- step: 3
title: Авторизация советом
actor: soviet
action: marketplace::authcontrib
description: >
Совет подписывает CONTRIB_AUTH (взнос имуществом) и RETURN_AUTH
(возврат имущества). По обеим авторизациям статус → `authorized`.
- step: 4
title: Поставка имущества
actor: contributor
action: marketplace::supply
description: Поставщик доставляет имущество на КУ и подписывает SUPPLY_ACT.
- step: 5
title: Подтверждение поставки
actor: chairman
action: marketplace::supplcnf
description: >
Представитель КУ подтверждает приём (SUPPLY_ACT_CONF). Применяется
o.mkt.supply — имущество поступает в SHARE_FUND (Дт 51 / Кт 80).
- step: 6
title: Получение заказчиком
actor: contributor
action: marketplace::receive
description: Заказчик получает имущество, подписывает RECEIVE_ACT.
- step: 7
title: Подтверждение получения
actor: contributor
action: marketplace::receivecnf
description: >
Заказчик подписывает RECEIVE_ACT_CONF. Применяется o.mkt.recv —
выплата поставщику (TRANSFER w.wal.share → w.mkt.payout, Дт 80 / Кт 51), установлен
гарантийный период.
- step: 8
title: Завершение по гарантии
actor: chairman
action: marketplace::complete
description: По истечении гарантийного срока заявка закрывается.
alternatives:
- branch: Отказ совета
at_step: 3
action: marketplace::declineacc
actor: soviet
description: Совет отклоняет — средства возвращаются, запись удаляется.
- branch: Отказ заказчика до принятия
at_step: 1
action: marketplace::decline
actor: contributor
description: Отмена до accept — средства возвращаются.
- branch: Отмена с комиссией
at_step: 4
action: marketplace::cancel
actor: contributor
description: Отмена до supplied2 — удерживается комиссия.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
- step: 1
title: Заявление о возврате имущества (RETURN_STMT)
template: mkt-return-stmt
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
- step: 1
title: Конвертация входящих средств (CONVERT_FROM)
template: mkt-convert-from
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
- step: 2
title: Конвертация исходящих (CONVERT_TO)
template: mkt-convert-to
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
- step: 2
title: Заявление об имущественном паевом взносе поставщика (CONTRIB_STMT)
template: mkt-contrib-stmt
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
- step: 3
title: Авторизация совета на имущественный взнос (CONTRIB_AUTH)
template: mkt-contrib-auth
registry_id: 0
signed_by: [soviet]
stored_in: requests.documents
- step: 3
title: Авторизация совета на возврат имущества (RETURN_AUTH)
template: mkt-return-auth
registry_id: 0
signed_by: [soviet]
stored_in: requests.documents
- step: 4
title: Акт поставки (SUPPLY_ACT)
template: mkt-supply-act
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
- step: 5
title: Акт подтверждения поставки (SUPPLY_ACT_CONF)
template: mkt-supply-act-conf
registry_id: 0
signed_by: [chairman]
stored_in: requests.documents
- step: 6
title: Акт получения (RECEIVE_ACT)
template: mkt-receive-act
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
- step: 7
title: Акт подтверждения получения (RECEIVE_ACT_CONF)
template: mkt-receive-act-conf
registry_id: 0
signed_by: [contributor]
stored_in: requests.documents
# ── Секция 6. Операции ──────────────────────────────────────────────────────
operations:
- ledger_code: o.mkt.supply
human_name: Подтверждение поставки
wallet_op: ISSUE
wallet_from: ''
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
debit: 51 # Расчётный счёт
credit: 80 # Паевой фонд (складочный капитал)
amount_ref: request.total_cost
triggered_by: marketplace::supplcnf
description: >
Имущество поступило на кооперативный участок. В ledger2 фиксируется
приход в SHARE_FUND поставщика — основание для последующей выплаты
из паевого фонда.
- ledger_code: o.mkt.recv
human_name: Подтверждение получения — выплата поставщику
wallet_op: TRANSFER
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
wallet_to: w.mkt.payout # Выплаты поставщикам
debit: 80 # Паевой фонд
credit: 51 # Расчётный счёт
amount_ref: request.base_cost
triggered_by: marketplace::receivecnf
description: >
После подтверждения получения заказчиком средства уходят с SHARE_FUND
поставщика на кошелёк выплат поставщикам — фактическая денежная
выплата за поставленное имущество.
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
related:
- process_type: p.wal.depo
id: public_wallet_deposit_process
relation: provides
note: >
Заказчик и поставщик должны иметь остаток на SHARE_FUND_PAY — его
создаёт «Внесение паевого взноса» (p.wal.depo).
@@ -49,6 +49,7 @@
#include "src/fund/fundwithdraw.cpp"
#include "src/participant/addparticipant.cpp"
#include "src/participant/setminamt.cpp"
#include "src/participant/block.cpp"
#include "src/participant/cancelreg.cpp"
#include "src/participant/unblock.cpp"
@@ -139,6 +139,9 @@ public:
//regaccount.cpp
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
//setminamt.cpp
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
//automator.cpp
[[eosio::action]] void automate(eosio::name coopname, uint64_t board_id, eosio::name member, eosio::name action_type, eosio::name provider, std::string encrypted_private_key);
[[eosio::action]] void disautomate(eosio::name coopname, uint64_t board_id, eosio::name member, uint64_t automation_id );
@@ -0,0 +1,27 @@
/**
* @brief Корректировка minimum_amount у уже принятого пайщика.
* Технический фикс для случаев, когда поле рассинхронизировано с кооп-параметром
* (например, у voskhod::ant исторически осталось 1 RUB вместо 300 RUB).
* @param coopname Наименование кооператива
* @param username Наименование пайщика
* @param minimum Новая сумма minimum_amount
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта @p coopname
*/
void soviet::setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum) {
require_auth(coopname);
auto cooperative = get_cooperative_or_fail(coopname);
cooperative.check_symbol_or_fail(minimum);
eosio::check(minimum.amount > 0, "minimum_amount должен быть положительным");
participants_index participants(_soviet, coopname.value);
auto p = participants.find(username.value);
eosio::check(p != participants.end(), "Пайщик не найден");
participants.modify(p, coopname, [&](auto &m){
m.minimum_amount = minimum;
});
}
@@ -17,10 +17,6 @@ void soviet::converttoaxn(eosio::name coopname, eosio::asset amount, document2 s
eosio::asset axon_quantity(axon_amount, _root_symbol);
// Списываем RUB с кошелька provider (_wallet_program)
Wallet::sub_available_funds(_soviet, _provider, coopname, amount, _wallet_program, "Конвертация RUB в AXON");
// Пополняем кошелёк членских взносов в ledger
std::string memo = "Членский взнос из числа средств паевого взноса по соглашению о подключении к платформе Кооперативной Экономики от пайщика с username=" + coopname.to_string();
@@ -38,7 +38,7 @@ void soviet::addbal(eosio::name coopname, eosio::name username, uint64_t program
b.coopname = coopname;
b.username = username;
b.agreement_id = 0;
b.available = asset(0, quantity.symbol) + quantity;
b.available = quantity;
b.blocked = asset(0, quantity.symbol);
b.membership_contribution = asset(0, quantity.symbol);
});
@@ -54,6 +54,4 @@ void soviet::addbal(eosio::name coopname, eosio::name username, uint64_t program
p.available = p.available.value_or(asset(0, quantity.symbol)) + quantity;
p.share_contributions = p.share_contributions.value_or(asset(0, quantity.symbol)) + quantity;
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -97,6 +97,13 @@ export const PROCESS_HASH_LOCATOR: Readonly<Record<string, HashLocation[]>> = Ob
// capital::pgproperties.property_hash — приём имущества в паевой фонд.
'p.cap.prop': [{ code: 'capital', table: 'pgproperties', field: 'property_hash' }],
// p.cap.preimp — первичный учёт РИД-взноса до перехода кооператива на
// электронный учёт (`o.cap.preimp`, ISSUE w.cap.preimp). Сущностной таблицы
// под этот учёт пока нет — пред-импорт-записи живут только в
// `userwallets[w.cap.preimp]`. Process_hash берётся из blockchain_actions
// (как у одноактовых процессов).
'p.cap.preimp': [],
'p.wal.depo': [{ code: 'wallet', table: 'deposits', field: 'deposit_hash' }],
'p.wal.wthdrw': [{ code: 'wallet', table: 'withdraws', field: 'withdraw_hash' }],
@@ -31,14 +31,13 @@ export class IssuePermissionsService {
) {}
/**
* Определяет роль пользователя для конкретной задачи
* @param username - имя пользователя
* @param coopname - имя кооператива
* @param projectHash - хеш проекта
* @param issueSubmaster - ответственный задачи
* @param issueCreators - создатели задачи
* @param userRole - роль пользователя в системе (chairman, member, etc.)
* @returns роль пользователя
* Определяет НАБОР ролей пользователя для конкретной задачи.
*
* Пользователь может одновременно нести несколько ролей (например, член совета
* + соавтор + исполнитель), и итоговые права на задачу — UNION разрешений по
* всем его ролям. См. {@link IssueAccessPolicyService.hasPermission}.
*
* @returns множество ролей; минимум {@link UserRole.GUEST} / {@link UserRole.CONTRIBUTOR}.
*/
async getUserRoleForIssue(
username: string | undefined,
@@ -47,74 +46,64 @@ export class IssuePermissionsService {
issueSubmaster?: string,
issueCreators?: string[],
userRole?: string
): Promise<UserRole> {
// Гости не имеют доступа
): Promise<Set<UserRole>> {
const roles = new Set<UserRole>();
if (!username) {
return UserRole.GUEST;
roles.add(UserRole.GUEST);
return roles;
}
// Сначала проверяем специфические роли проекта (они имеют приоритет над системными ролями)
// Проверяем, является ли пользователь мастером проекта
// Project-specific роли — собираем все, что верны.
const isMaster = await this.isProjectMaster(username, coopname, projectHash);
if (isMaster) {
return UserRole.MASTER;
roles.add(UserRole.MASTER);
}
// Проверяем, является ли пользователь ответственным
if (issueSubmaster === username) {
return UserRole.SUBMASTER;
roles.add(UserRole.SUBMASTER);
}
// Проверяем, является ли пользователь исполнителем (соисполнителем)
if (issueCreators && issueCreators.includes(username)) {
return UserRole.CREATOR;
roles.add(UserRole.CREATOR);
}
// Проверяем, является ли пользователь автором проекта
const segment = await this.segmentRepository.findOne({
username,
project_hash: projectHash,
coopname,
});
if (segment?.is_author) {
return UserRole.AUTHOR;
roles.add(UserRole.AUTHOR);
}
// Проверяем, является ли пользователь участником проекта
const contributor = await this.contributorRepository.findByUsernameAndCoopname(username, coopname);
if (contributor && contributor.appendixes.includes(projectHash)) {
return UserRole.CONTRIBUTOR;
roles.add(UserRole.CONTRIBUTOR);
}
// Теперь проверяем системные роли (только если нет специфических ролей проекта)
// Системные роли совета — добавляем независимо от project-specific ролей.
if (userRole === 'chairman' || userRole === 'member') {
return UserRole.BOARD_MEMBER;
roles.add(UserRole.BOARD_MEMBER);
}
// По умолчанию - участник проекта (если есть доступ)
return UserRole.CONTRIBUTOR;
// Минимальный фолбэк, если ничего из вышеперечисленного не сработало,
// но username известен — оставляем семантику «участник проекта».
if (roles.size === 0) {
roles.add(UserRole.CONTRIBUTOR);
}
return roles;
}
/**
* Проверяет, есть ли у пользователя разрешение на действие
* @param userRole - роль пользователя
* @param action - действие
* @returns true если действие разрешено
*/
hasPermission(userRole: UserRole, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(userRole, action);
/** UNION-проверка прав на действие над задачей по набору ролей. */
hasPermission(roles: Iterable<UserRole>, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(roles, action);
}
/**
* Проверяет разрешение на переход между статусами
* @param userRole - роль пользователя
* @param currentStatus - текущий статус
* @param newStatus - новый статус
* @returns true если переход разрешен
*/
canTransitionStatus(userRole: UserRole, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
return this.issueAccessPolicyService.canTransitionStatus(userRole, currentStatus, newStatus);
/** UNION-проверка перехода статусов по набору ролей. */
canTransitionStatus(roles: Iterable<UserRole>, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
return this.issueAccessPolicyService.canTransitionStatus(roles, currentStatus, newStatus);
}
/**
@@ -173,53 +162,58 @@ export class IssuePermissionsService {
}
/**
* Определяет роль пользователя для проекта
* @param username - имя пользователя
* @param project - проект
* @param userRole - системная роль пользователя (chairman, member, etc.)
* @returns роль пользователя для проекта
* Определяет НАБОР project-ролей пользователя.
*
* UNION-семантика по ролям. Симметрично с
* {@link ProjectPermissionsService.getProjectUserRole}.
*/
async getProjectUserRole(
username: string | undefined,
project: any, // ProjectDomainEntity
userRole?: string
): Promise<ProjectUserRole> {
// Гости не имеют доступа
): Promise<Set<ProjectUserRole>> {
const roles = new Set<ProjectUserRole>();
if (!username) {
return ProjectUserRole.GUEST;
roles.add(ProjectUserRole.GUEST);
return roles;
}
// Члены совета имеют полные права
if (userRole === 'chairman' || userRole === 'member') {
return userRole === 'chairman' ? ProjectUserRole.CHAIRMAN : ProjectUserRole.BOARD_MEMBER;
if (userRole === 'chairman') {
roles.add(ProjectUserRole.CHAIRMAN);
} else if (userRole === 'member') {
roles.add(ProjectUserRole.BOARD_MEMBER);
}
// Проверяем, является ли пользователь мастером проекта
const isMaster = await this.isProjectMaster(username, project.coopname, project.project_hash);
if (isMaster) {
return ProjectUserRole.MASTER;
roles.add(ProjectUserRole.MASTER);
}
const segment = await this.segmentRepository.findOne({
username,
project_hash: project.project_hash,
coopname: project.coopname,
});
if (segment?.is_author) {
roles.add(ProjectUserRole.AUTHOR);
}
// Проверяем, является ли пользователь участником проекта
const contributor = await this.contributorRepository.findByUsernameAndCoopname(username, project.coopname);
const isContributor = contributor && contributor.appendixes.includes(project.project_hash);
if (isContributor) {
return ProjectUserRole.CONTRIBUTOR;
if (contributor && contributor.appendixes.includes(project.project_hash)) {
roles.add(ProjectUserRole.CONTRIBUTOR);
}
// По умолчанию - участник (если есть доступ к проекту)
return ProjectUserRole.CONTRIBUTOR;
if (roles.size === 0) {
roles.add(ProjectUserRole.CONTRIBUTOR);
}
return roles;
}
/**
* Проверяет, есть ли у пользователя разрешение на действие над проектом
* @param userRole - роль пользователя для проекта
* @param action - действие над проектом
* @returns true если действие разрешено
*/
hasProjectPermission(userRole: ProjectUserRole, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(userRole, action);
/** UNION-проверка прав на действие над проектом по набору ролей. */
hasProjectPermission(roles: Iterable<ProjectUserRole>, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(roles, action);
}
/**
@@ -243,25 +237,22 @@ export class IssuePermissionsService {
currentStatus: IssueStatus,
userRole?: string
): Promise<void> {
// Определяем роль пользователя
const role = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Определяем набор ролей пользователя
const roles = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Проверяем разрешение на изменение статуса
if (!this.hasPermission(role, IssueAction.CHANGE_STATUS)) {
if (!this.hasPermission(roles, IssueAction.CHANGE_STATUS)) {
throw new Error(`У вас нет прав на изменение статуса задачи`);
}
// Проверяем разрешение на переход между статусами
if (!this.canTransitionStatus(role, currentStatus, newStatus)) {
if (!this.canTransitionStatus(roles, currentStatus, newStatus)) {
throw new Error(`Переход из статуса "${currentStatus}" в "${newStatus}" запрещен для вашей роли`);
}
// Дополнительные проверки для специальных статусов
if (newStatus === IssueStatus.DONE && !this.hasPermission(role, IssueAction.SET_DONE)) {
if (newStatus === IssueStatus.DONE && !this.hasPermission(roles, IssueAction.SET_DONE)) {
throw new Error('Только мастер проекта может устанавливать статус "Выполнена"');
}
if (newStatus === IssueStatus.ON_REVIEW && !this.hasPermission(role, IssueAction.SET_ON_REVIEW)) {
if (newStatus === IssueStatus.ON_REVIEW && !this.hasPermission(roles, IssueAction.SET_ON_REVIEW)) {
throw new Error('Только ответственный исполнитель может устанавливать статус "На проверке"');
}
}
@@ -284,11 +275,10 @@ export class IssuePermissionsService {
issueCreators: string[] | undefined,
userRole?: string
): Promise<void> {
// Определяем роль пользователя
const role = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Определяем набор ролей пользователя
const roles = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Проверяем разрешение на установку оценки
if (!this.hasPermission(role, IssueAction.SET_ESTIMATE)) {
if (!this.hasPermission(roles, IssueAction.SET_ESTIMATE)) {
throw new Error('Только мастер проекта может устанавливать оценку на задачи');
}
}
@@ -311,22 +301,16 @@ export class IssuePermissionsService {
issueCreators: string[] | undefined,
userRole?: string
): Promise<void> {
// Определяем роль пользователя
const role = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Определяем набор ролей пользователя
const roles = await this.getUserRoleForIssue(username, coopname, projectHash, issueSubmaster, issueCreators, userRole);
// Проверяем разрешение на установку приоритета
if (!this.hasPermission(role, IssueAction.SET_PRIORITY)) {
if (!this.hasPermission(roles, IssueAction.SET_PRIORITY)) {
throw new Error('Только мастер проекта может устанавливать приоритет на задачи');
}
}
/**
* Получает список допустимых статусов для перехода из текущего статуса для данной роли
* @param userRole - роль пользователя
* @param currentStatus - текущий статус задачи
* @returns массив допустимых статусов для перехода
*/
getAllowedStatusTransitions(userRole: UserRole, currentStatus: IssueStatus): IssueStatus[] {
return this.issueAccessPolicyService.getAllowedStatusTransitions(userRole, currentStatus);
/** UNION-список допустимых переходов статуса по набору ролей. */
getAllowedStatusTransitions(roles: Iterable<UserRole>, currentStatus: IssueStatus): IssueStatus[] {
return this.issueAccessPolicyService.getAllowedStatusTransitions(roles, currentStatus);
}
}
@@ -138,8 +138,8 @@ export class PermissionsService {
const username = currentUser.username;
// Определяем роль пользователя для этой задачи
const userRole = await this.issuePermissionsService.getUserRoleForIssue(
// Определяем НАБОР ролей пользователя для этой задачи (UNION-семантика).
const roles = await this.issuePermissionsService.getUserRoleForIssue(
username,
issue.coopname,
issue.project_hash,
@@ -152,18 +152,18 @@ export class PermissionsService {
const has_clearance = await this.isProjectContributor(username, issue.coopname, issue.project_hash);
// Рассчитываем права на основе матрицы доступа
const can_edit_issue = this.issuePermissionsService.hasPermission(userRole, IssueAction.EDIT_ISSUE);
const can_change_status = this.issuePermissionsService.hasPermission(userRole, IssueAction.CHANGE_STATUS);
const can_assign_creator = this.issuePermissionsService.hasPermission(userRole, IssueAction.ASSIGN_CREATOR);
const can_set_done = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_DONE);
const can_set_on_review = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_ON_REVIEW);
const can_set_estimate = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_ESTIMATE);
const can_set_priority = this.issuePermissionsService.hasPermission(userRole, IssueAction.SET_PRIORITY);
const can_delete_issue = this.issuePermissionsService.hasPermission(userRole, IssueAction.DELETE_ISSUE);
const can_create_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.CREATE_REQUIREMENT);
const can_edit_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.EDIT_REQUIREMENT);
const can_delete_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.DELETE_REQUIREMENT);
const can_complete_requirement = this.issuePermissionsService.hasPermission(userRole, IssueAction.COMPLETE_REQUIREMENT);
const can_edit_issue = this.issuePermissionsService.hasPermission(roles, IssueAction.EDIT_ISSUE);
const can_change_status = this.issuePermissionsService.hasPermission(roles, IssueAction.CHANGE_STATUS);
const can_assign_creator = this.issuePermissionsService.hasPermission(roles, IssueAction.ASSIGN_CREATOR);
const can_set_done = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_DONE);
const can_set_on_review = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_ON_REVIEW);
const can_set_estimate = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_ESTIMATE);
const can_set_priority = this.issuePermissionsService.hasPermission(roles, IssueAction.SET_PRIORITY);
const can_delete_issue = this.issuePermissionsService.hasPermission(roles, IssueAction.DELETE_ISSUE);
const can_create_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.CREATE_REQUIREMENT);
const can_edit_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.EDIT_REQUIREMENT);
const can_delete_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.DELETE_REQUIREMENT);
const can_complete_requirement = this.issuePermissionsService.hasPermission(roles, IssueAction.COMPLETE_REQUIREMENT);
let can_move_issue = false;
const issueProject = await this.projectRepository.findByHash(issue.project_hash);
@@ -174,8 +174,8 @@ export class PermissionsService {
const projectOpenForMove = st === ProjectStatus.PENDING || st === ProjectStatus.ACTIVE;
can_move_issue = projectPerms.can_manage_issues && projectOpenForMove;
}
// Получаем допустимые переходы статусов для текущего статуса и роли
const allowed_status_transitions = this.issuePermissionsService.getAllowedStatusTransitions(userRole, issue.status);
// Получаем допустимые переходы статусов для текущего статуса (UNION по ролям).
const allowed_status_transitions = this.issuePermissionsService.getAllowedStatusTransitions(roles, issue.status);
return {
can_edit_issue,
@@ -229,8 +229,8 @@ export class PermissionsService {
const username = currentUser.username;
// Определяем роль пользователя для этого проекта
const userRole = await this.projectPermissionsService.getProjectUserRole(username, project, currentUser.role);
// Определяем НАБОР project-ролей пользователя (UNION-семантика).
const roles = await this.projectPermissionsService.getProjectUserRole(username, project, currentUser.role);
// Проверяем наличие clearance (доступа к проекту)
const has_clearance = project.coopname
@@ -243,30 +243,30 @@ export class PermissionsService {
: false;
// Рассчитываем права на основе матрицы доступа
const can_edit_project = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.EDIT_PROJECT);
const can_manage_issues = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.MANAGE_ISSUES);
const can_edit_project = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.EDIT_PROJECT);
const can_manage_issues = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.MANAGE_ISSUES);
const can_change_project_status = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.CHANGE_PROJECT_STATUS
);
const can_delete_project = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.DELETE_PROJECT);
const can_set_master = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.SET_MASTER);
const can_manage_authors = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.MANAGE_AUTHORS);
const can_set_plan = this.projectPermissionsService.hasProjectPermission(userRole, ProjectAction.SET_PLAN);
const can_delete_project = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.DELETE_PROJECT);
const can_set_master = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.SET_MASTER);
const can_manage_authors = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.MANAGE_AUTHORS);
const can_set_plan = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.SET_PLAN);
const can_create_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.CREATE_REQUIREMENT
);
const can_edit_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.EDIT_REQUIREMENT
);
const can_delete_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.DELETE_REQUIREMENT
);
const can_complete_requirement = this.projectPermissionsService.hasProjectPermission(
userRole,
roles,
ProjectAction.COMPLETE_REQUIREMENT
);
@@ -30,52 +30,62 @@ export class ProjectPermissionsService {
) {}
/**
* Определяет роль пользователя для конкретного проекта
* Определяет НАБОР ролей пользователя для конкретного проекта.
*
* Один пользователь может одновременно быть, например, членом совета и
* соавтором — итоговые права на проект считаются как UNION разрешений по
* всем его ролям (см. {@link IssueAccessPolicyService.hasProjectPermission}).
*
* @param username - имя пользователя
* @param project - проект
* @param userRole - роль пользователя в системе (chairman, member, etc.)
* @returns роль пользователя для проекта
* @param userRole - системная роль пользователя (chairman / member / ...)
* @returns множество project-ролей пользователя; пустого множества не бывает —
* минимум {@link ProjectUserRole.GUEST}.
*/
async getProjectUserRole(username: string | undefined, project: any, userRole?: string): Promise<ProjectUserRole> {
// Гости не имеют доступа
async getProjectUserRole(
username: string | undefined,
project: any,
userRole?: string
): Promise<Set<ProjectUserRole>> {
const roles = new Set<ProjectUserRole>();
if (!username) {
return ProjectUserRole.GUEST;
roles.add(ProjectUserRole.GUEST);
return roles;
}
// Председатель совета имеет полные права
// Системные роли совета — добавляем независимо от project-specific ролей.
if (userRole === 'chairman') {
return ProjectUserRole.CHAIRMAN;
roles.add(ProjectUserRole.CHAIRMAN);
} else if (userRole === 'member') {
roles.add(ProjectUserRole.BOARD_MEMBER);
}
// Члены совета имеют расширенные права
if (userRole === 'member') {
return ProjectUserRole.BOARD_MEMBER;
}
// Проверяем, является ли пользователь мастером проекта
// Project-specific роли — собираем все, что верны.
const isMaster = await this.isProjectMaster(username, project.coopname, project.project_hash);
if (isMaster) {
return ProjectUserRole.MASTER;
roles.add(ProjectUserRole.MASTER);
}
// Проверяем, является ли пользователь автором проекта
const segment = await this.segmentRepository.findOne({
username,
project_hash: project.project_hash,
coopname: project.coopname,
});
if (segment?.is_author) {
return ProjectUserRole.AUTHOR;
roles.add(ProjectUserRole.AUTHOR);
}
// Проверяем, является ли пользователь участником проекта
const contributor = await this.contributorRepository.findByUsernameAndCoopname(username, project.coopname);
if (contributor && contributor.appendixes?.includes(project.project_hash)) {
return ProjectUserRole.CONTRIBUTOR;
roles.add(ProjectUserRole.CONTRIBUTOR);
}
// По умолчанию - гость
return ProjectUserRole.GUEST;
if (roles.size === 0) {
roles.add(ProjectUserRole.GUEST);
}
return roles;
}
/**
@@ -90,34 +100,18 @@ export class ProjectPermissionsService {
return project?.master === username;
}
/**
* Проверяет разрешение на действие
* @param userRole - роль пользователя
* @param action - действие
* @returns true если действие разрешено
*/
hasPermission(userRole: UserRole, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(userRole, action);
/** UNION-проверка прав на действие над задачей по набору ролей. */
hasPermission(roles: Iterable<UserRole>, action: IssueAction): boolean {
return this.issueAccessPolicyService.hasPermission(roles, action);
}
/**
* Проверяет разрешение на действие над проектом
* @param userRole - роль пользователя для проекта
* @param action - действие над проектом
* @returns true если действие разрешено
*/
hasProjectPermission(userRole: ProjectUserRole, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(userRole, action);
/** UNION-проверка прав на действие над проектом по набору ролей. */
hasProjectPermission(roles: Iterable<ProjectUserRole>, action: ProjectAction): boolean {
return this.issueAccessPolicyService.hasProjectPermission(roles, action);
}
/**
* Проверяет разрешение на переход между статусами
* @param userRole - роль пользователя
* @param currentStatus - текущий статус
* @param newStatus - новый статус
* @returns true если переход разрешен
*/
canTransitionStatus(userRole: UserRole, currentStatus: any, newStatus: any): boolean {
return this.issueAccessPolicyService.canTransitionStatus(userRole, currentStatus, newStatus);
/** UNION-проверка перехода статусов по набору ролей. */
canTransitionStatus(roles: Iterable<UserRole>, currentStatus: any, newStatus: any): boolean {
return this.issueAccessPolicyService.canTransitionStatus(roles, currentStatus, newStatus);
}
}
@@ -600,27 +600,29 @@ export const PROJECT_PERMISSION_MATRIX: Record<ProjectUserRole, Record<ProjectAc
/**
* Сервис политик доступа к задачам
* Содержит бизнес-правила доступа к задачам в доменном слое
*
* Все проверки прав работают по UNION-семантике: пользователь может одновременно
* нести несколько ролей (например, член совета + соавтор), и итоговое разрешение —
* это OR по всем его ролям. Так лечится регрессия, при которой board_member
* перебивал project-specific роль и обнулял её права.
*/
export class IssueAccessPolicyService {
/**
* Проверяет, есть ли у пользователя разрешение на действие
* @param userRole - роль пользователя
* @param action - действие
* @returns true если действие разрешено
* Проверяет, есть ли у пользователя разрешение на действие.
* UNION по ролям: достаточно одной роли с allow=true.
*/
hasPermission(userRole: UserRole, action: IssueAction): boolean {
return PERMISSION_MATRIX[userRole][action];
hasPermission(roles: Iterable<UserRole>, action: IssueAction): boolean {
for (const role of roles) {
if (PERMISSION_MATRIX[role]?.[action]) return true;
}
return false;
}
/**
* Проверяет разрешение на переход между статусами
* @param userRole - роль пользователя
* @param currentStatus - текущий статус
* @param newStatus - новый статус
* @returns true если переход разрешен
* Проверяет разрешение на переход между статусами.
* UNION по ролям: достаточно одной роли, для которой переход разрешён.
*/
canTransitionStatus(userRole: UserRole, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
canTransitionStatus(roles: Iterable<UserRole>, currentStatus: IssueStatus, newStatus: IssueStatus): boolean {
if (currentStatus === newStatus) {
return true; // Можно "перейти" в тот же статус
}
@@ -635,40 +637,42 @@ export class IssueAccessPolicyService {
return false;
}
return newStatusTransitions[userRole] || false;
for (const role of roles) {
if (newStatusTransitions[role]) return true;
}
return false;
}
/**
* Проверяет, есть ли у пользователя разрешение на действие над проектом
* @param userRole - роль пользователя для проекта
* @param action - действие над проектом
* @returns true если действие разрешено
* Проверяет, есть ли у пользователя разрешение на действие над проектом.
* UNION по ролям.
*/
hasProjectPermission(userRole: ProjectUserRole, action: ProjectAction): boolean {
return PROJECT_PERMISSION_MATRIX[userRole][action];
hasProjectPermission(roles: Iterable<ProjectUserRole>, action: ProjectAction): boolean {
for (const role of roles) {
if (PROJECT_PERMISSION_MATRIX[role]?.[action]) return true;
}
return false;
}
/**
* Получает список допустимых статусов для перехода из текущего статуса для данной роли
* @param userRole - роль пользователя
* @param currentStatus - текущий статус задачи
* @returns массив допустимых статусов для перехода (исключая текущий статус)
* Получает список допустимых статусов для перехода из текущего статуса.
* UNION по ролям: статус попадает в список, если хотя бы одна роль его разрешает.
*/
getAllowedStatusTransitions(userRole: UserRole, currentStatus: IssueStatus): IssueStatus[] {
getAllowedStatusTransitions(roles: Iterable<UserRole>, currentStatus: IssueStatus): IssueStatus[] {
const transitions = STATUS_TRANSITION_MATRIX[currentStatus];
if (!transitions) {
return [];
}
const rolesArr = [...roles];
const allowedStatuses: IssueStatus[] = [];
for (const [newStatus, rolePermissions] of Object.entries(transitions)) {
// Исключаем текущий статус из списка доступных переходов
if (newStatus === currentStatus) {
continue;
}
const isAllowed = rolePermissions[userRole] || false;
const isAllowed = rolesArr.some((r) => rolePermissions[r]);
if (isAllowed) {
allowedStatuses.push(newStatus as IssueStatus);
}
@@ -0,0 +1,109 @@
import {
IssueAccessPolicyService,
IssueAction,
ProjectAction,
ProjectUserRole,
UserRole,
} from '../../../src/extensions/capital/domain/services/access-policy.service';
import { IssueStatus } from '../../../src/extensions/capital/domain/enums/issue-status.enum';
describe('IssueAccessPolicyService — UNION-семантика по нескольким ролям', () => {
const policy = new IssueAccessPolicyService();
describe('hasProjectPermission', () => {
it('GUEST: запрещены все project-actions', () => {
const roles = new Set([ProjectUserRole.GUEST]);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(false);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_PROJECT)).toBe(false);
});
it('AUTHOR (соавтор): EDIT_REQUIREMENT=true, CHANGE_PROJECT_STATUS=false', () => {
const roles = new Set([ProjectUserRole.AUTHOR]);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.CHANGE_PROJECT_STATUS)).toBe(false);
});
it('BOARD_MEMBER (чистый): CHANGE_PROJECT_STATUS=true, EDIT_REQUIREMENT=false (как раньше)', () => {
const roles = new Set([ProjectUserRole.BOARD_MEMBER]);
expect(policy.hasProjectPermission(roles, ProjectAction.CHANGE_PROJECT_STATUS)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(false);
});
// Главный кейс — починка регрессии «соавтор-член-совета не может править артефакты»
it('BOARD_MEMBER + AUTHOR: получает права обеих ролей', () => {
const roles = new Set([ProjectUserRole.BOARD_MEMBER, ProjectUserRole.AUTHOR]);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.CREATE_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.DELETE_REQUIREMENT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.CHANGE_PROJECT_STATUS)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.SET_MASTER)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.MANAGE_ISSUES)).toBe(true);
});
it('CHAIRMAN + MASTER: union (председатель остаётся всемогущим, мастер ничего не отнимает)', () => {
const roles = new Set([ProjectUserRole.CHAIRMAN, ProjectUserRole.MASTER]);
expect(policy.hasProjectPermission(roles, ProjectAction.DELETE_PROJECT)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.SET_MASTER)).toBe(true);
expect(policy.hasProjectPermission(roles, ProjectAction.EDIT_REQUIREMENT)).toBe(true);
});
it('пустое множество: всё запрещено', () => {
expect(policy.hasProjectPermission(new Set(), ProjectAction.EDIT_REQUIREMENT)).toBe(false);
});
});
describe('hasPermission (issue-actions)', () => {
it('CONTRIBUTOR (чистый): EDIT_REQUIREMENT=false', () => {
expect(policy.hasPermission(new Set([UserRole.CONTRIBUTOR]), IssueAction.EDIT_REQUIREMENT)).toBe(false);
});
it('BOARD_MEMBER + AUTHOR на задаче: EDIT_REQUIREMENT=true (через AUTHOR)', () => {
const roles = new Set([UserRole.BOARD_MEMBER, UserRole.AUTHOR]);
expect(policy.hasPermission(roles, IssueAction.EDIT_REQUIREMENT)).toBe(true);
expect(policy.hasPermission(roles, IssueAction.CHANGE_STATUS)).toBe(true);
});
it('SUBMASTER + AUTHOR: SET_ON_REVIEW (через SUBMASTER) + EDIT_REQUIREMENT (через AUTHOR)', () => {
const roles = new Set([UserRole.SUBMASTER, UserRole.AUTHOR]);
expect(policy.hasPermission(roles, IssueAction.SET_ON_REVIEW)).toBe(true);
expect(policy.hasPermission(roles, IssueAction.EDIT_REQUIREMENT)).toBe(true);
});
});
describe('canTransitionStatus / getAllowedStatusTransitions', () => {
it('BACKLOG → DONE доступен MASTER, недоступен AUTHOR; union → доступен', () => {
expect(
policy.canTransitionStatus(new Set([UserRole.AUTHOR]), IssueStatus.BACKLOG, IssueStatus.DONE)
).toBe(false);
expect(
policy.canTransitionStatus(new Set([UserRole.MASTER]), IssueStatus.BACKLOG, IssueStatus.DONE)
).toBe(true);
expect(
policy.canTransitionStatus(
new Set([UserRole.AUTHOR, UserRole.MASTER]),
IssueStatus.BACKLOG,
IssueStatus.DONE
)
).toBe(true);
});
it('getAllowedStatusTransitions: union собирает разрешения по всем ролям', () => {
const authorOnly = policy.getAllowedStatusTransitions(new Set([UserRole.AUTHOR]), IssueStatus.BACKLOG);
const masterOnly = policy.getAllowedStatusTransitions(new Set([UserRole.MASTER]), IssueStatus.BACKLOG);
const union = policy.getAllowedStatusTransitions(
new Set([UserRole.AUTHOR, UserRole.MASTER]),
IssueStatus.BACKLOG
);
const expectedUnion = Array.from(new Set([...authorOnly, ...masterOnly])).sort();
expect([...union].sort()).toEqual(expectedUnion);
// Sanity: master может больше автора → union строго ⊇ author-only
for (const s of authorOnly) expect(union).toContain(s);
});
it('переход в тот же статус — true, даже для GUEST', () => {
expect(
policy.canTransitionStatus(new Set([UserRole.GUEST]), IssueStatus.BACKLOG, IssueStatus.BACKLOG)
).toBe(true);
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
+14 -2
View File
@@ -89,9 +89,11 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
human_name: 'Возврат паевого взноса пайщику' },
// capital (ADR-009: единые программные кошельки `w.cap.blago`/`w.cap.gen`)
// IMPORT и ACCEPT_PROPERTY — Dr 04 (НМА), не Dr 51: импорт/акт-2 фиксируют
// имущественный вклад как РИД, не деньги. Денежные взносы в Благорост — INVEST.
{ code: 'o.cap.import', process_type: 'p.cap.import', contract: 'capital',
name: 'IMPORT', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
debit: 51, credit: 80,
debit: 4, credit: 80,
human_name: 'Паевой взнос по ЦПП «Благорост» (офлайн-импорт)' },
{ code: 'o.cap.invest', process_type: 'p.cap.invest', contract: 'capital',
@@ -111,9 +113,19 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
{ code: 'o.cap.actprp', process_type: 'p.cap.prop', contract: 'capital',
name: 'ACCEPT_PROPERTY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.blago',
debit: 51, credit: 80,
debit: 4, credit: 80,
human_name: 'Паевой взнос (имущественный) по программе «Благорост»' },
{ code: 'o.cap.preimp', process_type: 'p.cap.preimp', contract: 'capital',
name: 'PREIMP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.preimp',
debit: 4, credit: 80,
human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
{ code: 'o.cap.drppre', process_type: 'p.cap.import', contract: 'capital',
name: 'DROP_PREIMP', wallet_op: 'BURN', wallet_from: 'w.cap.preimp', wallet_to: null,
debit: 80, credit: 4,
human_name: 'Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт' },
{ code: 'o.cap.lend', process_type: 'p.cap.debt', contract: 'capital',
name: 'LEND', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.loan',
debit: 58, credit: 51,
@@ -33,6 +33,7 @@ export const LEDGER2_PROCESS_REGISTRY: readonly ProcessMeta[] = [
{ type: 'p.cap.debt', contract: 'capital', name: 'DEBT', human_name: 'Займ пайщику' },
{ type: 'p.cap.rid', contract: 'capital', name: 'RID', human_name: 'Приём РИД в паевой фонд' },
{ type: 'p.cap.prop', contract: 'capital', name: 'PROPERTY', human_name: 'Приём имущественного паевого взноса' },
{ type: 'p.cap.preimp', contract: 'capital', name: 'PREIMP', human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
{ type: 'p.cap.wthcap', contract: 'capital', name: 'WTHCAP', human_name: 'Возврат паевого из ЦПП «Благорост» в кошелёк' },
// marketplace
@@ -24,10 +24,12 @@ export const LEDGER2_WALLET_REGISTRY: readonly WalletMeta[] = [
{ name: "w.wal.member", human_name: "ЦК — членская часть пайщика", kind: "USER_SHARED" },
{ name: "w.cap.blago", human_name: "ЦПП «Благорост» — единый кошелёк программы у пайщика", kind: "USER_SHARED" },
{ name: "w.cap.gen", human_name: "ЦПП «Генератор» — единый кошелёк программы у пайщика", kind: "USER_SHARED" },
{ name: "w.cap.preimp", human_name: "Первичный учёт РИД-взносов до подписания договора УХД", kind: "USER_SHARED" },
{ name: "w.reg.entry", human_name: "Вступительные взносы", kind: "COOPERATIVE" },
{ name: "w.wal.wthdrw", human_name: "Возвраты паевых взносов пайщикам", kind: "COOPERATIVE" },
{ name: "w.sov.infra", human_name: "Членские взносы за инфраструктуру кооп. платформы", kind: "COOPERATIVE" },
{ name: "w.sov.delgte", human_name: "Делегатские членские взносы", kind: "COOPERATIVE" },
{ name: "w.sov.expns", human_name: "Хозяйственные расходы из числа целевого финансирования", kind: "COOPERATIVE" },
{ name: "w.cap.loan", human_name: "Выданные пайщикам беспроцентные займы", kind: "COOPERATIVE" },
{ name: "w.mkt.payout", human_name: "Выплаты поставщикам", kind: "COOPERATIVE" },
] as const
@@ -51,4 +53,5 @@ export const LEDGER2_USER_SHARED_PROGRAM_MAPPING: readonly ProgramWalletMapping[
{ wallet_name: "w.wal.member", required_program_id: 1, program_label: "ЦК" },
{ wallet_name: "w.cap.blago", required_program_id: 4, program_label: "Благорост" },
{ wallet_name: "w.cap.gen", required_program_id: 3, program_label: "Генератор" },
{ wallet_name: "w.cap.preimp", required_program_id: 0, program_label: null },
] as const
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/desktop",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
"productName": "Desktop App",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/docs",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": true,
"packageManager": "pnpm@9.9.0",
"description": "Документация для монорепозитория",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/factory",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Фабрика юридических документов кооператива",
"author": "Alex Ant <chairman.voskhod@gmail.com>",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/inter",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Контракты и DI-токены для связи расширений контроллера без прямых зависимостей",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "migrator",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Утилита миграции данных между версиями",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/notifications",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"description": "Библиотека типобезопасных workflow-уведомлений для Novu",
"type": "module",
"private": false,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pkg-placeholder",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pkg-placeholder",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"license": "MIT",
"devDependencies": {
"@antfu/eslint-config": "^2.16.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/parser",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "Индексатор блокчейна через State History Plugin",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/sdk",
"type": "module",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": false,
"packageManager": "pnpm@9.9.0",
"description": "TypeScript SDK для работы с API кооператива",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/setup",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"private": true,
"description": "Интерактивный мастер настройки окружения",
"main": "index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2026.4.30-alpha-1",
"version": "2026.5.10-4",
"packages": [
"components/*",
"components/controller/src/plugins/*"
+23
View File
@@ -1,5 +1,28 @@
{
"name": "monocoop",
"packageManager": "pnpm@10.33.0",
"pnpm": {
"onlyBuiltDependencies": [
"@apollo/protobufjs",
"@eosrio/node-abieos",
"@nestjs/core",
"@parcel/watcher",
"@scarf/scarf",
"core-js",
"cpu-features",
"electron",
"esbuild",
"libxmljs2",
"msgpackr-extract",
"nx",
"protobufjs",
"puppeteer",
"simple-git-hooks",
"ssh2",
"unrs-resolver",
"vue-demi"
]
},
"dependencies": {
"@coopenomics/boot": "workspace:*",
"@coopenomics/cleos": "workspace:*",