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
1034 changed files with 36117 additions and 40016 deletions
+199 -151
View File
@@ -1,45 +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'
workflow_dispatch:
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 }}
@@ -51,22 +55,22 @@ jobs:
with:
fetch-depth: 0
- name: Resolve branch, build mode and version
- name: Resolve branch, build mode and tags
id: resolve
run: |
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
BRANCH="${{ github.ref_name }}"
TAG_NAME="${{ github.ref_name }}"
SHA="${{ github.sha }}"
# Версия — из закоммиченного 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"
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)
@@ -84,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
{
@@ -128,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/
@@ -243,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 \
@@ -262,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
@@ -282,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"
@@ -315,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
}
@@ -340,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
@@ -371,44 +314,149 @@ jobs:
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 коде **все имена идентификаторов — английские**:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.6.9-18",
"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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.6.9-18",
"version": "2026.5.23-4",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+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) {
@@ -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"
}
+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
+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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.6.9-18",
"version": "2026.5.23-4",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+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
@@ -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,
@@ -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;
}
}
@@ -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);
}
}
}
+1 -1
View File
@@ -239,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,21 +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 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
@@ -134,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.
};
/**
@@ -159,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.
@@ -171,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;
@@ -193,44 +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,
"Возврат регистрационного взноса при отказе совета" },
// 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,
"Возврат паевого взноса пайщику" },
@@ -314,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,
@@ -383,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() {
@@ -427,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;
}
@@ -483,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(),
@@ -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
@@ -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)
@@ -96,16 +94,15 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — 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::REGISTRATION_PENDING, "Регистрационный взнос в ожидании решения совета", WalletKind::USER_SHARED },
// COOPERATIVE (10) — единый кооперативный баланс, без L3
// COOPERATIVE (9) — единый кооперативный баланс, без L3
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
// CRPS-распределение между сегментами проекта не поддерживает per-user
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
@@ -113,7 +110,6 @@ inline constexpr std::array<Ledger2WalletMeta, 16> 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 },
@@ -232,14 +228,13 @@ struct Ledger2WalletProgramMapping {
uint64_t required_program_id; // 0 = исключение (без проверки)
};
inline constexpr std::array<Ledger2WalletProgramMapping, 7> 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::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
}};
/**
@@ -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);
@@ -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();
});
}
@@ -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,8 +8,6 @@
#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/account/createbranch.cpp"
@@ -88,8 +88,6 @@ public:
[[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,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:
@@ -201,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);
@@ -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 Миграция данных системы
* Выполняет миграцию данных системы при обновлении контракта.
@@ -22,46 +20,6 @@ void soviet::migrate() {
});
}
// ────────────────────────────────────────────────────────────────────────
// Разовая очистка ФАНТОМНЫХ участников кооператива 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);
}
}
}
}
// Старый код миграции (закомментирован)
// cooperatives2_index coops(_registrator, _registrator.value);
@@ -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.6.9-18",
"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
View File
@@ -71,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 -3
View File
@@ -1,6 +1,4 @@
{
"watch": ["./src"],
"ext": "ts",
"delay": 2000,
"signal": "SIGTERM"
"ext": "ts"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.6.9-18",
"version": "2026.5.23-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.6.9-18",
"version": "2026.5.23-4",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.6.9-18",
"version": "2026.5.23-4",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -76,6 +76,7 @@
"@coopenomics/factory": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/notifications": "workspace:*",
"@coopenomics/parser2": "^1.2.0",
"@coopenomics/provider-client": "2025.11.12-alpha-1",
"@coopenomics/sdk": "workspace:*",
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
@@ -97,7 +98,9 @@
"@nestjs/schedule": "^6.0.1",
"@nestjs/throttler": "^6.3.0",
"@nestjs/typeorm": "^10.0.2",
"@novu/api": "^1.4.0",
"@octokit/rest": "^22.0.1",
"@opensearch-project/opensearch": "^3.5.1",
"@sentry/nestjs": "^10.32.1",
"@sentry/node": "^10.32.1",
"@types/jest": "^29.5.12",
@@ -191,12 +194,12 @@
"@antfu/eslint-config": "^2.24.1",
"@graphql-codegen/cli": "^5.0.3",
"@graphql-codegen/typescript": "^4.1.1",
"@swc-node/register": "^1.11.1",
"@swc/core": "^1.15.33",
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
"@graphql-codegen/typescript-operations": "^4.3.1",
"@nestjs/testing": "^11.1.19",
"@redocly/cli": "^1.18.0",
"@swc-node/register": "^1.11.1",
"@swc/core": "^1.15.33",
"@types/express": "^4.17.21",
"@types/node": "^20.11.26",
"@typescript-eslint/eslint-plugin": "^5.10.0",
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -10,11 +10,14 @@ import { GraphqlModule } from './infrastructure/graphql/graphql.module';
import { MongooseModule } from '@nestjs/mongoose';
import config from '~/config/config';
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
import { ForkRegistryModule } from './shared/sync/fork';
import { GeneratorInfrastructureModule } from './infrastructure/generator/generator.module';
import { RedisModule } from './infrastructure/redis/redis.module';
import { NovuModule } from './infrastructure/novu/novu.module';
import { EventsInfrastructureModule } from './infrastructure/events/events.module';
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
// Domain modules
@@ -35,6 +38,7 @@ import { DesktopDomainModule } from './domain/desktop/desktop-domain.module';
import { MeetDomainModule } from './domain/meet/meet-domain.module';
import { GatewayDomainModule } from './domain/gateway/gateway-domain.module';
import { VaultDomainModule } from './domain/vault/vault-domain.module';
import { NotificationDomainModule } from './domain/notification/notification-domain.module';
import { LedgerDomainModule } from './domain/ledger/ledger-domain.module';
import { ProcessRegistryDomainModule } from './domain/process-registry/process-registry-domain.module';
import { ParserDomainModule } from './domain/parser/parser-domain.module';
@@ -56,7 +60,6 @@ import { QueueModule } from './application/queue/queue-app.module';
import { DocumentModule } from './application/document/document.module';
import { RedisAppModule } from './application/redis/redis-app.module';
import { DecisionModule } from './application/free-decision/decision.module';
import { DecisionAuthorizeModule } from './application/decision/decision.module';
import { AgreementModule } from './application/agreement/agreement.module';
import { ParticipantModule } from './application/participant/participant.module';
import { AgendaModule } from './application/agenda/agenda.module';
@@ -66,7 +69,6 @@ import { MeetModule } from './application/meet/meet.module';
import { GatewayModule } from './application/gateway/gateway.module';
import { WalletModule } from './application/wallet/wallet.module';
import { NotificationModule } from './application/notification/notification.module';
import { NotificationCenterModule } from './application/notification-center/notification-center.module';
import { LedgerModule } from './application/ledger/ledger.module';
import { Ledger2Module } from './application/ledger2/ledger2.module';
import { ProcessRegistryModule } from './application/process-registry/process-registry.module';
@@ -78,7 +80,6 @@ import { SettingsApplicationModule } from './application/settings/settings.modul
import { RegistrationModule } from './application/registration/registration.module';
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
import { SearchModule } from './application/search/search.module';
import { SignedDocumentsModule } from './application/signed-documents/signed-documents.module';
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
@Module({
@@ -86,20 +87,23 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
ConfigModule.forRoot({
isGlobal: true, // Чтобы .env был доступен глобально
}),
ScheduleModule.forRoot(), // @Cron / @Interval / @Timeout (Story 4.4 retention)
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 50,
},
]),
ScheduleModule.forRoot(), // @Interval/@Cron — нужен outbox-worker'у Центра уведомлений
// Infrastructure modules
MongooseModule.forRoot(config.mongoose.url),
DatabaseModule,
GraphqlModule,
ForkRegistryModule,
BlockchainModule,
GeneratorInfrastructureModule,
RedisModule,
NovuModule,
SearchInfrastructureModule,
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
@@ -131,6 +135,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
MeetDomainModule,
GatewayDomainModule,
VaultDomainModule,
NotificationDomainModule,
LedgerDomainModule,
ProcessRegistryDomainModule,
ParserDomainModule,
@@ -141,7 +146,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
AccountModule,
AgreementModule,
AgendaModule,
DecisionAuthorizeModule,
AppStoreModule,
AuthModule,
DesktopModule,
@@ -159,7 +163,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
GatewayModule,
WalletModule,
NotificationModule,
NotificationCenterModule,
LedgerModule,
Ledger2Module,
ProcessRegistryModule,
@@ -171,7 +174,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
RegistrationModule,
OnboardingApplicationModule,
SearchModule,
SignedDocumentsModule,
],
providers: [
{
@@ -2,8 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
import { AccountResolver } from './resolvers/account.resolver';
import { AccountService } from './services/account.service';
import { AccountInteractor } from './interactors/account.interactor';
import { RegistrationDeclineListener } from './services/registration-decline.listener';
import { AccountDomainModule } from '~/domain/account/account-domain.module';
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
import { UserDomainModule } from '~/domain/user/user-domain.module';
import { TokenApplicationModule } from '~/application/token/token-application.module';
import { EventsInfrastructureModule } from '~/infrastructure/events/events.module';
@@ -11,12 +11,13 @@ import { EventsInfrastructureModule } from '~/infrastructure/events/events.modul
@Module({
imports: [
AccountDomainModule,
NotificationDomainModule,
forwardRef(() => UserDomainModule),
forwardRef(() => TokenApplicationModule),
EventsInfrastructureModule,
],
controllers: [],
providers: [AccountInteractor, AccountService, AccountResolver, RegistrationDeclineListener],
providers: [AccountInteractor, AccountService, AccountResolver],
exports: [AccountInteractor, AccountService],
})
export class AccountModule {}
@@ -7,7 +7,6 @@ import { MonoAccountDTO } from './mono-account.dto';
import { UserAccountDTO } from '~/application/account/dto/base-user-account.dto';
import { ParticipantAccountDTO } from './participant-account.dto';
import { PrivateAccountDTO } from './private-account.dto';
import { RegistrationPaymentDTO } from './registration-payment.dto';
@ObjectType('Account')
export class AccountDTO {
@@ -59,15 +58,6 @@ export class AccountDTO {
@Type(() => PrivateAccountDTO)
public readonly private_account!: PrivateAccountDTO | null;
@Field(() => RegistrationPaymentDTO, {
description:
'сводка по вступительному (регистрационному) платежу пайщика. Позволяет восстановить шаг регистрации (ожидание решения совета или отклонение платежа) после перезагрузки страницы и в любой вкладке.',
nullable: true,
})
@ValidateNested()
@Type(() => RegistrationPaymentDTO)
public readonly registration_payment!: RegistrationPaymentDTO | null;
constructor(entity: AccountDomainEntity) {
this.username = entity.username;
this.blockchain_account = entity.blockchain_account || null;
@@ -75,8 +65,5 @@ export class AccountDTO {
this.user_account = entity.user_account ? new UserAccountDTO(entity.user_account) : null;
this.participant_account = entity.participant_account ? new ParticipantAccountDTO(entity.participant_account) : null;
this.private_account = entity.private_account ? new PrivateAccountDTO(entity.private_account) : null;
this.registration_payment = entity.registration_payment
? new RegistrationPaymentDTO(entity.registration_payment)
: null;
}
}
@@ -1,32 +0,0 @@
import { ObjectType, Field } from '@nestjs/graphql';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { RegistrationPaymentDomainInterface } from '~/domain/account/interfaces/registration-payment-domain.interface';
@ObjectType('RegistrationPayment')
export class RegistrationPaymentDTO {
@Field(() => PaymentStatusEnum, { description: 'Статус вступительного платежа' })
public readonly status: PaymentStatusEnum;
@Field(() => String, {
description: 'Причина изменения статуса. При отклонении платежа — причина отказа, которую видит пайщик.',
nullable: true,
})
public readonly message?: string | null;
@Field(() => String, { description: 'Хэш платежа' })
public readonly hash: string;
@Field(() => Number, { description: 'Сумма вступительного платежа' })
public readonly quantity: number;
@Field(() => String, { description: 'Символ валюты платежа' })
public readonly symbol: string;
constructor(entity: RegistrationPaymentDomainInterface) {
this.status = entity.status;
this.message = entity.message ?? null;
this.hash = entity.hash;
this.quantity = entity.quantity;
this.symbol = entity.symbol;
}
}
@@ -6,6 +6,10 @@ import { HttpApiError } from '~/utils/httpApiError';
import { UserDomainService, USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
import { TokenApplicationService } from '~/application/token/services/token-application.service';
import { GENERATOR_PORT, GeneratorPort } from '~/domain/document/ports/generator.port';
import {
NOTIFICATION_DOMAIN_SERVICE,
NotificationDomainService,
} from '~/domain/notification/services/notification-domain.service';
import { EventsService } from '~/infrastructure/events/events.service';
import type { GetAccountsInputDomainInterface } from '~/domain/account/interfaces/get-accounts-input.interface';
import type {
@@ -33,12 +37,8 @@ import { EntrepreneurDomainEntity } from '~/domain/branch/entities/entrepreneur-
import { ACCOUNT_BLOCKCHAIN_PORT, AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
import { CANDIDATE_REPOSITORY, CandidateRepository } from '~/domain/account/repository/candidate.repository';
import { userStatus } from '~/types/user.types';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { CandidateStatus } from '~/domain/registration/enum';
import { sha256 } from '~/utils/sha256';
import { registrationProfileFingerprint } from '~/utils/registration-profile-fingerprint';
import { normalizeUserEmail } from '~/utils/normalize-user-email';
import type {
SearchPrivateAccountsInputDomainInterface,
@@ -56,12 +56,12 @@ export class AccountInteractor {
private readonly searchPrivateAccountsRepository: SearchPrivateAccountsRepository,
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
@Inject(NOTIFICATION_DOMAIN_SERVICE) private readonly notificationDomainService: NotificationDomainService,
private readonly eventsService: EventsService,
@Inject(GENERATOR_PORT) private readonly generatorPort: GeneratorPort,
private readonly tokenApplicationService: TokenApplicationService,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService,
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService
) {}
private readonly logger = new Logger(AccountInteractor.name);
@@ -167,6 +167,25 @@ export class AccountInteractor {
throw new Error('Не получены входные данные для обновления');
}
// Novu: не блокируем ответ мутации — догоняем подписчика в фоне
try {
const account = await this.getAccount(user.username);
void this.notificationDomainService
.createSubscriberFromAccount(account)
.then(() => {
this.logger.log(`Подписчик NOVU обновлён для ${data.username}`);
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Ошибка обновления подписчика NOVU для ${data.username}: ${message}`, stack);
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Ошибка подготовки синхронизации NOVU для ${data.username}: ${message}`, stack);
}
// Получаем финальный аккаунт
const account = await this.getAccount(user.username);
@@ -182,40 +201,7 @@ export class AccountInteractor {
return result;
}
/**
* Удаление аккаунта из системы учёта провайдера (off-chain).
*
* deleteUserByUsername удаляет только строку users в PG блокчейн не трогает.
* Назначение почистить реестр от незавершённых/отклонённых регистраций
* (тестеры, брошенные попытки) и освободить занятый e-mail для перерегистрации.
*
* Единственный гард НЕ принят в кооператив. participant_account
* (accepted|blocked) появляется только после приёма советом, его наличие член
* кооператива. Всё остальное (черновик, заявление, оплачен/на рассмотрении
* совета, отклонён, возврат) удаляемо это и есть «незавершённая регистрация».
*
* Статус для гейта НЕ годится: воронка реально доходит лишь до `registered`
* (отказ совета и возврат взноса оставляют статус `registered`, а `failed`/
* `refunded` users.status никем не выставляются). Прежний allow-list
* `[created, joined, payed, failed, refunded]` исключал именно отклонённые/
* оплаченные брошенные регистрации ровно те, ради которых задумана чистка.
*
* On-chain аккаунт заводится при оплате взноса (статус registered) и в EOSIO
* неудаляем остаётся осиротевшим, имя в цепи занято навсегда. Для
* регистрационной чистки это приемлемо: off-chain учёт провайдера и цепь
* намеренно расходятся (см. registration-decline.listener карточку кандидата
* на цепи снимает refundpay, системный аккаунт остаётся).
*/
async deleteAccount(username: string): Promise<void> {
const account = await this.accountDomainService.getAccount(username);
if (account.participant_account) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Пайщик принят в кооператив — удаление невозможно.'
);
}
await this.userDomainService.deleteUserByUsername(username);
}
@@ -240,11 +226,11 @@ export class AccountInteractor {
const user = await this.createUser({ ...data, role: 'user' });
const tokens = await this.tokenApplicationService.generateAuthTokens(user.id);
// Настраиваем identity получателя
// Настраиваем подписчика NOVU
try {
await this.accountDomainService.setupNotificationSubscriber(user.username, 'регистрации');
} catch (error: any) {
this.logger.error(`Ошибка настройки identity получателя при регистрации ${data.username}: ${error.message}`, error.stack);
this.logger.error(`Ошибка настройки подписчика NOVU при регистрации ${data.username}: ${error.message}`, error.stack);
}
// Создаем нового кандидата в репозитории
@@ -282,58 +268,7 @@ export class AccountInteractor {
}
async getAccount(username: string): Promise<AccountDomainEntity> {
const account = await this.accountDomainService.getAccount(username);
// Прокидываем сводку по вступительному платежу, чтобы фронт восстановил шаг
// регистрации (ожидание/отклонение платежа) после перезагрузки и в любой вкладке.
// Актуально только пока пайщик ещё не принят (нет participant_account).
if (!account.participant_account) {
const payment = await this.paymentRepository.findLatestByUsernameAndType(
username,
PaymentTypeEnum.REGISTRATION
);
// Отказ совета (declinereg) заводит исходящий возврат REGISTRATION_REFUND.
// Сообщаем фронту отдельным статусом REFUNDED (входящий рег-платёж этот статус
// никогда не принимает) — иначе экран ожидания висит на «ожидаем решение
// совета», т.к. сам входящий платёж остаётся COMPLETED. Отказ относится к
// ТЕКУЩЕМУ циклу только если возврат свежее последнего вступительного платежа:
// после повторной подачи создаётся новый REGISTRATION (свежее старого возврата),
// и прошлый отказ больше не всплывает.
const refund = await this.paymentRepository.findLatestByUsernameAndType(
username,
PaymentTypeEnum.REGISTRATION_REFUND
);
const isCouncilDeclined =
!!refund &&
(!payment || new Date(refund.created_at).getTime() >= new Date(payment.created_at).getTime());
if (isCouncilDeclined && refund) {
// Два шага возврата для UI: пока касса не подтвердила исходящий платёж
// (refund != COMPLETED) — PROCESSING (кнопки повторной подачи нет, аккаунт
// на цепи ещё занят карточкой); после подтверждения — REFUNDED (refundpay
// снял карточку, повторная подача возможна).
const done = refund.status === PaymentStatusEnum.COMPLETED;
account.registration_payment = {
status: done ? PaymentStatusEnum.REFUNDED : PaymentStatusEnum.PROCESSING,
message: done
? 'Совет отказал в приёме. Регистрационный взнос возвращён. Вы можете подать заявку заново.'
: 'Совет отказал в приёме. Регистрационный взнос возвращается. Дождитесь его завершения, чтобы подать заявку заново.',
hash: refund.hash,
quantity: refund.quantity,
symbol: refund.symbol,
};
} else if (payment) {
account.registration_payment = {
status: payment.status,
message: payment.message ?? null,
hash: payment.hash,
quantity: payment.quantity,
symbol: payment.symbol,
};
}
}
return account;
return await this.accountDomainService.getAccount(username);
}
async getAccounts(
@@ -350,11 +285,7 @@ export class AccountInteractor {
};
for (const account of provider_accounts.items) {
// this.getAccount (а не accountDomainService.getAccount) — чтобы реестр получил
// registration_payment: иначе отказ совета (Registered + возврат взноса) на
// списке неотличим от «ожидает совет». Обогащение стоит 2 доп. запроса к
// платежам только для непринятых аккаунтов (гард `!participant_account`).
const item = await this.getAccount(account.username);
const item = await this.accountDomainService.getAccount(account.username);
result.items.push(item);
}
@@ -374,29 +305,6 @@ export class AccountInteractor {
throw new HttpApiError(HttpStatus.NOT_FOUND, `Кандидат с именем ${username} не найден`);
}
// Гард A (замок профиля): на цепь не должны уйти данные, отличные от
// подписанных в заявлении. Если в meta кандидата зафиксирован отпечаток
// (заявление подписано после внедрения гарда) — сверяем с текущим
// профилем. Кандидаты без отпечатка (регистрация шла до внедрения)
// пропускаются, чтобы не сломать уже идущие регистрации.
let candidateMeta: Record<string, any> = {};
try {
candidateMeta = candidate.meta ? JSON.parse(candidate.meta) : {};
} catch {
candidateMeta = {};
}
const lockedFingerprint = candidateMeta?.registration_profile_fingerprint;
if (lockedFingerprint) {
const account = await this.accountDomainService.getAccount(username);
const currentFingerprint = registrationProfileFingerprint(account?.private_account);
if (currentFingerprint && currentFingerprint !== lockedFingerprint) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Данные пайщика изменены после подписания заявления. Пожалуйста, переподпишите заявление перед регистрацией.'
);
}
}
try {
// Вызываем порт для регистрации в блокчейне, передавая объект кандидата целиком
await this.accountBlockchainPort.registerBlockchainAccount(candidate);
@@ -421,109 +329,6 @@ export class AccountInteractor {
}
}
/**
* Откат регистрации к редактированию данных (recovery drift/decline).
*
* Применим ТОЛЬКО до создания аккаунта в блокчейне: после reguser username
* необратимо занят, и нужна перерегистрация это отдельная ветка. Здесь же,
* пока аккаунта в цепи нет, безопасно вернуть пайщика к началу:
* - снимаем заморозку профиля и e-mail (users.status created);
* - сбрасываем кандидата (документы и отпечаток заявления), чтобы заявление
* пришлось переподписать под актуальные данные;
* - удаляем непринятую попытку регистрационного платежа, чтобы экран
* ожидания не показывал старый отказ.
*
* Если средства уже приняты (PAID/COMPLETED) откат запрещён: нужен возврат
* средств (отдельная ветка refund).
*/
async resetRegistration(username: string): Promise<AccountDomainEntity> {
const user = await this.userDomainService.getUserByUsername(username);
if (!user) {
throw new HttpApiError(HttpStatus.NOT_FOUND, `Пользователь ${username} не найден`);
}
// Повторная подача после отказа совета (вариант 1): в отличие от обычного
// отката (до приёма платежа), здесь аккаунт уже в блокчейне и взнос был принят —
// это нормально. Карточку участника на цепи снимает refundpay при завершении
// возврата, поэтому пускаем только когда возврат COMPLETED (иначе повторный
// reguser упадёт «повторное получение невозможно»). Отказ относится к текущему
// циклу, только если возврат свежее последнего вступительного платежа.
const payment = await this.paymentRepository.findLatestByUsernameAndType(
username,
PaymentTypeEnum.REGISTRATION
);
const refund = await this.paymentRepository.findLatestByUsernameAndType(
username,
PaymentTypeEnum.REGISTRATION_REFUND
);
const isCouncilDeclined =
!!refund &&
(!payment || new Date(refund.created_at).getTime() >= new Date(payment.created_at).getTime());
if (isCouncilDeclined && refund) {
if (refund.status !== PaymentStatusEnum.COMPLETED) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Возврат регистрационного взноса ещё выполняется. Подайте заявку заново после его завершения.'
);
}
// Старые платежи не удаляем: getAccount различает циклы по дате — новый
// вступительный платёж будет свежее этого возврата, и прошлый отказ исчезнет.
} else {
// Обычный откат до приёма платежа: аккаунт ещё не в блокчейне, взнос не принят.
const blockchainAccount = await this.accountDomainService.getBlockchainAccount(username);
if (
blockchainAccount ||
user.status === userStatus['4_Registered'] ||
user.status === userStatus['5_Active']
) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Регистрация уже отправлена в блокчейн — откат к редактированию невозможен, требуется перерегистрация.'
);
}
if (payment) {
if (payment.status === PaymentStatusEnum.PAID || payment.status === PaymentStatusEnum.COMPLETED) {
throw new HttpApiError(
HttpStatus.BAD_REQUEST,
'Вступительный взнос уже принят — для отката требуется возврат средств.'
);
}
if (payment.id) {
await this.paymentRepository.delete(payment.id);
}
}
}
// Кандидат: чистим документы и отпечаток заявления (потребуется переподпись).
const candidate = await this.candidateRepository.findByUsername(username);
if (candidate) {
let candidateMeta: Record<string, any> = {};
try {
candidateMeta = candidate.meta ? JSON.parse(candidate.meta) : {};
} catch {
candidateMeta = {};
}
delete candidateMeta.registration_profile_fingerprint;
await this.candidateRepository.update(username, {
status: CandidateStatus.PENDING,
documents: {},
meta: JSON.stringify(candidateMeta),
});
}
// Снимаем заморозку: профиль и e-mail снова можно менять.
await this.userDomainService.updateUserByUsername(username, {
status: userStatus['1_Created'],
is_registered: false,
has_account: false,
});
this.logger.log(`Регистрация ${username} откатана к редактированию данных`);
return await this.accountDomainService.getAccount(username);
}
/**
* Поиск приватных аккаунтов по запросу
* @param input Входные данные для поиска
@@ -12,11 +12,8 @@ import type { PaginationResultDomainInterface } from '~/domain/common/interfaces
import { RegisterAccountInputDTO } from '../dto/register-account-input.dto';
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
import { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
import { DeleteAccountInputDTO } from '../dto/delete-account-input.dto';
import { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
export const AccountsPaginationResult = createPaginationResult(AccountDTO, 'Accounts');
@@ -72,31 +69,20 @@ export class AccountResolver {
return this.accountService.registerAccount(data);
}
@Mutation(() => Boolean, {
name: 'deleteAccount',
description:
'Удалить аккаунт пайщика из системы учёта провайдера. Доступно только для незавершённых регистрационных статусов (черновик, неоплачен/отклонён). Активный, заблокированный и любой зарегистрированный в блокчейне аккаунт удалить нельзя. Используется для очистки реестра и освобождения e-mail под перерегистрацию.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async deleteAccount(
@Args('data', { type: () => DeleteAccountInputDTO })
data: DeleteAccountInputDTO
): Promise<boolean> {
await this.accountService.deleteAccount(data);
// @Mutation(() => Boolean, {
// name: 'deleteAccount',
// description: 'Удалить аккаунт из системы учёта провайдера',
// })
// @UseGuards(GqlJwtAuthGuard, RolesGuard)
// @AuthRoles(['chairman', 'member'])
// async deleteAccount(
// @Args('data', { type: () => DeleteAccountInputDTO })
// data: DeleteAccountInputDTO
// ): Promise<boolean> {
// await this.accountService.deleteAccount(data);
return true;
}
@Mutation(() => AccountDTO, {
name: 'resetRegistration',
description:
'Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств.',
})
@UseGuards(GqlJwtAuthGuard)
async resetRegistration(@CurrentUser() currentUser: MonoAccountDomainInterface): Promise<AccountDTO> {
return await this.accountService.resetRegistration(currentUser.username);
}
// return true;
// }
@Mutation(() => AccountDTO, {
name: 'updateAccount',
@@ -30,12 +30,6 @@ export class AccountService {
return new AccountDTO(account);
}
public async resetRegistration(username: string): Promise<AccountDTO> {
const account = await this.accountInteractor.resetRegistration(username);
return new AccountDTO(account);
}
public async getAccounts(
data?: GetAccountsInputDTO,
options?: PaginationInputDTO
@@ -1,119 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { CANDIDATE_REPOSITORY, CandidateRepository } from '~/domain/account/repository/candidate.repository';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentTypeEnum, PaymentDirectionEnum, VAT_EXEMPT_NOTE } from '~/domain/gateway/enums/payment-type.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
import { generateUniqueHash } from '~/utils/generate-hash.util';
import { RegistratorContract } from 'cooptypes';
// Имя on-chain события отказа в регистрации — из констант cooptypes, не хардкод.
const REGISTRATOR = RegistratorContract.contractName.production;
const DECLINE_REG_EVENT = `action::${REGISTRATOR}::${RegistratorContract.Actions.DeclineRegistration.actionName}`;
/**
* Заводит исходящий платёж-возврат при отказе совета в приёме пайщика.
*
* Отказ приходит on-chain как registrator::declinereg он срабатывает и при
* истечении срока повестки, и при отрицательном консенсусе (>50% совета против,
* PR #94), через общую decline-callback ветку.
*
* На момент отказа взнос по фондам НЕ разносился (confirmreg не было), но реальные
* деньги пайщика уже у кооператива на расчётном счёте (стоят на расчётах с пайщиком,
* счёт 76). Заводим исходящий платёж в реестр, чтобы кассир вернул вступительный +
* мин.паевой. Подтверждение кассиром проводит on-chain возврат: gateway::outcomplete
* registrator::refundpay (обратная проводка Дт 76 / Кт 51), см.
* gateway.interactor.processOutgoingPayment. Отклонить такой возврат нельзя.
*
* Хэш платежа-возврата = registration_hash: on-chain исходящий объект (создан
* declinereg через gateway::createoutpay) идентифицируется этим же хэшем, и
* completeOutcome({ outcome_hash }) при подтверждении кассой должен совпасть с ним.
*/
@Injectable()
export class RegistrationDeclineListener {
private readonly logger = new Logger(RegistrationDeclineListener.name);
constructor(
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository,
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
) {}
@OnEvent(DECLINE_REG_EVENT)
async onDeclineRegistration(action: ActionDomainInterface): Promise<void> {
const registration_hash = action?.data?.registration_hash as string | undefined;
if (!registration_hash) return;
const candidate = await this.candidateRepository.findByRegistrationHash(registration_hash);
if (!candidate) {
this.logger.warn(`declinereg: кандидат по registration_hash=${registration_hash} не найден — пропуск возврата`);
return;
}
// Исходный вступительный платёж — источник суммы и номера (№ в QR = hash8).
const original = await this.paymentRepository.findLatestByUsernameAndType(
candidate.username,
PaymentTypeEnum.REGISTRATION,
);
if (!original) {
this.logger.warn(`declinereg: исходный регистрационный платёж для ${candidate.username} не найден — пропуск возврата`);
return;
}
// Идемпотентность: повторный приход события (replay/форк) не плодит возвраты.
// Но проверяем возврат именно ТЕКУЩЕГО цикла. На повторной подаче после прошлого
// отказа в БД остаётся возврат прошлого цикла, и его hash совпадает с новым
// (registration_hash = sha256(username) детерминирован). Возврат текущего цикла
// создаётся ПОЗЖЕ текущего вступительного платежа — по этому и отличаем. Иначе
// старый возврат блокировал бы создание нового, latest refund оставался бы старше
// нового платежа, и фронт завис бы на «платёж принят». Детекция цикла по дате —
// как в account.interactor (getAccount / resetRegistration / createRegistrationPayment).
const existingRefund = await this.paymentRepository.findLatestByUsernameAndType(
candidate.username,
PaymentTypeEnum.REGISTRATION_REFUND,
);
if (
existingRefund &&
new Date(existingRefund.created_at).getTime() >= new Date(original.created_at).getTime()
) {
this.logger.debug(
`declinereg: возврат текущего цикла для ${candidate.username} уже существует (${existingRefund.id}) — пропуск`,
);
return;
}
const now = new Date();
const refund: PaymentDomainInterface = {
id: '',
coopname: candidate.coopname || original.coopname,
username: candidate.username,
quantity: original.quantity,
symbol: original.symbol,
type: PaymentTypeEnum.REGISTRATION_REFUND,
direction: PaymentDirectionEnum.OUTGOING,
// Совет уже решил (отказ) — повторная авторизация не нужна, платёж сразу
// готов к выплате кассиром (PENDING виден кассиру и имеет кнопку «Подтвердить»).
status: PaymentStatusEnum.PENDING,
// Назначение — зеркало входящего взноса: тот же № (первые 8 символов хэша
// исходного платежа, что показывался пайщику в QR) и та же НДС-оговорка
// (VAT_EXEMPT_NOTE). Назначение целиком хранится в memo — провайдеры её
// больше не дописывают. Кассир копирует этот текст as-is в возвратный платёж.
memo: `Возврат вступительного и минимального паевого взносов №${original.hash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
provider: original.provider,
secret: generateUniqueHash(),
// Хэш = registration_hash: совпадает с outcome_hash on-chain исходящего
// объекта (gateway::createoutpay в declinereg), чтобы completeOutcome при
// подтверждении кассой нашёл его и вызвал registrator::refundpay.
hash: registration_hash,
expired_at: undefined, // бессрочный — как и прочие исходящие платежи
created_at: now,
updated_at: now,
};
const created = await this.paymentRepository.create(refund);
this.logger.log(
`declinereg: создан возврат ${created.id} для ${candidate.username} на ${original.quantity} ${original.symbol}`,
);
}
}
@@ -18,6 +18,6 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
],
controllers: [],
providers: [AgendaInteractor, AgendaResolver, AgendaService],
exports: [AgendaService],
exports: [],
})
export class AgendaModule {}
@@ -26,16 +26,9 @@ export class AgendaWithDocumentsDTO implements AgendaWithDocumentsDomainInterfac
data: AgendaWithDocumentsDomainInterface,
usernameCertificate?: UserCertificateDomainInterface | null,
votesForCertificates?: UserCertificateDomainInterface[],
votesAgainstCertificates?: UserCertificateDomainInterface[],
councilMembersCount = 0
votesAgainstCertificates?: UserCertificateDomainInterface[]
) {
this.table = new BlockchainDecisionDTO(
data.table,
usernameCertificate,
votesForCertificates,
votesAgainstCertificates,
councilMembersCount
);
this.table = new BlockchainDecisionDTO(data.table, usernameCertificate, votesForCertificates, votesAgainstCertificates);
this.action = new BlockchainActionDTO(data.action);
this.documents = new DocumentPackageAggregateDTO(data.documents);
}
@@ -88,25 +88,16 @@ export class BlockchainDecisionDTO implements SovietContract.Tables.Decisions.ID
@Field(() => String, { nullable: true })
hash!: string;
@Field(() => Number, {
description:
'Текущее число членов совета (всего, как считает контракт). Нужно фронту для вычисления порога принятия/отклонения: за/против * 100 > council_members_count * 50',
})
council_members_count!: number;
constructor(
data?: any,
usernameCertificate?: UserCertificateDomainInterface | null,
votesForCertificates?: UserCertificateDomainInterface[],
votesAgainstCertificates?: UserCertificateDomainInterface[],
councilMembersCount = 0
votesAgainstCertificates?: UserCertificateDomainInterface[]
) {
if (data) {
Object.assign(this, data);
}
this.council_members_count = councilMembersCount;
// Вспомогательная функция для создания сертификата DTO
const createCertificateDTO = (
certificate: UserCertificateDomainInterface | null
@@ -80,47 +80,4 @@ export class AgendaInteractor {
}
return agenda;
}
/**
* Собирает ОДИН пункт повестки по хэшу документа-заявления. Нужен сразу после
* публикации свободного решения: возвращаем только что созданный вопрос фронту
* немедленно, без ожидания общего поллинга. Возвращает null, пока парсер ещё не
* проиндексировал действие newsubmitted или документ-заявление тогда
* вызывающая сторона повторит попытку через паузу.
*/
async getAgendaItemByHash(coopname: string, hash: string): Promise<AgendaWithDocumentsDomainInterface | null> {
const target = String(hash).toUpperCase();
// decision появляется на чейне почти мгновенно после публикации.
const decisions = (await this.blockchainPort.getAllRows(
SovietContract.contractName.production,
coopname,
'decisions'
)) as SovietContract.Tables.Decisions.IDecision[];
const decision = decisions.find((d) => String(d.hash).toUpperCase() === target);
if (!decision) return null;
// action newsubmitted индексируется парсером с лагом (~2 c) — пока его нет,
// вернём null, и вызывающая сторона повторит тик.
const actionResponse = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
filter: JSON.stringify({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Registry.NewSubmitted.actionName,
receiver: process.env.COOPNAME,
'data.package': target,
}),
page: 1,
limit: 1,
});
const action = actionResponse?.results?.[0];
if (!action) return null;
const documents = await this.documentPackageAggregator.buildDocumentPackageAggregate(action);
// Тот же фильтр, что в getAgenda: без агрегата заявления пункт не отображается.
if (!documents.statement?.documentAggregate) return null;
return { table: decision, action, documents };
}
}
@@ -1,11 +1,13 @@
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
import config from '~/config/config';
import { SovietContract } from 'cooptypes';
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import { Workflows } from '@coopenomics/notifications';
/**
@@ -17,8 +19,8 @@ import { Workflows } from '@coopenomics/notifications';
@Injectable()
export class AgendaNotificationService implements OnModuleInit {
constructor(
@Inject(NOTIFICATION_PORT)
private readonly notificationPort: NotificationPort,
@Inject(NOVU_WORKFLOW_PORT)
private readonly novuWorkflowAdapter: NovuWorkflowAdapter,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountPort: AccountDataPort,
private readonly logger: WinstonLoggerService
@@ -89,17 +91,17 @@ export class AgendaNotificationService implements OnModuleInit {
continue;
}
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.NewAgenda.id,
to: {
subscriberId: memberSubscriberId,
email: memberEmail,
},
payload,
};
try {
await this.notificationPort.notify({
coopname: action.coopname,
workflowId: Workflows.NewAgenda.id,
to: {
subscriberId: memberSubscriberId,
email: memberEmail,
username: member.username,
},
payload,
});
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
sentCount++;
} catch (error: any) {
this.logger.error(`Ошибка отправки уведомления члену совета ${member.username}: ${error.message}`);
@@ -3,25 +3,17 @@ import { AgendaWithDocumentsDTO } from '../dto/agenda-with-documents.dto';
import { AgendaInteractor } from '../interactors/agenda.interactor';
import { UserCertificateDomainPort, USER_CERTIFICATE_DOMAIN_PORT } from '~/domain/user/ports/user-certificate-domain.port';
import type { UserCertificateDomainInterface } from '~/domain/user/interfaces/user-certificate-domain.interface';
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
import config from '~/config/config';
@Injectable()
export class AgendaService {
constructor(
private readonly agendaInteractor: AgendaInteractor,
@Inject(USER_CERTIFICATE_DOMAIN_PORT) private readonly userCertificateDomainPort: UserCertificateDomainPort,
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
@Inject(USER_CERTIFICATE_DOMAIN_PORT) private readonly userCertificateDomainPort: UserCertificateDomainPort
) {}
public async getAgenda(): Promise<AgendaWithDocumentsDTO[]> {
const agenda = await this.agendaInteractor.getAgenda();
// Состав совета (`soviet`) дёргаем один раз — фронту нужен порог консенсуса.
// Считаем всех членов совета (как контрактный get_members_count = members.size()).
const boards = await this.sovietBlockchainPort.getBoards(config.coopname);
const councilMembersCount = boards.find((b) => b.type === 'soviet')?.members.length ?? 0;
// Обрабатываем каждый элемент повестки дня
const processedAgenda = await Promise.all(
agenda.map(async (item) => {
@@ -46,34 +38,10 @@ export class AgendaService {
}
}
return new AgendaWithDocumentsDTO(
item,
usernameCertificate,
votesForCertificates,
votesAgainstCertificates,
councilMembersCount
);
return new AgendaWithDocumentsDTO(item, usernameCertificate, votesForCertificates, votesAgainstCertificates);
})
);
return processedAgenda;
}
/**
* Возвращает ОДИН пункт повестки по хэшу документа-заявления (или null, если он
* ещё не проиндексирован парсером). Используется сразу после публикации
* свободного решения, чтобы фронт показал созданный вопрос немедленно.
*/
public async getAgendaItemByHash(hash: string): Promise<AgendaWithDocumentsDTO | null> {
const item = await this.agendaInteractor.getAgendaItemByHash(config.coopname, hash);
if (!item) return null;
const boards = await this.sovietBlockchainPort.getBoards(config.coopname);
const councilMembersCount = boards.find((b) => b.type === 'soviet')?.members.length ?? 0;
const usernameCertificate = await this.userCertificateDomainPort.getCertificateByUsername(item.table.username);
// Только что созданный вопрос ещё без голосов — сертификаты голосовавших пусты.
return new AgendaWithDocumentsDTO(item, usernameCertificate, [], [], councilMembersCount);
}
}
@@ -1,11 +1,13 @@
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
import config from '~/config/config';
import { SovietContract } from 'cooptypes';
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import { Workflows } from '@coopenomics/notifications';
/**
@@ -17,8 +19,8 @@ import { Workflows } from '@coopenomics/notifications';
@Injectable()
export class DecisionNotificationService implements OnModuleInit {
constructor(
@Inject(NOTIFICATION_PORT)
private readonly notificationPort: NotificationPort,
@Inject(NOVU_WORKFLOW_PORT)
private readonly novuWorkflowAdapter: NovuWorkflowAdapter,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountPort: AccountDataPort,
private readonly logger: WinstonLoggerService
@@ -58,7 +60,7 @@ export class DecisionNotificationService implements OnModuleInit {
const userEmail = user.provider_account?.email;
const subscriberId = user.provider_account?.subscriber_id?.trim();
if (!subscriberId) {
this.logger.warn(`subscriber_id пользователя ${username} не найден`);
this.logger.warn(`subscriber_id пользователя ${username} не найден — пропуск Novu`);
return;
}
if (!userEmail) {
@@ -78,17 +80,17 @@ export class DecisionNotificationService implements OnModuleInit {
decisionUrl: `${config.frontend_url}`,
};
// Отправляем уведомление через Центр уведомлений
await this.notificationPort.notify({
coopname: action.coopname,
workflowId: Workflows.DecisionApproved.id,
// Отправляем уведомление
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.DecisionApproved.id,
to: {
subscriberId,
email: userEmail,
username,
},
payload,
});
};
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
this.logger.log(`Уведомление отправлено пользователю ${username} о принятии решения ${decisionId}`);
} catch (error: any) {
this.logger.error(`Ошибка при обработке нового решения совета: ${error.message}`, error.stack);
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { DecisionResolver } from './resolvers/decision.resolver';
import { DecisionService } from './services/decision.service';
import { DecisionInteractor } from './use-cases/decision.interactor';
@Module({
providers: [DecisionResolver, DecisionService, DecisionInteractor],
})
export class DecisionAuthorizeModule {}
@@ -1,24 +0,0 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsNumber, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
@InputType('AuthorizeDecisionInput')
export class AuthorizeDecisionInputDTO {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта председателя совета' })
@IsString()
chairman!: string;
@Field(() => Int, { description: 'Идентификатор решения' })
@IsNumber()
decision_id!: number;
@Field(() => SignedDigitalDocumentInputDTO, { description: 'Подписанный председателем документ утверждения решения' })
@ValidateNested()
@Type(() => SignedDigitalDocumentInputDTO)
document!: SignedDigitalDocumentInputDTO;
}
@@ -1,13 +0,0 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsNumber, IsString } from 'class-validator';
@InputType('DeclineDecisionInput')
export class DeclineDecisionInputDTO {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
coopname!: string;
@Field(() => Int, { description: 'Идентификатор решения' })
@IsNumber()
decision_id!: number;
}
@@ -1,41 +0,0 @@
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { DecisionService } from '../services/decision.service';
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
import { DeclineDecisionInputDTO } from '../dto/decline-decision-input.dto';
@Resolver()
export class DecisionResolver {
constructor(private readonly decisionService: DecisionService) {}
@Mutation(() => TransactionDTO, {
name: 'authorizeDecision',
description: 'Утвердить и исполнить решение совета',
})
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async authorizeDecision(
@Args('data', { type: () => AuthorizeDecisionInputDTO }) data: AuthorizeDecisionInputDTO
): Promise<TransactionDTO> {
return this.decisionService.authorizeDecision(data);
}
@Mutation(() => TransactionDTO, {
name: 'declineDecision',
description: 'Отклонить решение совета по отрицательному консенсусу (большинство голосов против)',
})
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async declineDecision(
@Args('data', { type: () => DeclineDecisionInputDTO }) data: DeclineDecisionInputDTO
): Promise<TransactionDTO> {
return this.decisionService.declineDecision(data);
}
}
@@ -1,20 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DecisionInteractor } from '../use-cases/decision.interactor';
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
import { DeclineDecisionInputDTO } from '../dto/decline-decision-input.dto';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
@Injectable()
export class DecisionService {
constructor(private readonly decisionInteractor: DecisionInteractor) {}
public async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactionDTO> {
const result = await this.decisionInteractor.authorizeDecision(data);
return result as TransactionDTO;
}
public async declineDecision(data: DeclineDecisionInputDTO): Promise<TransactionDTO> {
const result = await this.decisionInteractor.declineDecision(data);
return result as TransactionDTO;
}
}
@@ -1,42 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import { SovietContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
import { DeclineDecisionInputDTO } from '../dto/decline-decision-input.dto';
@Injectable()
export class DecisionInteractor {
constructor(
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils
) {}
async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactResult> {
const authorizeData: SovietContract.Actions.Decisions.Authorize.IAuthorize = {
coopname: data.coopname,
chairman: data.chairman,
decision_id: data.decision_id,
document: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.document),
};
const execData: SovietContract.Actions.Decisions.Exec.IExec = {
// executer — аудит-метка инициатора; авторизация транзакции идёт ключом кооператива.
executer: data.chairman,
coopname: data.coopname,
decision_id: data.decision_id,
};
return await this.sovietBlockchainPort.authorizeDecision(authorizeData, execData);
}
async declineDecision(data: DeclineDecisionInputDTO): Promise<TransactResult> {
const declineData: SovietContract.Actions.Decisions.Declinedec.IDeclineDecision = {
coopname: data.coopname,
decision_id: data.decision_id,
};
return await this.sovietBlockchainPort.declineDecision(declineData);
}
}
@@ -9,12 +9,6 @@ import { DocumentPackageAggregator } from '~/domain/document/aggregators/documen
import { DocumentAggregator } from '~/domain/document/aggregators/document.aggregator';
import type { DocumentAggregateDomainInterface } from '~/domain/document/interfaces/document-domain-aggregate.interface';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import {
SIGNED_DOCUMENT_REPOSITORY,
type SignedDocumentRepository,
} from '~/domain/document/repository/signed-document.repository';
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
import config from '~/config/config';
@Injectable()
export class DocumentInteractor {
@@ -22,8 +16,7 @@ export class DocumentInteractor {
@Inject(forwardRef(() => DocumentDomainService)) private readonly documentDomainService: DocumentDomainService,
@Inject(forwardRef(() => DocumentPackageAggregator))
private readonly documentPackageAggregator: DocumentPackageAggregator,
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator,
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly signedDocumentRepository: SignedDocumentRepository
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator
) {}
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
@@ -33,56 +26,52 @@ export class DocumentInteractor {
public async getDocumentsAggregate(
data: GetDocumentsInputDomainInterface
): Promise<PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface>> {
const { type, page = 1, limit = 100, query = {}, after_block, before_block, actions } = data;
const { type = 'newsubmitted', page = 1, limit = 100, query, after_block, before_block, actions } = data;
// Read-path из PG-реестра подписанных документов (C28-21): отдаём ГОТОВЫЙ агрегат,
// собранный на этапе ingestion/backfill — без обращений к explorer/Mongo и без сборки на лету.
const result = await this.signedDocumentRepository.findAggregates({
coopname: config.coopname,
status: this.mapTypeToStatus(type),
actions: actions && actions.length > 0 ? (actions as unknown as string[]) : undefined,
username: this.extractReceiverScope(query),
hash: this.extractHashFilter(query),
afterBlock: after_block,
beforeBlock: before_block,
// Добавляем фильтры по блокам в query
const blockFilters: Record<string, unknown> = { ...query };
if (after_block !== undefined || before_block !== undefined) {
const blockFilter: Record<string, unknown> = {};
if (after_block !== undefined) {
blockFilter.$gte = after_block;
}
if (before_block !== undefined) {
blockFilter.$lte = before_block;
}
blockFilters.block_num = blockFilter;
}
// Добавляем фильтр по массиву действий (data.action)
// DocumentAction enum значения автоматически преобразуются в строки
if (actions && actions.length > 0) {
blockFilters['data.action'] = { $in: actions };
}
const signedDocuments = await this.documentDomainService.getImmutableSignedDocuments({
type,
page,
limit,
query: blockFilters,
});
return {
items: result.items,
totalCount: result.total,
totalPages: Math.ceil(result.total / limit) || 0,
const response: PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface> = {
items: [],
totalCount: signedDocuments.total,
totalPages: Math.ceil(signedDocuments.total / limit),
currentPage: page,
};
}
// Прежний фильтр был по ИМЕНИ действия, не по статусу-партиции:
// «Все входящие» (newsubmitted) — у каждого пакета есть submitted-действие, поэтому это ВСЕ
// документы кооператива (решённые/аннулированные тоже входят) → статус НЕ ограничиваем;
// «Только утверждённые» (newresolved) — лишь решённые → status=Resolved.
// (Партиция по статусу скрыла бы решённые документы из вкладки «Все входящие» — регрессия.)
private mapTypeToStatus(type?: 'newsubmitted' | 'newresolved'): SignedDocumentStatus | undefined {
return type === 'newresolved' ? SignedDocumentStatus.Resolved : undefined;
}
for (const raw_action_document of signedDocuments.results) {
const documentPackageAggregate = await this.documentPackageAggregator.buildDocumentPackageAggregate(
raw_action_document
);
// Скоуп выборки повторяет прежний explorer-фильтр `receiver: username` из DocumentService.
// Документ soviet через require_recipient уведомляет И кооператив, И пайщика-субъекта, поэтому:
// receiver === coopname → весь кооператив (фильтр только по coopname — как и было);
// receiver === <пайщик> → его документы (колонка username = субъект заявления).
private extractReceiverScope(query: Record<string, unknown>): string | undefined {
const receiver = query['receiver'];
if (typeof receiver === 'string' && receiver.length > 0 && receiver !== config.coopname) {
return receiver;
response.items.push(documentPackageAggregate);
}
return undefined;
}
// Фильтр по конкретному документу: Union-страница шлёт filter.document.hash (upper-case).
private extractHashFilter(query: Record<string, unknown>): string | undefined {
const document = query['document'] as Record<string, unknown> | undefined;
const hash = (document?.['hash'] ?? query['hash']) as unknown;
return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
return response;
}
/**
@@ -7,7 +7,6 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
import { GeneratorInfrastructureModule } from '~/infrastructure/generator/generator.module';
import { UserDomainModule } from '~/domain/user/user-domain.module';
import { FreeDecisionInteractor } from './interactors/free-decision.interactor';
import { AgendaModule } from '../agenda/agenda.module';
@Module({
imports: [
@@ -16,7 +15,6 @@ import { AgendaModule } from '../agenda/agenda.module';
DocumentDomainModule,
GeneratorInfrastructureModule,
forwardRef(() => UserDomainModule),
AgendaModule,
],
controllers: [],
providers: [FreeDecisionResolver, FreeDecisionService, FreeDecisionInteractor],
@@ -12,7 +12,6 @@ import { CreateProjectFreeDecisionInputDTO } from '../dto/create-project-free-de
import { FreeDecisionGenerateDocumentInputDTO } from '../../document/documents-dto/free-decision-document.dto';
import { FreeDecisionService } from '../services/free-decision.service';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { AgendaWithDocumentsDTO } from '~/application/agenda/dto/agenda-with-documents.dto';
@Resolver()
export class FreeDecisionResolver {
@@ -50,18 +49,16 @@ export class FreeDecisionResolver {
return this.freeDecisionService.generateFreeDecision(data, options);
}
@Mutation(() => AgendaWithDocumentsDTO, {
@Mutation(() => Boolean, {
name: 'publishProjectOfFreeDecision',
nullable: true,
description:
'Опубликовать предложенную повестку и проект решения для голосования совета. Возвращает созданный пункт повестки (или null, если он ещё не проиндексирован) для немедленного отображения на фронте.',
description: 'Опубликовать предложенную повестку и проект решения для дальнейшего голосования совета по нему',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async publishProjectOfFreeDecision(
@Args('data', { type: () => PublishProjectFreeDecisionInputDTO })
data: PublishProjectFreeDecisionInputDTO
): Promise<AgendaWithDocumentsDTO | null> {
): Promise<boolean> {
return this.freeDecisionService.publishProjectOfFreeDecision(data);
}
@@ -8,25 +8,10 @@ import type { FreeDecisionGenerateDocumentInputDTO } from '../../document/docume
import { FreeDecisionInteractor } from '~/application/free-decision/interactors/free-decision.interactor';
import type { ProjectFreeDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/project-free-decision-document.dto';
import type { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { AgendaService } from '~/application/agenda/services/agenda.service';
import type { AgendaWithDocumentsDTO } from '~/application/agenda/dto/agenda-with-documents.dto';
// Повестка собирается join'ом таблицы decisions из блокчейна (доступна сразу)
// с проиндексированными парсером действием newsubmitted и документом-заявлением.
// Решение появляется на чейне мгновенно, но индексация парсером action'а занимает
// ~2 c — поэтому опрашиваем повестку короткими тиками, пока вопрос не соберётся,
// и возвращаем его фронту немедленно (без ожидания общего поллинга страницы).
const PUBLISH_FETCH_DELAY_MS = 400;
const PUBLISH_FETCH_ATTEMPTS = 13; // ~5 c суммарно — запас над типичными ~2 c
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@Injectable()
export class FreeDecisionService {
constructor(
private readonly freeDecisionInteractor: FreeDecisionInteractor,
private readonly agendaService: AgendaService
) {}
constructor(private readonly freeDecisionInteractor: FreeDecisionInteractor) {}
public async generateProjectOfFreeDecision(
data: ProjectFreeDecisionGenerateDocumentInputDTO,
@@ -46,26 +31,9 @@ export class FreeDecisionService {
return document as unknown as GeneratedDocumentDTO;
}
public async publishProjectOfFreeDecision(
data: PublishProjectFreeDecisionInputDTO
): Promise<AgendaWithDocumentsDTO | null> {
await this.freeDecisionInteractor.publishProjectOfFreeDecision(data);
// decision.hash в блокчейне == «общий хэш» подписанного заявления (hash =
// doc_hash + meta_hash), НЕ doc_hash (хэш только содержимого) — их легко
// перепутать, и матч по doc_hash молча никогда не сработает.
const hash = data.document.hash;
let item: AgendaWithDocumentsDTO | null = null;
for (let attempt = 0; attempt < PUBLISH_FETCH_ATTEMPTS; attempt++) {
item = await this.agendaService.getAgendaItemByHash(hash);
if (item) break;
await sleep(PUBLISH_FETCH_DELAY_MS);
}
// null допустим: если парсер не успел проиндексировать — фронт покажет вопрос
// на ближайшем тике поллинга (деградация, не ошибка).
return item;
public async publishProjectOfFreeDecision(data: PublishProjectFreeDecisionInputDTO): Promise<boolean> {
const selected = await this.freeDecisionInteractor.publishProjectOfFreeDecision(data);
return selected;
}
public async createProjectOfFreeDecision(data: CreateProjectFreeDecisionInputDTO): Promise<CreatedProjectFreeDecisionDTO> {
@@ -12,7 +12,6 @@ import type {
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
@Injectable()
export class GatewayInteractorAdapter implements GatewayInteractorPort {
@@ -37,14 +36,6 @@ export class GatewayInteractorAdapter implements GatewayInteractorPort {
return await this.gatewayInteractor.createWithdraw(data);
}
async prepareWithdraw(data: CreateWithdrawInputDomainInterface): Promise<PaymentDomainInterface> {
return await this.gatewayInteractor.prepareWithdraw(data);
}
async persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.persistWithdraw(paymentData);
}
async setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.setPaymentStatus(data);
}
@@ -8,10 +8,4 @@ export class SetPaymentStatusInputDTO {
@Field(() => PaymentStatusEnum, { description: 'Новый статус платежа' })
status!: PaymentStatusEnum;
@Field(() => String, {
description: 'Причина изменения статуса. При отклонении платежа показывается пайщику как причина отказа.',
nullable: true,
})
message?: string;
}
@@ -7,6 +7,7 @@ import { PaymentController } from './controllers/payment.controller';
import { GatewayInteractor } from './interactors/gateway.interactor';
import { GatewayNotificationHandler } from './handlers/gateway-notification.handler';
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
import { NovuModule } from '~/infrastructure/novu/novu.module';
import { UserDomainModule } from '~/domain/user/user-domain.module';
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
import { SystemModule } from '~/application/system/system.module';
@@ -18,6 +19,7 @@ import { RedisModule } from '~/infrastructure/redis/redis.module';
imports: [
forwardRef(() => GatewayDomainModule),
forwardRef(() => GatewayInfrastructureModule),
NovuModule,
UserInfrastructureModule,
UserDomainModule,
AccountInfrastructureModule,
@@ -9,7 +9,7 @@ import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.en
import { GatewayBlockchainPort, GATEWAY_BLOCKCHAIN_PORT } from '~/domain/gateway/ports/gateway-blockchain.port';
import { PaymentRepository, PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentDirectionEnum, PaymentTypeEnum, VAT_EXEMPT_NOTE } from '~/domain/gateway/enums/payment-type.enum';
import { PaymentDirectionEnum, PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
import type { CreateInitialPaymentInputDomainInterface } from '~/domain/gateway/interfaces/create-initial-payment-input-domain.interface';
import type { CreateDepositPaymentInputDomainInterface } from '~/domain/gateway/interfaces/create-deposit-payment-input-domain.interface';
@@ -113,15 +113,6 @@ export class GatewayInteractor {
throw new NotFoundException(`Не удалось найти платеж с ID ${data.id}`);
}
// Сохраняем причину изменения статуса (например, причину отклонения платежа),
// чтобы пайщик увидел её на странице регистрации даже после перезагрузки/в другой вкладке.
if (data.message !== undefined && result.id) {
const updated = await this.paymentRepository.update(result.id, { message: data.message });
if (updated) {
result.message = updated.message;
}
}
// Обрабатываем платеж при статусе PAID
if (statusEnum === PaymentStatusEnum.PAID) {
if (result.direction === PaymentDirectionEnum.INCOMING) {
@@ -203,49 +194,6 @@ export class GatewayInteractor {
private async processOutgoingPayment(payment: PaymentDomainInterface) {
this.logger.log(`Обрабатываем исходящий платеж ${payment.id}`);
// Возврат вступительного/мин.паевого при отказе совета. Деньги пайщика стоят
// на расчётах с пайщиком (счёт 76, w.reg.pend) с момента приёма платежа.
// Подтверждение кассой проводит on-chain возврат: completeOutcome →
// gateway::outcomplete → registrator::refundpay (обратная проводка Дт 76 / Кт 51,
// сжигание w.reg.pend). outcome_hash = payment.hash = registration_hash —
// on-chain исходящий объект создан declinereg через gateway::createoutpay.
//
// MIGRATION (снять условие после 30.07.2026): кандидаты, принятые ДО релиза
// двухфазного учёта, on-chain исходящего объекта не имеют (declinereg для них
// не звал createoutpay — не было баланса на w.reg.pend). Для них completeOutcome
// падает «Объект возврата не существует» — это штатный старый путь: проводок
// нет, возврат чисто off-chain, помечаем COMPLETED.
if (payment.type === PaymentTypeEnum.REGISTRATION_REFUND) {
try {
const completeOutcomeData: CompleteOutcomeDomainInterface = {
coopname: payment.coopname,
outcome_hash: payment.hash,
};
await this.gatewayBlockchainPort.completeOutcome(completeOutcomeData);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Возврат регистрации ${payment.hash} подтверждён (проводка Дт 76 / Кт 51)`);
} catch (e: any) {
const message = e?.message ?? String(e);
// переходный период до 30.07.2026: on-chain объекта нет — старый путь
if (message.includes('Объект возврата не существует')) {
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.warn(
`Возврат регистрации ${payment.hash}: on-chain объект не найден (переходный период до 30.07.2026) — подтверждён off-chain без проводок`,
);
} else {
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.FAILED, message });
}
this.logger.error(`Ошибка подтверждения возврата регистрации ${payment.hash}: ${message}`, e);
}
}
return;
}
try {
const completeOutcomeData: CompleteOutcomeDomainInterface = {
coopname: payment.coopname,
@@ -304,51 +252,19 @@ export class GatewayInteractor {
// Валидируем символ
QuantityUtils.validateSymbol(symbol);
// Регистрационный платёж одноразовый. Если у пайщика уже есть рег-платёж в
// «живом» статусе (ожидает оплаты / в обработке / оплачен / принят) — возвращаем
// его, а не создаём второй. Иначе при перезаходе/перезагрузке страницы оплаты в
// момент приёма платежа findActivePendingPayment (только PENDING) промахивался —
// первый платёж уже PAID/COMPLETED, и заводился платёж-дубль, который нельзя ни
// принять (аккаунт уже зарегистрирован), ни осмысленно отклонить.
// Новый ордер заводим только если прошлого нет либо он провалился/истёк/отменён
// (легитимная повторная попытка, в т.ч. после resetRegistration).
const lastRegistrationPayment = await this.paymentRepository.findLatestByUsernameAndType(
// Проверяем, нет ли уже активного платежа этого типа для пользователя с такой же суммой
const existingPayment = await this.paymentRepository.findActivePendingPayment(
data.username,
PaymentTypeEnum.REGISTRATION
PaymentTypeEnum.REGISTRATION,
amount,
symbol
);
// Повторная подача после отказа совета: прошлый цикл закрыт возвратом —
// REGISTRATION_REFUND свежее последнего вступительного платежа. Тогда старый
// рег-платёж принадлежит ЗАВЕРШЁННОМУ циклу и переиспользовать его нельзя:
// иначе вернули бы исполненный QR (COMPLETED ∈ reusableStatuses), а новый
// платёж в реестре совета не появился бы. Заводим новый ордер. Детекция цикла
// по дате — та же, что в account.interactor (getAccount / resetRegistration).
const lastRegistrationRefund = await this.paymentRepository.findLatestByUsernameAndType(
data.username,
PaymentTypeEnum.REGISTRATION_REFUND
);
const supersededByRefund =
!!lastRegistrationRefund &&
!!lastRegistrationPayment &&
new Date(lastRegistrationRefund.created_at).getTime() >=
new Date(lastRegistrationPayment.created_at).getTime();
const reusableStatuses = [
PaymentStatusEnum.PENDING,
PaymentStatusEnum.PROCESSING,
PaymentStatusEnum.PAID,
PaymentStatusEnum.COMPLETED,
];
if (
!supersededByRefund &&
lastRegistrationPayment &&
reusableStatuses.includes(lastRegistrationPayment.status)
) {
if (existingPayment) {
this.logger.log(
`Регистрационный платёж для ${data.username} уже существует (${lastRegistrationPayment.id}, статус ${lastRegistrationPayment.status}) — повторный ордер не создаём`
`Найден существующий активный регистрационный платеж для пользователя ${data.username} на сумму ${amount} ${symbol}`
);
return new PaymentDomainEntity(lastRegistrationPayment, { isNewlyCreated: false });
return new PaymentDomainEntity(existingPayment, { isNewlyCreated: false });
}
// Получаем настройки для определения провайдера
@@ -371,7 +287,7 @@ export class GatewayInteractor {
direction: PaymentDirectionEnum.INCOMING,
provider,
status: PaymentStatusEnum.PENDING,
memo: `Вступительный и минимальный паевой взносы №${hash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
memo: `Вступительный и минимальный паевой взносы №${hash.slice(0, 8)}`,
payment_method_id: undefined,
expired_at: expiredAt,
created_at: now,
@@ -461,7 +377,7 @@ export class GatewayInteractor {
direction: PaymentDirectionEnum.INCOMING,
provider: provider,
status: PaymentStatusEnum.PENDING,
memo: `Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк" №${hash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
memo: `Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк" №${hash.slice(0, 8)}`,
secret,
payment_method_id: undefined,
expired_at: expiredAt,
@@ -509,16 +425,9 @@ export class GatewayInteractor {
}
/**
* Подготовить исходящий платеж (withdraw) к созданию: все валидации + сборка
* записи, но БЕЗ записи в БД.
*
* Вынесено отдельно от персиста, чтобы вызывающий мог сначала провести
* валидацию, затем выполнить on-chain транзакцию, и только при её успехе
* зафиксировать платёж (см. WalletInteractor.createWithdraw). Иначе при
* отклонении транзакции блокчейном (например, недостаточно L3-средств) в
* разделе «Платежи» оставался бы фантомный исходящий платёж со статусом FAILED.
* Создать исходящий платеж (withdraw)
*/
async prepareWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainInterface> {
async createWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
// Обновляем истекшие платежи перед созданием нового
await this.paymentRepository.expireOutdatedPayments();
@@ -577,7 +486,7 @@ export class GatewayInteractor {
// готовый к выплате. Переход AWAITING_AUTHORIZATION → PENDING происходит
// в WithdrawAuthorizationListener при on-chain action wallet::authwthd.
status: PaymentStatusEnum.AWAITING_AUTHORIZATION,
memo: `Возврат паевого взноса №${data.payment_hash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
memo: `Возврат паевого взноса №${data.payment_hash.slice(0, 8)}`,
secret: generateUniqueHash(),
payment_method_id: data.method_id,
payment_details: paymentDetails,
@@ -587,14 +496,7 @@ export class GatewayInteractor {
hash: data.payment_hash, // Используем переданный payment_hash
};
return paymentData;
}
/**
* Зафиксировать ранее подготовленный исходящий платеж в БД.
* Вызывается только после успешной on-chain транзакции.
*/
async persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity> {
// Создаем платеж в базе данных
const createdPayment = await this.paymentRepository.create(paymentData);
if (!createdPayment.id) {
@@ -602,24 +504,12 @@ export class GatewayInteractor {
}
this.logger.log(
`Создан исходящий платеж ${paymentData.hash} для пользователя ${paymentData.username} на сумму ${paymentData.quantity} ${paymentData.symbol} с платежным методом ${paymentData.payment_method_id}`
`Создан исходящий платеж ${data.payment_hash} для пользователя ${data.username} на сумму ${data.quantity} ${data.symbol} с платежным методом ${data.method_id}`
);
return new PaymentDomainEntity(createdPayment);
}
/**
* Создать исходящий платеж (withdraw): подготовка + немедленный персист.
*
* Не использует on-chain проверку подходит только там, где запись о платеже
* в БД должна существовать безусловно. Для возврата паевого взноса используется
* связка prepareWithdraw on-chain транзакция persistWithdraw.
*/
async createWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
const paymentData = await this.prepareWithdraw(data);
return await this.persistWithdraw(paymentData);
}
/**
* Обновить все истекшие платежи в статус EXPIRED
* Может вызываться периодически или по требованию
@@ -1,11 +1,12 @@
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
import config from '~/config/config';
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import type { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentDirectionEnum } from '~/domain/gateway/enums/payment-type.enum';
import { Workflows } from '@coopenomics/notifications';
/**
@@ -14,8 +15,8 @@ import { Workflows } from '@coopenomics/notifications';
@Injectable()
export class PaymentNotificationService implements OnModuleInit {
constructor(
@Inject(NOTIFICATION_PORT)
private readonly notificationPort: NotificationPort,
@Inject(NOVU_WORKFLOW_PORT)
private readonly novuWorkflowAdapter: NovuWorkflowAdapter,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountPort: AccountDataPort,
private readonly logger: WinstonLoggerService
@@ -44,7 +45,7 @@ export class PaymentNotificationService implements OnModuleInit {
const userEmail = user.provider_account?.email;
const subscriberId = user.provider_account?.subscriber_id?.trim();
if (!subscriberId) {
this.logger.warn(`subscriber_id пользователя ${payment.username} не найден`);
this.logger.warn(`subscriber_id пользователя ${payment.username} не найден — пропуск Novu`);
return;
}
if (!userEmail) {
@@ -55,16 +56,9 @@ export class PaymentNotificationService implements OnModuleInit {
// Получаем отображаемое имя пользователя
const userName = await this.accountPort.getDisplayName(payment.username);
// Выбираем workflow по статусу И направлению платежа: исходящий PAID — это
// возврат взноса пайщику, у него своё уведомление «Возврат выполнен», а не
// «Платёж принят» (иначе пайщик при возврате получает письмо как на приём).
const isOutgoing = payment.direction === PaymentDirectionEnum.OUTGOING;
// Выбираем workflow в зависимости от статуса платежа
const workflowId =
payment.status === PaymentStatusEnum.PAID
? isOutgoing
? Workflows.PaymentRefunded.id
: Workflows.PaymentPaid.id
: Workflows.PaymentCancelled.id;
payment.status === PaymentStatusEnum.PAID ? Workflows.PaymentPaid.id : Workflows.PaymentCancelled.id;
// Формируем данные для workflow (без приватных данных)
const payload: Workflows.PaymentPaid.IPayload | Workflows.PaymentCancelled.IPayload = {
@@ -75,17 +69,17 @@ export class PaymentNotificationService implements OnModuleInit {
paymentUrl: `${config.frontend_url}`, //TODO: точную ссылку потом
};
// Отправляем уведомление через Центр уведомлений
await this.notificationPort.notify({
coopname: config.coopname,
workflowId,
// Отправляем уведомление
const triggerData: WorkflowTriggerDomainInterface = {
name: workflowId,
to: {
subscriberId,
email: userEmail,
username: payment.username,
},
payload,
});
};
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
this.logger.log(
`Уведомление отправлено пользователю ${payment.username} о статусе платежа ${payment.id} (${payment.status})`
);
@@ -17,4 +17,7 @@ export class Ledger2WalletDTO {
@Field(() => String, { description: 'Доступный баланс' })
available!: string;
@Field(() => String, { description: 'Заблокированный баланс' })
blocked!: string;
}

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