Compare commits

..

28 Commits

Author SHA1 Message Date
ant b84b02f1b4 Merge pull request 'sync-arch: parser2 ^1.2.0 + прокидывание block_time из ActionEvent/DeltaEvent [C28-13]' (#92) from parser2-block-time-and-version into parser2
Typecheck / desktop (pull_request) Successful in 12m46s
Typecheck / controller (pull_request) Successful in 12m28s
Reviewed-on: #92
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-05 10:45:08 +00:00
coopops c33ec53230 [C28-13][@ant] feat(controller): parser2 ^1.2.0 + прокидывание block_time из ActionEvent/DeltaEvent
- @coopenomics/parser2: "1.1.0" → "^1.2.0" — снят точный pin, апгрейд парсера = npm install
- IAction/IDelta: + block_time?: string (ISO-8601, parser1 не давал, parser2 1.2.0 отдаёт)
- parser2-event.mapper.ts: пробрасывает event.block_time в IAction.block_time и IDelta.block_time
- mapper.test: 2 новых проверки на block_time (delta + action) — 9/9 зелёные

Поверх ветки parser2 (#68). Native-delta остаётся явно проигнорированной (как у parser1), это отдельное расширение интерфейса при необходимости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:05:31 +00:00
ant 7dcca3a2ae Merge pull request 'Эпик 4 sync-arch: правильная обработка форков блокчейна (релиз 1.1.2) [C28-14]' (#54) from parser2-epic-4-forks into parser2
Typecheck / desktop (pull_request) Successful in 13m20s
Typecheck / controller (pull_request) Successful in 13m21s
Reviewed-on: #54
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-03 08:32:46 +00:00
ant 70752b2caa Merge pull request 'sync-arch: явные ошибки при unknown contract version / status (Story 6.5) [C28-16]' (#57) from parser2-epic-6-canonical-storage into parser2-epic-4-forks
Reviewed-on: #57
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-03 08:31:35 +00:00
coopops 8c5568bcf8 [C28-16][@ant] revert: откат Story 6.1 (namespace bc + replaceBc) + Story 6.2 (signedDocumentFields) — вынос за MVP-релиз parser2
Цель релиза 1.1.2 — «затащить parser2 нормально», т.е. заменить транспорт parser1→parser2.
Эпик 6 «единый порядок хранения данных» — отдельная архитектурная санитация, не связана с
заменой транспорта. Включение её в MVP-релиз привнесло инвазивные изменения:

- Story 6.1 (namespace `db`/`bc` + `replaceBc` + переписанный ProjectDomainEntity) вводит
  двойной стандарт: одна entity на новом паттерне, 21 entity на старом. Потребители
  читают `project.master` (плоский флэт) — частичная миграция создаёт регрессии у resolver'ов.
  Полная миграция = большой blast radius на 22 entity + Epic 9.5 backlog. Откладывается
  в отдельный sync-arch sanitation-эпик.

- Story 6.2 (declarative `signedDocumentFields` + `normalizeSignedDocuments` + path-parser)
  — DRY-рефакторинг без функциональной выгоды. AppendixDeltaMapper и так руками вызывал
  `convertChainDocumentToDomainFormat`. Польза появится когда подписанных полей станет
  много — пока их одно поле в одной точке.

Остаётся: Story 6.5 (UnsupportedContractVersionError + auditUnknownStatus +
`BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT`). Очевидный фикс silent loss при schema drift,
не инвазивный.

Удалено: composite-entity.contract.test.ts, signed-document-normalization.test.ts,
delta-mapper-signed-doc.contract.test.ts. CLAUDE.md секция Composite-Entity ADR-008
помечена как «будущая цель, не сейчас».

10 jest blockchain unit suites / 64 tests зелёные. tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 10:37:48 +00:00
coopops 30b79ad1f7 [C28-16][@ant] revert: откат Story 6.3 (zod) + 6.4 (_checksum) — pre-mature и за гранью sync-слоя
Story 6.3 (per-doc-type zod schemas): валидация `signatures.length === 1/2` зашивала
документную семантику в нормализатор parser-event. Число и валидность подписей — домен
контракта (`require_auth`), не бэкенда. При расширении доктайпов контракта (1→2 подписи)
sync падал бы на структурно-валидной цепи. Структурный transform `meta: JSON-string → object`
(Story 6.2) остаётся.

Story 6.4 (`_checksum` колонка + sha256(canonical-json(bc))): потребитель — Epic 7 nightly
snapshot и Epic 8 reconciliation — ещё не реализованы. Колонка хранила бы пустую нагрузку
+ +20% storage + лишний sha256 на каждом save/update. Принцип «не добавлять контрольных
полей до появления потребителя». Алгоритм canonicalStringify заведём как часть Epic 8.

Удалено: src/shared/sync/{checksum.util.ts, signed-document-schemas.ts} + 3 теста +
applyBcChecksum + колонка _checksum + поле _checksum на BaseDomainEntity. Story 6.1
(namespace db/bc + replaceBc), 6.2 (declarative signedDocumentFields + meta transform),
6.5 (UnsupportedContractVersionError + auditUnknownStatus) сохраняются.

13 jest blockchain unit suites / 196 tests зелёные. tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:37:57 +00:00
coopops 87747d1782 [C28-16][@ant] feat: UnsupportedContractVersionError + auditUnknownStatus — Story 6.5
Новые shared/sync/errors:
- UnsupportedContractVersionError(entityName, ctx{contract,table,primary_key,
  block_num}) — носит контекст для DLQ-операторской диагностики.
- auditUnknownStatus(entityName, receivedStatus, logger, allowedStatuses?) —
  фиксирует unknown-status drift в audit-trail с ожидаемыми вариантами.

AbstractEntitySyncService.processDelta: mapper вернул null → logger.error
("UNSUPPORTED_CONTRACT_VERSION", ctx) ВСЕГДА (раньше был silent warn). В strict-mode
(config.blockchain.unsupported_version_strict=true) дополнительно throw — парсер не
ACK'нет дельту, DLQ сработает. UnsupportedContractVersionError из try/catch
пробрасывается дальше; остальные ошибки по-прежнему логируются и return null.

config.ts: BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT (default false) → blockchain
.unsupported_version_strict. Default false — не ломать прод немедленно;
включается на стенде после подтверждения отсутствия schema drift.

ProjectDomainEntity.mapStatusToDomain эталонно — default ветка вместо silent
UNDEFINED-возврата вызывает auditUnknownStatus с полным списком ожидаемых:
[pending,active,voting,result,finalized,cancelled]. Остальные mapStatusToDomain
(state, vote, segment, debt и т.д.) — Epic 9.5.

unsupported-version-explicit-error.test.ts (6) — UnsupportedContractVersionError
конструктор/контекст; auditUnknownStatus с/без allowedStatuses;
processDelta non-strict (logger.error + null), strict (throw),
happy-path (handleSyncDelta вызывается).

229/229 blockchain unit зелёные.

Эпик 6 закрыт целиком: 6.1 namespace, 6.2 normalizeSignedDocuments, 6.3 Zod schemas,
6.4 checksum, 6.5 explicit errors. Release 1.1.2 движется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:52:40 +00:00
coopops f7a182e51f [C28-16][@ant] feat: detrministic _checksum sha256(bc canonical-json) — Story 6.4
checksum.util.ts: canonicalStringify(value) (рекурсивная сортировка ключей объектов,
порядок массивов сохраняется, bigint → string, undefined → "null"); computeBcChecksum
→ sha256-hex 64 chars от canonical-stringified bc-namespace. Без внешних зависимостей.

BaseTypeormEntity получает колонку `_checksum varchar(64) nullable` —
legacy записи без bc-namespace получают хэш от "null" (стабильно, но не несёт
информации); после миграции Epic 9.5 на namespace все блокчейн-зеркала получат
содержательный checksum. BaseDomainEntity получает поле `_checksum?: string | null`.

BaseBlockchainRepository.save и .update вызывают protected applyBcChecksum(domain,
typeormEntity) ПОСЛЕ mapper.toEntity и ДО repository.save — proseться через единую
точку, не лезем в 22 mapper'а. Поле bc-namespace → reconciliation Epic 8.2
сравнивает только то, что реально в цепи (локальные db-поля типа matrix_room_id
из цепи не получаются и в checksum их быть не должно).

checksum.util.test.ts (14) — canonicalStringify: primitives/array/nested/bigint/sort;
computeBcChecksum: 64 hex chars / детерминизм / change-detection / null-stable /
порядок массивов / контрольный фиксированный хэш на nested-структуре (catch для
несовместимого изменения алгоритма).

base-repo-checksum.contract.test.ts (3) — bc заданный → checksum по нему;
bc undefined → checksum от null; повторный вызов → одинаковый _checksum.

223/223 blockchain unit зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:44:55 +00:00
coopops 9774abb296 [C28-16][@ant] feat: Zod per-doc-type schemas + valid-pre-transform — Story 6.3
Новый файл shared/sync/signed-document-schemas.ts: signatureInfoSchema (ISignatureInfo)
+ три per-doc-type схемы IChainDocument2:
- chainDocumentSchema — default ≥1 подпись (стандартный мультисиг кооператива).
- singleSignatureChainDocumentSchema — ровно 1 (одиночный автор).
- twoSignatureChainDocumentSchema — ровно 2 (двухподписные акты signsupp/signchair
  и signact1/signact2; OQ-13: ВСЕ подписанты массивом, primary не выделяется).

SignedDocField (Story 6.2) расширен optional schema?: ZodTypeAny — backward-compat
для legacy полей без валидации. AbstractBlockchainDeltaMapper.normalizeSignedDocuments
делает schema.parse ДО transform: при schema-drift цепи (новая структура IChainDocument2
из коопконтракта) — Zod бросает ZodError, mapper.try/catch отдаёт null + warn, Story 6.5
повысит до alert. До этого PR drift молча проходил, meta оставался JSON-строкой.

Эталон: appendix-delta.mapper.ts — добавлен singleSignatureChainDocumentSchema
(приложение к ТЭМ имеет одного автора).

signed-document-schemas.test.ts (16) — каждая схема: valid → ok, обязательные поля
отсутствуют → fail, неверное число подписей → fail. signed-document-normalization
расширен 3 тестами на schema-pre-transform: валидная schema → нормализация проходит,
single-signature на 2 подписи → ZodError, missing hash → ZodError + meta не обновлена
(transform не вызвался).

206/206 blockchain unit зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:27:00 +00:00
coopops 724cd381cc [C28-16][@ant] feat: AbstractDeltaMapper.normalizeSignedDocuments + SignedDocField — Story 6.2
AbstractBlockchainDeltaMapper получает protected readonly signedDocumentFields
ReadonlyArray<SignedDocField> (default []) + helper normalizeSignedDocuments(data).
SignedDocField описывает путь к IChainDocument2-полю в TBlockchainData; bracket-
нотация поддерживает массивы: `appendix` (top-level), `statement.attachments[]
.signed_attachment` (nested-array, E12). parseSignedDocPath → PathSegment[],
applyAtPath in-place трансформирует через
DomainToBlockchainUtils.convertChainDocumentToDomainFormat.

appendix-delta.mapper.ts — эталон: ручной convertChainDocumentToDomainFormat
(value.appendix) заменён декларацией signedDocumentFields = [{ path: 'appendix' }]
+ this.normalizeSignedDocuments({ ...value }).

signed-document-normalization.test.ts (16) — парсер 4 кейса + normalize 7 кейсов
(top-level, nested-array, missing-field no-op, multiple fields, empty config,
null/undefined, immutability caveat).

delta-mapper-signed-doc.contract.test.ts (3) — guard: mapper с непустым
signedDocumentFields обязан вызвать this.normalizeSignedDocuments — иначе
silent data corruption (meta остаётся JSON-строкой, Story 6.4 checksum
не сходится).

IPFS lazy resolver (E14) и миграция остальных mapper'ов с ручной нормализацией —
вне scope, тречится Epic 9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:21:09 +00:00
coopops 5aa1a21a97 [C28-16][@ant] feat: namespace db/bc/derived + replaceBc helper — Story 6.1
BaseDomainEntity получает generic <TDb, TBc>: this.db (shallow-copy databaseData),
this.bc (nullable, заполняется через utility replaceBc()), block_num/present/status
остаются на базе (hot-path stale-delta guard). Backward-compat: TBc=unknown по
дефолту — 22 legacy entity компилируются без изменений.

replaceBc вынесен utility-функцией, не методом класса — protected/public метод
ломает structural typing там, где Omit<XxxDomainEntity,...> используется в сигнатурах
репо (state-typeorm-repo).

ProjectDomainEntity — эталон: PROJECT_BC_KEYS как const satisfies, updateFromBlockchain
вызывает replaceBc(this, blockchainData, PROJECT_BC_KEYS) вместо запрещённого
Object.assign(this, blockchainData). Плоские поля остаются для legacy compat —
миграция остальных потребителей на entity.bc.* трекется Epic 9.5.

composite-entity.contract.test.ts — рекурсивно сканит src/**/*.entity.ts, для
не-legacy entity запрещает Object.assign(this, ...). Legacy allowlist на 22 файла
с явной отсылкой на Epic 9.5. 99/99 entity prove'нуты + Project явно проверен
на replaceBc + PROJECT_BC_KEYS.

controller/CLAUDE.md — секция Composite-Entity (ADR-008) актуализирована: BC_KEYS
с satisfies ReadonlyArray<keyof IXxxBlockchainData> — canonical-ordered источник
для Story 6.4 checksum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:11:34 +00:00
coopops 933e5b5ffe [C28-14][@ant] feat: архив инвалидированных сущностей + retention LIB-1000 — Story 4.4
handleFork теперь не делает hard-delete, а атомарно переносит снесённые форком live-ряды
в invalidated_entities и инвалидированные снимки entity_versions в invalidated_entity_versions
(две таблицы — разные семантики). Порядок: archiveInvalidatedSince → restoreFromVersions →
archiveInvalidatedVersionsSince. forkEventId (controller-формат chain:fork:N:short) пробрасывается
от handleEvent → processFork → ForkRegistry.runAll → syncer.handleFork → архив.fork_event_id
для forensic-группировки.

BlockchainArchiveRetentionService (@Cron, default ежечасно) читает LIB через
BlockchainService.getInfo() и удаляет архив старше LIB-1000 блоков. RETENTION_HORIZON_BLOCKS=1000
хардкод (свойство сети, не оператора). Env-переключатели:
BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED (default true), BLOCKCHAIN_ARCHIVE_RETENTION_CRON.

EntityVersioningService расширен archive-методами под DataSource.transaction. Контракт
IForkAwareSyncer.handleFork(forkBlockNum, forkEventId?) — обратно совместим (2-й optional).
IBlockchainSyncRepository.archive* — optional, fallback на старую find+delete для off-chain
(allowlist Story 4.3).

ScheduleModule.forRoot() добавлен в app.module (раньше не было).

Tests: 14 новых unit (entity-archive, retention, handleFork contract) + 52 регрессионных
(processFork, fork-registry, base-repo-contract, no-onevent) — 66 зелёных, tsc=0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:43:10 +00:00
coopops d886d780fc [C28-14][@ant] test: contract guard BaseBlockchainRepository + block_num типизация — Story 4.3
Audit-проход без миграции рантайма. Полная карта entity/repo и сравнений
block_num — в blago _bmad-output/tracks/sync-arch/.../audit-report-4-3.md.

Изменения в коде:

1. tests/unit/blockchain/base-blockchain-repository.contract.test.ts — НОВЫЙ
   regression-guard. Сканирует все *.typeorm-entity.ts extends BaseTypeormEntity
   и для каждой ищет *.typeorm-repository.ts extends BaseBlockchainRepository.
   Allowlist OFF_CHAIN_BASE_ENTITIES для 5 off-chain артефактов (comment,
   cycle, issue, story, time-entry) — у них block_num унаследован vestigial,
   синкера нет, форк их не откатывает (корректно). Тест ловит регрессию,
   если кто-то добавит блокчейн-mirror entity без BaseBlockchainRepository
   — иначе entity_versions молча перестанут писаться, форк превратится в
   hard delete (silent data loss).

2. controller/CLAUDE.md — два новых правила:
   (a) разнобой типов block_num: BaseTypeormEntity (capital+shared) использует
       integer+number — корректно; ActionEntity/DeltaEntity/ForkEntity/
       SyncStateEntity — bigint+number type-mismatch (PG возвращает string,
       TS говорит number). Hot-path везде явно Number() либо PG bind, так
       что runtime safe. Технический долг в Epic 9 — bigint Transformer.
   (b) контракт «entity с block_num → repo extends BaseBlockchainRepository»
       + ссылка на regression test + allowlist OFF_CHAIN_BASE_ENTITIES.

3. tests/unit/blockchain/base-blockchain-repository.contract.test.ts:
   нюанс — entityKindFromFileName нормализует суффикс -typeorm (chairman
   approval-typeorm.entity.ts vs approval.typeorm-repository.ts mismatch
   имён файлов).

Findings (полное в audit-report-4-3.md):
- 25 entity extends BaseTypeormEntity; 20 имеют BaseBlockchainRepository.
- 5 без BaseBlockchainRepository — off-chain артефакты (легитимно).
- 4 infra entity с bigint+number type-mismatch (runtime safe, тех. долг).
- Direct typeormRepo.save для блокчейн-зеркал — НЕ найдено (контракт соблюдён).
- Все сравнения block_num в sync-core используют Number() или PG bind.

Tests: 2/2 contract-test зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:51:22 +00:00
coopops 68b37efbe9 [C28-14][@ant] refactor: снять DEC-013 pause-barrier и @OnEvent('fork::*') — Story 4.2
После Story 4.1 ForkRegistry уже даёт sequential rollback всех syncer'ов через
pull-DiscoveryService, а saveFork идёт прямым вызовом из processFork. Старый
broadcast-путь через EventEmitter ('fork::*' + emitAsyncWithTimeout с TTL
force-resume из Story 1.3 DEC-013) — рудимент: дублирует saveFork (через
BlockchainEventHandlerService.handleForkEvent), повторно зовёт handleFork в
каждом syncer'е (idempotent no-op после ForkRegistry, но лишний DB-traffic).

Удалено:
- @OnEvent('fork::*') + handle*Fork методы в 20 файлах:
  · 3 в infrastructure/database/typeorm/blockchain/services/ (user-agreement, user-wallet, agreement)
  · 16 в extensions/capital/application/syncers/
  · 1 в extensions/chairman/infrastructure/blockchain/services/ (approval)
  · 1 дубль saveFork в domain/parser/services/blockchain-event-handler.service.ts
  · orphan dead-метод в application/wallet/services/program-wallet-sync.service.ts
- Шаг 4 (deprecated emitAsyncWithTimeout) из BlockchainConsumerService.processFork.
- BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS из config/config.ts (zod + config объект).
- Упоминание env-var из controller/CLAUDE.md.

Что осталось:
- EventsService.emitAsyncWithTimeout — определение в infrastructure/events/events.service.ts.
  Каллеров в src/ нет; не удаляю утилиту — возможно пригодится для Epic 5
  (waitForDelta / pool retry). Удалят на следующем рефакторе если не использована.
- AbstractEntitySyncService.handleFork остаётся: его теперь зовёт только ForkRegistry.
- @OnEvent('action::*') и @OnEvent('delta::*') в BlockchainEventHandlerService —
  валидный dispatch pipeline (ADR-002), не трогаем.

Tests:
- processFork.test.ts: убраны expect'ы на emit; новый regression test что
  events.emit/emitAsyncWithTimeout НЕ вызываются с fork:: префиксом.
- НОВЫЙ tests/unit/blockchain/no-onevent-fork.test.ts: grep-guard по src/
  на @OnEvent('fork::*') (комментарии отфильтрованы) → fail на регрессии.
- Все 6 suites зелёные (52 теста), tsc --noEmit 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:42:39 +00:00
coopops e810d3152f [C28-14][@ant] feat: ForkRegistry sequential apply форка — Story 4.1
Цель — снять параллельный @OnEvent('fork::*') broadcast в 20+ syncer-ов и
заменить на sequential обход через ForkRegistryService, что в сочетании
с single-active XREADGROUP parser2 даёт натуральный барьер форка (INV-T03,
NFR10). Старый broadcast путь оставлен deprecated на этот релиз — Story 4.2
удалит вместе с pause-barrier из DEC-013.

Реализация:
- shared/sync/fork/{interface,service,module}: IForkAwareSyncer + symbol-marker
  FORK_AWARE_MARKER, ForkRegistryService с pull-сбором через DiscoveryService
  на onApplicationBootstrap, sequential for-of await + priority-ordering.
- AbstractEntitySyncService: implements IForkAwareSyncer (marker через class
  field), handleFork теперь re-throw (catch удалён) — контракт sequential
  ForkRegistry.runAll. Не требует super.onModuleInit() у 20 наследников
  (DiscoveryService снимает push-зависимость).
- BlockchainConsumerService.handleEvent для fork: dedup-gate через
  computeForkEventId → processFork (runAll → deleteDedupAfterBlock → saveFork
  → deprecated emit) → markEventApplied. Для action/delta — markEventApplied
  теперь пишет block_num.
- ConsumerDedup: новая колонка block_num (bigint nullable) + индекс +
  deleteAfterBlock(blockNum). Старые NULL-записи не затрагиваются.
- event-id.util: новый computeForkEventId в формате chain:fork:block:short_id
  (полное «fork», единый стиль с action/delta — parser2-формат chain:f:... не
  используется).

Тесты (51 кейс, 5 suites):
- unit fork-registry: sequential apply, error propagation, priority, bootstrap
  discovery (15 кейсов).
- unit blockchain-consumer.processFork: порядок шагов, dedup-gate, mark после
  processFork, fail-paths, parser2-format isolation (11 кейсов).
- unit consumer-dedup repository: markApplied с/без blockNum, deleteAfterBlock
  edge-cases (8 кейсов).
- unit event-id.util: computeForkEventId детерминизм + поведение на коротком
  block_id (5 новых кейсов).
- integration fork-flow: bootstrap → delta(N+1)→fork(N)→delta(N+2), rollback
  не трогает <=N, error propagation (4 кейса с реальным Nest DI).

tsc 0 ошибок, jest 51/51 зелёные.

Spec: blago/production/13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/_bmad-output/tracks/sync-arch/implementation-artifacts/spec-4-1-fork-kak-event-v-unified-stream.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:52:38 +00:00
coopops 99c22b0064 [C28-13][@ant] chore: обновить pnpm-lock.yaml до @coopenomics/parser2@1.1.0
Lockfile висел на 1.0.3, потому что 1.1.0 ещё не был опубликован
в npm в момент bump'а package.json. Сейчас 1.1.0 в реестре, pnpm
install подхватил его + новую транзитивную @coopenomics/coopos-ship-reader@0.2.0
и @napi-rs/nice@1.1.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:26:30 +00:00
ant 4c0fbefca9 Merge pull request 'Эпик 3 sync-arch: переезд транспорта controller с parser1 на parser2 ParserClient [C28-13]' (#35) from parser2-epic-3-transport into parser2
Reviewed-on: #35
2026-05-28 11:57:20 +00:00
coopops 8fdc99a7f9 [C28-13][@ant] feat: маппер parser2 отдаёт реальные поля action-трейса + dep parser2 1.1.0 — паритет с parser1, без заглушек
transaction_id/creator_action_ordinal/context_free/elapsed/console/
account_ram_deltas + receipt.auth_sequence теперь идут из ActionEvent
parser2 1.1.0 (добавлены в сам пакет + ship-reader 0.2.0). Нужны
ledger2 для cross-link родительского apply (transaction_id+action_ordinal)
и blockchain-explorer. tsc --noEmit контроллера против новых типов — чисто.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:08:24 +00:00
coopops 8d27c4e245 [C28-13][@ant] feat: заменить транспорт parser1 на parser2 ParserClient — единый движок без флагов
Контроллер читает события через @coopenomics/parser2 ParserClient вместо самодельного
consumer'а поверх Redis-стрима notifications (его писал parser1). Никаких флагов и
параллельной работы двух движков: одна система — либо работает на parser2, либо нет.

Удалена ручная обвязка (RedisStreamService, consumer-group, recoverOwnPending, XAUTOCLAIM,
XTRIM) — всё это ParserClient делает сам (single-active-lock, recover, dead-letter после
N провалов). Новый consume-loop ведёт генератор stream() вручную: next() при успехе
(XACK внутри parser2), throw() при ошибке (учёт провалов / dead-letter) — наивный for-await
неверен, проброс из тела не доходит до catch вокруг yield и убивал бы консьюмер.

Маппер parser2-event.mapper переводит ParserEvent → IDelta/IAction (DEC-T09), обработчики
processAction/processDelta/processFork не тронуты. Дедуп по event_id теперь безусловный
(флаг BLOCKCHAIN_DEDUP_ENABLED убран), формат event_id оставлен прежним (action/delta).

ВНИМАНИЕ: parser2 ActionEvent не несёт transaction_id/creator_action_ordinal — ledger2
cross-link родительского apply на них опирается; в маппере заглушки, проверить на прогоне.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:58:12 +00:00
coopops 0e3ccb4356 Revert "[C28-13][@ant] fix: выровнять формулу event_id под parser2 v1.0.3 — сверка перед cutover"
This reverts commit 306835d0fc.
2026-05-25 15:42:46 +00:00
coopops 306835d0fc [C28-13][@ant] fix: выровнять формулу event_id под parser2 v1.0.3 — сверка перед cutover
Локальная формула computeDeltaEventId/computeActionEventId расходилась с движком
parser2 (дискриминанты delta/action вместо a/d, block_id[0..8] вместо [0..16]).
Это закрывает сверку из Story 3.3 phase 2, перенесённую вперёд: computeEventId
теперь импортируем из опубликованного пакета. Выровнял байт-в-байт, golden-тест
сверен против исходника parser2 (delta и action совпадают). Без выравнивания при
overlap dual-consume один event получал бы два разных id в legacy и в движке и
dedup-gate промахнулся бы = silent data loss. Поправил формат event_id в CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:18:41 +00:00
ant 10e5b83ac9 Merge pull request 'Эпик 2 sync-arch: фундамент идемпотентности — event_id + consumer_dedup (релиз 1.1.1) [C28-12]' (#33) from parser2-epic-2-idempotency into parser2
Reviewed-on: #33
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-05-25 10:05:56 +00:00
coopops 33d936e5c8 [C28-12][@ant] refactor: убрать DDL-миграцию consumer_dedup — таблицу создаёт synchronize:true через entity, отдельная миграция избыточна по текущей конвенции typeorm (ревью PR #33)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 07:45:54 +00:00
coopops a73f07fa0c [C28-12][@ant] feat: локальное вычисление event_id с dual-write и dedup-gate за флагом — повтор события распознаётся как no-op, gate выключен до сверки формулы с parser2 в Epic 3 (Stories 2.2, 2.3, DEC-T08, DEC-020)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:31:00 +00:00
coopops 77f358e865 [C28-12][@ant] feat: добавить таблицу consumer_dedup с миграцией и репозиторием — фундамент идемпотентности для распознавания повторно применённых событий блокчейна (Story 2.1, INV-09)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:30:53 +00:00
ant 2afc71c281 Merge pull request 'Эпик 1 sync-arch: срочные фиксы синхронизации (релиз 1.1.1) [C28-11]' (#32) from parser2-epic-1-hardening into parser2
Reviewed-on: #32
2026-05-24 18:17:23 +00:00
coopops 9cf19aa8fe [C28-11][@ant] fix: барьер форка и вынос задержки action-emit в конфиг — откаты форка завершаются до обработки следующей дельты, исчезает хардкод 3000мс (Stories 1.3, 1.4, DEC-007)
Stories 1.3 + 1.4 затрагивают общие файлы (config.ts, blockchain-consumer.service.ts), поэтому одним коммитом:
- 1.4: ACTION_EMIT_DELAY_MS=3000 → config.blockchain.action_emit_delay_ms (env BLOCKCHAIN_ACTION_EMIT_DELAY_MS). Порядок save→ACK→setTimeout(emit) уже был корректен.
- 1.3: EventsService.emitAsyncWithTimeout + await в processFork = пауза consumer'а до завершения @OnEvent('fork::*')-откатов; TTL force-resume = config.blockchain.fork_pause_timeout_ms. Временно до Epic 3/ForkRegistry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:38:42 +00:00
coopops 4b8121b0b7 [C28-11][@ant] fix: guard монотонности block_num в createIfNotExists — устаревшая дельта из раннего блока больше не затирает более свежую запись на create-пути (Story 1.1, DEC-008)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:38:41 +00:00
1465 changed files with 37179 additions and 71275 deletions
+200 -188
View File
@@ -1,60 +1,49 @@
name: Release
# Релизный workflow по push'у в testnet/main (FF-промоушн-модель, см.
# scripts/RELEASE.md).
# Единый релизный workflow по push'у тэга `v*`.
#
# Порядок (через jobs.needs):
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
# 2) publish-packages — npm publish через lerna (только ветка main)
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
# Gitea (только ветка main); он сам тянет mono и
# пересобирает доки.
#
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
# 2) publish-packages — npm publish через lerna (если не -alpha)
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
#
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
# если релиз контрактов или контейнеров провалился.
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
# стартовали параллельно по одному тэгу — отсюда гонка из инцидента 2026-05-13
# с ledger2 walletop sweep'ом (см. историю release.yaml до объединения).
#
# Триггер — push в testnet/main с изменением lerna.json. Версию бампает
# `lerna version` ОДИН раз на dev (scripts/cut-release.sh); тот же коммит едет
# вверх по fast-forward (scripts/promote.sh). lerna.json меняется только на
# релизном бампе, поэтому обычные feature-пуши деплой не триггерят. dev в
# триггере НЕТ: cut на dev только бампает версию, деплой начинается с testnet
# (staging) и завершается main (production).
# Гейт `-alpha` унифицирован: пакеты + доки + триггер docs пропускают только
# не-alpha тэги. -beta/-rc/-test в проекте не используются (см. CLAUDE.md),
# поэтому проще иметь один паттерн `!contains(github.ref, '-alpha')`.
# IS_PROD в release-job остаётся на `(alpha|beta|rc|test)` — он определяет
# деплой-webhook (testnet vs production) и тэг `:latest` у образов;
# намеренно строже, чтобы случайный -rc-тэг не уехал на прод.
#
# Окружение определяет ВЕТКА (main → production, testnet → staging), а не
# суффикс версии: единая версия едет во все среды, поэтому source of truth —
# ветка, на которую сделан промоушн. Версия читается из закоммиченного
# lerna.json. Гейты npm-publish/доки — `branch == main`.
# Резолв ветки. Тэг семантики окружения не несёт (например `v2026.5.13-alpha-2`
# может лежать на testnet или dev — определяет именно ветка, на которую сделан
# merge перед тэгом). Поэтому ищем первое совпадение SHA с remote-ветками
# main/testnet/dev. branch экспортируется как outputs.branch для downstream jobs.
on:
push:
branches: [testnet, main]
paths:
- 'lerna.json'
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
workflow_dispatch:
inputs:
environment:
description: 'Куда деплоить'
type: choice
options:
- testnet
- main
required: true
ref:
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
required: false
default: ''
tags:
- 'v*'
permissions:
contents: write
jobs:
# Гейт TS-типов перед сборкой образов. Reusable workflow `typecheck.yaml`
# запускает vue-tsc на desktop и tsc --noEmit на controller. Нужно потому,
# что `lerna run build` в корневом Dockerfile НЕ ловит TS-ошибки:
# quasar build идёт через esbuild с выключенным vueTsc, а у controller'а
# build-скрипта вообще нет (ts-node в рантайме).
typecheck:
uses: ./.github/workflows/typecheck.yaml
release:
needs: typecheck
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.resolve.outputs.tag_name }}
@@ -65,32 +54,23 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
# (пусто = верхушка ветки окружения, на которой запущен workflow).
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
- name: Resolve branch, build mode and version
- name: Resolve branch, build mode and tags
id: resolve
run: |
# Окружение: при ручном workflow_dispatch — из inputs.environment;
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
# из реально вычекнутого дерева, не из ветки-триггера.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BRANCH="${{ github.event.inputs.environment }}"
else
BRANCH="${{ github.ref_name }}"
fi
SHA="$(git rev-parse HEAD)"
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
# testnet/main. Тэг как триггер больше не используется.
RAW_VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' lerna.json | head -1)"
if [ -z "$RAW_VERSION" ]; then
echo "::error::Не удалось прочитать version из lerna.json"
TAG_NAME="${{ github.ref_name }}"
SHA="${{ github.sha }}"
BRANCH=""
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
if [ -z "$BRANCH" ]; then
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
exit 1
fi
TAG_NAME="v${RAW_VERSION}"
case "$BRANCH" in
main)
@@ -108,22 +88,17 @@ jobs:
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
*)
echo "::error::Неподдерживаемая ветка $BRANCH (ожидались testnet/main/dev)"
exit 1
;;
esac
# Окружение деплоя определяет ВЕТКА: main → production, иначе → testnet.
# IS_PROD управляет деплой-webhook (PRODUCTION vs TESTNET) и тэгом :latest
# у образов. Раньше критерием был суффикс -alpha у тэга; теперь единая
# версия едет во все среды, поэтому источник истины — ветка промоушна.
if [ "$BRANCH" = "main" ]; then
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
else
# IS_PROD определяет webhook деплоя (PRODUCTION vs TESTNET) и тэг :latest.
# Сознательно строже гейта publish-* — случайный -rc/-beta тэг не должен
# уехать на прод-вебхук, даже если в npm/docs его опубликовали.
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
else
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
fi
{
@@ -152,41 +127,14 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
# act_runner сам job исполняется в контейнере → хостовый демон не видит
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
# build-all.sh остаётся для локальной сборки (оборачивает тот же
# build_contracts_cdt.sh в docker).
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
# без правки CMakeLists.
- name: Install CDT 4.2.0 toolchain (.deb)
run: |
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# build-essential — для host-компилятора (project() в CMakeLists);
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
# были из сборки исходников, из .deb не тянутся.
$SUDO apt-get install -y --no-install-recommends \
curl ca-certificates cmake make build-essential \
libz3-4 libtinfo6 libxml2 zlib1g
curl -fsSL -o /tmp/cdt.deb \
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
$SUDO apt-get install -y /tmp/cdt.deb
$SUDO mkdir -p /cdt
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
cdt-cpp --version || true
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile contracts
working-directory: components/contracts
run: |
./build_contracts_cdt.sh "$BUILD_MODE"
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
@@ -267,17 +215,6 @@ jobs:
- name: Build and push contracts image
working-directory: components/contracts
run: |
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
@@ -286,14 +223,14 @@ jobs:
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
dpush "$IMAGE:$CONTRACTS_TAG"
docker push "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
dpush "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
if [ -n "$CONTRACTS_EXTRA" ]; then
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
dpush "$IMAGE:$CONTRACTS_EXTRA"
docker push "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
@@ -306,31 +243,15 @@ jobs:
- name: Build and push base image
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
dpush "dicoop/mono-base:$TAG_NAME"
docker push "dicoop/mono-base:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
dpush dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
@@ -339,10 +260,10 @@ jobs:
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
dpush "dicoop/$SVC:$TAG_NAME"
docker push "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
dpush "dicoop/$SVC:latest"
docker push "dicoop/$SVC:latest"
fi
}
@@ -364,16 +285,14 @@ jobs:
# ============================================================================
# publish-packages — npm publish (бывший publish-packages.yaml).
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
# доезжает до main по FF.
# Гейт: не-alpha. Не зависит от ветки.
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
# pnpm-lock.yaml → Lerna EUNCOMMIT.
# ============================================================================
publish-packages:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
if: ${{ !contains(github.ref, '-alpha') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -390,61 +309,154 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
- name: Generate controller schema.gql (required by sdk prepublishOnly)
working-directory: components/controller
run: pnpm run generate-schema
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ============================================================================
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
# Гейт: ветка main.
#
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
# Целевой workflow слушает workflow_dispatch с входами
# mono_sha/mono_ref.
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
# Гейт: не-alpha + ветка main.
# ВНИМАНИЕ: gh-pages step пушит на github.com/coopenomics/mono — это GitHub
# Pages. При переезде на Gitea Actions нужно будет либо подложить PAT для
# внешнего push'а в GitHub, либо переключить публикацию на Gitea Pages.
# ============================================================================
publish-docs:
needs: release
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Версия pnpm берётся из `packageManager` корневого package.json,
# синхронно с publish-packages job'ом и Dockerfile'ами.
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python requirements
run: |
python -m venv venv
source venv/bin/activate
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
working-directory: ./components/docs
- name: Install Node.js dependencies
run: pnpm install
working-directory: ./components/docs
- name: Patch spectaql-config.yml for CI
run: |
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
working-directory: ./components/controller
- name: Show patched spectaql-config.yml
run: cat spectaql-config.yml
working-directory: ./components/controller
- name: Build cooptypes
run: pnpm run build
working-directory: ./components/cooptypes
- name: Generate controller docs
run: pnpm run docs
working-directory: ./components/controller
- name: Copy controller docs
run: |
mkdir -p ./components/docs/docs/graphql
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
- name: Generate sdk docs
run: pnpm run docs
working-directory: ./components/sdk
- name: Copy sdk docs
run: |
mkdir -p ./components/docs/docs/sdk
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
- name: Generate cooptypes docs
run: pnpm run docs
working-directory: ./components/cooptypes
- name: Copy cooptypes docs
run: |
mkdir -p ./components/docs/docs/cooptypes
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
# публикуется как поддиректория на том же домене. Vite собирает
# с base='/standards/' (см. vite.config.ts), относительные пути
# внутри dist/ и hash-router работают корректно.
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
- name: Build standards-site
run: pnpm run build
working-directory: ./components/contracts/standards-site
- name: Copy standards-site into docs/standards
run: |
mkdir -p ./components/docs/docs/standards
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
- name: Build docs (mkdocs)
run: |
source venv/bin/activate
mkdocs build
working-directory: ./components/docs
- name: Remove specific large file before publishing
run: |
rm -f ./components/docs/site/sdk/typedoc.json
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
echo "ERROR: typedoc.json still exists!"
exit 1
else
echo "SUCCESS: typedoc.json removed successfully"
fi
- name: Publish to GitHub Pages
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
working-directory: ./components/docs
env:
GIT_AUTHOR_NAME: github-actions
GIT_AUTHOR_EMAIL: github-actions@github.com
GIT_COMMITTER_NAME: github-actions
GIT_COMMITTER_EMAIL: github-actions@github.com
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger docs deployment webhook
if: ${{ success() }}
run: |
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
# ============================================================================
# trigger-coopenomics-docs — repository_dispatch в coopenomics/coopenomics
# (бывший build-contracts-docs.yaml). Гейт идентичен publish-docs.
# ============================================================================
trigger-coopenomics-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
run: |
curl -fsSL -X POST \
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
# ============================================================================
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
# (приёмник деплоит доки на своей стороне). Гейт: ветка main.
# ============================================================================
trigger-mono-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger docs deployment webhook
run: |
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
- name: Trigger Coopenomics deployment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.COOPENOMICS_PAT }}
repository: coopenomics/coopenomics
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
+4 -12
View File
@@ -13,25 +13,17 @@ name: Typecheck
#
# Триггеры:
# - pull_request на dev — гейт перед мерджем в основную ветку;
# - workflow_call — оставлен для переиспользования, но release.yaml его
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
# проверяются на PR в dev до того, как код доедет до тэга).
# - workflow_call — переиспользуется из release.yaml как `needs:` у release-job,
# чтобы битый тэг не уехал в DockerHub.
#
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
# а тэги покрывает workflow_call из release.yaml).
on:
pull_request:
branches: [dev]
workflow_call:
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
# прежних прогонов, разные PR друг друга не трогают.
concurrency:
group: typecheck-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
desktop:
runs-on: ubuntu-latest
-3
View File
@@ -1,5 +1,4 @@
node_modules/
node_modules
lerna-debug.log
components/controller/graph.png
blockchain-data/
@@ -23,5 +22,3 @@ components/contracts/cpp/ledger2/scripts/out/
.env.testnet
.pnpm-store
schema.gql
.claude
-31
View File
@@ -38,8 +38,6 @@ ln -s /home/admin/mono-ai-4/components/controller/.env $WT/components/controller
**Базовая ветка mono-ai-1 — `dev`.** Прямые коммиты в `dev` для мелких фиксов разрешены и не требуют feature-ветки/PR (отменено 2026-05-22 пользователем). Для крупных задач — feature-ветка (`feat/...` или `fix/...`) от dev и PR в dev; merge делает пользователь на GitHub. Push в `main` / `mvp` — по-прежнему только через PR.
**Релиз/деплой — FF-промоушн `dev → testnet → main`** (см. `scripts/RELEASE.md`). Версию бампает `lerna` ОДИН раз на dev (`scripts/cut-release.sh`), тот же коммит едет вверх по fast-forward (`scripts/promote.sh testnet|main`). `testnet`/`main` не несут своих коммитов — только FF-указатели, поэтому merge-конфликтов нет. **В `testnet`/`main` прямых коммитов и повторных бампов версии быть не должно** — это ломает FF. Деплой триггерит push в testnet/main с изменением `lerna.json`; окружение определяет ветка (main → prod). Старые `publish-alpha.sh`/`publish-prod.sh` (merge `-X theirs` + per-branch бамп + back-merge) удалены — они и плодили 20-package.json конфликты.
**Не stash'ить `-u` при unstaged WIP пользователя.** Это создаёт окно для потери при drop/конфликте. Если нужно временно убрать unstaged — `git stash push -- <конкретные-paths>` либо коммит-в-feature, и только свои файлы. Кейс 2026-05-18: после `git stash -u`+`drop` при конфликте потерял WIP пользователя (infra.ts/config.ts/quasar.config.cjs и др.).
### Story-by-story для проекта «Стол заказов» (mono-ai-4 на marketplace2)
@@ -186,35 +184,6 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
| Что | Путь |
|---|---|
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
**Запреты:**
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
## Frontend desktop — English имена
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
-8
View File
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
COPY . .
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
# Отключаем сами загрузки, пакеты всё равно ставятся.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
PUPPETEER_SKIP_DOWNLOAD=true
# Используем существующий lockfile. Если он не совпадает с workspace —
# падать сразу, не дрейфовать незаметно.
RUN pnpm install --frozen-lockfile
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.21-1",
"version": "2026.5.23-4",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+2 -2
View File
@@ -362,9 +362,9 @@ export async function runCli(argv: string[]): Promise<void> {
)
.action(async (paths: string[]) => {
const root = requireRoot()
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
success(
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
)
})
-16
View File
@@ -38,22 +38,6 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
}
}
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
let file: ReturnType<typeof matter>
try {
file = matter(raw)
}
catch {
return undefined
}
const t = file.data?.type
if (t === 'project' || t === 'issue' || t === 'story') {
return t
}
return undefined
}
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
const trimmed = body.endsWith('\n') ? body : `${body}\n`
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
+12 -41
View File
@@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { peekBlagoEntityType } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
return out
}
type FileCheck =
| { kind: 'unreadable' }
| { kind: 'non-entity' }
| { kind: 'clean' }
| { kind: 'dirty' }
/**
* Решение по одному файлу для staging:
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
* — есть в индексе и sha256 совпал → clean,
* — иначе dirty.
*/
async function classifyFileForAdd(
root: string,
rel: string,
index: Awaited<ReturnType<typeof loadIndex>>,
): Promise<FileCheck> {
let raw: string
try {
raw = await fs.readFile(path.join(root, rel), 'utf8')
}
catch {
return { kind: 'unreadable' }
}
if (peekBlagoEntityType(raw) === undefined) {
return { kind: 'non-entity' }
}
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
const entry = findByRelativePath(index, rel)
if (!entry) {
return { kind: 'dirty' }
return true
}
try {
const raw = await fs.readFile(path.join(root, rel), 'utf8')
return sha256Hex(raw) !== entry.content_etag_local
}
catch {
return true
}
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
}
export interface RunAddResult {
@@ -71,8 +50,6 @@ export interface RunAddResult {
skippedIgnored: number
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
skippedPullOnlyArtifacts: number
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
skippedNonEntity: number
}
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
let skippedUnchanged = 0
let skippedIgnored = 0
let skippedPullOnlyArtifacts = 0
let skippedNonEntity = 0
for (const t of expanded) {
const abs = path.resolve(root, t)
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
skippedPullOnlyArtifacts += 1
continue
}
const check = await classifyFileForAdd(root, rel, index)
if (check.kind === 'non-entity') {
skippedNonEntity += 1
continue
}
if (check.kind === 'clean') {
if (!(await isDirtyVsIndex(root, rel, index))) {
skippedUnchanged += 1
continue
}
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
const stagedPaths = [...set].sort()
await saveStaging(root, { paths: stagedPaths })
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
}
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
messageLastTsByRoom: Record<string, number>
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByProject: Record<string, string>
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByRoom: Record<string, string>
}
function empty(): CommunicationCursorsFile {
return {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByProject }
: {},
transcriptionLastEndedExclusiveByRoom:
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
: {},
}
}
catch {
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
}
export interface TranscriptionHeaderModel {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
const header = [
`# Транскрипция звонка`,
``,
`- Matrix room: \`${transcription.matrixRoomId}\``,
`- LiveKit room: \`${transcription.roomId}\``,
`- Начало: ${start.toISOString()}`,
`- Окончание: ${end ? end.toISOString() : '—'}`,
@@ -25,7 +25,7 @@ import {
transcriptionMeetingFileStemUtc,
type CommunicationDayLine,
} from './communication-markdown.js'
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
import { syncEntityFile } from './sync-entity-file.js'
interface ProjectRowLite {
@@ -183,19 +183,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
}
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
if (kind === 'MEMBERS') {
return 'komnata-paishchikov'
}
if (kind === 'COUNCIL') {
return 'komnata-soveta'
}
const slug = generateSlug(displayLabel) || 'komnata'
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
return `${slug}-${shortId}`
}
export async function pullProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
@@ -211,7 +198,6 @@ export async function pullProjectCommunicationArtifacts(
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -370,6 +356,7 @@ export async function pullProjectCommunicationArtifacts(
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
matrixRoomId: String(tr.matrixRoomId),
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
@@ -435,207 +422,3 @@ export async function pullProjectCommunicationArtifacts(
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
}
}
/**
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
*/
export async function pullNonProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
): Promise<void> {
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
try {
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
}
catch (e) {
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
return
}
if (rooms.length === 0) {
return
}
let cursors: CommunicationCursorsFile
try {
cursors = await loadCommunicationCursors(ctx.root)
}
catch (e) {
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
for (const room of rooms) {
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
const basePath = `rooms/${folder}`
const roomTitle = room.displayLabel || room.matrixRoomId
// Сообщения комнаты — по календарным суткам UTC новее курсора.
try {
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
const afterTs = last ?? 0
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
})
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
for (const utcDate of dates) {
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
})
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
originServerTs: m.originServerTs,
authorLabel: m.authorLabel,
coopUsername: m.coopUsername,
kind: String(m.kind),
bodyText: m.bodyText,
}))
if (lines.length === 0) {
continue
}
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
])
const rel = `${basePath}/messages/${utcDate}.md`
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
await syncEntityFile({
root: ctx.root,
index,
entityType: 'room_message_day',
entityHash,
relativePath: rel,
content,
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
label: `переписка ${utcDate} (${room.matrixRoomId})`,
})
}
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
variables: { data: { matrixRoomId: room.matrixRoomId } },
})
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
}
}
catch (e) {
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
}
// Транскрипции звонков комнаты + sibling memo.
try {
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
interface TranscriptionCandidate {
id: string
endedAt: Date
memo: string
updatedAt: Date | undefined
}
const byId = new Map<string, TranscriptionCandidate>()
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
})
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
for (const t of list) {
const end = dateFromUnknown(t.endedAt)
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
continue
}
const prev = byId.get(t.id)
if (!prev || end > prev.endedAt) {
byId.set(t.id, {
id: t.id,
endedAt: end,
memo: typeof t.memo === 'string' ? t.memo : '',
updatedAt: dateFromUnknown(t.updatedAt),
})
}
}
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
let maxEnded: Date | null = null
for (const c of newMeetings) {
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
variables: { data: { id: c.id } },
})
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
continue
}
const tr = pack.transcription
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
const endedAtTr: Date | string | null | undefined
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
},
pack.segments.map(s => ({
speakerName: s.speakerName,
text: s.text,
startOffset: s.startOffset,
endOffset: s.endOffset,
})),
)
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const rel = `${basePath}/meetings/${stem}.md`
const entityHash = c.id.toLowerCase()
await syncEntityFile({
root: ctx.root,
index,
entityType: 'call_transcription',
entityHash,
relativePath: rel,
content: md,
remoteUpdatedAt: toUpdatedIso(c.endedAt),
label: `транскрипция ${c.id}`,
})
if (!maxEnded || c.endedAt > maxEnded) {
maxEnded = c.endedAt
}
}
for (const c of allCompleted) {
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const memoRel = `${basePath}/meetings/${stem}.memo.md`
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
await syncTranscriptionMemoFile({
root: ctx.root,
index,
transcriptionId: c.id,
relativePath: memoRel,
serverMemo: c.memo,
remoteUpdatedAtIso: memoRemoteUpdatedAt,
})
}
if (maxEnded) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
}
else if (tExIso === undefined) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
}
}
catch (e) {
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
}
}
try {
await saveCommunicationCursors(ctx.root, cursors)
}
catch (e) {
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
}
}
+1 -3
View File
@@ -28,7 +28,7 @@ import {
storyFileRelativePath,
workspaceBasePath,
} from './layout.js'
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
import { syncEntityFile } from './sync-entity-file.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
@@ -329,8 +329,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
await pullNonProjectCommunicationArtifacts(ctx, index)
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
await saveIndex(ctx.root, index)
+3 -43
View File
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations, Queries } from '@coopenomics/sdk'
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
import { parseBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import { warn } from '../ui/output.js'
@@ -58,30 +58,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
staging = await loadStaging(ctx.root)
}
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
const nonEntityInStaging: string[] = []
for (const p of staging.paths) {
try {
const raw = await fs.readFile(stagingAbs(p), 'utf8')
if (peekBlagoEntityType(raw) === undefined) {
nonEntityInStaging.push(normalizeRelativePath(p))
}
}
catch {
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
}
}
if (nonEntityInStaging.length > 0) {
const drop = new Set(nonEntityInStaging)
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
for (const p of nonEntityInStaging) {
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
}
staging = await loadStaging(ctx.root)
}
if (staging.paths.length === 0) {
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
}
@@ -99,24 +75,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
const abs = path.join(ctx.root, n)
const raw = await fs.readFile(abs, 'utf8')
let parsed: ReturnType<typeof parseBlagoMarkdown>
try {
parsed = parseBlagoMarkdown(raw)
}
catch (err) {
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
let type: ReturnType<typeof validateParsedForPush>['type']
let hash: string
try {
({ type, hash } = validateParsedForPush(parsed))
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
const parsed = parseBlagoMarkdown(raw)
const { type, hash } = validateParsedForPush(parsed)
const entry = findByHash(index, type, hash)
const pKind = pendingKindForEntityType(type)
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
+2
View File
@@ -367,6 +367,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
if (entry.entity_type === 'call_transcription') {
interface TranscriptionRestorePack {
transcription: {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -393,6 +394,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
}
const content = renderCallTranscriptionMarkdown(
{
matrixRoomId: tr.matrixRoomId,
roomId: tr.roomId,
startedAt: tr.startedAt,
endedAt: tr.endedAt,
+1 -8
View File
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
COPY . .
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
&& pnpm rebuild --filter "@coopenomics/boot..."
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
# свой bundle с inlined workspace-deps.
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.7.21-1",
"version": "2026.5.23-4",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
@@ -26,7 +26,6 @@
"boot:remote": "esno src/index.ts boot:remote",
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
"create-coop": "esno src/index.ts create-coop",
"clear": "./scripts/clear.sh",
"start": "./scripts/restart.sh",
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Полный перезапуск dev-среды для теста Благороста одной командой:
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
# 2) ожидание готовности controller (GraphQL отвечает);
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
#
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
bash "$SCRIPT_DIR/extra_reboot.sh"
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
echo "Ждём готовности controller ($GRAPHQL_URL)..."
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
sleep 3
done
echo "Controller готов."
UP_TO="${SEED_UP_TO:-04-contributor}"
echo "Сидируем capital до фазы $UP_TO..."
cd "$SCRIPT_DIR/.."
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
+13 -22
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
# Загружаем per-instance конфиг из корня репо.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -10,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
echo "Останавливаем и удаляем контейнеры..."
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
echo "Удаляем тома баз данных..."
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
# Удаляем blockchain data через контейнер (alpine под root стирает данные
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
# Удаляем blockchain data
echo "Удаляем blockchain data..."
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -51,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
# Запускаем boot процесс
echo "Запускаем boot процесс..."
pnpm run boot:clean
@@ -60,6 +51,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose restart coopback || true
echo "Перезапуск завершен!"
+14 -17
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
# Загружаем per-instance конфиг из корня репо.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем контроллер перед очисткой данных
echo "Останавливаем контроллер..."
docker compose down coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres monoredis cooparser coopback || true
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем blockchain data.
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
# Удаляем blockchain data
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
# Запускаем boot процесс
echo "Запускаем boot процесс..."
pnpm run boot:extra
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose up -d coopback
echo "Перезапуск завершен!"
+22 -37
View File
@@ -291,46 +291,31 @@ export default class Blockchain {
async activateFeature(feature: Feature) {
await this.update_pass_instance()
try {
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
console.log('Фича активирована: ', feature.name)
}
catch (e: any) {
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
const errName = e?.json?.error?.name
const errCode = e?.json?.error?.code
const msg = e instanceof Error ? e.message : String(e)
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
return
}
throw e
}
console.log('Фича активирована: ', feature.name)
}
async createToken(params: TokenContract.Interfaces.ICreate) {
-5
View File
@@ -128,9 +128,4 @@ export default [
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'expense',
path: path.join(userBase, 'expense'),
target: 'expense',
},
]
-4
View File
@@ -154,10 +154,6 @@ export default {
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'expense',
code_permissions_to: ['expense'],
},
// {
// name: provider_chairman,
// },
@@ -1,11 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [],
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
}
@@ -1,33 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
],
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "SAVANNA"
}
+9 -19
View File
@@ -1,6 +1,7 @@
import config from '../configs'
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
import { installExtraData, installInitialData, startInfra } from './infra'
import { startCoop } from './cooperative'
import { CooperativeClass, startCoop } from './cooperative'
export async function boot() {
const blockchain = await startInfra()
@@ -13,29 +14,18 @@ export async function boot() {
}
export async function bootClean() {
// Только инфраструктура: контракты, фичи, токен, системные параметры.
// Ни совета, ни программ — программы (createPrograms) требуют существующий
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
await startInfra()
const blockchain = await startInfra()
console.log('Создаём программы (Благорост и маркетплейс)')
const cooperative = new CooperativeClass(blockchain)
await cooperative.createPrograms(config.provider)
}
export async function bootExtra() {
const blockchain = await startInfra()
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
// это и есть программная on-chain активация, которая триггерит аренду VM в
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
// сервера (например, просто пересев совета/чейна для других задач), поэтому
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
if (process.env.EXTRA_RENT === '1') {
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
}
else {
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
}
await installExtraData(blockchain) // Добавить дополнительных пайщиков
console.log('Инициализируем статус системы в PostgreSQL')
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
+1 -1
View File
@@ -76,7 +76,7 @@ export class CooperativeClass {
await this.blockchain.createProgram({
coopname,
username: coopname,
type: 'capital',
type: 'blagorost',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
+4 -14
View File
@@ -15,9 +15,6 @@ import { sleep } from '../utils'
import { generateRandomSHA256 } from '../utils/randomHash'
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
import { CooperativeClass } from './cooperative'
import { signProgramAgreement } from './sign-program-agreement'
import { fakeDocument } from '../tests/shared/fakeDocument'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
@@ -677,17 +674,10 @@ export async function installExtraData(blockchain: Blockchain) {
status: 'active',
})
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
// eosio.token::is_can_transfer «Получатель не является участником целевой
// потребительской программы кошелька» — и инстанс навсегда застревает в
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
// Ресурсы для партнёра: powerup CPU/NET.
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
// eosio.token::transfer падает на проверке membership в wallet program,
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
await blockchain.powerup({
payer: 'eosio',
receiver: account.username,
@@ -1,6 +1,6 @@
/**
* Фаза 01 программы УХД (id=3, type='generator') и Благорост
* (id=4, type='capital') + capital::setconfig.
* (id=4, type='blagorost') + capital::setconfig.
*
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
* setconfig только если `state` пуст.
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
await blockchain.api.transact({
actions: [{
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
export const ratePerHour = '1000.0000 RUB'
export const sourceProgramName = 'generator'
export const capitalProgramName = 'capital'
export const capitalProgramName = 'blagorost'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.7.21-1",
"version": "2026.5.23-4",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
-1
View File
@@ -58,7 +58,6 @@ add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(expense)
add_contract_build(system)
# Опции для тестов
+7 -5
View File
@@ -1,13 +1,15 @@
#!/usr/bin/env bash
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
# используется в CI (там CDT ставится из .deb, без docker), поэтому
# флаги сборки в одном месте и не разъезжаются.
mode="${1:-prod}"
if [ "$mode" = "test" ]; then
is_testnet="ON"
else
is_testnet="OFF"
fi
docker run --rm --name cdt \
--volume "$(pwd):/project" \
-w /project \
dicoop/blockchain:latest \
/bin/bash -c "./build_contracts_cdt.sh $mode"
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Компиляция всех контрактов кооперативной экономики через CDT.
#
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
#
# Два способа подготовить это окружение:
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
# (в образе CDT уже лежит в /cdt/build);
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
#
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
# не разъезжались.
set -euo pipefail
mode="${1:-prod}"
if [ "$mode" = "test" ]; then
is_testnet="ON"
else
is_testnet="OFF"
fi
mkdir -p build
cd build
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
make
@@ -1,65 +0,0 @@
/**
* @brief Инициировать программный расход через шасси expense.
*
* capital только инициатор. Резервирует средства в @c program_expense_pool,
* создаёт скелет записи progexpense (status=CREATED, как маркер pending), затем
* шлёт inline action `expense::createexp(...)` с callback handler `{capital, onpgexpdone}`.
* Шасси expense обслуживает весь дальнейший flow (авторизация совета, выдача аванса,
* отчёт, закрытие либо отклонение) и на терминальном переходе шлёт inline `onpgexpdone`.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (= proposal_hash в шасси)
* @param creator Инициатор расхода
* @param items Позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* @param description Описание для отчётности
* @param statement Документ-заявление (ProgramExpenseStatement)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname.
*/
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement, {creator});
eosio::check(!items.empty(), "Программный расход должен содержать хотя бы один item");
// Сумма расхода — сумма planned_amount всех items.
asset amount = items.front().planned_amount;
amount.amount = 0;
for (const auto &it : items) {
amount += it.planned_amount;
}
Wallet::validate_asset(amount);
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
auto exist = Capital::get_program_expense(coopname, expense_hash);
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
// Резерв в пуле до отправки в шасси — две параллельные заявки не превысят пул.
Capital::State::reserve_program_expense(coopname, amount);
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
amount, description, statement);
// Inline action в шасси expense с callback handler на capital::onpgexpdone.
// data пустой — capital резолвит запись по expense_hash, дополнительный payload не нужен.
ExpenseDomain::callback_handler callback{
.contract = _capital,
.action = Names::Capital::ON_PROGRAM_EXPENSE_DONE,
.data = std::vector<char>{}
};
// Authority — _capital: у capital@eosio.code нет coopname@active, а
// expense::createexp принимает контракты-инициаторы по contracts_whitelist.
eosio::action(
eosio::permission_level{_capital, "active"_n},
_expense,
Names::External::CREATE_EXPENSE_PROPOSAL,
std::make_tuple(coopname, creator, expense_hash,
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
).send();
}
@@ -1,65 +0,0 @@
/**
* @brief Callback от шасси expense на финализацию program-расхода.
*
* Шасси expense шлёт его inline (authority _expense@active) когда закончило
* flow (closeexp или declexp). capital не делает ledger2-проводок
* они уже сделаны шасси на payexp/reportexp/returnexp/overspendexp. capital
* только обновляет @c program_expense_pool и удаляет запись из @c progexpenses.
*
* status == ExpenseDomain::ProposalStatus::CLOSED (4):
* total_actual <= reserved consume(total_actual) + release(остаток);
* total_actual > reserved (был overspendexp) consume(reserved) +
* прямое списание превышения из program_expense_pool.
* status == ExpenseDomain::ProposalStatus::DECLINED (5):
* release(reserved); decline возможен только до оплат (total_actual == 0).
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (ключ в progexpenses)
* @param status ExpenseDomain::ProposalStatus
* @param total_actual Фактически потраченная сумма
* @param data Произвольный payload от инициатора (не используется на MVP)
*
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _expense inline-callback шасси.
*/
void capital::onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data) {
require_auth(_expense);
auto pgexp = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
const auto reserved = pgexp.amount;
if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::CLOSED)) {
eosio::check(total_actual.symbol == reserved.symbol,
"Символ total_actual не совпадает с зарезервированной суммой");
eosio::check(total_actual.amount >= 0,
"total_actual не может быть отрицательным");
if (total_actual.amount <= reserved.amount) {
if (total_actual.amount > 0) {
Capital::State::consume_program_expense(coopname, total_actual);
}
const asset leftover = reserved - total_actual;
if (leftover.amount > 0) {
Capital::State::release_program_expense(coopname, leftover);
}
} else {
// Перерасход (overspendexp в шасси): резерв списываем полностью,
// превышение — напрямую из свободного пула программных расходов.
Capital::State::consume_program_expense(coopname, reserved);
const asset excess = total_actual - reserved;
Capital::State::spend_program_expense_pool(coopname, excess);
}
} else if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::DECLINED)) {
// Шасси отклоняет СЗ только до первой оплаты — резерв возвращается целиком.
eosio::check(total_actual.amount == 0,
"DECLINED с ненулевым total_actual — нарушение инварианта шасси");
Capital::State::release_program_expense(coopname, reserved);
} else {
eosio::check(false, "onpgexpdone принимает только статусы CLOSED либо DECLINED");
}
Capital::Expenses::delete_program_expense(coopname, pgexp.id);
}
@@ -1,33 +0,0 @@
/**
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
*
* Программные расходы это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp)
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
* при расходах не изменяются.
*
* @param coopname Кооператив (scope state)
* @param amount Сумма пополнения (символ строго совпадает с программой)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
*/
void capital::topupprogexp(name coopname, eosio::asset amount) {
require_auth(coopname);
Capital::State::topup_program_expense_pool(coopname, amount);
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
// на 51 с момента взносов — выделяется только назначение под расходы).
// Синтетический process_hash — у пополнения нет естественного анкера.
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
name{}, process_hash, "capital:topupprogexp");
}
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
auto idx = user_wallets.get_index<"byuserwallet"_n>();
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
// больше не используется, проверка preimp.blocked == 0 излишня.
eosio::check(preimp_it->blocked.amount == 0,
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
Ledger2::apply(
_capital, coopname, operations::capital::DROP_PREIMP,
preimp_it->available, username, contributor_hash,
@@ -95,12 +95,6 @@
#include "app/debt_managment/create_debt/declinedebt.cpp"
#include "app/debt_managment/settle_debt/settledebt.cpp"
// Управление программными расходами через шасси expense.
// capital — инициатор + получатель callback, шасси обслуживает весь flow.
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
#include "app/expense_managment/program_expenses/createpgexp.cpp"
#include "app/expense_managment/program_expenses/onpgexpdone.cpp"
// Управление расходами
#include "app/expense_managment/create_expense/approveexpns.cpp"
#include "app/expense_managment/create_expense/capauthexpns.cpp"
@@ -48,7 +48,6 @@ constexpr uint128_t CRPS_PRECISION_FACTOR = 100000000000000ULL; ///< Масшт
#include <eosio/asset.hpp>
#include "../lib/index.hpp"
#include "domain/index.hpp"
#include "../expense/expense.hpp" // ExpenseDomain::item / callback_handler — для inline-action в шасси
using namespace eosio;
@@ -323,38 +322,4 @@ public:
[[eosio::action]]
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
// Программные расходы через шасси expense.
// capital — инициатор + получатель callback, шасси держит весь flow расхода.
[[eosio::action]]
void topupprogexp(name coopname, eosio::asset amount);
/**
* @brief Инициировать программный расход через шасси expense.
* Резервирует amount в program_expense_pool, создаёт запись progexpense,
* шлёт inline action expense::createexp с callback={capital, onpgexpdone}.
* Шасси обслуживает весь flow (auth/pay/report/close|decline) и шлёт callback.
*
* items[] позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
* (item.mechanics: ADVANCE аванс, DIRECT прямая оплата).
*/
[[eosio::action]]
void createpgexp(name coopname, checksum256 expense_hash, name creator,
std::vector<ExpenseDomain::item> items,
std::string description, document2 statement);
/**
* @brief Callback от шасси expense на финализацию program-расхода.
* Шасси шлёт его inline (authority _expense@active, require_auth(_expense))
* после терминального перехода flow (CLOSED либо DECLINED).
*
* status == CLOSED consume(total_actual) + release(остаток резерва);
* при total_actual > reserved (перерасход) превышение
* списывается напрямую из program_expense_pool.
* status == DECLINED release(reserved); decline возможен только до оплат.
*/
[[eosio::action]]
void onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
eosio::asset total_actual, std::vector<char> data);
};
@@ -9,26 +9,23 @@ namespace Capital::Core {
* @brief Совокупный баланс программы Благорост на уровне кооператива.
*
* Источник ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
* поэтому L2 авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
* создан (никто не инвестировал) нулевой баланс.
*
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
* используется, баланс кошелька = available.
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
* (Эпик 3 invariant), поэтому L2 авторитетная сумма для CRPS-знаменателя.
* Если кошелёк ещё не создан (никто не инвестировал) нулевой баланс.
*/
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
wallets2_index wallets(_ledger2, coopname.value);
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
return it->available;
return it->available + it->blocked;
}
/**
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
*
* Источник `userwallets[(w.cap.blago, username)]`. Запись существует
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
* поэтому отсутствие = нулевой баланс.
*/
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
userwallets_index user_wallets(_ledger2, coopname.value);
@@ -36,6 +33,6 @@ namespace Capital::Core {
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
auto it = idx.find(key);
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
return it->available;
return it->available + it->blocked;
}
}
@@ -178,96 +178,8 @@ namespace Capital::Expenses {
Capital::expense_index expenses(_capital, coopname.value);
auto expense = expenses.find(expense_id);
eosio::check(expense != expenses.end(), "Расход не найден");
expenses.erase(expense);
}
} // namespace Capital::Expenses
namespace Capital {
/**
* @brief Таблица программных расходов целевые списания «Благорост»,
* не привязанные к конкретному проекту.
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): progexpenses
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
uint64_t id; ///< Внутренний ключ
name coopname; ///< Имя кооператива
name username; ///< Инициатор расхода (председатель / казначей)
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
uint64_t fund_id; ///< Идентификатор фонда списания
eosio::asset amount; ///< Сумма расхода
std::string description; ///< Описание (для отчётности)
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
document2 approved_statement; ///< Записка, принятая председателем
document2 authorization; ///< Решение совета
time_point_sec spended_at = current_time_point();
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_expense_hash() const { return expense_hash; }
};
typedef eosio::multi_index<
"progexpenses"_n, program_expense,
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
> program_expense_index;
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
program_expense_index t(_capital, coopname.value);
auto idx = t.get_index<"byhash"_n>();
auto itr = idx.find(hash);
if (itr == idx.end()) return std::nullopt;
return program_expense(*itr);
}
} // namespace Capital
namespace Capital::Expenses {
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
auto v = Capital::get_program_expense(coopname, expense_hash);
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
return v.value();
}
inline void create_program_expense(
eosio::name coopname,
const checksum256 &expense_hash,
eosio::name username,
const eosio::asset &amount,
const std::string &description,
const document2 &statement
) {
Capital::program_expense_index t(_capital, coopname.value);
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
t.emplace(coopname, [&](auto &e) {
e.id = id;
e.coopname = coopname;
e.username = username;
e.expense_hash = expense_hash;
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
e.status = Status::CREATED;
e.spended_at = current_time_point();
e.expense_statement = statement;
e.amount = amount;
e.description = description;
});
}
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.erase(itr);
}
} // namespace Capital::Expenses
@@ -1,7 +1,5 @@
#pragma once
#include <eosio/binary_extension.hpp>
using namespace eosio;
using std::string;
@@ -42,18 +40,7 @@ namespace Capital {
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
config config; ///< Управляемая конфигурация контракта
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
// struct и только через binary_extension — иначе ломается десериализация
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
// перед config напрямую (без extension) и задеплоены на прод — таблица state
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
// пустые), чтобы порядок хвостовых extension оставался консистентным.
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -63,16 +50,6 @@ namespace Capital {
namespace State {
/**
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
* ещё не материализовано (старая запись, созданная до добавления
* program_expense_* через binary_extension).
*/
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
return v.has_value() ? v.value() : asset(0, sym);
}
/**
* @brief Обновляет глобальное состояние новыми значениями.
*
@@ -101,7 +78,7 @@ inline global_state get_global_state(name coopname) {
/**
* @brief Проверяет что контракт инициализирован.
*
*
* @param coopname Имя кооператива.
*/
inline void is_initialized(name coopname) {
@@ -109,116 +86,6 @@ inline void is_initialized(name coopname) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
}
/**
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
* Вызывается из action topupprogexp (председатель).
*/
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма пополнения должна быть положительной");
eosio::check(itr->global_available_invest_pool >= amount,
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
s.global_available_invest_pool -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Резервирует средства под approved/authorized программный расход:
* program_expense_pool -= amount, program_expense_reserved += amount.
*/
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
reserved += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Освобождает зарезервированное при отклонении программного расхода:
* program_expense_reserved -= amount, program_expense_pool += amount.
*/
inline void release_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
pool += amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Окончательное списание программного расхода (callback onpgexpdone, CLOSED):
* program_expense_reserved -= amount. Деньги уже ушли через шасси expense,
* ledger2-проводки сделаны на payexp/reportexp/returnexp.
*/
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
reserved -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
/**
* @brief Прямое списание из свободного пула программных расходов превышение
* факта над резервом при закрытии расхода с перерасходом (overspendexp
* в шасси выдал доплату из BLAGOROST_FUND сверх зарезервированного).
*/
inline void spend_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
global_state_inst.modify(itr, _capital, [&](auto &s) {
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
pool -= amount;
s.program_expense_pool = pool;
s.program_expense_reserved = reserved;
});
}
}// namespace State
}// namespace Capital
@@ -1,10 +0,0 @@
cmake_minimum_required(VERSION 3.5)
project(expense)
find_package(cdt)
add_contract(expense expense expense.cpp)
if(IS_TESTNET)
target_compile_definitions(expense PUBLIC IS_TESTNET=1)
endif()
@@ -1,425 +0,0 @@
// expense.cpp — шасси расходов (первый потребитель: Благорост).
//
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
// Кошелёк-пул приходит в createexp параметром source_wallet; ledger2-коды всех
// операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
// (фабричная настройка — подключение нового пула не меняет этот контракт).
// Механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
// callback-handler сохраняется как переменная.
#include "expense.hpp"
namespace D = ExpenseDomain;
// ── helpers ──────────────────────────────────────────────────────────────────
namespace {
asset zero_like(const asset& a) { return asset{0, a.symbol}; }
asset sum_planned(const std::vector<D::item>& items) {
if (items.empty()) return asset{0, eosio::symbol{"RUB", 4}};
asset total = zero_like(items.front().planned_amount);
for (const auto& it : items) total += it.planned_amount;
return total;
}
// Набор ledger2-кодов операций шасси по кошельку-источнику СЗ. Один источник =
// один набор (EXPENSE_OPERATION_SETS в ledger2) — в коде expense нет
// захардкоженных operation_code, контракт настраивается на любой пул.
const ExpenseOperationSet& expense_ops_for(eosio::name source_wallet) {
const auto* set = find_expense_operation_set(source_wallet);
eosio::check(set != nullptr,
"Для кошелька-источника не настроен набор операций шасси расходов (EXPENSE_OPERATION_SETS)");
return *set;
}
D::proposals_index get_proposals(eosio::name self, eosio::name coopname) {
return D::proposals_index(self, coopname.value);
}
auto find_proposal(D::proposals_index& tbl, const eosio::checksum256& proposal_hash) {
auto idx = tbl.get_index<"byhash"_n>();
return idx.find(proposal_hash);
}
void send_callback_if_any(const D::callback_handler& cb,
eosio::name coopname,
const eosio::checksum256& proposal_hash,
uint8_t status,
const eosio::asset& amount,
eosio::name self) {
if (cb.contract.value == 0) return;
// Payload: (coopname, proposal_hash, status, total_actual, data).
// status = ExpenseDomain::ProposalStatus — инициатор различает CLOSED vs DECLINED.
// coopname нужен инициатору для скоупа таблиц (capital::progexpenses scope=coopname).
eosio::action(
eosio::permission_level{self, "active"_n},
cb.contract,
cb.action,
std::make_tuple(coopname, proposal_hash, status, amount, cb.data)
).send();
}
} // namespace
// ── actions ──────────────────────────────────────────────────────────────────
void expense::createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<D::item> items,
D::callback_handler callback,
document2 statement) {
// Прямой вызов backend'а — авторизация кооператива; inline от контракта-инициатора
// (capital::createpgexp и т.п.) — авторизация по whitelist, как в ledger2::apply.
if (!has_auth(coopname)) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
}
verify_document_or_fail(statement, {username});
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
eosio::check(ledger2_is_known_wallet(source_wallet),
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
// Источник определяет ledger2-коды всех операций жизненного цикла —
// отказываем сразу при создании, а не в момент оплаты.
expense_ops_for(source_wallet);
auto tbl = get_proposals(get_self(), coopname);
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
"СЗ с таким proposal_hash уже существует");
// Механика оплаты задаётся на каждом item отдельно (ADVANCE | DIRECT) —
// в одном СЗ допустимо смешение: ledger2-проводка payexp идёт по-позиционно
// с кодом, выведенным из механики конкретного item'а.
for (auto& it : items) {
eosio::check(it.planned_amount.is_valid() && it.planned_amount.amount > 0,
"planned_amount каждого item должен быть положительным");
eosio::check(it.mechanics <= static_cast<uint8_t>(D::Mechanics::DIRECT),
"Неизвестная mechanics item (0 = ADVANCE, 1 = DIRECT)");
eosio::check(it.recipient_type <= static_cast<uint8_t>(D::RecipientType::ORG),
"Неизвестный recipient_type item");
// Инвариант: пайщик (SELF/MEMBER) получает только аванс под отчёт на личные
// реквизиты (расходом станет после его отчёта чеком); организация — только
// прямую оплату по выставленным реквизитам, аванс ей не выдаётся.
if (it.recipient_type == static_cast<uint8_t>(D::RecipientType::ORG)) {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::DIRECT),
"Организации/ИП доступна только оплата по счёту (DIRECT)");
} else {
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::ADVANCE),
"Пайщику средства выдаются только авансом под отчёт (ADVANCE)");
eosio::check(it.recipient != name(),
"recipient обязателен для получателя-пайщика");
eosio::check(is_account(it.recipient),
"Аккаунт получателя-пайщика не существует");
}
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
it.actual_amount = zero_like(it.planned_amount);
}
const auto total = sum_planned(items);
tbl.emplace(get_self(), [&](auto& row) {
row.id = tbl.available_primary_key();
row.coopname = coopname;
row.proposal_hash = proposal_hash;
row.username = username;
row.source_wallet = source_wallet;
row.status = static_cast<uint8_t>(D::ProposalStatus::CREATED);
row.items = items;
row.total_planned = total;
row.total_actual = zero_like(total);
row.callback = callback;
row.statement_doc = statement;
row.decision_doc = document2{};
row.created_at = eosio::current_time_point();
row.updated_at = row.created_at;
});
// СЗ утверждает совет (как возвраты паевых взносов): ставим вопрос в повестку.
// Утверждение председателем после голосования → callback authexp от _soviet;
// отклонение (отрицательный консенсус / просрочка) → declexp с причиной.
::Soviet::create_agenda(
_expense,
coopname,
username,
get_valid_soviet_action("createexp"_n),
proposal_hash,
_expense,
"authexp"_n,
"declexp"_n,
statement,
std::string("")
);
}
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED),
"Авторизовать можно только СЗ в статусе CREATED");
verify_document_or_fail(decision);
idx.modify(it, get_self(), [&](auto& row) {
row.decision_doc = decision;
row.status = static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED);
row.updated_at = eosio::current_time_point();
});
}
void expense::declexp(name coopname, checksum256 proposal_hash, std::string reason) {
require_auth(_soviet);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
// Отклонение возможно только пока ни один item не оплачен: после payexp деньги
// уже ушли через ledger2, и decline разъехался бы с учётом инициатора
// (callback DECLINED возвращает инициатору весь резерв). Частично исполненный
// СЗ завершается обычным путём: reportexp / returnexp → closeexp.
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED),
"Отклонить можно только СЗ без оплат (статусы CREATED / AUTHORIZED)");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
row.updated_at = eosio::current_time_point();
});
// Callback инициатору (capital::onpgexpdone и т.п.); total_actual здесь всегда 0 —
// оплат до decline не было (см. проверку статусов выше).
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::DECLINED),
it->total_actual, get_self());
}
void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED) ||
it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Оплата возможна только из статусов AUTHORIZED / PARTIALLY_PAID");
eosio::check(actual_amount.is_valid() && actual_amount.amount > 0,
"actual_amount должен быть положительным");
bool item_found = false;
uint8_t paid_mechanics = 0;
name paid_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::APPROVED),
"Item уже оплачен или закрыт");
eosio::check(actual_amount.symbol == i.planned_amount.symbol,
"Символ actual_amount не совпадает с планом item");
eosio::check(actual_amount.amount <= i.planned_amount.amount,
"Сумма оплаты не может превышать план item; доплата сверх плана — через overspendexp");
i.actual_amount = actual_amount;
// DIRECT не имеет фазы подотчёта (нет ADVANCE_HOLD): закрывающие документы
// прикладывает сам кассир, item считается отчитанным сразу после оплаты.
i.status = static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::DIRECT
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
: static_cast<uint8_t>(D::ItemStatus::PAID);
paid_mechanics = i.mechanics;
paid_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += actual_amount;
// DIRECT-only СЗ становится готов к закрытию сразу после последней оплаты.
row.status = all_reported
? static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED)
: static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
row.updated_at = eosio::current_time_point();
});
// ledger2-проводка по механике оплаченного item'а — выдача аванса или прямая
// оплата; коды выводятся из кошелька-источника СЗ. СЗ может смешивать механики.
// username нужен ledger2 только для USER_SHARED-стороны (кошелёк-подотчёт) —
// это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
const auto& ops = expense_ops_for(it->source_wallet);
const auto code = static_cast<D::Mechanics>(paid_mechanics) == D::Mechanics::ADVANCE
? ops.advance
: ops.direct;
Ledger2::apply(get_self(), coopname, code, actual_amount,
paid_recipient, proposal_hash, "expense:payexp");
}
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Отчёт возможен только по СЗ в статусе PARTIALLY_PAID");
asset item_amount = asset{0, it->total_actual.symbol};
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Отчёт возможен только для оплаченных items");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"reportexp применим только к ADVANCE-механике");
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
item_amount = i.actual_amount;
item_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
all_reported = false;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
if (all_reported) {
row.status = static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED);
}
row.updated_at = eosio::current_time_point();
});
// ledger2: BURN кошелька-подотчёта на остаточный actual item'а (выдано
// возвращено + доплачено), без бухпроводки — проводка уже сделана при выдаче.
// Подотчёт числится на получателе аванса — его username и закрывает burn.
// Полный возврат аванса (actual == 0) — burn не нужен, подотчёт уже пуст.
if (item_amount.amount > 0) {
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).report, item_amount,
item_recipient, proposal_hash, "expense:reportexp");
}
}
void expense::closeexp(name coopname, checksum256 proposal_hash) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED),
"Закрыть можно только СЗ в статусе REPORT_SUBMITTED");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::CLOSED);
row.updated_at = eosio::current_time_point();
});
// Callback на финализацию (capital::onpgexpdone и т.п.) — если был установлен при createexp.
send_callback_if_any(it->callback, coopname, proposal_hash,
static_cast<uint8_t>(D::ProposalStatus::CLOSED),
it->total_actual, get_self());
}
void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount) {
require_auth(coopname);
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Возврат возможен только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"returnexp применим только к ADVANCE-механике");
eosio::check(return_amount.is_valid() &&
return_amount.symbol == i.actual_amount.symbol,
"Символ return_amount не совпадает с item");
eosio::check(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
"return_amount должен быть положительным и не превышать actual_amount");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — после
// возврата остатка получатель штатно отчитывается reportexp по
// фактически потраченной части (при полном возврате — actual == 0).
i.actual_amount -= return_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual -= return_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: TRANSFER подотчёт → пул-источник (зеркало выдачи аванса).
// Подотчёт снимается с получателя аванса.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).refund, return_amount,
item_recipient, proposal_hash, "expense:returnexp");
}
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount) {
require_auth(coopname);
eosio::check(overspend_amount.amount > 0, "overspend_amount должен быть положительным");
auto tbl = get_proposals(get_self(), coopname);
auto idx = tbl.get_index<"byhash"_n>();
auto it = idx.find(proposal_hash);
eosio::check(it != idx.end(), "СЗ не найден");
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
"Перерасход регистрируется только из статуса PAID");
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
"overspendexp применим только к ADVANCE-механике");
eosio::check(overspend_amount.is_valid() &&
overspend_amount.symbol == i.actual_amount.symbol,
"Символ overspend_amount не совпадает с item");
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — подотчёт
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
// который сделает burn ADVANCE_HOLD на итоговый actual.
i.actual_amount += overspend_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += overspend_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: доплата перерасхода — TRANSFER пул-источник → подотчёт получателя
// (зеркало выдачи аванса на сумму доплаты). Закрытие на полную сумму — в reportexp.
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).overspend, overspend_amount,
item_recipient, proposal_hash, "expense:overspend");
}
@@ -1,233 +0,0 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include "../lib/index.hpp"
using namespace eosio;
using std::string;
/**
* @defgroup public_expense Контракт EXPENSE
* @brief Шасси расходов цифрового кооператива (MVP Благорост).
*
* Универсальный контракт: principal storage = СЗ-расход с массивом items;
* кошелёк-источник (пул) приходит параметром source_wallet, ledger2-коды всех
* операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
* (ledger2/operations.hpp) подключение нового пула не меняет контракт;
* механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
* callback на финализацию (контракт+action+data) сохраняется при createexp
* как переменная `expense` агностичен к программе-получателю.
*
* See: components/desktop/extensions/expenses/NAMING-C28-28.md (v3).
*/
namespace ExpenseDomain {
/**
* @brief Способ оплаты item-а в СЗ.
* ADVANCE пайщик-получатель оплачивает, затем приносит чек.
* DIRECT кассир/председатель платит организации напрямую.
*/
enum class Mechanics : uint8_t {
ADVANCE = 0,
DIRECT = 1,
};
/**
* @brief Получатель платежа в item-е.
* SELF сам создатель СЗ.
* MEMBER другой пайщик.
* ORG внешняя организация.
*/
enum class RecipientType : uint8_t {
SELF = 0,
MEMBER = 1,
ORG = 2,
};
enum class ProposalStatus : uint8_t {
CREATED = 0, ///< СЗ создан, ждёт подписи signact1 создателя.
AUTHORIZED = 1, ///< СЗ подписан signact2 (совет), готов к оплате.
PARTIALLY_PAID = 2, ///< Часть item-ов оплачена.
REPORT_SUBMITTED = 3, ///< Отчёт о расходе подан, ждёт авторизации совета.
CLOSED = 4, ///< СЗ-отчёт авторизован — расход закрыт.
DECLINED = 5, ///< СЗ отклонён до или после авторизации.
};
enum class ItemStatus : uint8_t {
APPROVED = 0, ///< Запланирован, ждёт оплаты.
PAID = 1, ///< Оплачен (ADVANCE — выдан пайщику; DIRECT — оплачен организации).
REPORTED = 2, ///< Закрыт отчётом (чек приложен). DIRECT-item получает его сразу при payexp.
RETURNED = 3, ///< Зарезервировано, не используется: returnexp уменьшает actual_amount, статус остаётся PAID до отчёта.
OVERSPENT = 4, ///< Зарезервировано, не используется: overspendexp увеличивает actual_amount, статус остаётся PAID до отчёта.
};
/**
* @brief Callback на финализацию устанавливается при createexp как переменная.
*
* Контракт expense не знает про capital/blagorost он просто вызывает inline
* action на (contract, action) и прокидывает data наружу. Пустой contract = нет callback.
*
* Пример: для расхода в РИД-проект Благороста UI/backend заполняет
* { "capital"_n, "onexpreport"_n, packed(wip_project_hash) }.
*/
struct callback_handler {
eosio::name contract;
eosio::name action;
std::vector<char> data;
EOSLIB_SERIALIZE(callback_handler, (contract)(action)(data))
};
/**
* @brief Item одна строка СЗ-расхода.
*/
struct item {
eosio::checksum256 item_hash;
uint8_t mechanics; ///< Mechanics
uint8_t recipient_type; ///< RecipientType
eosio::name recipient; ///< username (для SELF/MEMBER), либо name-идентификатор (для ORG)
std::string description;
eosio::asset planned_amount;
eosio::asset actual_amount; ///< 0 пока не оплачено / не отчиталось
uint8_t status; ///< ItemStatus
EOSLIB_SERIALIZE(item, (item_hash)(mechanics)(recipient_type)(recipient)(description)(planned_amount)(actual_amount)(status))
};
/**
* @brief Таблица СЗ-расходов. Scope = coopname.
*/
struct [[eosio::table, eosio::contract("expense")]] proposal {
uint64_t id;
eosio::name coopname; ///< кооператив (= scope таблицы; дублируем полем по канону)
eosio::checksum256 proposal_hash;
eosio::name username; ///< создатель
eosio::name source_wallet; ///< семантически — из какого ЦПП/фонда (w.cap.blago, w.sov.expns, …)
uint8_t status; ///< ProposalStatus
std::vector<item> items;
eosio::asset total_planned;
eosio::asset total_actual;
callback_handler callback; ///< опционально; contract.value == 0 = нет callback
document2 statement_doc; ///< type=2010, signact1 создателя
document2 decision_doc; ///< type=2011, signact2 совета
eosio::time_point_sec created_at;
eosio::time_point_sec updated_at;
uint64_t primary_key() const { return id; }
eosio::checksum256 by_hash() const { return proposal_hash; }
uint64_t by_username() const { return username.value; }
uint64_t by_status() const { return static_cast<uint64_t>(status); }
EOSLIB_SERIALIZE(proposal,
(id)(coopname)(proposal_hash)(username)(source_wallet)(status)
(items)(total_planned)(total_actual)(callback)(statement_doc)(decision_doc)
(created_at)(updated_at))
};
using proposals_index = eosio::multi_index<"proposals"_n, proposal,
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<proposal, eosio::checksum256, &proposal::by_hash>>,
eosio::indexed_by<"byusername"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_username>>,
eosio::indexed_by<"bystatus"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_status>>
>;
} // namespace ExpenseDomain
/**
* @ingroup public_contracts
* @brief Шасси расходов 8 actions, MVP только Благорост.
*/
class [[eosio::contract("expense")]] expense : public contract {
public:
using contract::contract;
/**
* @brief Создать и подать СЗ-расход.
*
* Создание и подача одна транзакция (slug createexp). На входе: items + источник
* (source_wallet) + опц. callback на финализацию.
* Подписывает создатель (signact1 statement_doc, type=2010).
*
* Авторизация: coopname (прямой вызов backend'а) либо контракт-инициатор из
* contracts_whitelist (inline, например capital::createpgexp).
* Механика оплаты per-item (item.mechanics: ADVANCE | DIRECT); ledger2-код
* операции выводится из механики позиции в момент оплаты payexp.
*
* Ставит вопрос в повестку совета (soviet::createagenda, тип createexp):
* утверждение совета callback authexp, отклонение declexp.
*/
[[eosio::action]]
void createexp(name coopname, name username,
checksum256 proposal_hash,
name source_wallet,
std::vector<ExpenseDomain::item> items,
ExpenseDomain::callback_handler callback,
document2 statement);
/**
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
* Callback решения совета вызывается только контрактом soviet после
* голосования и утверждения председателем. После этого расход доступен для оплаты.
*/
[[eosio::action]]
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
/**
* @brief Отклонить СЗ. Callback решения совета (отрицательный консенсус
* голосов «против» либо просрочка повестки) вызывается только контрактом
* soviet. Возможно только до первой оплаты (CREATED / AUTHORIZED):
* после payexp средства уже ушли через ledger2, и СЗ завершается обычным
* путём reportexp / returnexp closeexp.
*/
[[eosio::action]]
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
/**
* @brief Оплатить item выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
* Контракт зовёт Ledger2::apply с кодом из набора операций кошелька-источника
* (EXPENSE_OPERATION_SETS: source_wallet + механика item'а operation_code).
* actual_amount не может превышать план item (доплата через overspendexp).
* DIRECT-item не имеет фазы подотчёта и помечается REPORTED сразу; когда все
* items REPORTED proposal переходит в REPORT_SUBMITTED прямо из payexp.
*/
[[eosio::action]]
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount);
/**
* @brief Отчёт о расходе (ADVANCE) пайщик закрывает item чеком.
* Зовёт Ledger2::apply(o.exp.advrpt) на остаточный actual item'а (при полном
* возврате аванса burn пропускается). Когда все items reported статус
* proposal становится REPORT_SUBMITTED.
*/
[[eosio::action]]
void reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash);
/**
* @brief Закрытие расхода советом финальный signact2 СЗ-отчёта.
* Если у proposal заполнен callback отправляет inline action на (callback.contract, callback.action, callback.data).
*/
[[eosio::action]]
void closeexp(name coopname, checksum256 proposal_hash);
/**
* @brief Возврат неиспользованного аванса (ADVANCE-остаток).
* Settlement-запись: уменьшает actual_amount item'а, статус остаётся PAID
* фактически потраченная часть закрывается затем штатным reportexp.
* Зовёт Ledger2::apply(o.exp.advret).
*/
[[eosio::action]]
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount);
/**
* @brief Доплата при перерасходе (ADVANCE).
* Settlement-запись: увеличивает actual_amount item'а, статус остаётся PAID.
* Зовёт Ledger2::apply(o.exp.over) выдача доплаты; закрытие подотчёта на
* полную сумму (выдано + доплата) делает последующий reportexp.
*/
[[eosio::action]]
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount);
};
@@ -67,12 +67,12 @@ void ledger2::revert(eosio::name coopname,
eosio::check(original_operation_id != 0, "revert: original_operation_id обязателен");
// -------- validate mirror_wallet_op --------
// Зеркало revert — только TRANSFER (обмен wallet_from/wallet_to) либо BURN
// (зеркало ISSUE: изъятие с wallet_from). Бывшие BLOCK/UNBLOCK упразднены
// (2026-05-24); ISSUE/NONE как зеркало смысла не имеют.
eosio::check(mirror_wallet_op == static_cast<uint8_t>(WalletOp::TRANSFER) ||
mirror_wallet_op == static_cast<uint8_t>(WalletOp::BURN),
"revert: mirror_wallet_op должен быть TRANSFER или BURN");
eosio::check(mirror_wallet_op <= 5, "revert: неизвестный mirror_wallet_op");
// Не позволяем откатывать через BLOCK/UNBLOCK — они асимметричны и нет
// адекватного зеркала в одной операции (BLOCK + UNBLOCK — обратные сами по себе).
eosio::check(mirror_wallet_op != static_cast<uint8_t>(WalletOp::BLOCK) &&
mirror_wallet_op != static_cast<uint8_t>(WalletOp::UNBLOCK),
"revert: BLOCK/UNBLOCK не подлежат откату через revert (они симметричны сами себе)");
// -------- validate mirror wallets/accounts --------
if (mirror_wallet_from.value != 0) {
@@ -1,5 +1,5 @@
/**
* @brief Атомарная операция по кошельку (issue/transfer/burn).
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
*
* Внутренний action ledger2 вызывается только через inline из apply().
* Auth: только сам ledger2 (require_auth(get_self())).
@@ -53,15 +53,13 @@ void ledger2::walletop(eosio::name coopname,
eosio::check(amount.symbol == _root_govern_symbol,
"walletop: некорректный символ валюты");
eosio::check(memo.size() < 256, "walletop: memo > 255");
// Допустимы только ISSUE(0), TRANSFER(1), BURN(4). NONE(5) — это только
// бухпроводка без кошелькового движения, apply.cpp для неё walletop не диспатчит;
// прямой вызов был бы no-op и сбил бы parity. Бывшие BLOCK(2)/UNBLOCK(3)/
// BURN_BLOCKED(6) упразднены (2026-05-24): резерв возврата теперь TRANSFER на
// w.wal.wpend, их op_code больше не валиден.
eosio::check(op_code == static_cast<uint8_t>(WalletOp::ISSUE) ||
op_code == static_cast<uint8_t>(WalletOp::TRANSFER) ||
op_code == static_cast<uint8_t>(WalletOp::BURN),
"walletop: недопустимый op_code");
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
op_code != static_cast<uint8_t>(WalletOp::NONE),
"walletop: неизвестный op_code");
wallets2_index wallets(get_self(), coopname.value);
userwallets_index user_wallets(get_self(), coopname.value);
@@ -174,7 +172,7 @@ void ledger2::walletop(eosio::name coopname,
// миграций 048/049.
//
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
// ниже case'ы ISSUE/TRANSFER/BURN), а sender-guard на
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
// вне транзакционного hot path.
@@ -229,6 +227,60 @@ void ledger2::walletop(eosio::name coopname,
cleanup_l3_if_empty(wallet_from);
break;
}
case WalletOp::BLOCK: {
eosio::check(wallet_from.value != 0, "walletop BLOCK: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop BLOCK: wallet_to должен быть пустым");
auto it = wallets.find(wallet_from.value);
eosio::check(it != wallets.end() && it->available >= amount,
std::string{"walletop BLOCK: недостаточно available на кошельке "} +
wallet_from.to_string());
wallets.modify(it, payer, [&](auto& w) {
w.available -= amount;
w.blocked += amount;
});
if (is_user_shared_l3(wallet_from)) {
auto uw = find_l3(wallet_from);
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
uw->available >= amount,
std::string{"walletop BLOCK: недостаточно L3-available у пайщика "} +
username.to_string() + " на " + wallet_from.to_string());
auto uw_pri = user_wallets.find(uw->id);
user_wallets.modify(uw_pri, payer, [&](auto& r) {
r.available -= amount;
r.blocked += amount;
});
}
break;
}
case WalletOp::UNBLOCK: {
eosio::check(wallet_from.value != 0, "walletop UNBLOCK: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop UNBLOCK: wallet_to должен быть пустым");
auto it = wallets.find(wallet_from.value);
eosio::check(it != wallets.end() && it->blocked >= amount,
std::string{"walletop UNBLOCK: недостаточно blocked на кошельке "} +
wallet_from.to_string());
wallets.modify(it, payer, [&](auto& w) {
w.blocked -= amount;
w.available += amount;
});
if (is_user_shared_l3(wallet_from)) {
auto uw = find_l3(wallet_from);
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
uw->blocked >= amount,
std::string{"walletop UNBLOCK: недостаточно L3-blocked у пайщика "} +
username.to_string() + " на " + wallet_from.to_string());
auto uw_pri = user_wallets.find(uw->id);
user_wallets.modify(uw_pri, payer, [&](auto& r) {
r.blocked -= amount;
r.available += amount;
});
}
break;
}
case WalletOp::BURN: {
eosio::check(wallet_from.value != 0, "walletop BURN: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop BURN: wallet_to должен быть пустым");
@@ -259,6 +311,30 @@ void ledger2::walletop(eosio::name coopname,
eosio::check(false, "walletop NONE: запрещённый op_code");
break;
}
case WalletOp::BURN_BLOCKED: {
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
auto it = wallets.find(wallet_from.value);
eosio::check(it != wallets.end() && it->blocked >= amount,
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
wallet_from.to_string());
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
if (is_user_shared_l3(wallet_from)) {
auto uw = find_l3(wallet_from);
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
uw->blocked >= amount,
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
username.to_string() + " на " + wallet_from.to_string());
auto uw_pri = user_wallets.find(uw->id);
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
}
cleanup_l2_if_empty(wallet_from);
cleanup_l3_if_empty(wallet_from);
break;
}
}
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
@@ -1,4 +1,4 @@
#include <array>
#include <vector>
/**
* @brief Универсальное миграционное действие контракта ledger2 точка
@@ -9,33 +9,23 @@
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
* (как в `capital::migrate`).
*
* Текущая задача (2026-05-24): свёртка `blocked available` по ВСЕМ коопам.
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
*
* Контекст: механика «заблокированного» баланса упразднена (см.
* `lib/core/ledger2/operations.hpp` удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
* соблюдался только на проекте, не на пайщике.
*
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
* каждого свернуть `blocked available` на уровнях L2 (`wallets2`) и L3
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
* не меняется только субсчёт.
* Архитектурный фикс: w.cap.gen COOPERATIVE-пул без L3. Чтобы UI/бэкенд
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
* (синхронен с Σ старых userwallets), его не трогаем.
*
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон no-op.
*
* Сигнатура без аргументов действие вызывается автоматически при деплое
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
*
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) их `blocked`
* относится к старой механике и при свёртке в `available` вернётся пайщику как
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
* деплоя с этой миграцией.
* Идемпотентно: повторный вызов на чистой БД no-op (lower_bound пуст).
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
*
* @ingroup public_ledger2_actions
*
@@ -44,101 +34,24 @@
void ledger2::migrate() {
require_auth(get_self());
cooperatives2_index coops(_registrator, _registrator.value);
const eosio::name target_coop = "voskhod"_n;
for (auto c = coops.begin(); c != coops.end(); ++c) {
const eosio::name coopname = c->username;
userwallets_index user_wallets(get_self(), target_coop.value);
auto idx = user_wallets.get_index<"bywallet"_n>();
// --- L3: userwallets[coopname] ---
// Модифицируем только не-ключевые поля (available/blocked) — итерация по
// первичному индексу с modify безопасна (порядок строк не меняется).
userwallets_index user_wallets(get_self(), coopname.value);
for (auto it = user_wallets.begin(); it != user_wallets.end(); ++it) {
if (it->blocked.amount <= 0) continue;
user_wallets.modify(it, get_self(), [&](auto& r) {
r.available += r.blocked;
r.blocked = eosio::asset(0, r.blocked.symbol);
});
}
// --- L2: wallets2[coopname] ---
wallets2_index wallets(get_self(), coopname.value);
for (auto it = wallets.begin(); it != wallets.end(); ++it) {
if (it->blocked.amount <= 0) continue;
wallets.modify(it, get_self(), [&](auto& w) {
w.available += w.blocked;
w.blocked = eosio::asset(0, w.blocked.symbol);
});
}
// Собираем primary id записей до erase (модификация контейнера при
// итерации через secondary index — небезопасна).
std::vector<uint64_t> ids_to_erase;
for (auto it = idx.lower_bound(ledger2_wallets::GENERATOR_FUND.value);
it != idx.end() && it->wallet_name == ledger2_wallets::GENERATOR_FUND;
++it) {
ids_to_erase.push_back(it->id);
}
// ──────────────────────────────────────────────────────────────────────────
// Разовая коррекция СЧЕТОВ под удаление фантомных пайщиков fgrtejiwnynn
// (инцидент 2026-06-04). Каждый из 6 дублей-аккаунтов при регистрации внёс
// минимальный паевой 300 RUB (o.reg.putmin: Dr 51 / Cr 80, ISSUE w.reg.minshr).
// Эти суммы — фантомные (нет реального пайщика), их нужно убрать из паевого
// фонда и с расчётного счёта. Снимаем L3-доли фантомов на w.reg.minshr,
// суммируем фактически снятое и ровно на эту сумму уменьшаем L2-кошелёк и
// бухсчета 80 (Cr) и 51 (Dr). Реальные пайщики (их доли на том же w.reg.minshr
// и счетах) не затрагиваются — трогаем только перечисленные username.
//
// Список синхронизирован с registrator::migrate и soviet::migrate.
// Идемпотентно: после первого прогона L3-строк фантомов нет → removed == 0 →
// L2/бухсчета не трогаются (вся коррекция в одной atomic-tx).
{
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
const std::array<eosio::name, 6> PHANTOMS = {
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
};
const eosio::name MINSHR = ledger2_wallets::MIN_SHARE_FUND; // w.reg.minshr
eosio::asset removed(0, _root_govern_symbol);
// 1) Снимаем L3-доли фантомов на w.reg.minshr, суммируем снятое.
userwallets_index uw(get_self(), PHANTOM_COOP.value);
auto uw_byuser = uw.get_index<"byuser"_n>();
for (const auto& u : PHANTOMS) {
auto it = uw_byuser.lower_bound(u.value);
while (it != uw_byuser.end() && it->username == u) {
if (it->wallet_name == MINSHR) {
removed += it->available + it->blocked;
it = uw_byuser.erase(it);
} else {
++it;
}
}
}
if (removed.amount > 0) {
// 2) L2 w.reg.minshr: available -= removed; удалить кошелёк, если опустел.
wallets2_index w2(get_self(), PHANTOM_COOP.value);
auto wit = w2.find(MINSHR.value);
if (wit != w2.end()) {
w2.modify(wit, get_self(), [&](auto& w) { w.available -= removed; });
if (wit->is_empty()) w2.erase(wit);
}
// 3) Бухсчета (scope=coopname): Cr 80 (паевой фонд) -= removed,
// Dr 51 (расчётный счёт) -= removed; пересчёт сальдо по типу счёта.
// Инвариант Σ Dr == Σ Cr сохраняется (обе стороны уменьшены на removed).
accounts2_index acc(get_self(), PHANTOM_COOP.value);
auto a80 = acc.find(ledger2_accounts::SHARE_FUND);
if (a80 != acc.end()) {
acc.modify(a80, get_self(), [&](auto& a) {
a.credit_balance -= removed;
a.balance = account2::compute_balance(a.account_type, a.debit_balance, a.credit_balance);
});
}
auto a51 = acc.find(ledger2_accounts::BANK_ACCOUNT);
if (a51 != acc.end()) {
acc.modify(a51, get_self(), [&](auto& a) {
a.debit_balance -= removed;
a.balance = account2::compute_balance(a.account_type, a.debit_balance, a.credit_balance);
});
}
for (uint64_t id : ids_to_erase) {
auto pri = user_wallets.find(id);
if (pri != user_wallets.end()) {
user_wallets.erase(pri);
}
}
}
+5 -11
View File
@@ -80,13 +80,12 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
static const std::set<eosio::name> soviet_actions = {
"joincoop"_n, //регистрация пайщика
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
//MEET
"creategm"_n,//предложение повестки планового общего собрание
"completegm"_n, //решение общего собрания пайщиков
@@ -103,10 +102,7 @@ static const std::set<eosio::name> soviet_actions = {
//WALLET
"createwthd"_n, //создать заявление на возврат паевого взноса
//EXPENSE
"createexp"_n, //служебная записка-смета о расходах (шасси расходов)
//LEDGER
"ledgerwthd"_n, //заявление на списание со счета через ledger
@@ -169,7 +165,6 @@ static constexpr uint64_t _capital_program_id = 4;
static constexpr eosio::name _ledger = "ledger"_n;
static constexpr eosio::name _ledger2 = "ledger2"_n;
static constexpr eosio::name _apps = "apps"_n;
static constexpr eosio::name _expense = "expense"_n;
static constexpr eosio::name _power_account = "eosio.power"_n;
static constexpr eosio::name _saving_account = "eosio.saving"_n;
@@ -198,8 +193,7 @@ static constexpr uint64_t _capital_program_id = 4;
"ledger"_n,
"ledger2"_n,
"capital"_n,
"apps"_n,
"expense"_n
"apps"_n
// Добавьте остальные стандартные или пользовательские контракты по необходимости
};
@@ -245,7 +239,7 @@ static constexpr uint64_t _capital_program_id = 4;
#ifdef IS_TESTNET
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
#else
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
#endif
/**
@@ -23,11 +23,6 @@ enum class AccountType : uint8_t {
ACTIVE_PASSIVE = 2,
};
// Пересмотр 2026-06-06: добавлен счёт 76 «Расчёты с пайщиками» (А-П) —
// суспенс для регистрационного взноса между приёмом денег кассой
// (Dr 51 / Cr 76) и решением совета. На одобрении переносится на 80/86
// (Dr 76 / Cr 80, Dr 76 / Cr 86), на отказе возвращается (Dr 76 / Cr 51).
/**
* @brief План счетов ledger2 (MVP) со смещением *1000.
*
@@ -37,13 +32,12 @@ enum class AccountType : uint8_t {
* состояния «принятый коммит» (паевой взнос имуществом в переходе РИД
* в программу Благорост): commit Dr 08 / Cr 80, accept Dr 04 / Cr 08.
*
* Состав (7 счетов):
* Состав (6 счетов):
*
* - 04 Нематериальные активы (РИД, принятые в паевой фонд)
* - 08 Вложения во внеоборотные активы (промежуточное состояние)
* - 51 Расчётный счёт
* - 58 Финансовые вложения (выданные пайщикам беспроцентные займы)
* - 76 Расчёты с пайщиками (суспенс регистрационного взноса до решения совета)
* - 80 Паевой фонд (складочный капитал)
* - 86 Целевое финансирование (без субсчетов)
*
@@ -61,9 +55,6 @@ struct ledger2_accounts {
static constexpr uint64_t BANK_ACCOUNT = 51 * 1000; ///< 51 — Расчётный счёт (А)
static constexpr uint64_t FINANCIAL_INVESTMENTS = 58 * 1000; ///< 58 — Финансовые вложения (А)
// Активно-пассивные
static constexpr uint64_t PARTICIPANT_SETTLEMENTS = 76 * 1000; ///< 76 — Расчёты с пайщиками (А-П): суспенс регистрационного взноса до решения совета
// Пассивы
static constexpr uint64_t SHARE_FUND = 80 * 1000; ///< 80 — Паевой фонд (П)
static constexpr uint64_t TARGET_RECEIPTS = 86 * 1000; ///< 86 — Целевое финансирование (П)
@@ -88,12 +79,11 @@ struct Ledger2AccountMeta {
* `constexpr std::array` + `string_view` чтобы не было dynamic init
* при загрузке контракта и тип был полностью заморожен на этапе сборки.
*/
inline constexpr std::array<Ledger2AccountMeta, 7> LEDGER2_ACCOUNT_MAP = {{
inline constexpr std::array<Ledger2AccountMeta, 6> LEDGER2_ACCOUNT_MAP = {{
{ ledger2_accounts::INTANGIBLE_ASSETS, "Нематериальные активы", AccountType::ACTIVE },
{ ledger2_accounts::NON_CURRENT_INVESTMENTS, "Вложения во внеоборотные активы", AccountType::ACTIVE },
{ ledger2_accounts::BANK_ACCOUNT, "Расчётный счёт", AccountType::ACTIVE },
{ ledger2_accounts::FINANCIAL_INVESTMENTS, "Финансовые вложения", AccountType::ACTIVE },
{ ledger2_accounts::PARTICIPANT_SETTLEMENTS, "Расчёты с пайщиками", AccountType::ACTIVE_PASSIVE },
{ ledger2_accounts::SHARE_FUND, "Паевой фонд (складочный капитал)", AccountType::PASSIVE },
{ ledger2_accounts::TARGET_RECEIPTS, "Целевое финансирование", AccountType::PASSIVE },
}};
@@ -31,9 +31,9 @@
* - `WalletOp::WALLET_ONLY` удалён (ADR-003): «без бухпроводок» определяется
* парой `(debit_account_id == 0, credit_account_id == 0)` на уровне записи.
* Для `o.cap.invest` теперь TRANSFER без проводок (оба account_id == 0).
* - `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
* без `wallet_to`. Используется в `o.wal.wthcpl` (сжигание резерва возврата
* с `w.wal.wpend`), `o.cap.drppre` и как зеркало ISSUE в `revert`.
* - Добавлен `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
* без `wallet_to`. На текущем этапе в `OPERATION_REGISTRY` не используется
* зарезервирован под будущие операции штатного сжигания.
* - Единые программные кошельки: `BLAGOROST_FUND` (`w.cap.blago`) и
* `GENERATOR_FUND` (`w.cap.gen`) заменили ранее декомпозированные
* `bginv/bgprop/bgrid/bgmem` и `gncom/gnmem` (ADR-009).
@@ -54,22 +54,16 @@ namespace operations {
// registrator
namespace registrator {
inline constexpr eosio::name PAY_ENTRANCE = "o.reg.payent"_n; ///< Оплата вступительного взноса (Dr 51 / Cr 86, ISSUE ENTRANCE_FEES). Одношаговый путь adduser (без совета).
inline constexpr eosio::name PUT_MINSHARE = "o.reg.putmin"_n; ///< Внесение минимального паевого при регистрации (Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND). Одношаговый путь adduser (без совета).
// Двухфазный путь через совет (reguser → confirmpay → confirmreg/declinereg):
inline constexpr eosio::name RECEIVE_PAYMENT = "o.reg.inpay"_n; ///< Приём регистрационного взноса кассой в ожидание решения совета (Dr 51 / Cr 76, ISSUE REGISTRATION_PENDING).
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
inline constexpr eosio::name PAY_ENTRANCE = "o.reg.payent"_n; ///< Оплата вступительного взноса (Dr 51 / Cr 86, ISSUE ENTRANCE_FEES).
inline constexpr eosio::name PUT_MINSHARE = "o.reg.putmin"_n; ///< Внесение минимального паевого при регистрации (Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND).
}
// wallet
namespace wallet {
inline constexpr eosio::name COMPLETE_DEPOSIT = "o.wal.depcpl"_n; ///< Завершение внесения паевого взноса (Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY).
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN с WITHDRAW_PENDING — деньги уходят из системы, без wallet_to).
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING (резерв, без Dr/Cr).
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY (снятие резерва, без Dr/Cr).
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY — деньги уходят из системы, без wallet_to).
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: BLOCK на SHARE_FUND_PAY (без Dr/Cr).
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: UNBLOCK на SHARE_FUND_PAY (без Dr/Cr).
}
// capital
@@ -86,7 +80,6 @@ namespace operations {
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name PROGRAM_EXPENSE_TOPUP = "o.cap.pgtop"_n; ///< Пополнение пула программных расходов из инвестиций программы (ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr — деньги уже на 51, выделяется кооперативный резерв под расходы; паевые L3-кошельки пайщиков не трогаются).
}
// marketplace
@@ -100,27 +93,6 @@ namespace operations {
inline constexpr eosio::name CONVERT_AXN = "o.sov.axncnv"_n; ///< Трансляция паевого взноса в членский (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → DELEGATE_FEES).
}
// expense — шасси расходов (MVP: только Благорост; хозрасходы из членских — отдельный эпик).
//
// Принципы (см. components/desktop/extensions/expenses/NAMING-C28-28.md):
// - Контракт `expense` универсальный: operation_code передаётся в payload.
// - Источник оплат — КООПЕРАТИВНЫЙ пул расходов (PROGRAM_EXPENSE_POOL),
// пополняемый o.cap.pgtop; личные L3-кошельки пайщиков (w.cap.blago)
// при оплатах СЗ не изменяются.
// - При расходе из Благороста паевой фонд (80) НЕ трогается: меняется только форма
// актива 51 → 08 (Дт 08 / Кт 51 для обеих механик).
// - ADVANCE-отчёт (`o.exp.advrpt`) НЕ создаёт новой бухпроводки: проводка уже
// сделана на `o.exp.blgadv` при выдаче.
// - Callback на финализацию — переменная (`callback{contract, action, data}`),
// заполняется при `expense::createexp`; expense ничего не знает про capital.
namespace expense {
inline constexpr eosio::name BLAGO_ADVANCE = "o.exp.blgadv"_n; ///< Выдача подотчётных из пула расходов (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51).
inline constexpr eosio::name BLAGO_DIRECT = "o.exp.blgdir"_n; ///< Прямая оплата из пула расходов (BURN PROGRAM_EXPENSE_POOL, Dr 08 / Cr 51).
inline constexpr eosio::name ADVANCE_REPORT = "o.exp.advrpt"_n; ///< Закрытие подотчёта пайщика (BURN ADVANCE_HOLD, без бухпроводки — canal 08/51 уже сделан на blgadv).
inline constexpr eosio::name ADVANCE_RETURN = "o.exp.advret"_n; ///< Возврат неиспользованного подотчёта (TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL, Dr 51 / Cr 08).
inline constexpr eosio::name OVERSPEND = "o.exp.over"_n; ///< Доплата сверх подотчёта (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51); сразу за ней expense вызывает ADVANCE_REPORT.
}
// migration (только из migrate.cpp)
//
// В OPERATION_REGISTRY включены **только** те транзиты, которые проводятся
@@ -157,23 +129,14 @@ namespace operations {
* `(debit_account_id == 0, credit_account_id == 0)` на уровне записи реестра.
* Compile-time правило `(debit==0) (credit==0)` ловит смешанные пары.
*/
//
// Удаление BLOCK/UNBLOCK/BURN_BLOCKED (2026-05-24): механика «заблокированного»
// баланса упразднена. Резерв средств под заявку на возврат паевого теперь
// выражается переводом на отдельный кошелёк-резерв `w.wal.wpend` (TRANSFER),
// возврат резерва — обратным TRANSFER, завершение — BURN с резерва. Поле
// `blocked` в таблицах wallets2/userwallets оставлено deprecated (всегда 0
// после ledger2::migrate-свёртки) — физическое удаление поля = небезопасная
// смена layout таблицы на живых коопах, выносится в отдельный cleanup-деплой.
//
// Числовые значения ISSUE/TRANSFER/BURN/NONE СОХРАНЕНЫ (не перенумерованы),
// чтобы исторические op_code в blockchain_actions читались бэкендом без сдвига
// смысла (2 и 3 — бывшие BLOCK/UNBLOCK — больше не выдаются и невалидны на входе).
enum class WalletOp : uint8_t {
ISSUE = 0, ///< первичный вход средств на кошелёк wallet_to (wallet_from = empty)
TRANSFER = 1, ///< перемещение wallet_from → wallet_to (с Dr/Cr ИЛИ без — по парам account_id)
BLOCK = 2, ///< available-=amount, blocked+=amount на wallet_from
UNBLOCK = 3, ///< blocked-=amount, available+=amount на wallet_from
BURN = 4, ///< изъятие amount с wallet_from->available, без wallet_to. Покрывает оба кейса: (a) штатное сжигание как бизнес-операция в OPERATION_REGISTRY; (b) зеркало ISSUE при `ledger2::revert` (различие — через operation_code: `o.adj.rev` для adjustment-mirror).
NONE = 5, ///< только бухпроводка без перемещения средств (wallet_from = empty, wallet_to = empty, debit ≠ 0, credit ≠ 0). Покрывает кейсы внутрибалансовых проводок типа Dr 04 / Cr 08 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
BURN_BLOCKED = 6, ///< изъятие amount из wallet_from->blocked, без wallet_to. Используется при завершении возврата паевого (`o.wal.wthcpl`): средства предварительно заблокированы через BLOCK (REQUEST_WITHDRAW), на завершении сжигаются — выходят из системы (получателя на цепи нет). На L3 синхронно: from_uw->blocked -= amount.
};
/**
@@ -182,7 +145,7 @@ enum class WalletOp : uint8_t {
* Семантика полей по `wallet_op`:
* - ISSUE: wallet_from = eosio::name{}, wallet_to = required.
* - TRANSFER: wallet_from = required, wallet_to = required ( from).
* - BURN: wallet_from = required, wallet_to = eosio::name{}.
* - BLOCK / UNBLOCK / BURN / BURN_BLOCKED: wallet_from = required, wallet_to = eosio::name{}.
*
* Семантика бух.проводки:
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
@@ -194,7 +157,7 @@ struct OperationRegistryEntry {
eosio::name process_type; ///< тип процесса с префиксом `p.<contract>.<noun>`
WalletOp wallet_op;
eosio::name wallet_from; ///< пустое имя для ISSUE
eosio::name wallet_to; ///< пустое имя для BURN
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN/BURN_BLOCKED
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
const char* human_name;
@@ -216,53 +179,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
"Минимальный паевой взнос пайщика при регистрации" },
// 2a. Приём регистрационного взноса кассой (поток через совет): Dr 51 / Cr 76, ISSUE REGISTRATION_PENDING.
// Деньги получены, но взнос ещё не признан — висит на расчётах с пайщиком (76)
// до решения совета. Сумма = вступительный + минимальный паевой.
{ operations::registrator::RECEIVE_PAYMENT, processes::registrator::ACCEPT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::REGISTRATION_PENDING,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::PARTICIPANT_SETTLEMENTS,
"Приём регистрационного взноса в ожидание решения совета" },
// 2b. Зачисление минимального паевого по решению совета: Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND.
{ operations::registrator::SETTLE_MINSHARE, processes::registrator::ACCEPT, WalletOp::TRANSFER,
ledger2_wallets::REGISTRATION_PENDING, ledger2_wallets::MIN_SHARE_FUND,
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::SHARE_FUND,
"Зачисление минимального паевого взноса по решению совета" },
// 2c. Зачисление вступительного по решению совета: Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES.
{ operations::registrator::SETTLE_ENTRANCE, processes::registrator::ACCEPT, WalletOp::TRANSFER,
ledger2_wallets::REGISTRATION_PENDING, ledger2_wallets::ENTRANCE_FEES,
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::TARGET_RECEIPTS,
"Зачисление вступительного взноса по решению совета" },
// 2d. Возврат регистрационного взноса при отказе совета: Dr 76 / Cr 51, BURN REGISTRATION_PENDING.
// Отдельный процесс p.reg.refund: приём взноса прерывается, начинается возврат.
// Деньги уходят из системы (банковский перевод кандидату), получателя на цепи нет.
{ operations::registrator::REFUND, processes::registrator::REFUND, WalletOp::BURN,
ledger2_wallets::REGISTRATION_PENDING, eosio::name{},
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
"Возврат регистрационного взноса при отказе совета" },
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
0, 0,
"Перенос минимального паевого на главный при выходе из кооператива" },
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
"Внесение пайщиком паевого взноса" },
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN WITHDRAW_PENDING.
// Сжигание из кошелька-резерва (TRANSFER в резерв был на REQUEST_WITHDRAW):
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY.
// Сжигание из заблокированной суммы пайщика (BLOCK был на REQUEST_WITHDRAW):
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN,
ledger2_wallets::WITHDRAW_PENDING, eosio::name{},
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN_BLOCKED,
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
"Возврат паевого взноса пайщику" },
@@ -346,19 +273,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
"Трансляция паевого взноса из ЦПП «Цифровой Кошелёк» в членский взнос за пользование инфраструктурой" },
// 15. Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING
// (без Dr/Cr — оба кошелька на счёте 80; резерв средств на время рассмотрения).
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::WITHDRAW_PENDING,
// 15. Запрос на возврат паевого: BLOCK SHARE_FUND_PAY (без Dr/Cr — внутри одного бухсчёта 80).
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BLOCK,
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
0, 0,
"Резервирование паевого под запрос на возврат" },
"Блокировка паевого под запрос на возврат" },
// 16. Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY
// (без Dr/Cr — зеркало REQUEST_WITHDRAW; возврат резерва пайщику).
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
ledger2_wallets::WITHDRAW_PENDING, ledger2_wallets::SHARE_FUND_PAY,
// 16. Отклонение запроса на возврат: UNBLOCK SHARE_FUND_PAY (без Dr/Cr — зеркало REQUEST_WITHDRAW).
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::UNBLOCK,
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
0, 0,
"Снятие резерва паевого после отклонения запроса на возврат" },
"Разблокировка паевого после отклонения запроса на возврат" },
// 17. Возврат из ЦПП «Благорост» в Цифровой Кошелёк: TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY (без Dr/Cr — оба счёта 80, зеркало INVEST).
{ operations::capital::WITHDRAW_FROM_CAPITAL, processes::capital::WTHCAP, WalletOp::TRANSFER,
@@ -384,72 +309,6 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
// 19a. Пополнение пула программных расходов: ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr.
// Совет выделяет часть свободных инвестиций программы под целевые расходы:
// деньги физически на 51 с момента взносов, здесь появляется кооперативный
// резерв-кошелёк, из которого шасси expense оплачивает СЗ. Паевые L3-кошельки
// пайщиков (w.cap.blago, счёт 80) не изменяются — права требования сохраняются.
{ operations::capital::PROGRAM_EXPENSE_TOPUP, processes::capital::PGEXP, WalletOp::ISSUE,
eosio::name{}, ledger2_wallets::PROGRAM_EXPENSE_POOL,
0, 0,
"Пополнение пула программных расходов ЦПП «Благорост»" },
// ----- Шасси расходов (o.exp.*) — вызываются из контракта expense -----
//
// Базовое состояние Благороста ДО расхода:
// - Деньги физически на 51 с момента o.wal.depcpl (Dr 51 / Cr 80).
// - 80 (паевой) наполнен; кошелёк w.cap.blago.
// - 08 пустой (`o.cap.invest` — TRANSFER без проводок).
//
// Принцип: расход не уменьшает паевой фонд (80). Меняется только форма актива
// 51 → 08 (банк уходит, появляется WIP-проект).
// 20. Выдача подотчётных из пула программных расходов: Dr 08 / Cr 51,
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
// Источник — КООПЕРАТИВНЫЙ пул расходов (пополняется o.cap.pgtop); личные
// L3-кошельки пайщиков (w.cap.blago) при оплате СЗ не трогаются — их паевые
// взносы в программе не уменьшаются. Деньги физически уходят пайщику (Cr 51),
// стоимость капитализируется в WIP (Dr 08). ADVANCE_HOLD фиксирует
// ответственность получателя аванса (USER_SHARED) до отчёта.
{ operations::expense::BLAGO_ADVANCE, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Выдача подотчётных из пула расходов ЦПП «Благорост»" },
// 21. Прямая оплата из пула программных расходов (DIRECT): Dr 08 / Cr 51,
// BURN PROGRAM_EXPENSE_POOL. Оплата организации по счёту; деньги уходят с 51,
// стоимость капитализируется в 08. Кошелёк-резерв не задействован.
{ operations::expense::BLAGO_DIRECT, processes::expense::PROPOSAL, WalletOp::BURN,
ledger2_wallets::PROGRAM_EXPENSE_POOL, eosio::name{},
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Прямая оплата из пула расходов ЦПП «Благорост»" },
// 22. Закрытие подотчёта пайщика по отчёту: BURN ADVANCE_HOLD, БЕЗ бухпроводки.
// Проводка Dr 08 / Cr 51 уже сделана на BLAGO_ADVANCE при выдаче. При отчёте только
// снимается кошелёк-резерв пайщика — никакого canal 08/51 второй раз.
{ operations::expense::ADVANCE_REPORT, processes::expense::PROPOSAL, WalletOp::BURN,
ledger2_wallets::ADVANCE_HOLD, eosio::name{},
0, 0,
"Закрытие подотчёта пайщика по отчёту" },
// 23. Возврат неиспользованного подотчёта: Dr 51 / Cr 08,
// TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL.
// Зеркало BLAGO_ADVANCE: деньги возвращаются на 51, WIP-стоимость уменьшается,
// остаток снова доступен пулу расходов.
{ operations::expense::ADVANCE_RETURN, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::ADVANCE_HOLD, ledger2_wallets::PROGRAM_EXPENSE_POOL,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::NON_CURRENT_INVESTMENTS,
"Возврат неиспользованного подотчёта в пул расходов" },
// 24. Доплата сверх подотчёта (перерасход): Dr 08 / Cr 51,
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
// Зеркало BLAGO_ADVANCE на сумму перерасхода. Контракт expense сразу за OVERSPEND
// вызывает ADVANCE_REPORT — две последовательные записи в одной транзакции `expense::overspendexp`.
{ operations::expense::OVERSPEND, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Доплата сверх подотчёта (перерасход)" },
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
@@ -481,7 +340,7 @@ static constexpr size_t OPERATION_REGISTRY_SIZE = sizeof(OPERATION_REGISTRY) / s
// оба существуют в `LEDGER2_ACCOUNT_MAP`.
// 4. Для TRANSFER: `wallet_from` ≠ `wallet_to`, оба ≠ 0.
// 5. Для ISSUE: `wallet_from` == 0 и `wallet_to` ≠ 0.
// 6. Для BURN: `wallet_from` ≠ 0, `wallet_to` == 0.
// 6. Для BLOCK / UNBLOCK / BURN / BURN_BLOCKED: `wallet_from` ≠ 0, `wallet_to` == 0.
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
namespace ledger2_registry_detail {
constexpr bool operation_codes_unique() {
@@ -525,11 +384,11 @@ namespace ledger2_registry_detail {
return true;
}
// Правило 6: BURN — wallet_from required, wallet_to == 0 (ADR-003).
// Правило 6: BURN / BURN_BLOCKED — wallet_from required, wallet_to == 0 (ADR-003).
constexpr bool burn_pattern_correct() {
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
const auto& e = OPERATION_REGISTRY[i];
if (e.wallet_op != WalletOp::BURN) continue;
if (e.wallet_op != WalletOp::BURN && e.wallet_op != WalletOp::BURN_BLOCKED) continue;
if (e.wallet_from.value == 0) return false;
if (e.wallet_to.value != 0) return false;
}
@@ -581,7 +440,7 @@ static_assert(ledger2_registry_detail::dr_ne_cr_when_posting(),
static_assert(ledger2_registry_detail::transfer_wallet_from_ne_to(),
"OPERATION_REGISTRY: TRANSFER с wallet_from == wallet_to или одним из них == 0");
static_assert(ledger2_registry_detail::burn_pattern_correct(),
"OPERATION_REGISTRY: BURN требует wallet_from ≠ 0 и wallet_to == 0");
"OPERATION_REGISTRY: BURN / BURN_BLOCKED требует wallet_from ≠ 0 и wallet_to == 0");
static_assert(ledger2_registry_detail::none_pattern_correct(),
"OPERATION_REGISTRY: NONE требует wallet_from == 0, wallet_to == 0 и обе проводки заполненными");
static_assert(ledger2_registry_detail::accounts_exist_in_map(),
@@ -632,105 +491,3 @@ inline constexpr const OperationAdjustmentEntry* find_adjustment(eosio::name ope
}
return nullptr;
}
// =====================================================================
// Наборы операций шасси расходов — фабричная настройка контракта expense.
// =====================================================================
//
// Контракт `expense` агностичен к программе-источнику: кошелёк-пул приходит в
// `createexp` параметром `source_wallet`, а ledger2-коды всех пяти операций
// жизненного цикла (аванс / прямая оплата / отчёт / возврат / перерасход)
// выводятся из этого кошелька через таблицу ниже — в коде expense нет ни
// одного захардкоженного operation_code.
//
// Подключение шасси к новому пулу (например, кошельку членских взносов
// кооперативного участка) = добавить 5 операций в OPERATION_REGISTRY и одну
// строку здесь. Контракт expense при этом не меняется.
struct ExpenseOperationSet {
eosio::name source_wallet; ///< пул-источник средств (COOPERATIVE-кошелёк)
eosio::name advance; ///< выдача аванса под отчёт (TRANSFER pool → подотчёт)
eosio::name direct; ///< прямая оплата организации по счёту (BURN pool)
eosio::name report; ///< закрытие подотчёта по отчёту (BURN подотчёта)
eosio::name refund; ///< возврат неиспользованного аванса (TRANSFER подотчёт → pool)
eosio::name overspend; ///< доплата при перерасходе (TRANSFER pool → подотчёт)
};
static constexpr ExpenseOperationSet EXPENSE_OPERATION_SETS[] = {
// Пул программных расходов ЦПП «Благорост» — source_wallet заполняет
// capital::createpgexp при создании СЗ через inline expense::createexp.
{ ledger2_wallets::PROGRAM_EXPENSE_POOL,
operations::expense::BLAGO_ADVANCE,
operations::expense::BLAGO_DIRECT,
operations::expense::ADVANCE_REPORT,
operations::expense::ADVANCE_RETURN,
operations::expense::OVERSPEND },
};
static constexpr size_t EXPENSE_OPERATION_SETS_SIZE =
sizeof(EXPENSE_OPERATION_SETS) / sizeof(EXPENSE_OPERATION_SETS[0]);
// Compile-time валидация наборов: каждый код существует в OPERATION_REGISTRY,
// тип wallet-операции и привязка кошельков соответствуют роли кода в наборе.
namespace ledger2_expense_sets_detail {
constexpr const OperationRegistryEntry* find_op(eosio::name code) {
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
if (OPERATION_REGISTRY[i].code == code) return &OPERATION_REGISTRY[i];
}
return nullptr;
}
constexpr bool source_wallets_unique() {
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
for (size_t j = i + 1; j < EXPENSE_OPERATION_SETS_SIZE; ++j) {
if (EXPENSE_OPERATION_SETS[i].source_wallet == EXPENSE_OPERATION_SETS[j].source_wallet) {
return false;
}
}
}
return true;
}
constexpr bool sets_consistent() {
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
const auto& s = EXPENSE_OPERATION_SETS[i];
const auto* adv = find_op(s.advance);
const auto* dir = find_op(s.direct);
const auto* rep = find_op(s.report);
const auto* ref = find_op(s.refund);
const auto* ovr = find_op(s.overspend);
if (!adv || !dir || !rep || !ref || !ovr) return false;
// Аванс: пул → кошелёк-подотчёт.
if (adv->wallet_op != WalletOp::TRANSFER || adv->wallet_from != s.source_wallet) return false;
const eosio::name hold = adv->wallet_to;
// Прямая оплата: сжигание с пула (подотчёт не задействован).
if (dir->wallet_op != WalletOp::BURN || dir->wallet_from != s.source_wallet) return false;
// Отчёт: сжигание подотчёта.
if (rep->wallet_op != WalletOp::BURN || rep->wallet_from != hold) return false;
// Возврат: подотчёт → пул.
if (ref->wallet_op != WalletOp::TRANSFER ||
ref->wallet_from != hold || ref->wallet_to != s.source_wallet) return false;
// Перерасход: пул → подотчёт (зеркало аванса на сумму доплаты).
if (ovr->wallet_op != WalletOp::TRANSFER ||
ovr->wallet_from != s.source_wallet || ovr->wallet_to != hold) return false;
}
return true;
}
} // namespace ledger2_expense_sets_detail
static_assert(ledger2_expense_sets_detail::source_wallets_unique(),
"EXPENSE_OPERATION_SETS: source_wallet должен быть уникален");
static_assert(ledger2_expense_sets_detail::sets_consistent(),
"EXPENSE_OPERATION_SETS: набор операций не согласован с OPERATION_REGISTRY "
"(коды/типы wallet-операций/привязка кошельков)");
/**
* @brief Поиск набора операций шасси расходов по кошельку-источнику.
*/
inline const ExpenseOperationSet* find_expense_operation_set(eosio::name source_wallet) {
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
if (EXPENSE_OPERATION_SETS[i].source_wallet == source_wallet) {
return &EXPENSE_OPERATION_SETS[i];
}
}
return nullptr;
}
@@ -35,8 +35,7 @@ namespace processes {
// registrator
namespace registrator {
inline constexpr eosio::name ACCEPT = "p.reg.accept"_n; ///< Приём пайщика в кооператив (o.reg.payent + o.reg.putmin; для потока через совет — o.reg.inpay + o.reg.setmin + o.reg.setent).
inline constexpr eosio::name REFUND = "p.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (o.reg.refund). Отдельный процесс: приём взноса прерывается, начинается возврат.
inline constexpr eosio::name ACCEPT = "p.reg.accept"_n; ///< Приём пайщика в кооператив (o.reg.payent + o.reg.putmin).
}
// wallet
@@ -54,7 +53,6 @@ namespace processes {
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
inline constexpr eosio::name PGEXP = "p.cap.pgexp"_n; ///< Пул программных расходов: пополнение из инвестиций программы (o.cap.pgtop, одноактовый).
}
// marketplace
@@ -62,11 +60,6 @@ namespace processes {
inline constexpr eosio::name REQUEST = "p.mkt.reqst"_n; ///< Цикл запроса маркетплейса (o.mkt.supply + o.mkt.recv).
}
// expense — шасси расходов (СЗ → авторизация → платёж → отчёт → закрытие)
namespace expense {
inline constexpr eosio::name PROPOSAL = "p.exp.expns"_n; ///< Цикл расхода по СЗ: o.exp.blgadv|blgdir (платёж) + опц. o.exp.over (перерасход) + o.exp.advrpt (отчёт ADVANCE) + опц. o.exp.advret (возврат). Анкер процесса — proposal_hash.
}
// soviet
namespace soviet {
inline constexpr eosio::name AXN_CONVERT = "p.sov.axncnv"_n; ///< Конвертация паевого RUB → делегатский ЧВ (одноактовый).
@@ -27,7 +27,7 @@
* w.mkt.* Маркетплейс (выплаты поставщикам)
*
* Sentinel `eosio::name{}` (пустое имя, value=0) «кошелёк вне системы»
* для ISSUE (нет wallet_from) и для BURN (нет wallet_to).
* для ISSUE (нет wallet_from) и для BURN/BLOCK/UNBLOCK (нет wallet_to).
*
* При первом ISSUE/TRANSFER кошелёк создаётся автоматически по записи
* из WALLET_REGISTRY. При обнулении available+blocked запись удаляется.
@@ -46,13 +46,11 @@ struct ledger2_wallets {
// wallet — паевой фонд + возвраты + ЦК
static constexpr eosio::name SHARE_FUND_PAY = "w.wal.share"_n; ///< Паевой взнос пайщика (USER_SHARED)
static constexpr eosio::name CK_MEMBER = "w.wal.member"_n; ///< ЦК — членская часть пайщика (USER_SHARED)
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: исторический sink возвратов. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
static constexpr eosio::name WITHDRAW_PENDING = "w.wal.wpend"_n; ///< Резерв паевого под заявку на возврат (COOPERATIVE-пул). o.wal.wthreq переводит сюда с w.wal.share, o.wal.wthdec возвращает обратно, o.wal.wthcpl сжигает отсюда. Заменил механику blocked/BLOCK/UNBLOCK 2026-05-24.
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: после переключения o.wal.wthcpl с TRANSFER на BURN_BLOCKED кошелёк больше не получает новых средств. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
// registrator — минимальный паевой + вступительные + суспенс
// registrator — минимальный паевой + вступительные
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)
static constexpr eosio::name REGISTRATION_PENDING = "w.reg.pend"_n; ///< Регистрационный взнос в ожидании решения совета (суспенс счёта 76, USER_SHARED, без сверки соглашений — кандидат ещё не член). o.reg.inpay → сюда (Dr 51/Cr 76); o.reg.setmin/setent уносят на 80/86; o.reg.refund сжигает (Dr 76/Cr 51).
// soviet — членские (инфраструктура) + делегатские + хоз.расходы + использованные паевые
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
@@ -65,16 +63,9 @@ struct ledger2_wallets {
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (COOPERATIVE — кооперативный пул, без L3-разреза по пайщику; L3-разрез из ADR-009 отменён из-за несовместимости с CRPS-перераспределением, см. wallets.hpp:107)
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
static constexpr eosio::name PROGRAM_EXPENSE_POOL = "w.cap.pgexp"_n; ///< Пул программных расходов ЦПП «Благорост» (COOPERATIVE) — кооперативный кошелёк, из которого шасси expense оплачивает СЗ; пополняется topupprogexp (o.cap.pgtop), паевые L3-кошельки пайщиков (w.cap.blago) при расходах не трогаются
// marketplace — выплаты
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
// expense — шасси расходов (подотчёт пайщика, USER_SHARED)
// Зеркало паттерна w.wal.wpend: кошелёк-резерв на пайщике-получателе ADVANCE-механики.
// На момент выдачи аванса фиксирует ответственность пайщика; на отчёте — BURN без новой бухпроводки
// (canal Дт 08 / Кт 51 уже сделан на o.exp.blgadv).
static constexpr eosio::name ADVANCE_HOLD = "w.exp.adv"_n; ///< Подотчётные средства пайщика (USER_SHARED; резерв при ADVANCE-механике шасси расходов)
};
/**
@@ -103,17 +94,15 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (7) — L3-разрез по пайщику
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (5) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
{ ledger2_wallets::ADVANCE_HOLD, "Подотчётные средства пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::REGISTRATION_PENDING, "Регистрационный взнос в ожидании решения совета", WalletKind::USER_SHARED },
// COOPERATIVE (10) — единый кооперативный баланс, без L3 (Generator + 9 «единых пулов»)
// COOPERATIVE (9) — единый кооперативный баланс, без L3
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
// CRPS-распределение между сегментами проекта не поддерживает per-user
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
@@ -121,14 +110,12 @@ inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", WalletKind::COOPERATIVE },
{ ledger2_wallets::WITHDRAW_PENDING, "Резерв паевого под заявку на возврат", WalletKind::COOPERATIVE },
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::PROGRAM_EXPENSE_POOL, "Пул программных расходов ЦПП «Благорост»", WalletKind::COOPERATIVE },
}};
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
@@ -241,46 +228,13 @@ struct Ledger2WalletProgramMapping {
uint64_t required_program_id; // 0 = исключение (без проверки)
};
inline constexpr std::array<Ledger2WalletProgramMapping, 8> 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 — РИД-учёт до перехода на электронный учёт, без проверки */ },
{ ledger2_wallets::ADVANCE_HOLD, 0 /* w.exp.adv — подотчёт пайщика по СЗ; программа-источник проверена контрактом expense, повторная gate не нужна */ },
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
}};
/**
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
* кооператива (заявление registry 200 одобрение совета `confirmexit`).
*
* Единый источник истины для суммы возврата. При одобрении выхода советом
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
* генерируется в cooptypes (`gen:from-cpp` `wallets.generated.ts`) и
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
* тем, что реально вернёт контракт.
*
* Состав только паевые/возвратные USER_SHARED-кошельки:
* w.reg.minshr минимальный паевой взнос;
* w.wal.share целевой паевой взнос (ЦК);
* w.cap.blago паевой взнос в ЦПП «Благорост».
*
* НЕ входят: w.wal.member (членский невозвратный), w.exp.adv (подотчёт под
* расход), w.cap.gen (Генератор COOPERATIVE, без L3-разреза по пайщику),
* w.cap.preimp (пред-импорт-учёт РИД).
*
* Каждому не-главному кошельку сета должна соответствовать операция переноса
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
* иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
*/
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
ledger2_wallets::MIN_SHARE_FUND,
ledger2_wallets::SHARE_FUND_PAY,
ledger2_wallets::BLAGOROST_FUND,
}};
/**
+1 -9
View File
@@ -50,10 +50,6 @@ namespace Names {
// Коллбэки для расходов
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
// Программные расходы обслуживаются шасси expense; capital видит только
// инициирование (createpgexp) и этот callback завершения flow.
constexpr eosio::name ON_PROGRAM_EXPENSE_DONE = "onpgexpdone"_n;
// Коллбэки для долгов
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
@@ -84,13 +80,10 @@ namespace Names {
// Внешние действия в других контрактах
constexpr eosio::name CREATE_OUTPAY = "createoutpay"_n;
constexpr eosio::name OPEN_PROGRAM_WALLET = "openprogwall"_n;
// Действия для отправки в soviet
constexpr eosio::name CREATE_APPROVAL = "createapprv"_n;
constexpr eosio::name CREATE_AGENDA = "createagenda"_n;
// Шасси expense
constexpr eosio::name CREATE_EXPENSE_PROPOSAL = "createexp"_n;
}
namespace SovietActions {
@@ -127,7 +120,6 @@ namespace Names {
constexpr eosio::name CREATE_INVESTMENT = "createinvest"_n; // акцепт инвестиции
constexpr eosio::name CREATE_PROGRAM_INVESTMENT = "createpinv"_n; // акцепт программной инвестиции
constexpr eosio::name CREATE_EXPENSE = "createexpnse"_n; // акцепт расхода
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n; // акцепт программного расхода
constexpr eosio::name CREATE_WITHDRAW_1 = "createwthd1"_n; // акцепт возврата из задания
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
@@ -19,12 +19,6 @@ using createapprv_interface = void(CREATEAPPRV_SIGNATURE);
#define DECLINE_CALLBACK_SIGNATURE name coopname, checksum256 hash, std::string reason
using decline_callback_interface = void(DECLINE_CALLBACK_SIGNATURE);
// Общая ветка отказа решения совета: отправка коллбэка отклонения инициатору
// (если решение пришло из внешнего процесса) + удаление решения из таблицы.
// Используется при просрочке (cancelexprd) и при достижении отрицательного
// консенсуса голосов «против» (voteagainst). Определение — cancelexprd.cpp.
void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, const std::string &reason);
#define AUTHORIZE_CALLBACK_SIGNATURE name coopname, checksum256 hash, document2 authorization
using authorize_callback_interface = void(AUTHORIZE_CALLBACK_SIGNATURE);
@@ -17,7 +17,6 @@
#include "table_registrator_verification.hpp"
#include "table_registrator_candidates.hpp"
#include "table_registrator_candidates_legacy.hpp"
#include "table_registrator_exits.hpp"
// coops (soviet)
#include "coops_access_helpers.hpp"
@@ -12,18 +12,3 @@ account get_account_or_fail(eosio::name username) {
return *account_row;
}
// Возврат аккаунта к состоянию «можно подать заявку заново»: снимаем карточку
// участника (type) и сторейджи, которые проставил reguser. Сам аккаунт (username,
// ключи) сохраняется — повторная подача идёт на том же аккаунте. Вызывается при
// терминальном закрытии кандидата (отказ совета + возврат взноса): после этого
// reguser снова проходит проверку type=="" и принимает новое заявление.
inline void reset_account_card(eosio::name username) {
accounts_index accounts(_registrator, _registrator.value);
auto account_row = accounts.find(username.value);
if (account_row == accounts.end()) return;
accounts.modify(account_row, _registrator, [&](auto &a) {
a.type = ""_n;
a.storages.clear();
});
}
@@ -1,71 +0,0 @@
#pragma once
#include <optional>
#include <eosio/asset.hpp>
#include <eosio/crypto.hpp>
#include <eosio/eosio.hpp>
#include "../consts.hpp"
#include "document_core.hpp"
namespace Registrator {
using namespace eosio;
/**
* @ingroup public_tables
* @ingroup public_registrator_tables
* @par table: exits (registrator)
*
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
* процесс возврата паевого взноса.
*
* Жизненный цикл (зеркало вступления reguserconfirmreg и возврата
* wallet::createwthdauthwthdcompletewthd):
* - exitcoop запись со статусом `pending`, повестка совета `leavecoop`;
* - confirmexit совет одобрил: статус `authorized`, консолидация
* минимального паевого на главный (o.reg.mvmin), резерв
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
* - completexit кассир подтвердил выплату: проводка Дт80/Кт51
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
* запись стирается;
* - declinexit отказ совета или отклонение платежа: снятие резерва
* (o.wal.wthdec, если он был сделан) и стирание записи.
*
* `quantity` итоговая сумма возврата, вычисляется контрактом в момент
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
* ledger2), а не доверяется клиенту.
*/
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
name username;
name coopname;
name status; ///< pending | authorized
time_point_sec created_at;
document2 statement; ///< заявление о выходе (registry 200)
document2 approved_statement; ///< решение совета о выходе (авторизация)
checksum256 exit_hash;
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
uint64_t primary_key() const { return username.value; }
checksum256 by_hash() const { return exit_hash; }
};
typedef multi_index<
"exits"_n, exit,
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
exits_index;
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
exits_index primary_index(_registrator, coopname.value);
auto secondary_index = primary_index.get_index<"byhash"_n>();
auto itr = secondary_index.find(hash);
if (itr == secondary_index.end()) {
return std::nullopt;
}
return *itr;
}
} // namespace Registrator
@@ -8,10 +8,10 @@
# Источники правды в коде:
# • cpp/registrator/registrator.hpp — actions
# • cpp/registrator/src/user/{reguser,confirmpay,confirmreg,declinepay,declinereg}.cpp
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.inpay, o.reg.setmin, o.reg.setent)
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.payent, o.reg.putmin)
# • cpp/lib/core/ledger2/processes.hpp — processes::registrator::ACCEPT
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.pend, w.reg.entry, w.reg.minshr
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 76 / 80 / 86
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.entry, w.reg.minshr
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 80 / 86
# ─────────────────────────────────────────────────────────────────────────────
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
@@ -51,9 +51,8 @@ actions:
role: progress
purpose: >
Кассир подтверждает зачисление взносов: карточка переходит в `payed`,
повестка `joincoop` с колбэками отправлена в совет. Деньги получены,
но взнос ещё не признан — он ставится на расчёты с пайщиком
(Дт 51 / Кт 76) и ждёт решения совета.
повестка `joincoop` с колбэками отправлена в совет. Учётных проводок
ещё нет.
- name: registrator::declinepay
human: Отклонить оплату
@@ -61,27 +60,25 @@ actions:
role: reject
purpose: >
Кассир отклоняет платёж: запись кандидата удаляется, операции в книге
Ledger2 не создаются (деньги ещё не были приняты). Кандидат может
подать заявление повторно.
Ledger2 не создаются. Кандидат может подать заявление повторно.
- name: registrator::confirmreg
human: Утвердить советом
actor: soviet
role: closer
purpose: >
Совет утверждает приём: статус карточки → `active`, взнос признаётся —
сумма с расчётов с пайщиком (счёт 76) переносится на паевой фонд (80)
и целевое финансирование (86) операциями `o.reg.setmin` и
`o.reg.setent`. Кандидат добавлен в список участников.
Совет утверждает приём: статус карточки → `active`, в книге Ledger2
одновременно проводятся `o.reg.payent` и `o.reg.putmin`, кандидат
добавлен в список участников.
- name: registrator::declinereg
human: Отклонить советом
actor: soviet
role: reject
purpose: >
Совет отказывает в приёме: процесс приёма взноса прерывается и
начинается отдельный процесс возврата «Возврат регистрационного
взноса» (p.reg.refund) — полученная сумма возвращается кандидату.
Совет отказывает в приёме: запись кандидата удаляется, операции
в книге Ledger2 не создаются. Возврат внесённых сумм оформляется
отдельно.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
# Составная сущность: статус отслеживается в таблице `candidates` (created / payed),
@@ -102,36 +99,23 @@ states:
- name: payed
human: Взносы оплачены
description: >
Деньги получены, карточка в `payed`, в совет ушла повестка `joincoop`.
Полученная сумма стоит на расчётах с пайщиком (Дт 51 / Кт 76) и ждёт
решения совета: при одобрении — признаётся взносом, при отказе —
возвращается.
Деньги получены, карточка в `payed`, в совет ушла повестка
`joincoop`. Проводок ещё нет — они сработают на решении совета.
kind: normal
- name: active
human: Пайщик активен
description: >
`accounts.status` = `active`, карточка кандидата удалена, в книге
Ledger2 взнос признан — сумма перенесена со счёта 76 на 80 и 86
операциями `o.reg.setmin` и `o.reg.setent`. С этого момента доступны
все остальные кооперативные процессы.
Ledger2 проведены `o.reg.payent` и `o.reg.putmin`. С этого момента
доступны все остальные кооперативные процессы.
kind: final
- name: removed
human: Отклонено
description: >
Запись кандидата удалена на этапе оплаты (платёж не поступил/отклонён) —
деньги не принимались, проводок не было.
kind: virtual
virtual: true
- name: refunding
human: Возврат в процессе
description: >
Совет отказал кандидату, чьи деньги уже стоят на счёте 76. Процесс
приёма прерывается, и управление переходит в отдельный процесс
«Возврат регистрационного взноса» (p.reg.refund), где касса проводит
возврат и подтверждает его.
Запись кандидата удалена, проводок в книге Ledger2 не было.
Если взносы успели поступить, возврат оформляется вне процесса.
kind: virtual
virtual: true
@@ -149,9 +133,6 @@ transitions:
to: payed
action: registrator::confirmpay
actor: gateway_operator
ledger_code: p.reg.accept
operations:
- o.reg.inpay
guards:
- Кассир подтвердил зачисление суммы = initial + minimum.
@@ -168,22 +149,17 @@ transitions:
actor: soviet
ledger_code: p.reg.accept
operations:
- o.reg.setmin
- o.reg.setent
- o.reg.payent
- o.reg.putmin
guards:
- Совет принял положительное решение по повестке.
- from: payed
to: refunding
to: removed
action: registrator::declinereg
actor: soviet
operations: []
guards:
- Совет принял отрицательное решение по повестке.
- >
Проводок здесь нет: процесс приёма прерывается и управление переходит
в отдельный процесс «Возврат регистрационного взноса» (p.reg.refund),
где возврат проводится на шаге подтверждения кассой (o.reg.refund).
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
@@ -214,37 +190,33 @@ scenario:
actor: gateway_operator
action: registrator::confirmpay
description: >
Деньги пришли — встают на расчёты с пайщиком, карточка движется к совету.
Деньги пришли — карточка движется к совету.
Кандидат оплачивает счёт (банковский перевод или криптоплатёж).
Кассир, получив подтверждение зачисления от платёжной системы,
вызывает `confirmpay`. Контракт переводит кандидата в статус
`payed`, создаёт повестку в совете с колбэками
`confirmreg` / `declinereg` и ставит полученную сумму на учёт
операцией `o.reg.inpay` (Дт 51 / Кт 76) — деньги получены, но
взнос ещё не признан и ждёт решения совета.
`payed` и создаёт повестку в совете с колбэками
`confirmreg` / `declinereg`. Учётных проводок ещё нет.
pre:
- Кандидат в статусе `created`.
- На расчётный счёт получена сумма initial + minimum.
post:
- Кандидат в статусе `payed`.
- В совете открыта повестка `joincoop`.
- В книге Ledger2 проведена o.reg.inpay (Дт 51 / Кт 76).
- step: 3
title: Утверждение советом
actor: soviet
action: registrator::confirmreg
description: >
Совет утверждает приём — взнос признаётся, кандидат становится пайщиком.
Совет утверждает приём — два взноса встают на учёт, кандидат становится пайщиком.
Совет рассматривает повестку `joincoop`. При положительном
решении контракт `confirmreg`: (1) меняет `accounts.status`
на `active`; (2) добавляет нового пайщика в список участников
совета (`soviet::addpartcpnt`); (3) признаёт взнос — переносит
сумму с расчётов с пайщиком (76) на паевой фонд и целевое
финансирование двумя операциями: `o.reg.setmin` (минимальный
паевой, Дт 76 / Кт 80) и `o.reg.setent` (вступительный, Дт 76 / Кт 86).
совета (`soviet::addpartcpnt`); (3) применяет в книге Ledger2
две операции — `o.reg.payent` (вступительный взнос, Дт 51 / Кт 86)
и `o.reg.putmin` (минимальный паевой взнос, Дт 51 / Кт 80).
Запись кандидата удаляется.
pre:
- Кандидат в статусе `payed`.
@@ -252,7 +224,7 @@ scenario:
post:
- accounts.status = `active` — пайщик стал активным членом кооператива.
- Пайщик добавлен в soviet::participants.
- В книге Ledger2 проведены o.reg.setmin + o.reg.setent.
- В книге Ledger2 проведены o.reg.payent + o.reg.putmin.
- Запись кандидата удалена из таблицы candidates.
alternatives:
@@ -264,8 +236,8 @@ scenario:
Платёж не прошёл или был отменён.
Кассир вызывает `declinepay`, кандидат удаляется, операции
в книге Ledger2 не создаются (деньги ещё не приняты). Кандидат
может повторить подачу заявления.
в книге Ledger2 не создаются. Кандидат может повторить подачу
заявления.
- branch: Отказ совета
at_step: 3
@@ -274,11 +246,9 @@ scenario:
description: >
Совет отказывает кандидату.
Процесс приёма взноса прерывается и начинается отдельный процесс
«Возврат регистрационного взноса» (p.reg.refund): кандидат переводится
в состояние возврата, кассе поручается вернуть деньги. Сама проводка
Дт 76 / Кт 51 и удаление кандидата происходят там — на шаге, когда
касса фактически проведёт возврат и подтвердит его.
Запись кандидата удаляется, совершённый платёж подлежит возврату
озврат оформляется вне этого процесса). Операции в книге Ledger2
не создаются.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
@@ -295,72 +265,39 @@ documents:
stored_in: "(authorization — в параметре действия, не хранится в candidates после удаления записи)"
# ── Секция 6. Операции ──────────────────────────────────────────────────────
# Процесс мульти-операционный: три записи OPERATION_REGISTRY с process_type = p.reg.accept.
# Двухфазный учёт: на приёме денег (confirmpay) сумма ставится на расчёты с
# пайщиком (76), на одобрении совета (confirmreg) — признаётся взносом (80/86).
#
# ПЕРЕХОДНЫЙ ПЕРИОД (до 30.07.2026): кандидаты, чей платёж был принят ДО релиза
# двухфазного учёта, не имеют суспенса на счёте 76. Для них одобрение совета
# проводится по старому одношаговому пути — прямой ISSUE Дт 51 / Кт 80
# (o.reg.putmin) и Дт 51 / Кт 86 (o.reg.payent), без переноса с 76. Признак —
# отсутствие баланса кандидата на w.reg.pend (см. registration_migration.hpp).
# Процесс мульти-операционный: две записи OPERATION_REGISTRY с process_type = p.reg.accept.
# Обе срабатывают на закрывающем действии confirmreg.
operations:
- ledger_code: o.reg.inpay
human_name: Приём регистрационного взноса в ожидание решения совета
- ledger_code: o.reg.payent
human_name: Вступительный взнос пайщика
wallet_op: ISSUE
wallet_from: ''
wallet_to: w.reg.pend
debit: 51
credit: 76
amount_ref: candidate.initial + candidate.minimum
triggered_by: registrator::confirmpay
description: >
Постановка полученного регистрационного взноса на учёт. Сумма
«вступительный + минимальный паевой» зачисляется на кошелёк
«Регистрационный взнос в ожидании решения совета» (w.reg.pend);
двойная запись Дт 51 / Кт 76 — деньги на расчётном счёте, обязательство
перед пайщиком на счёте расчётов. Признание или возврат — позже.
- ledger_code: o.reg.setmin
human_name: Зачисление минимального паевого взноса по решению совета
wallet_op: TRANSFER
wallet_from: w.reg.pend
wallet_to: w.reg.minshr
debit: 76
credit: 80
amount_ref: candidate.minimum
triggered_by: registrator::confirmreg
description: >
Признание минимального паевого взноса по решению совета. Сумма
`candidate.minimum` переносится с кошелька ожидания (w.reg.pend) на
«Минимальный паевой взнос» (w.reg.minshr); двойная запись Дт 76 / Кт 80 —
стартовый вклад в паевой фонд.
- ledger_code: o.reg.setent
human_name: Зачисление вступительного взноса по решению совета
wallet_op: TRANSFER
wallet_from: w.reg.pend
wallet_to: w.reg.entry
debit: 76
debit: 51
credit: 86
amount_ref: candidate.initial
triggered_by: registrator::confirmreg
description: >
Признание вступительного взноса по решению совета. Сумма
`candidate.initial` переносится с кошелька ожидания (w.reg.pend) на
«Вступительные взносы» (w.reg.entry); двойная запись Дт 76 / Кт 86 —
целевые поступления в кооперативный фонд.
Постановка вступительного взноса на учёт. Сумма `candidate.initial`
зачисляется на кошелёк «Вступительные взносы» (w.reg.entry); двойная
запись Дт 51 / Кт 86 — целевые поступления в кооперативный фонд.
- ledger_code: o.reg.putmin
human_name: Минимальный паевой взнос при регистрации
wallet_op: ISSUE
wallet_from: ''
wallet_to: w.reg.minshr
debit: 51
credit: 80
amount_ref: candidate.minimum
triggered_by: registrator::confirmreg
description: >
Парная операция к `o.reg.payent`. Сумма `candidate.minimum`
появляется на кошельке «Минимальный паевой взнос» (w.reg.minshr);
двойная запись Дт 51 / Кт 80 — стартовый вклад в паевой фонд.
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
related:
- process_type: p.reg.refund
id: public_registrator_refund_process
relation: triggers
note: >
При отрицательном решении совета процесс приёма прерывается и
запускается «Возврат регистрационного взноса» (p.reg.refund): сумма,
стоящая на расчётах с пайщиком (счёт 76), возвращается кандидату.
- process_type: p.wal.depo
id: public_wallet_deposit_process
relation: triggers
@@ -1,213 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Возврат регистрационного взноса» — процесс возврата кандидату
# полученного регистрационного взноса при отказе совета в приёме.
#
# Это самостоятельный процесс: приём пайщика (p.reg.accept) прерывается на
# решении совета, и вместо признания взноса начинается его возврат. Возврат
# идёт в два шага: совет выносит отказ и поручает кассе вернуть деньги, а
# списание со счёта происходит только после того, как касса фактически проведёт
# исходящий перевод кандидату и подтвердит это. До подтверждения деньги стоят
# на расчётах с пайщиком (счёт 76).
#
# Источники правды в коде:
# • cpp/registrator/registrator.hpp — действия declinereg / refundpay
# • cpp/registrator/src/user/declinereg.cpp — отказ совета, создание исходящего возврата
# • cpp/registrator/src/user/refundpay.cpp — подтверждение возврата кассой, проводка и закрытие
# • cpp/registrator/src/user/declinerfnd.cpp — страж: отмена возврата невозможна
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.refund)
# • cpp/lib/core/ledger2/processes.hpp — processes::registrator::REFUND
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.pend
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 76
# ─────────────────────────────────────────────────────────────────────────────
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
process_type: p.reg.refund
id: public_registrator_refund_process
title: Возврат регистрационного взноса
slug: refund
status: proposed
contract: registrator
summary: >
Когда совет отказывает кандидату в приёме, полученный регистрационный
взнос возвращается ему: совет поручает возврат, касса проводит перевод и
подтверждает его, после чего запись кандидата закрывается.
purpose: >
«Возврат регистрационного взноса» — терминальный процесс приёма пайщика
по отрицательному решению. Полученные кассой деньги стоят на расчётах
с пайщиком (счёт 76) в ожидании решения; при отказе совета они
возвращаются кандидату. Возврат денег не может быть отменён — права
кандидата на свои средства безусловны.
roles:
- contributor # несостоявшийся пайщик (получатель возврата)
- soviet # совет кооператива (выносит отказ и поручает возврат)
- chairman # председатель / кассир (проводит и подтверждает возврат)
# ── Секция 2. Действия контракта (блокчейн-уровень) ─────────────────────────
actions:
- name: registrator::declinereg
human: Отклонить советом
actor: soviet
role: opener
purpose: >
Совет отказывает кандидату в приёме. Процесс приёма прерывается:
контракт переводит кандидата в состояние возврата и поручает кассе
вернуть полученный взнос. Деньги остаются на расчётах с пайщиком
(счёт 76) — со счёта они ещё не уходят.
- name: registrator::refundpay
human: Подтвердить возврат
actor: chairman
role: closer
purpose: >
Касса фактически провела исходящий перевод денег кандидату и
подтверждает это. Контракт проводит возврат обратной проводкой
(Дт 76 / Кт 51) операцией `o.reg.refund` и закрывает запись кандидата.
После этого аккаунт освобождается для повторной подачи заявления на
том же аккаунте: несостоявшийся пайщик может подать заявку заново.
- name: registrator::declinerfnd
human: Отклонить возврат (недопустимо)
actor: chairman
role: guard
purpose: >
Отмена возврата регистрационного взноса невозможна — действие всегда
завершается ошибкой. Существует только как обязательная развилка
процессинга; в интерфейсе кассы кнопки отклонения возврата нет.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: "registrator::candidate"
entity_human: "Кандидат"
entity_source: cpp/registrator/src/user/declinereg.cpp
states:
- name: payed
human: Взносы оплачены
description: >
Входное состояние процесса: деньги получены и стоят на расчётах
с пайщиком (w.reg.pend, счёт 76), повестка `joincoop` рассмотрена
советом с отрицательным решением.
kind: normal
- name: refunding
human: Возврат в процессе
description: >
Совет отказал и поручил кассе вернуть взнос. Создан исходящий платёж
возврата; деньги по-прежнему стоят на расчётах с пайщиком (w.reg.pend,
счёт 76) и ждут, пока касса фактически проведёт перевод и подтвердит его.
kind: normal
- name: refunded
human: Взнос возвращён
description: >
Касса подтвердила исходящий перевод. В книге Ledger2 проведена
обратная операция `o.reg.refund` (Дт 76 / Кт 51) — обязательство перед
кандидатом закрыто, деньги ушли со счёта. Запись кандидата удалена.
kind: final
transitions:
- from: payed
to: refunding
action: registrator::declinereg
actor: soviet
operations: []
guards:
- Совет принял отрицательное решение по повестке `joincoop`.
- Кандидат в статусе `payed` (деньги были получены и стоят на счёте 76).
- from: refunding
to: refunded
action: registrator::refundpay
actor: chairman
ledger_code: p.reg.refund
operations:
- o.reg.refund
guards:
- Касса фактически провела исходящий перевод денег кандидату.
- Кандидат в статусе `refunding`.
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
steps:
- step: 1
title: Отказ совета и поручение на возврат
actor: soviet
action: registrator::declinereg
description: >
Совет рассматривает повестку `joincoop` и принимает отрицательное
решение. Процесс приёма прерывается: кандидат переводится в состояние
«возврат в процессе», и кассе поручается вернуть полученный
регистрационный взнос. Деньги остаются на расчётах с пайщиком
(w.reg.pend, счёт 76) — со счёта они ещё не уходят. Кандидат получает
уведомление об отказе.
pre:
- Кандидат в статусе `payed` — деньги получены и стоят на счёте 76.
- Совет принял отрицательное решение.
post:
- Кандидат в статусе `refunding`.
- Создан исходящий платёж возврата на сумму взноса.
- Деньги по-прежнему на счёте 76 (проводки ещё нет).
- step: 2
title: Касса возвращает деньги и подтверждает
actor: chairman
action: registrator::refundpay
description: >
Касса проводит фактический перевод денег кандидату и подтверждает,
что возврат выполнен. Контракт возвращает полученный регистрационный
взнос обратной проводкой `o.reg.refund` (Дт 76 / Кт 51): сумма
«вступительный + минимальный паевой» списывается с кошелька ожидания
(w.reg.pend), обязательство перед кандидатом закрывается, деньги
уходят со счёта. Запись кандидата удаляется. Отменить возврат нельзя.
pre:
- Кандидат в статусе `refunding`.
- Касса фактически провела исходящий перевод кандидату.
post:
- В книге Ledger2 проведена o.reg.refund (Дт 76 / Кт 51).
- Запись кандидата удалена из картотеки кандидатов.
- Кандидат получил свои деньги.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
- action: registrator::declinereg
title: Решение совета об отказе в приёме пайщика
registry_id: 501
signed_by: [ Совет ]
stored_in: "(решение совета — в повестке joincoop)"
# ── Секция 6. Операции ──────────────────────────────────────────────────────
# Возврат проводится одной операцией o.reg.refund — на шаге подтверждения кассой
# (registrator::refundpay), не на решении совета. Отмена возврата невозможна
# (действие-страж declinerfnd всегда завершается ошибкой).
#
# ПЕРЕХОДНЫЙ ПЕРИОД (до 30.07.2026): кандидаты, чей платёж был принят ДО релиза
# двухфазного учёта, суспенса на счёте 76 не имеют — для них on-chain проводки
# возврата нет (как было раньше), деньги возвращает бэкенд банковским переводом,
# а запись кандидата закрывается сразу при отказе совета. Операция o.reg.refund
# проводится только при наличии баланса на w.reg.pend.
operations:
- ledger_code: o.reg.refund
human_name: Возврат регистрационного взноса при отказе совета
wallet_op: BURN
wallet_from: w.reg.pend
wallet_to: ''
debit: 76
credit: 51
amount_ref: candidate.initial + candidate.minimum
triggered_by: registrator::refundpay
description: >
Возврат полученного регистрационного взноса. Сумма «вступительный +
минимальный паевой» списывается с кошелька «Регистрационный взнос
в ожидании решения совета» (w.reg.pend); двойная запись Дт 76 / Кт 51 —
закрытие обязательства перед кандидатом и уход денег с расчётного
счёта. Получателя на цепи нет — деньги фактически переводит касса.
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
related:
- process_type: p.reg.accept
id: public_registrator_accept_process
relation: interrupts
note: >
Возврат — отрицательная развилка процесса «Приём пайщика»
(p.reg.accept). Деньги, поставленные на расчёты с пайщиком операцией
`o.reg.inpay` на этапе оплаты, при отказе совета не признаются
взносом, а возвращаются этим процессом.
@@ -8,16 +8,8 @@
#include "src/user/confirmreg.cpp"
#include "src/user/declinereg.cpp"
#include "src/user/declinepay.cpp"
#include "src/user/refundpay.cpp"
#include "src/user/declinerfnd.cpp"
#include "src/user/reguser.cpp"
#include "src/exit/exit_helpers.hpp"
#include "src/exit/exitcoop.cpp"
#include "src/exit/confirmexit.cpp"
#include "src/exit/completexit.cpp"
#include "src/exit/declinexit.cpp"
#include "src/account/createbranch.cpp"
#include "src/account/newaccount.cpp"
#include "src/account/updateaccnt.cpp"
@@ -85,17 +85,9 @@ public:
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
[[eosio::action]] void refundpay(name coopname, checksum256 registration_hash);
[[eosio::action]] void declinerfnd(name coopname, checksum256 registration_hash, std::string reason);
[[eosio::action]] void createbranch(eosio::name coopname, eosio::name braname);
@@ -1,35 +0,0 @@
/**
* @brief Завершение возврата паевого взноса при выходе из кооператива.
* Кассир подтвердил исходящий платёж gateway вызывает этот коллбэк.
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
* Возврат назад невозможен повторное участие только через новую регистрацию.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p gateway
*/
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
require_auth(_gateway);
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
eosio::name username = e->username;
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
e->quantity, username, exit_hash, memo);
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
Registrator::finalize_member_exit(coopname, username);
exits.erase(e);
}
@@ -1,76 +0,0 @@
/**
* @brief Одобрение советом выхода пайщика из кооператива.
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
* исходящий платёж в gateway. Если возвращать нечего выход завершается сразу
* без платежа.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @param authorization Документ-решение совета о выходе
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
require_auth(_soviet);
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
eosio::name username = e->username;
// оповещаем пайщика
require_recipient(username);
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
if (balance.amount <= 0) continue;
total_return += balance;
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
}
exits.modify(e, _soviet, [&](auto &row) {
row.status = "authorized"_n;
row.approved_statement = authorization;
row.quantity = total_return;
});
if (total_return.amount > 0) {
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
total_return, username, exit_hash, memo_req);
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
Action::send<createoutpay_interface>(
_gateway,
"createoutpay"_n,
_registrator,
coopname,
username,
exit_hash,
total_return,
_registrator,
"completexit"_n,
"declinexit"_n
);
} else {
// Возвращать нечего — финализируем выход без платежа.
Registrator::finalize_member_exit(coopname, username);
exits.erase(e);
}
}
@@ -1,41 +0,0 @@
/**
* @brief Отказ в выходе из кооператива.
* Вызывается либо советом при отклонении заявления (до резервирования средств),
* либо gateway при отклонении исходящего платежа (после резервирования). Если
* сумма возврата уже была зарезервирована снимаем резерв (o.wal.wthdec:
* w.wal.wpend w.wal.share). Пайщик остаётся действующим членом кооператива.
* @param coopname Наименование кооператива
* @param exit_hash Хэш процесса выхода
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
*/
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
check_auth_and_get_payer_or_fail({_soviet, _gateway});
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(exist.has_value(), "Объект выхода не найден");
Registrator::exits_index exits(_registrator, coopname.value);
auto e = exits.find(exist->username.value);
eosio::name username = e->username;
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
if (e->status == "authorized"_n && e->quantity.amount > 0) {
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
e->quantity, username, exit_hash, memo);
}
// оповещаем пайщика
require_recipient(username);
exits.erase(e);
}
@@ -1,95 +0,0 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/eosio.hpp>
#include "../../../lib/index.hpp"
/**
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
*
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
* весь lib/index.hpp уже доступен.
*/
namespace Registrator {
using namespace eosio;
/**
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
*
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
* userwallets контракта ledger2. Если записи нет нулевая сумма в базовой
* валюте управления.
*/
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
userwallets_index userwallets(_ledger2, coopname.value);
auto idx = userwallets.get_index<"byuserwallet"_n>();
auto it = idx.find(combine_ids(wallet_name.value, username.value));
if (it == idx.end()) {
return asset(0, _root_govern_symbol);
}
return it->available;
}
/**
* @brief Консолидация доступного паевого кошелька пайщика на главный
* (`w.wal.share`) перед резервом возврата при выходе.
*
* Главный кошелёк (`w.wal.share`) уже целевой перенос не нужен. Для остальных
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
* главный:
* w.reg.minshr o.reg.mvmin (MOVE_MINSHARE);
* w.cap.blago o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
*
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
* валит транзакцию с явным сообщением защита от тихой потери средств.
*/
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
eosio::name op;
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
} else {
eosio::check(false,
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
}
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
wallet_name.to_string() + ", username=" + username.to_string();
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
}
/**
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
* аккаунта в registrator.
*
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
* для пайщика начинает падать он лишён права подавать заявления.
*/
inline void finalize_member_exit(name coopname, name username) {
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
action(
permission_level{_registrator, "active"_n},
_soviet,
"delpartcpnt"_n,
std::make_tuple(coopname, username)
).send();
// блокируем аккаунт в картотеке registrator
accounts_index accounts(_registrator, _registrator.value);
auto account = accounts.find(username.value);
eosio::check(account != accounts.end(), "Аккаунт не найден");
accounts.modify(account, _registrator, [&](auto &a) {
a.status = "blocked"_n;
});
}
} // namespace Registrator
@@ -1,57 +0,0 @@
/**
* @brief Подача заявления на выход из кооператива.
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
* @param coopname Наименование кооператива
* @param username Имя пайщика, выходящего из кооператива
* @param exit_hash Хэш процесса выхода
* @param statement Подписанное заявление о выходе (registry 200)
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
require_auth(coopname);
get_cooperative_or_fail(coopname);
// выйти может только действующий пайщик (не заблокированный)
get_participant_or_fail(coopname, username);
// повторная подача запрещена — у пайщика может быть только один процесс выхода
Registrator::exits_index exits(_registrator, coopname.value);
auto existing = exits.find(username.value);
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
// проверяем подпись заявления о выходе
verify_document_or_fail(statement);
exits.emplace(coopname, [&](auto &e) {
e.username = username;
e.coopname = coopname;
e.status = "pending"_n;
e.created_at = current_time_point();
e.statement = statement;
e.exit_hash = exit_hash;
e.quantity = asset(0, _root_govern_symbol);
});
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
::Soviet::create_agenda(
_registrator,
coopname,
username,
get_valid_soviet_action("leavecoop"_n),
exit_hash,
_registrator, // callback_contract
"confirmexit"_n, // callback при одобрении
"declinexit"_n, // callback при отказе
statement,
std::string("")
);
}
@@ -1,5 +1,3 @@
#include <array>
/**
* @brief Миграция данных контракта.
* Обновляет счетчики активных пайщиков для всех кооперативов
@@ -42,56 +40,4 @@
coop.active_participants_count = active_count;
});
}
// ──────────────────────────────────────────────────────────────────────────
// Разовая очистка ФАНТОМНЫХ пайщиков кооператива fgrtejiwnynn (инцидент
// 2026-06-04). Причина: install.interactor прогнан 3× (2 провала + успех);
// каждый прогон минтил на цепи новые аккаунты совета (adduser) со случайным
// username, а откат в catch чистил только off-chain. На цепи осели 6 дублей-
// аккаунтов одних и тех же 3 учредителей (см. ~/gorozhane-dup-install-cleanup.md
// и ~/fixes.md). Удаляем записи registrator::accounts и пересчитываем счётчик
// активных пайщиков из soviet::participants (источник истины).
//
// Список username — единственный авторитет (дедуп off-chain по ФИО невозможен
// on-chain — личных данных в registrator::accounts нет). ОДИН И ТОТ ЖЕ список
// продублирован в soviet::migrate и ledger2::migrate — править синхронно.
// Идемпотентно: повторный прогон не находит записей → no-op.
{
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
const std::array<eosio::name, 6> PHANTOMS = {
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
};
auto pcoop = coops2.find(PHANTOM_COOP.value);
if (pcoop != coops2.end() && pcoop->is_cooperative) {
accounts_index accounts(_registrator, _registrator.value);
for (const auto& u : PHANTOMS) {
auto acc = accounts.find(u.value);
if (acc == accounts.end()) continue; // уже удалён / отсутствует
// Дедуп-гард: трогаем только аккаунты, зарегистрированные ЭТИМ коопом.
if (acc->registrator != PHANTOM_COOP) continue;
accounts.erase(acc);
}
// Пересчитываем счётчик активных пайщиков из реестра soviet::participants
// (источник истины), ИСКЛЮЧАЯ фантомов явно — счётчик сходится к верному
// значению за ОДИН деплой независимо от порядка выполнения migrate
// (registrator::migrate vs soviet::migrate). Если бы считали всех has_vote,
// а soviet ещё не удалил фантомов — счётчик временно завысился бы.
participants_index pparts(_soviet, PHANTOM_COOP.value);
uint64_t active = 0;
for (auto p = pparts.begin(); p != pparts.end(); ++p) {
bool is_phantom = false;
for (const auto& ph : PHANTOMS) {
if (ph == p->username) { is_phantom = true; break; }
}
if (!is_phantom && p->has_vote) active++;
}
coops2.modify(pcoop, _registrator, [&](auto& coop) {
coop.active_participants_count = active;
});
}
}
}
@@ -23,14 +23,7 @@ void registrator::confirmpay(name coopname, checksum256 registration_hash){
candidates.modify(candidate, _registrator, [&](auto &c){
c.status = "payed"_n;
});
// Деньги получены кассой, но взнос ещё не признан — ставим на расчёты с
// пайщиком (Dr 51 / Cr 76, ISSUE w.reg.pend) до решения совета. Сумма —
// вступительный + минимальный паевой. Признание/возврат — в confirmreg/declinereg.
eosio::asset registration_quantity = candidate -> initial + candidate -> minimum;
std::string memo = "Приём регистрационного взноса в ожидание решения совета от username=" + candidate -> username.to_string();
Ledger2::apply(_registrator, coopname, operations::registrator::RECEIVE_PAYMENT, registration_quantity, candidate -> username, registration_hash, memo);
action(permission_level{ _registrator, "active"_n}, _soviet, "createagenda"_n,
std::make_tuple(
coopname,
@@ -1,5 +1,3 @@
#include "registration_migration.hpp"
/**
* @brief Подтверждение регистрации пользователя.
* Подтверждает регистрацию кандидата советом и добавляет его в кооператив
@@ -47,28 +45,11 @@ void registrator::confirmreg(eosio::name coopname, checksum256 registration_hash
)
).send();
// Совет одобрил — признаём взнос. Гарды amount>0: у кооператива может не быть
// вступительного (initial==0) — ledger2::apply падает на нулевой сумме.
//
// MIGRATION (снять условие после 30.07.2026, см. registration_migration.hpp):
// для кандидатов, принятых ДО релиза двухфазного учёта (нет баланса на
// w.reg.pend), признаём по старому пути — прямой ISSUE (Dr 51 / Cr 80|86);
// для новых — переносим суспенс с 76 на 80/86 (TRANSFER w.reg.pend → ...).
bool has_pending = Registrator::get_registration_pending_balance(coopname, candidate -> username).amount > 0;
std::string memo = "Минимальный паевой взнос при вступлении пайщика с username=" + candidate -> username.to_string();
Ledger2::apply(_registrator, coopname, operations::registrator::PUT_MINSHARE, candidate -> minimum, candidate -> username, registration_hash, memo);
if (candidate -> minimum.amount > 0) {
std::string memo = "Зачисление минимального паевого взноса по решению совета, username=" + candidate -> username.to_string();
Ledger2::apply(_registrator, coopname,
has_pending ? operations::registrator::SETTLE_MINSHARE : operations::registrator::PUT_MINSHARE,
candidate -> minimum, candidate -> username, registration_hash, memo);
}
if (candidate -> initial.amount > 0) {
std::string memo = "Зачисление вступительного взноса по решению совета, username=" + candidate -> username.to_string();
Ledger2::apply(_registrator, coopname,
has_pending ? operations::registrator::SETTLE_ENTRANCE : operations::registrator::PAY_ENTRANCE,
candidate -> initial, candidate -> username, registration_hash, memo);
}
memo = "Вступительный взнос при вступлении пайщика с username=" + candidate -> username.to_string();
Ledger2::apply(_registrator, coopname, operations::registrator::PAY_ENTRANCE, candidate -> initial, candidate -> username, registration_hash, memo);
// Увеличиваем счетчик активных пайщиков
cooperatives2_index cooperatives(_registrator, _registrator.value);
auto coop_itr = cooperatives.find(coopname.value);
@@ -14,7 +14,7 @@ void registrator::declinepay(name coopname, checksum256 registration_hash, std::
auto exist_candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
eosio::check(exist_candidate.has_value(), "Кандидат не найден");
eosio::check(!exist_candidate.has_value(), "Кандидат не найден");
Registrator::candidates_index candidates(_registrator, coopname.value);
@@ -1,5 +1,3 @@
#include "registration_migration.hpp"
/**
* @brief Отклонение регистрации пользователя.
* Отклоняет регистрацию кандидата советом
@@ -12,53 +10,5 @@
* @note Авторизация требуется от аккаунта: @p soviet
*/
void registrator::declinereg(name coopname, checksum256 registration_hash, std::string reason) {
require_auth(_soviet);
auto candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
eosio::check(candidate.has_value(), "Кандидат не найден");
// оповещаем кандидата об отказе совета
require_recipient(candidate -> username);
// Совет отказал — процесс приёма взноса прерывается, начинается процесс
// возврата (p.reg.refund). Деньги НЕ списываем здесь: они стоят на расчётах
// с пайщиком (счёт 76, кошелёк w.reg.pend) до тех пор, пока касса фактически
// не проведёт исходящий возврат. Обратную проводку Дт 76 / Кт 51 выполняет
// коллбэк refundpay по подтверждению кассы (gateway::outcomplete) — симметрично
// возврату паевого в контракте 'wallet' (authwthd → createoutpay → completewthd).
//
// MIGRATION (снять условие после 30.07.2026, см. registration_migration.hpp):
// кандидаты, принятые ДО релиза двухфазного учёта, суспенса на w.reg.pend
// не имеют — для них on-chain возврата нет (как было раньше), банковский
// возврат оформляет бэкенд, а запись кандидата закрываем сразу.
eosio::asset pending = Registrator::get_registration_pending_balance(coopname, candidate -> username);
Registrator::candidates_index candidates(_registrator, coopname.value);
auto it = candidates.find(candidate -> username.value);
if (pending.amount > 0) {
// Новый путь: переводим кандидата в возврат и создаём исходящий платёж в
// gateway с коллбэком после подтверждения кассой. Деньги остаются на счёте 76
// вплоть до фактического проведения возврата кассой (refundpay).
candidates.modify(it, _registrator, [&](auto &c){
c.status = "refunding"_n;
});
action(permission_level{ _registrator, "active"_n}, _gateway, "createoutpay"_n,
std::make_tuple(
coopname,
candidate -> username,
registration_hash,
pending,
_registrator,
"refundpay"_n,
"declinerfnd"_n
)
).send();
} else {
// Миграционный путь: возвращать на цепи нечего — закрываем кандидата и
// освобождаем аккаунт для повторной подачи (снимаем карточку участника).
if (it != candidates.end()) candidates.erase(it);
reset_account_card(candidate -> username);
}
}
//событие отклонения регистрации советом
}
@@ -1,19 +0,0 @@
/**
* @brief Отклонение возврата регистрационного взноса НЕДОПУСТИМО.
* Возврат полученного регистрационного взноса при отказе совета не может быть
* отменён: средства кандидата обязаны быть ему возвращены. Действие существует
* только как обязательный decline-коллбэк gateway (createoutpay требует имя
* decline-действия) и всегда завершается ошибкой. На фронте кнопки отклонения
* возврата нет; этот страж защищает от прямого вызова gateway::outdecline.
* @param coopname Наименование кооператива
* @param registration_hash Хэш регистрации (= outcome_hash исходящего платежа)
* @param reason Причина отклонения (игнорируется)
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p gateway
*/
void registrator::declinerfnd(name coopname, checksum256 registration_hash, std::string reason) {
require_auth(_gateway);
eosio::check(false, "Отмена возврата регистрационного взноса невозможна");
}
@@ -1,42 +0,0 @@
#include "registration_migration.hpp"
/**
* @brief Подтверждение возврата регистрационного взноса.
* Коллбэк gateway после фактического проведения кассой исходящего возврата
* (gateway::outcomplete). Проводит обратную проводку Дт 76 / Кт 51 (сжигание
* суспенса w.reg.pend) и закрывает запись кандидата. Деньги уходят со счёта 76
* ровно в этот момент не раньше, чем касса подтвердит исходящий перевод.
* @param coopname Наименование кооператива
* @param registration_hash Хэш регистрации (= outcome_hash исходящего платежа)
* @ingroup public_actions
* @ingroup public_registrator_actions
* @note Авторизация требуется от аккаунта: @p gateway
*/
void registrator::refundpay(name coopname, checksum256 registration_hash) {
require_auth(_gateway);
auto candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
eosio::check(candidate.has_value(), "Кандидат не найден");
eosio::check(candidate -> status == "refunding"_n, "Кандидат не находится в процессе возврата");
// оповещаем кандидата о завершении возврата
require_recipient(candidate -> username);
// Сжигаем фактический остаток на w.reg.pend — он равен полученной сумме
// (вступительный + минимальный паевой). Обратная проводка Дт 76 / Кт 51.
eosio::asset pending = Registrator::get_registration_pending_balance(coopname, candidate -> username);
eosio::check(pending.amount > 0, "Нет средств на возврат");
std::string memo = "Возврат регистрационного взноса при отказе совета, username=" + candidate -> username.to_string();
Ledger2::apply(_registrator, coopname, operations::registrator::REFUND, pending, candidate -> username, registration_hash, memo);
// Возврат завершён — удаляем кандидата из картотеки.
Registrator::candidates_index candidates(_registrator, coopname.value);
auto it = candidates.find(candidate -> username.value);
candidates.erase(it);
// Освобождаем аккаунт для повторной подачи на том же username: снимаем
// карточку участника, иначе reguser упадёт «повторное получение невозможно».
reset_account_card(candidate -> username);
}
@@ -1,42 +0,0 @@
#pragma once
// ─────────────────────────────────────────────────────────────────────────────
// ВРЕМЕННЫЙ МИГРАЦИОННЫЙ КОД — СНЯТЬ УСЛОВИЕ ПОСЛЕ 30.07.2026.
//
// На момент релиза двухфазного учёта регистрационного взноса (счёт 76,
// кошелёк w.reg.pend) в системе могут быть «зависшие» кандидаты: их платёж
// уже принят кассой (статус `payed`), но ПО СТАРОМУ пути — без операции
// o.reg.inpay, то есть БЕЗ баланса на w.reg.pend.
//
// Когда совет вынесет по ним решение уже на новом контракте:
// • confirmreg — нельзя делать TRANSFER с пустого w.reg.pend (упадёт
// «недостаточно средств»); для таких кандидатов признаём взнос по-старому
// прямым ISSUE (o.reg.putmin / o.reg.payent, Dr 51 / Cr 80|86).
// • declinereg — возврата через ledger2 нет (как было раньше): on-chain
// суспенса нет, банковский возврат оформляет бэкенд.
//
// Признак пути — наличие баланса кандидата на w.reg.pend (счёт 76):
// > 0 → принят по новому пути (o.reg.inpay) → признание/возврат через 76;
// == 0 → принят до релиза → старый путь.
//
// После 30.07.2026 (когда все зависшие на момент релиза кандидаты гарантированно
// получат решение совета) этот файл и ветвления в confirmreg/declinereg убрать,
// оставив только новый путь.
// ─────────────────────────────────────────────────────────────────────────────
namespace Registrator {
/**
* @brief Баланс кандидата на суспенс-кошельке регистрационного взноса
* (w.reg.pend, счёт 76) из ledger2 L3 (userwallets). Запись существует только
* пока available > 0 (auto-delete на нуле), поэтому отсутствие = нулевой баланс.
*/
inline eosio::asset get_registration_pending_balance(eosio::name coopname, eosio::name username) {
userwallets_index user_wallets(_ledger2, coopname.value);
auto idx = user_wallets.get_index<"byuserwallet"_n>();
auto it = idx.find(combine_ids(ledger2_wallets::REGISTRATION_PENDING.value, username.value));
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
return it->available;
}
} // namespace Registrator
@@ -5,7 +5,7 @@
# получить от совета согласие на выполнение действия. Поток:
#
# контракт-инициатор → newpackage / create_agenda → soviet::decisions
# ↓ голосование (votefor / voteagainst)
# ↓ голосование (votefor / voteagainst / cancelvote)
# approved=true → authorize (председатель)
# ↓
# authorized=true → exec → callback в исходный контракт (confirmreg /
@@ -19,8 +19,8 @@
#
# Источники в коде:
# • cpp/soviet/src/doc/newpackage.cpp
# • cpp/soviet/src/vote/{votefor,voteagainst}.cpp
# • cpp/soviet/src/decision/{authorize,cancelexprd,declinedec,exec}.cpp
# • cpp/soviet/src/vote/{votefor,voteagainst,cancelvote}.cpp
# • cpp/soviet/src/decision/{authorize,cancelexprd,exec}.cpp
# ─────────────────────────────────────────────────────────────────────────────
process_type: sov.authpkg
@@ -74,9 +74,17 @@ actions:
actor: Член совета
role: reject
purpose: >
Член совета голосует «против». Голос фиксируется; решение само не
закрывается. Когда против высказалось больше половины состава совета,
председатель снимает решение явным действием (см. «Отклонить решение»).
Член совета голосует «против». При достижении консенсуса по отказу
решение помечается как rejected и при exec в исходный контракт
уйдёт decline-callback.
- name: soviet::cancelvote
human: Отозвать голос
actor: Член совета
role: progress
purpose: >
Член совета отзывает ранее поданный голос до перехода решения
в финальный статус.
- name: soviet::authorize
human: Утвердить протокол
@@ -99,22 +107,11 @@ actions:
- name: soviet::cancelexprd
human: Отменить просроченное
actor: Кооператив
role: reject
purpose: >
Если решение не было утверждено в срок, оно отменяется как
просроченное: в исходный процесс уходит уведомление об отказе,
решение закрывается. Снятие просроченных решений происходит
автоматически по истечении срока.
- name: soviet::declinedec
human: Отклонить решение
actor: Председатель
role: reject
purpose: >
Если против решения проголосовало больше половины состава совета,
председатель явно отклоняет его, не дожидаясь истечения срока: в
исходный процесс уходит уведомление об отказе, решение закрывается.
Если решение не было утверждено в срок, оно отменяется как
просроченное, в исходный контракт callback не уходит.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: soviet::decision
@@ -153,9 +150,9 @@ states:
- name: rejected
human: Отклонено
description: >
Председатель отклонил решение по большинству «против» (declinedec) либо
решение отменено как просроченное (cancelexprd, автоматически по сроку).
В обоих случаях в исходный контракт уходит decline-callback.
Совет проголосовал «против» либо решение отменено как просроченное.
В исходный контракт уходит decline-callback (или callback
не вызывается, если просрочка).
kind: virtual
virtual: true
@@ -177,15 +174,15 @@ transitions:
- from: pending
to: rejected
action: soviet::declinedec
actor: Председатель
action: soviet::voteagainst
actor: Член совета
guards:
- Большинство голосов «против».
- from: pending
to: rejected
action: soviet::cancelexprd
actor: Кооператив
actor: Председатель
guards:
- Истёк срок принятия решения.
@@ -226,8 +223,8 @@ scenario:
actor: Член совета
action: soviet::votefor
description: >
Члены совета голосуют по решению (votefor / voteagainst).
При достижении большинства «за» — approved=true.
Члены совета голосуют по решению (votefor / voteagainst /
cancelvote). При достижении большинства «за» — approved=true.
pre:
- Решение в `pending`.
post:
@@ -262,21 +259,18 @@ scenario:
alternatives:
- branch: Отказ совета
at_step: 2
action: soviet::declinedec
actor: Председатель
action: soviet::voteagainst
actor: Член совета
description: >
Когда против высказалось больше половины состава совета, голос «против»
лишь фиксируется. Председатель явно отклоняет решение (declinedec), не
дожидаясь срока: в исходный контракт уходит decline-callback (например,
registrator::declinereg), запись решения удаляется.
Большинство «против» — при exec в исходный контракт уйдёт
decline-callback (например, registrator::declinereg).
- branch: Просрочка
at_step: 2
action: soviet::cancelexprd
actor: Кооператив
actor: Председатель
description: >
Решение не принято в срок — автоматически отменяется по истечении
срока: в исходный контракт уходит decline-callback, запись удаляется.
Решение не принято в срок, отменяется без вызова callback.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
# Документ повестки приходит из исходного процесса — конкретный registry_id
@@ -9,10 +9,10 @@
# • cpp/soviet/src/decision/freedecision.cpp
# • cpp/soviet/src/vote/votefor.cpp
# • cpp/soviet/src/vote/voteagainst.cpp
# • cpp/soviet/src/vote/cancelvote.cpp
# • cpp/soviet/src/decision/authorize.cpp
# • cpp/soviet/src/decision/exec.cpp
# • cpp/soviet/src/decision/cancelexprd.cpp
# • cpp/soviet/src/decision/declinedec.cpp (внутри cancelexprd.cpp)
# ─────────────────────────────────────────────────────────────────────────────
process_type: sov.decision
@@ -63,9 +63,17 @@ actions:
actor: Член совета
role: reject
purpose: >
Член совета голосует «против». Голос фиксируется; решение само не
закрывается. Когда против высказалось больше половины состава совета,
председатель снимает решение явным действием (см. «Отклонить решение»).
Член совета голосует «против». При достижении консенсуса по отказу
решение переходит в статус rejected; протокол не оформляется,
исполнения не происходит.
- name: soviet::cancelvote
human: Отозвать голос
actor: Член совета
role: progress
purpose: >
Член совета отзывает ранее поданный голос (за или против) до того,
как решение перешло в финальный статус.
- name: soviet::authorize
human: Утвердить протокол
@@ -88,21 +96,11 @@ actions:
- name: soviet::cancelexprd
human: Отменить просроченное
actor: Кооператив
actor: Член совета
role: reject
purpose: >
Если решение не было утверждено в срок, оно отменяется как
просроченное. Запись удаляется без исполнения. Снятие просроченных
решений происходит автоматически по истечении срока.
- name: soviet::declinedec
human: Отклонить решение
actor: Председатель
role: reject
purpose: >
Если против решения проголосовало больше половины состава совета,
председатель явно отклоняет его, не дожидаясь истечения срока.
Запись удаляется без исполнения.
Если решение не было утверждено в срок, оно может быть отменено
как просроченное. Запись удаляется без исполнения.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: soviet::decision
@@ -167,15 +165,15 @@ transitions:
- from: created
to: rejected
action: soviet::declinedec
actor: Председатель
action: soviet::voteagainst
actor: Член совета
guards:
- Большинство голосов «против».
- from: created
to: rejected
action: soviet::cancelexprd
actor: Кооператив
actor: Член совета
guards:
- Истёк срок принятия решения.
@@ -216,7 +214,7 @@ scenario:
actor: Член совета
action: soviet::votefor
description: >
Члены совета поочерёдно голосуют (votefor / voteagainst).
Члены совета поочерёдно голосуют (votefor / voteagainst / cancelvote).
При достижении большинства «за» решение помечается как approved.
pre:
- Решение ещё не закрыто (created).
@@ -250,22 +248,21 @@ scenario:
- События newresolved/newdecision разосланы.
alternatives:
- branch: Отклонение советом
- branch: Отклонение голосованием
at_step: 2
action: soviet::declinedec
actor: Председатель
action: soviet::voteagainst
actor: Член совета
description: >
Когда против высказалось большинство состава совета, голос «против»
лишь фиксируется. Председатель явно отклоняет решение (declinedec) —
запись удаляется, протокол не оформляется.
Большинство голосов «против» — решение помечается как rejected
и не утверждается председателем.
- branch: Просрочка
at_step: 2
action: soviet::cancelexprd
actor: Кооператив
actor: Член совета
description: >
Если решение не было принято в срок, оно автоматически отменяется
по истечении срока — запись удаляется как просроченная.
Если решение не было принято в срок, любой член совета может
вызвать cancelexprd — запись удаляется как просроченная.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
@@ -49,7 +49,6 @@
#include "src/fund/fundwithdraw.cpp"
#include "src/participant/addparticipant.cpp"
#include "src/participant/delparticipant.cpp"
#include "src/participant/setminamt.cpp"
#include "src/participant/block.cpp"
#include "src/participant/cancelreg.cpp"
@@ -139,9 +139,6 @@ public:
//regaccount.cpp
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
//delparticipant.cpp
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
//setminamt.cpp
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
@@ -204,7 +201,6 @@ public:
//decisions
[[eosio::action]] void withdraw(eosio::name coopname, eosio::name username, uint64_t withdraw_id, document2 statement);
[[eosio::action]] void cancelexprd(eosio::name coopname, uint64_t decision_id);
[[eosio::action]] void declinedec(eosio::name coopname, uint64_t decision_id);
void withdraw_effect(eosio::name executer, eosio::name coopname, uint64_t decision_id, uint64_t batch_id);
@@ -9,14 +9,10 @@
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта: @p coopname
* @note Авторизация требуется от аккаунта: @p chairman
*/
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
// Авторизует кооператив (действие проводится через бэкенд ключом кооператива).
// Согласие председателя при этом не теряется: оно подтверждается криптографически
// его личной подписью на документе (verify_document_or_fail ниже) и проверкой
// is_valid_chairman — без валидного подписанного председателем документа авторизация невозможна.
require_auth(coopname);
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
require_auth(chairman);
boards_index boards(_soviet, coopname.value);
autosigner_index autosigner(_soviet, coopname.value);
@@ -31,9 +27,7 @@ void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t deci
verify_document_or_fail(document);
// RAM-плательщик — кооператив: транзакцию авторизует coopname (require_auth выше),
// поэтому увеличивать RAM аккаунта председателя нельзя (он tx не подписывал).
decisions.modify(decision, coopname, [&](auto &d){
decisions.modify(decision, chairman, [&](auto &d){
d.authorized_by = chairman;
d.authorized = !decision -> authorized;
d.authorization = document;
@@ -9,23 +9,30 @@
* @note Авторизация требуется от аккаунта: @p coopname
*/
// Общая ветка отказа решения: при наличии полей коллбэка отправляет инициатору
// коллбэк отклонения, затем удаляет решение из оперативной таблицы. Вызывается
// из cancelexprd (просрочка) и voteagainst (отрицательный консенсус).
void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, const std::string &reason) {
void soviet::cancelexprd(eosio::name coopname, uint64_t decision_id) {
require_auth(coopname);
decisions_index decisions(_soviet, coopname.value);
auto decision = decisions.find(decision_id);
eosio::check(decision != decisions.end(), "Решение не найдено");
// Проверка истечения срока
bool expired = true;
if ( decision -> expired_at.has_value() ) {
expired = (eosio::time_point_sec(eosio::current_time_point()) > decision->expired_at.value());
}
eosio::check(expired, "Срок действия решения ещё не истёк");
// Проверка наличия всех необходимых полей для отправки коллбэка
if (decision->callback_contract.has_value() &&
decision->decline_callback.has_value() &&
decision->hash.has_value() &&
decision->callback_contract.value() != ""_n &&
decision->confirm_callback.value() != ""_n &&
if (decision->callback_contract.has_value() &&
decision->decline_callback.has_value() &&
decision->hash.has_value() &&
decision->callback_contract.value() != ""_n &&
decision->confirm_callback.value() != ""_n &&
decision->decline_callback.value() != ""_n
) {
// Отправка коллбэка отклонения
Action::send<decline_callback_interface>(
decision->callback_contract.value(),
@@ -34,61 +41,10 @@ void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, cons
_soviet,
coopname,
decision->hash.value(),
reason
std::string("Отклонение по истечению срока")
);
}
// Удаление решения
decisions.erase(decision);
}
void soviet::cancelexprd(eosio::name coopname, uint64_t decision_id) {
require_auth(coopname);
decisions_index decisions(_soviet, coopname.value);
auto decision = decisions.find(decision_id);
eosio::check(decision != decisions.end(), "Решение не найдено");
// Проверка истечения срока
bool expired = true;
if ( decision -> expired_at.has_value() ) {
expired = (eosio::time_point_sec(eosio::current_time_point()) > decision->expired_at.value());
}
eosio::check(expired, "Срок действия решения ещё не истёк");
decline_and_erase_decision(coopname, decision_id, std::string("Отклонение по истечению срока"));
}
/**
* @brief Явное отклонение решения советом по отрицательному консенсусу
* Проводится председателем (ключом кооператива), когда против решения
* проголосовало большинство состава совета, до истечения срока. Развязано с
* cancelexprd: просрочка гасится автоматически по сроку, а отрицательно принятое
* решение снимается с повестки только этим явным действием.
* @param coopname Наименование кооператива
* @param decision_id Идентификатор решения для отклонения
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
void soviet::declinedec(eosio::name coopname, uint64_t decision_id) {
require_auth(coopname);
decisions_index decisions(_soviet, coopname.value);
auto decision = decisions.find(decision_id);
eosio::check(decision != decisions.end(), "Решение не найдено");
// Отрицательный консенсус: против должно высказаться больше половины состава
// совета (порог зеркален votefor — 50%, строгое «больше»).
auto board = get_board_by_type_or_fail(coopname, "soviet"_n);
auto [votes_for_count, votes_against_count] = decision->get_votes_count();
uint64_t total_members = board.get_members_count();
uint64_t consensus_percent = 50;
eosio::check(votes_against_count * 100 > total_members * consensus_percent,
"Решение не отклонено советом: нет большинства голосов против");
decline_and_erase_decision(coopname, decision_id, std::string("Отклонено советом: большинство голосов против"));
}
@@ -10,14 +10,11 @@
* @param coopname Имя кооператива
* @param decision_id Идентификатор решения для исполнения
*
* @note Авторизация требуется от аккаунта: @p coopname
* @note Авторизация требуется от аккаунта: @p executer
*/
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
// Исполнение проводится через бэкенд ключом кооператива. Параметр executer остаётся
// в payload как аудит-метка (кто инициировал исполнение) и не используется для авторизации
// inline-эффектов — те выполняются под собственными правами контракта (_soviet/_gateway/_fund).
require_auth(coopname);
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
require_auth(executer);
decisions_index decisions(_soviet, coopname.value);
auto decision = decisions.find(decision_id);
@@ -1,38 +0,0 @@
/**
* @brief Удаление пайщика из кооператива при выходе
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
* пайщик был активным и имел право голоса уменьшает счётчик активных
* пайщиков в registrator (как это делает `block`).
* @param coopname Наименование кооператива
* @param username Наименование выбывающего пайщика
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта в белом списке контрактов
*/
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
check_auth_and_get_payer_or_fail(contracts_whitelist);
get_cooperative_or_fail(coopname);
participants_index participants(_soviet, coopname.value);
auto participant = participants.find(username.value);
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
bool had_vote = participant->has_vote;
bool was_active = participant->status == "accepted"_n;
if (was_active && had_vote) {
action(
permission_level{_soviet, "active"_n},
_registrator,
"decparticpnt"_n,
std::make_tuple(coopname, username)
).send();
}
participants.erase(participant);
}
@@ -124,7 +124,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
row.type = "user"_n;
row.coopname = coopname;
row.program_id = 0;
row.draft_id = 4;
row.draft_id = 3;
});
//создаём соглашение для шаблона политики конфиденциальности
@@ -132,7 +132,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
row.type = "privacy"_n;
row.coopname = coopname;
row.program_id = 0;
row.draft_id = 3;
row.draft_id = 4;
});
}
@@ -1,5 +1,3 @@
#include <array>
/**
* @brief Миграция данных системы
* Выполняет миграцию данных системы при обновлении контракта.
@@ -12,62 +10,14 @@
void soviet::migrate() {
require_auth(_soviet); // Проверяем авторизацию
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
// Предыдущая версия этой миграции переименовывала programs.program_type в
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
// 'capital' — каноничное значение (используется coagreements.type, contract-
// константой _capital_program и всем controller-кодом); эта миграция приводит
// programs.program_type к нему же. Идемпотентно.
{
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "capital"_n;
});
}
}
// ────────────────────────────────────────────────────────────────────────
// Разовая очистка ФАНТОМНЫХ участников кооператива fgrtejiwnynn (инцидент
// 2026-06-04). Удаляем строки soviet::participants для 6 дублей-аккаунтов,
// оставшихся на цепи после 3-кратного прогона install.interactor (см.
// ~/gorozhane-dup-install-cleanup.md). Эти участники не голосовали и не
// входят в совет (createboard для них не выполнялся).
//
// Список username синхронизирован с registrator::migrate и ledger2::migrate.
// Дедуп-гард: удаляем фантомов ТОЛЬКО если в коопе остаётся хотя бы один
// НЕ-фантомный участник (никогда не опустошаем реестр). Идемпотентно.
{
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
const std::array<eosio::name, 6> PHANTOMS = {
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
};
auto is_phantom = [&](eosio::name u) {
for (const auto& p : PHANTOMS) if (p == u) return true;
return false;
};
participants_index participants(_soviet, PHANTOM_COOP.value);
// Гарантируем, что после удаления останется ≥1 реальный участник.
uint64_t real_participants = 0;
for (auto it = participants.begin(); it != participants.end(); ++it) {
if (!is_phantom(it->username)) real_participants++;
}
if (real_participants > 0) {
for (const auto& u : PHANTOMS) {
auto it = participants.find(u.value);
if (it != participants.end()) {
participants.erase(it);
}
}
}
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "blagorost"_n;
});
}
@@ -60,12 +60,8 @@ void soviet::voteagainst(
auto board = get_board_by_type_or_fail(coopname, "soviet"_n);
eosio::check(board.is_voting_member(username), "У вас нет права голоса");
decision -> check_for_any_vote_exist(username);
decision -> check_for_any_vote_exist(username);
// Голос «против» только фиксируется. Отрицательный консенсус НЕ затирает
// решение автоматически: отказ при достигнутом большинстве «против» проводит
// председатель явным действием declinedec (развязано с авто-отменой по сроку
// cancelexprd). Так принятое отрицательно решение не исчезает само из повестки.
add_vote_against(coopname, username, decision->id);
};
@@ -3,11 +3,10 @@
# программу «Цифровой Кошелёк» (SHARE_FUND_PAY).
#
# Четырёхактовый процесс с одной авторизацией советом. Запись заявки живёт в
# таблице `withdraws` от создания до выплаты или отказа. На подаче заявки сумма
# переводится в кошелёк-резерв (WITHDRAW_PENDING, w.wal.wpend). На закрывающем
# действии completewthd срабатывает операция o.wal.wthcpl — сжигание суммы из
# резерва (BURN w.wal.wpend): деньги уходят из системы пайщику банковским
# переводом, получателя на цепи нет.
# таблице `withdraws` от создания до выплаты или отказа. На закрывающем
# действии completewthd срабатывает одна операция o.wal.wthcpl — сжигание
# заблокированной суммы (BURN_BLOCKED w.wal.share): деньги уходят из системы
# пайщику банковским переводом, получателя на цепи нет.
#
# Источники правды в коде:
# • cpp/wallet/wallet.hpp — actions
@@ -44,8 +43,8 @@ actions:
actor: contributor
role: opener
purpose: >
Пайщик создаёт заявку на возврат паевого взноса. Контракт переводит
сумму с кошелька SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING и
Пайщик создаёт заявку на возврат паевого взноса. Контракт блокирует
сумму на кошельке SHARE_FUND_PAY (статус available → blocked) и
создаёт повестку в совете о возврате с callback'ом авторизации.
- name: wallet::authwthd
human: Авторизовать выплату
@@ -61,17 +60,16 @@ actions:
role: closer
purpose: >
Gateway подтвердил списание средств в пользу пайщика. Это закрывающее
действие: применяется операция o.wal.wthcpl (BURN w.wal.wpend)
— сумма из резерва сжигается, в бухгалтерии проходит обратная
действие: применяется операция o.wal.wthcpl (BURN_BLOCKED w.wal.share)
заблокированная сумма сжигается, в бухгалтерии проходит обратная
проводка Дт 80 / Кт 51. Запись заявки удаляется.
- name: wallet::declinewthd
human: Отклонить
actor: soviet
role: reject
purpose: >
Отказ на любом из этапов до выплаты. Сумма возвращается из резерва на
кошелёк пайщика (o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY),
запись заявки удаляется.
Отказ на любом из этапов до выплаты. Сумма разблокируется на кошельке
пайщика (UNBLOCK через o.wal.wthdec), запись заявки удаляется.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: wallet::withdraw
@@ -82,8 +80,8 @@ states:
- name: pending
human: Ожидает решения совета
description: >
Заявка создана, сумма переведена в кошелёк-резерв (SHARE_FUND_PAY →
WITHDRAW_PENDING), повестка отправлена в совет.
Заявка создана, сумма заблокирована на кошельке пайщика (available → blocked),
повестка отправлена в совет.
kind: normal
- name: authorized
human: Выплата отправлена
@@ -94,15 +92,15 @@ states:
- name: completed
human: Возврат выплачен
description: >
Gateway подтвердил списание, сумма из резерва сжигается
(BURN w.wal.wpend). В бухгалтерии прошла обратная
Gateway подтвердил списание, заблокированная сумма сжигается на кошельке
пайщика (BURN_BLOCKED w.wal.share). В бухгалтерии прошла обратная
проводка Дт 80 / Кт 51. Запись заявки удалена.
kind: final
- name: removed
human: Отклонено
description: >
Возврат не состоялся: сумма возвращена из резерва на кошелёк пайщика
(o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
Возврат не состоялся: сумма разблокирована (UNBLOCK через o.wal.wthdec),
запись удалена.
kind: virtual
virtual: true
@@ -174,7 +172,7 @@ scenario:
- Сумма возврата ≤ доступного остатка.
post:
- В таблице withdraws создана запись со статусом `pending`.
- Сумма переведена с SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING.
- На кошельке пайщика сумма переведена available → blocked.
- Совет получил повестку о возврате.
- step: 2
@@ -199,13 +197,13 @@ scenario:
action: wallet::completewthd
description: >
Gateway подтверждает списание средств в пользу пайщика. Контракт
применяет ledger2-операцию o.wal.wthcpl (BURN w.wal.wpend с
применяет ledger2-операцию o.wal.wthcpl (BURN_BLOCKED w.wal.share с
обратной проводкой Дт 80 / Кт 51) и удаляет запись заявки.
pre:
- Заявка в статусе `authorized`.
- Gateway подтвердил списание.
post:
- Сумма из резерва сожжена (WITHDRAW_PENDING, w.wal.wpend).
- Заблокированная сумма сожжена с SHARE_FUND_PAY (w.wal.share).
- В ledger2 применена операция o.wal.wthcpl.
- Запись заявки удалена.
@@ -216,8 +214,8 @@ scenario:
actor: soviet
description: >
Совет принял отрицательное решение либо Gateway отклонил платёж.
Сумма возвращена из резерва на кошелёк пайщика (o.wal.wthdec:
WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
Сумма разблокирована на кошельке пайщика (UNBLOCK через o.wal.wthdec),
запись удалена.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
@@ -236,45 +234,45 @@ documents:
# ── Секция 6. Операции ──────────────────────────────────────────────────────
operations:
- ledger_code: o.wal.wthreq
human_name: Резервирование паевого под запрос на возврат
wallet_op: TRANSFER
human_name: Блокировка паевого под запрос на возврат
wallet_op: BLOCK
wallet_from: w.wal.share
wallet_to: w.wal.wpend
wallet_to: ~
debit: ~
credit: ~
amount_ref: withdraw.quantity
triggered_by: wallet::createwthd
description: >
На создании заявки контракт переводит сумму с кошелька пайщика
(SHARE_FUND_PAY) в кошелёк-резерв (WITHDRAW_PENDING). Бухгалтерская
проводка не создаётся — движение внутри одного бухсчёта 80.
На создании заявки контракт блокирует сумму на кошельке пайщика
(SHARE_FUND_PAY): available → blocked. Бухгалтерская проводка не
создаётся — движение внутри одного бухсчёта 80.
- ledger_code: o.wal.wthdec
human_name: Снятие резерва паевого после отклонения запроса на возврат
wallet_op: TRANSFER
wallet_from: w.wal.wpend
wallet_to: w.wal.share
human_name: Разблокировка паевого после отклонения запроса на возврат
wallet_op: UNBLOCK
wallet_from: w.wal.share
wallet_to: ~
debit: ~
credit: ~
amount_ref: withdraw.quantity
triggered_by: wallet::declinewthd
description: >
Зеркало REQUEST_WITHDRAW: сумма возвращается из резерва (WITHDRAW_PENDING)
на кошелёк пайщика (SHARE_FUND_PAY). Бухгалтерская проводка не создаётся.
Зеркало REQUEST_WITHDRAW: blocked → available на кошельке пайщика.
Бухгалтерская проводка не создаётся.
- ledger_code: o.wal.wthcpl
human_name: Возврат паевого взноса пайщику
wallet_op: BURN
wallet_from: w.wal.wpend # кошелёк-резерв возвратов
wallet_op: BURN_BLOCKED
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
wallet_to: ~ # деньги уходят из системы, получателя на цепи нет
debit: 80 # Паевой фонд (складочный капитал)
credit: 51 # Расчётный счёт
amount_ref: withdraw.quantity
triggered_by: wallet::completewthd
description: >
Обратная операция к o.wal.depcpl: зарезервированная сумма сжигается с
кошелька-резерва (w.wal.wpend) — деньги уходят из системы банковским
переводом пайщику. На цепи получателя нет.
Обратная операция к o.wal.depcpl: заблокированная сумма сжигается с
кошелька пайщика «ЦПП Цифровой Кошелёк» (w.wal.share) — деньги уходят
из системы банковским переводом пайщику. На цепи получателя нет.
В бухгалтерии прошла проводка Дт 80 / Кт 51 — паевой фонд уменьшился,
деньги ушли с расчётного счёта пайщику.
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.7.21-1",
"version": "2026.5.23-4",
"private": true,
"type": "module",
"scripts": {
@@ -9,6 +9,9 @@
"build:one": "./build.sh $1",
"build:one:test": "contract=$1 mode=test ./build.sh",
"enter": "./boot/scripts/enter.sh",
"testnet": "git checkout testnet && git merge dev && lerna publish prerelease && git push && git checkout dev && git merge testnet",
"production": "git checkout main && git merge -X theirs testnet && lerna publish --conventional-commits --conventional-graduate && git push && git checkout dev && git merge main",
"docs-publish": "pnpm gh-pages --nojekyll -d docs/site --repo git@github.com:coopenomics/monocoop.git",
"standards:dev": "pnpm --filter @coopenomics/standards-site dev",
"standards:build": "pnpm --filter @coopenomics/standards-site build",
"standards:preview": "pnpm --filter @coopenomics/standards-site preview"
+6 -7
View File
@@ -34,13 +34,6 @@ SMTP_USERNAME=
SMTP_PASSWORD=
EMAIL_FROM="'КО-ПОЧТАЛЬОН' <mail@coopenomics.world>"
# Напоминатель об отчёте по авансу под отчёт (extensions/expenses).
# Интервал тика сканера зеркала расходов (мс). Дефолт 6 ч. Письмо пайщику —
# не чаще раза в неделю (троттлит идемпотентность Центра уведомлений).
EXPENSE_ADVANCE_REMINDER_INTERVAL_MS=21600000
# Сколько дней аванс может висеть без отчёта до первого напоминания. Дефолт 3.
EXPENSE_ADVANCE_REMINDER_GRACE_DAYS=3
# BLOCKHAIN
BLOCKCHAIN_RPC=http://127.0.0.1:8888
CHAIN_ID=cae86058a6d8698833afb474ab8a5ad8599c6cf54f9ebcf275dbac7055c16fe1
@@ -78,6 +71,12 @@ SENTRY_DSN=
GITHUB_TOKEN=
# OpenSearch (поисковая система документов, опционально)
OPENSEARCH_NODE=https://opensearch:9200
OPENSEARCH_ENABLED=false
OPENSEARCH_USERNAME=admin
OPENSEARCH_PASSWORD=
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
-6
View File
@@ -1,9 +1,3 @@
# Convert text file line endings to lf
* text eol=lf
*.js text
# Бинарные шрифты — без EOL-нормализации (перекрывает "* text eol=lf")
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
+1 -1
View File
@@ -472,7 +472,7 @@ BaseExtModule
- `ChairmanBlockchainAdapter` — взаимодействие с контрактом `soviet`
- **Резолверы**: `ApprovalResolver`, `ChairmanOnboardingResolver`
- **Крон**: Проверка решений каждые N минут (`checkInterval`)
- **Конфигурация**: `checkInterval`, `onboarding_*` — флаги прохождения мастера настройки
- **Конфигурация**: `checkInterval`, `cancelApprovedDecisions`, `onboarding_*` — флаги прохождения мастера настройки
### `capital` — Благорост (целевая программа)
- **Структура**: `domain/`, `infrastructure/`, `application/` — самое крупное расширение
+17 -13
View File
@@ -58,6 +58,7 @@ _Критичные правила и паттерны для AI-агентов
### Language-Specific (TypeScript)
- **Bigint из PostgreSQL приходит как STRING.** Всегда `Number(blockNum) < Number(currentBlockNum)`, никогда не полагаться на `<`/`>` для `bigint`-колонок напрямую.
- **block_num — разнобой типов в текущей кодовой базе (Story 4.3 audit).** `BaseTypeormEntity` (capital + shared) использует `@Column({ type: 'integer' }) block_num!: number` (PG возвращает number — корректно). Инфраструктурные entity (`ActionEntity`, `DeltaEntity`, `ForkEntity`, `SyncStateEntity`) используют `@Column({ type: 'bigint' }) block_num!: number`**type-mismatch** (runtime будет string, TS говорит number). `ConsumerDedupEntity``bigint` + `string | null` (корректно). Это технический долг (Epic 9 backlog: bigint Transformer). Hot-path везде явно делает `Number()` либо использует TypeORM bind, так что runtime safe. При добавлении нового блокчейн-зеркала — выбрать одно из: (a) `integer` + `number`; (b) `bigint` + Transformer возвращающий `number`; (c) `bigint` + `string` с явным `Number()` в кодe. НЕ `bigint` + declaration `number` без Transformer.
- **`Object.assign(this, blockchainData)` запрещено** в sync-сущностях — ломает типизацию. Только явное копирование полей.
- **Lowercase hash полей (`project_hash`, `listing_hash`)** — нормализация **в конструкторе/mapValue**, НЕ в mapper'ах и НЕ повторно в `updateFromBlockchain`.
- **Discriminated union для write-mutation response:** `{ status: 'applied' | 'pending' | 'failed' | 'conflict' }`. Клиент должен switch на статусе.
@@ -76,6 +77,8 @@ _Критичные правила и паттерны для AI-агентов
- `@Column({ type: 'bigint', nullable: true })` для `block_num`. Для `jsonb``@Column({ type: 'jsonb' })`.
- `ADD COLUMN NOT NULL` на больших таблицах — **двухэтапно**: ADD nullable → backfill → ALTER NOT NULL.
- Repository `extends BaseBlockchainRepository<DomainEntity, TypeormEntity>`. `findBySyncKey`, `createIfNotExists`, `deleteByBlockNumGreaterThan`, `restoreFromVersions`**наследуются**, не реализовывать руками.
- **Контракт «entity с block_num → repo extends BaseBlockchainRepository» (Story 4.3).** Любая `*.typeorm-entity.ts` extends `BaseTypeormEntity` ОБЯЗАНА иметь репозиторий extends `BaseBlockchainRepository` — иначе `entity_versions` не пишется (silent), а форк-rollback превращается в hard delete без восстановления. CI grep-guard: `tests/unit/blockchain/base-blockchain-repository.contract.test.ts`. Allowlist для 5 off-chain entity (`comment`, `cycle`, `issue`, `story`, `time-entry` — vestigial block_num, не блокчейн-зеркала). Новое исключение в allowlist — только с обоснованием в audit-report.
- **Архив форка вместо hard-delete (Story 4.4).** `handleFork(N, eventId?)` НЕ удаляет live-ряды и снимки версий — переносит в `invalidated_entities` / `invalidated_entity_versions` (атомарно через DataSource.transaction). Порядок: archiveInvalidatedSince → restoreFromVersions → archiveInvalidatedVersionsSince. `fork_event_id` группирует записи одного форка (для forensic). Retention — `BlockchainArchiveRetentionService` ежечасно удаляет архив старше `LIB - 1000` блоков. LIB читается через `BlockchainService.getInfo()` (вариант C, RPC `/v1/chain/get_info`). Окно `RETENTION_HORIZON_BLOCKS = 1000` ХАРДКОД (свойство сети, не оператора). Env-переключатели: `BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED` (default true), `BLOCKCHAIN_ARCHIVE_RETENTION_CRON` (default `0 * * * *`).
**parser2 integration:**
- `ParserClient` subscribe с `subscriptionId = "controller-${coopname}"`, `consumerName = "primary"` (детерминирован), `startFromBlock: 'last_known'`.
@@ -87,14 +90,16 @@ _Критичные правила и паттерны для AI-агентов
- `{contract}{Entity}Updated` / `Deleted` / `RolledBack` / `PendingRetry` / `Failed`**pubsub канал** для GraphQL subscriptions. Per-contract, не global.
- `entitysynced::{contract}::{table}` — для business-side-effect listeners (Matrix / Notification / и т.п.).
### Composite-Entity (ADR-008) — СТРОГО
### Composite-Entity (ADR-008) — будущая цель, не сейчас
- Namespaced: `entity.db.X` (DB-поля) / `entity.bc?.Y` (blockchain, nullable) / `entity.derived.Z` (computed getters).
- **НЕ** писать `entity.X` напрямую — ломает изоляцию.
- Конструктор `(databaseData, blockchainData?)` — обязан `throw` на sync-key mismatch.
- `updateFromBlockchain` возвращает **новый экземпляр** (immutable) или мутирует только `this.bc`, `this.block_num`, `this.present` — БЕЗ `Object.assign`.
- `derived` getter — детерминирован (NO `new Date()` в конструкторе / getter — ломает snapshot tests).
- Все signed-document поля нормализуются через `AbstractDeltaMapper.normalizeSignedDocuments` на основе `signedDocumentFields: SignedDocField[]` декларативно, НЕ руками в mapper.
ADR-008 описывает целевой паттерн `entity.db.X` / `entity.bc?.Y` / `entity.derived.Z`, заменяющий
`Object.assign(this, blockchainData)`. Переход вынесен за пределы MVP-релиза parser2 в отдельный
sync-arch sanitation-эпик: blast radius на 22 entity + потребители «плоских» полей в resolver'ах
делают эту миграцию большой и рискованной задачей, несвязанной с заменой транспорта parser1→parser2.
До отдельного эпика — текущий код продолжает использовать flat-namespace + `Object.assign` в
`updateFromBlockchain`. Не вводить namespace частично на одной entity — двойной канон хуже единого
старого.
### Dispatch pipeline (ADR-002, ADR-009) — СТРОГО
@@ -216,7 +221,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
- `BLOCKCHAIN_RECONCILE_CRON` default `'0 * * * *'`
- `BLOCKCHAIN_RECONCILE_SAMPLE_SIZE` default 100
- `BLOCKCHAIN_RECONCILE_TOLERANCE_BLOCKS` default 10
- `BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS` default 30000
- `BLOCKCHAIN_DLQ_MAX_RETRIES` default 5
- `BLOCKCHAIN_MAX_TX_RETRIES` default 3
- `BLOCKCHAIN_PENDING_TX_MAX_AGE_SECONDS` default 3600
@@ -251,12 +255,13 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
- `await capitalBlockchainPort.getProject(hash)` в resolver / application — **retire**. Только `repository.findBySyncKey`.
- RPC fallback "если PG не отдал" — **запрещено**. Если PG null → pending status наверх.
### ❌ Composite-entity anti-patterns
### ❌ Domain-entity anti-patterns
- `project.matrix_room_id` (flat access) — **запрещено**. Правильно: `project.db.matrix_room_id`.
- `project.master` (flat access) — **запрещено**. Правильно: `project.bc?.master`.
- `Object.assign(this, blockchainData)` в update — **запрещено**.
- Новый `new Date()` в конструкторе или derived-getter — **запрещено** (ломает snapshot-tests).
- Миграция на namespace `entity.db.X` / `entity.bc?.Y` — целевой паттерн ADR-008, но отложен
в отдельный sync-arch sanitation-эпик: текущий код всё ещё использует flat-namespace +
`Object.assign(this, blockchainData)` в `updateFromBlockchain`. Не разводить два стандарта
частично.
### ❌ Fork handling anti-patterns
@@ -296,7 +301,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
### ⚡ Performance
- `JSON.stringify` на сущностях с nested signed-documents — использовать `json-stable-stringify` для canonical checksum.
- Reconciliation cron — sample N=100 rows, **не** full scan на hot path.
- IPFS fetch для signed-doc — **lazy resolver вне consumer critical path**, не в mapper.
- `waitForDelta` memory leak — timer cleanup обязателен (см. INV-T10).
-8
View File
@@ -27,19 +27,11 @@ RUN apt-get update && apt-get install -y \
libharfbuzz-dev \
libcairo2 \
libpango1.0-dev \
fontconfig \
&& rm -rf /var/lib/apt/lists/* \
&& python3 -m venv /venv \
&& /venv/bin/pip install WeasyPrint==67 \
&& rm -rf /root/.cache/pip
# Шрифт Arial устанавливаем СИСТЕМНО (а не встраиваем base64 в каждый html).
# Иначе weasyprint парсил бы ~270 КБ TTF на каждый рендер (≈1.3 сек/документ).
# Системный шрифт fontconfig парсит один раз и кеширует. Файл тот же —
# глифы/вёрстка идентичны прежним.
COPY assets/fonts/arial.ttf /usr/share/fonts/truetype/arial/arial.ttf
RUN fc-cache -f && fc-match Arial
# Установка пути к виртуальному окружению в PATH
ENV PATH="/venv/bin:$PATH"
Binary file not shown.
@@ -1,126 +0,0 @@
import { createHash } from 'node:crypto';
import type { DataSource } from 'typeorm';
import mongoose from 'mongoose';
import config from '~/config/config';
type MigrationLogger = {
info: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
const CAPITAL_DOC_REGISTRY_ID = 994;
const TARGET_COOPNAME = 'voskhod';
const COLLECTION_NAME = 'doc_private_data';
const capitalProgramPrivateData = {
generator_program_purpose:
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
eoap_definition:
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
generator_task_goal:
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
idea_unit_cost:
"50",
idea_unit_cost_words:
"пятьдесят",
blagorost_goal_expansion:
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
blagorost_goal_reason:
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
blagorost_task_expansion:
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
blagorost_task_development:
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
return_source_description:
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
return_additional_source:
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.map((key) => {
const v = (value as Record<string, unknown>)[key];
return `${JSON.stringify(key)}:${stableStringify(v)}`;
});
return `{${entries.join(',')}}`;
}
function calculateSha256(value: unknown): string {
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
}
export default {
name: 'Backfill capital_program_doc_data_hash for voskhod',
validUntil: new Date('2027-05-01T00:00:00Z'),
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
return true;
}
const hash = calculateSha256(capitalProgramPrivateData);
let mongo: mongoose.Connection | null = null;
try {
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
const db = mongo.db;
if (!db) {
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
}
const collection = db.collection(COLLECTION_NAME);
await collection.createIndex({ hash: 1 }, { unique: true });
await collection.updateOne(
{ hash },
{
$setOnInsert: {
hash,
registry_id: CAPITAL_DOC_REGISTRY_ID,
payload: capitalProgramPrivateData,
_created_at: new Date(),
},
},
{ upsert: true }
);
const result = await dataSource.query(
`UPDATE extensions
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
updated_at = NOW()
WHERE name = 'capital'`,
[hash]
);
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
return false;
} finally {
await mongo?.close();
}
},
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
return true;
}
await dataSource.query(
`UPDATE extensions
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
updated_at = NOW()
WHERE name = 'capital'`
);
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
return true;
},
};
+1 -3
View File
@@ -1,6 +1,4 @@
{
"watch": ["./src"],
"ext": "ts",
"delay": 2000,
"signal": "SIGTERM"
"ext": "ts"
}

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