Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee7eb93df | |||
| 1f15f368a6 | |||
| f72b4900ab | |||
| f859651038 | |||
| d763f08fec | |||
| 805ac73ed7 | |||
| b82b778b81 |
@@ -1,54 +1,38 @@
|
||||
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)
|
||||
# 2) publish-packages — npm publish через lerna (если не -alpha)
|
||||
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
|
||||
# Gitea (только ветка main); он сам тянет mono и
|
||||
# пересобирает доки.
|
||||
# Gitea (если не -alpha + ветка main); он сам тянет
|
||||
# mono и пересобирает доки.
|
||||
#
|
||||
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
|
||||
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
|
||||
#
|
||||
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
|
||||
# если релиз контрактов или контейнеров провалился.
|
||||
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
|
||||
# стартовали параллельно по одному тэгу — отсюда гонка из инцидента 2026-05-13
|
||||
# с ledger2 walletop sweep'ом (см. историю release.yaml до объединения).
|
||||
#
|
||||
# Триггер — push в testnet/main с изменением lerna.json. Версию бампает
|
||||
# `lerna version` ОДИН раз на dev (scripts/cut-release.sh); тот же коммит едет
|
||||
# вверх по fast-forward (scripts/promote.sh). lerna.json меняется только на
|
||||
# релизном бампе, поэтому обычные feature-пуши деплой не триггерят. dev в
|
||||
# триггере НЕТ: cut на dev только бампает версию, деплой начинается с testnet
|
||||
# (staging) и завершается main (production).
|
||||
# Гейт `-alpha` унифицирован: пакеты + доки + триггер docs пропускают только
|
||||
# не-alpha тэги. -beta/-rc/-test в проекте не используются (см. CLAUDE.md),
|
||||
# поэтому проще иметь один паттерн `!contains(github.ref, '-alpha')`.
|
||||
# IS_PROD в release-job остаётся на `(alpha|beta|rc|test)` — он определяет
|
||||
# деплой-webhook (testnet vs production) и тэг `:latest` у образов;
|
||||
# намеренно строже, чтобы случайный -rc-тэг не уехал на прод.
|
||||
#
|
||||
# Окружение определяет ВЕТКА (main → production, testnet → staging), а не
|
||||
# суффикс версии: единая версия едет во все среды, поэтому source of truth —
|
||||
# ветка, на которую сделан промоушн. Версия читается из закоммиченного
|
||||
# lerna.json. Гейты npm-publish/доки — `branch == main`.
|
||||
# Резолв ветки. Тэг семантики окружения не несёт (например `v2026.5.13-alpha-2`
|
||||
# может лежать на testnet или dev — определяет именно ветка, на которую сделан
|
||||
# merge перед тэгом). Поэтому ищем первое совпадение SHA с remote-ветками
|
||||
# main/testnet/dev. branch экспортируется как outputs.branch для downstream jobs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [testnet, main]
|
||||
paths:
|
||||
- 'lerna.json'
|
||||
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
|
||||
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
|
||||
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Куда деплоить'
|
||||
type: choice
|
||||
options:
|
||||
- testnet
|
||||
- main
|
||||
required: true
|
||||
ref:
|
||||
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
|
||||
required: false
|
||||
default: ''
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -65,32 +49,23 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
|
||||
# (пусто = верхушка ветки окружения, на которой запущен workflow).
|
||||
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
|
||||
|
||||
- name: Resolve branch, build mode and version
|
||||
- name: Resolve branch, build mode and tags
|
||||
id: resolve
|
||||
run: |
|
||||
# Окружение: при ручном workflow_dispatch — из inputs.environment;
|
||||
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
|
||||
# из реально вычекнутого дерева, не из ветки-триггера.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
BRANCH="${{ github.event.inputs.environment }}"
|
||||
else
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
fi
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
|
||||
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
|
||||
# testnet/main. Тэг как триггер больше не используется.
|
||||
RAW_VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' lerna.json | head -1)"
|
||||
if [ -z "$RAW_VERSION" ]; then
|
||||
echo "::error::Не удалось прочитать version из lerna.json"
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
BRANCH=""
|
||||
for candidate in main testnet dev; do
|
||||
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
|
||||
BRANCH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
|
||||
exit 1
|
||||
fi
|
||||
TAG_NAME="v${RAW_VERSION}"
|
||||
|
||||
case "$BRANCH" in
|
||||
main)
|
||||
@@ -108,22 +83,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
|
||||
|
||||
{
|
||||
@@ -364,16 +334,14 @@ jobs:
|
||||
|
||||
# ============================================================================
|
||||
# publish-packages — npm publish (бывший publish-packages.yaml).
|
||||
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
|
||||
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
|
||||
# доезжает до main по FF.
|
||||
# Гейт: не-alpha. Не зависит от ветки.
|
||||
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
|
||||
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
|
||||
# pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
# ============================================================================
|
||||
publish-packages:
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.branch == 'main' }}
|
||||
if: ${{ !contains(github.ref, '-alpha') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -390,25 +358,13 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
|
||||
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
|
||||
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
|
||||
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
|
||||
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
|
||||
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
|
||||
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
|
||||
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
|
||||
- name: Generate controller schema.gql (required by sdk prepublishOnly)
|
||||
working-directory: components/controller
|
||||
run: pnpm run generate-schema
|
||||
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
|
||||
# Гейт: ветка main.
|
||||
# Гейт: не-alpha + ветка main.
|
||||
#
|
||||
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
|
||||
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
|
||||
@@ -420,7 +376,7 @@ jobs:
|
||||
# ============================================================================
|
||||
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)
|
||||
@@ -436,11 +392,11 @@ jobs:
|
||||
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
|
||||
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
|
||||
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
|
||||
# (приёмник деплоит доки на своей стороне). Гейт: ветка main.
|
||||
# (приёмник деплоит доки на своей стороне). Гейт: не-alpha + ветка main.
|
||||
# ============================================================================
|
||||
trigger-mono-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 docs deployment webhook
|
||||
|
||||
@@ -24,14 +24,6 @@ on:
|
||||
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
|
||||
|
||||
@@ -23,5 +23,3 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
@@ -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 коде **все имена идентификаторов — английские**:
|
||||
|
||||
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
|
||||
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
|
||||
COPY . .
|
||||
|
||||
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
|
||||
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
|
||||
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
|
||||
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
|
||||
# Отключаем сами загрузки, пакеты всё равно ставятся.
|
||||
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
|
||||
PUPPETEER_SKIP_DOWNLOAD=true
|
||||
|
||||
# Используем существующий lockfile. Если он не совпадает с workspace —
|
||||
# падать сразу, не дрейфовать незаметно.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -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}.`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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, начинающийся
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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() : '—'}`,
|
||||
|
||||
@@ -211,7 +211,6 @@ export async function pullProjectCommunicationArtifacts(
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +369,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,
|
||||
@@ -577,6 +577,7 @@ export async function pullNonProjectCommunicationArtifacts(
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: String(tr.matrixRoomId),
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
|
||||
|
||||
COPY . .
|
||||
|
||||
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
|
||||
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
|
||||
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
|
||||
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
|
||||
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
|
||||
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
|
||||
&& pnpm rebuild --filter "@coopenomics/boot..."
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
|
||||
|
||||
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
|
||||
# свой bundle с inlined workspace-deps.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
@@ -26,7 +26,6 @@
|
||||
"boot:remote": "esno src/index.ts boot:remote",
|
||||
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
|
||||
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
|
||||
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
|
||||
"create-coop": "esno src/index.ts create-coop",
|
||||
"clear": "./scripts/clear.sh",
|
||||
"start": "./scripts/restart.sh",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Полный перезапуск dev-среды для теста Благороста одной командой:
|
||||
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
|
||||
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
|
||||
# 2) ожидание готовности controller (GraphQL отвечает);
|
||||
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
|
||||
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
|
||||
#
|
||||
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
bash "$SCRIPT_DIR/extra_reboot.sh"
|
||||
|
||||
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
|
||||
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
|
||||
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
|
||||
echo "Ждём готовности controller ($GRAPHQL_URL)..."
|
||||
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
|
||||
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
|
||||
sleep 3
|
||||
done
|
||||
echo "Controller готов."
|
||||
|
||||
UP_TO="${SEED_UP_TO:-04-contributor}"
|
||||
echo "Сидируем capital до фазы $UP_TO..."
|
||||
cd "$SCRIPT_DIR/.."
|
||||
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
|
||||
|
||||
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
|
||||
@@ -10,23 +10,20 @@ 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 monoredis cooparser coopback || 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.
|
||||
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
|
||||
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
|
||||
# данные под root). Единый способ с reboot.sh / extra_reboot.sh.
|
||||
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'
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
|
||||
@@ -128,9 +128,4 @@ export default [
|
||||
path: path.join(userBase, 'apps'),
|
||||
target: 'apps',
|
||||
},
|
||||
{
|
||||
name: 'expense',
|
||||
path: path.join(userBase, 'expense'),
|
||||
target: 'expense',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -154,10 +154,6 @@ export default {
|
||||
name: 'apps',
|
||||
code_permissions_to: ['apps'],
|
||||
},
|
||||
{
|
||||
name: 'expense',
|
||||
code_permissions_to: ['expense'],
|
||||
},
|
||||
// {
|
||||
// name: provider_chairman,
|
||||
// },
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"protocol_feature_type": "builtin",
|
||||
"dependencies": [],
|
||||
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
|
||||
"subjective_restrictions": {
|
||||
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
|
||||
"preactivation_required": true,
|
||||
"enabled": true
|
||||
},
|
||||
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"protocol_feature_type": "builtin",
|
||||
"dependencies": [
|
||||
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
|
||||
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
|
||||
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
|
||||
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
|
||||
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
|
||||
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
|
||||
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
|
||||
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
|
||||
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
|
||||
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
|
||||
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
|
||||
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
|
||||
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
|
||||
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
|
||||
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
|
||||
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
|
||||
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
|
||||
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
|
||||
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
|
||||
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
|
||||
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
|
||||
],
|
||||
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
|
||||
"subjective_restrictions": {
|
||||
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
|
||||
"preactivation_required": true,
|
||||
"enabled": true
|
||||
},
|
||||
"builtin_feature_codename": "SAVANNA"
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class CooperativeClass {
|
||||
await this.blockchain.createProgram({
|
||||
coopname,
|
||||
username: coopname,
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
title: 'Целевая потребительская программа "Благорост"',
|
||||
announce: '',
|
||||
description: '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
|
||||
* (id=4, type='capital') + capital::setconfig.
|
||||
* (id=4, type='blagorost') + capital::setconfig.
|
||||
*
|
||||
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
|
||||
* setconfig — только если `state` пуст.
|
||||
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
await blockchain.api.transact({
|
||||
actions: [{
|
||||
|
||||
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
|
||||
}
|
||||
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
|
||||
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
|
||||
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
|
||||
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
|
||||
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
|
||||
|
||||
export const ratePerHour = '1000.0000 RUB'
|
||||
export const sourceProgramName = 'generator'
|
||||
export const capitalProgramName = 'capital'
|
||||
export const capitalProgramName = 'blagorost'
|
||||
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
|
||||
export const circulationAccountId = 80_000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -58,7 +58,6 @@ add_contract_build(registrator)
|
||||
add_contract_build(gateway)
|
||||
add_contract_build(marketplace)
|
||||
add_contract_build(apps)
|
||||
add_contract_build(expense)
|
||||
add_contract_build(system)
|
||||
|
||||
# Опции для тестов
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @brief Инициировать программный расход через шасси expense.
|
||||
*
|
||||
* capital — только инициатор. Резервирует средства в @c program_expense_pool,
|
||||
* создаёт скелет записи progexpense (status=CREATED, как маркер pending), затем
|
||||
* шлёт inline action `expense::createexp(...)` с callback handler `{capital, onpgexpdone}`.
|
||||
* Шасси expense обслуживает весь дальнейший flow (авторизация совета, выдача аванса,
|
||||
* отчёт, закрытие либо отклонение) и на терминальном переходе шлёт inline `onpgexpdone`.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода (= proposal_hash в шасси)
|
||||
* @param creator Инициатор расхода
|
||||
* @param items Позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
|
||||
* @param description Описание для отчётности
|
||||
* @param statement Документ-заявление (ProgramExpenseStatement)
|
||||
*
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname.
|
||||
*/
|
||||
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
|
||||
std::vector<ExpenseDomain::item> items,
|
||||
std::string description, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
verify_document_or_fail(statement, {creator});
|
||||
eosio::check(!items.empty(), "Программный расход должен содержать хотя бы один item");
|
||||
|
||||
// Сумма расхода — сумма planned_amount всех items.
|
||||
asset amount = items.front().planned_amount;
|
||||
amount.amount = 0;
|
||||
for (const auto &it : items) {
|
||||
amount += it.planned_amount;
|
||||
}
|
||||
Wallet::validate_asset(amount);
|
||||
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
|
||||
|
||||
auto exist = Capital::get_program_expense(coopname, expense_hash);
|
||||
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
|
||||
|
||||
// Резерв в пуле до отправки в шасси — две параллельные заявки не превысят пул.
|
||||
Capital::State::reserve_program_expense(coopname, amount);
|
||||
|
||||
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
|
||||
amount, description, statement);
|
||||
|
||||
// Inline action в шасси expense с callback handler на capital::onpgexpdone.
|
||||
// data пустой — capital резолвит запись по expense_hash, дополнительный payload не нужен.
|
||||
ExpenseDomain::callback_handler callback{
|
||||
.contract = _capital,
|
||||
.action = Names::Capital::ON_PROGRAM_EXPENSE_DONE,
|
||||
.data = std::vector<char>{}
|
||||
};
|
||||
|
||||
// Authority — _capital: у capital@eosio.code нет coopname@active, а
|
||||
// expense::createexp принимает контракты-инициаторы по contracts_whitelist.
|
||||
eosio::action(
|
||||
eosio::permission_level{_capital, "active"_n},
|
||||
_expense,
|
||||
Names::External::CREATE_EXPENSE_PROPOSAL,
|
||||
std::make_tuple(coopname, creator, expense_hash,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
|
||||
).send();
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @brief Callback от шасси expense на финализацию program-расхода.
|
||||
*
|
||||
* Шасси expense шлёт его inline (authority — _expense@active) когда закончило
|
||||
* flow (closeexp или declexp). capital не делает ledger2-проводок —
|
||||
* они уже сделаны шасси на payexp/reportexp/returnexp/overspendexp. capital
|
||||
* только обновляет @c program_expense_pool и удаляет запись из @c progexpenses.
|
||||
*
|
||||
* status == ExpenseDomain::ProposalStatus::CLOSED (4):
|
||||
* total_actual <= reserved → consume(total_actual) + release(остаток);
|
||||
* total_actual > reserved (был overspendexp) → consume(reserved) +
|
||||
* прямое списание превышения из program_expense_pool.
|
||||
* status == ExpenseDomain::ProposalStatus::DECLINED (5):
|
||||
* release(reserved); decline возможен только до оплат (total_actual == 0).
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода (ключ в progexpenses)
|
||||
* @param status ExpenseDomain::ProposalStatus
|
||||
* @param total_actual Фактически потраченная сумма
|
||||
* @param data Произвольный payload от инициатора (не используется на MVP)
|
||||
*
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p _expense — inline-callback шасси.
|
||||
*/
|
||||
void capital::onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
|
||||
eosio::asset total_actual, std::vector<char> data) {
|
||||
require_auth(_expense);
|
||||
|
||||
auto pgexp = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
|
||||
const auto reserved = pgexp.amount;
|
||||
|
||||
if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::CLOSED)) {
|
||||
eosio::check(total_actual.symbol == reserved.symbol,
|
||||
"Символ total_actual не совпадает с зарезервированной суммой");
|
||||
eosio::check(total_actual.amount >= 0,
|
||||
"total_actual не может быть отрицательным");
|
||||
|
||||
if (total_actual.amount <= reserved.amount) {
|
||||
if (total_actual.amount > 0) {
|
||||
Capital::State::consume_program_expense(coopname, total_actual);
|
||||
}
|
||||
const asset leftover = reserved - total_actual;
|
||||
if (leftover.amount > 0) {
|
||||
Capital::State::release_program_expense(coopname, leftover);
|
||||
}
|
||||
} else {
|
||||
// Перерасход (overspendexp в шасси): резерв списываем полностью,
|
||||
// превышение — напрямую из свободного пула программных расходов.
|
||||
Capital::State::consume_program_expense(coopname, reserved);
|
||||
const asset excess = total_actual - reserved;
|
||||
Capital::State::spend_program_expense_pool(coopname, excess);
|
||||
}
|
||||
} else if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::DECLINED)) {
|
||||
// Шасси отклоняет СЗ только до первой оплаты — резерв возвращается целиком.
|
||||
eosio::check(total_actual.amount == 0,
|
||||
"DECLINED с ненулевым total_actual — нарушение инварианта шасси");
|
||||
Capital::State::release_program_expense(coopname, reserved);
|
||||
} else {
|
||||
eosio::check(false, "onpgexpdone принимает только статусы CLOSED либо DECLINED");
|
||||
}
|
||||
|
||||
Capital::Expenses::delete_program_expense(coopname, pgexp.id);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
|
||||
*
|
||||
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
|
||||
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
|
||||
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
|
||||
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
|
||||
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp) —
|
||||
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
|
||||
* при расходах не изменяются.
|
||||
*
|
||||
* @param coopname Кооператив (scope state)
|
||||
* @param amount Сумма пополнения (символ строго совпадает с программой)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
|
||||
*/
|
||||
void capital::topupprogexp(name coopname, eosio::asset amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
Capital::State::topup_program_expense_pool(coopname, amount);
|
||||
|
||||
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
|
||||
// на 51 с момента взносов — выделяется только назначение под расходы).
|
||||
// Синтетический process_hash — у пополнения нет естественного анкера.
|
||||
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
|
||||
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
|
||||
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
|
||||
|
||||
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
|
||||
name{}, process_hash, "capital:topupprogexp");
|
||||
}
|
||||
@@ -95,12 +95,6 @@
|
||||
#include "app/debt_managment/create_debt/declinedebt.cpp"
|
||||
#include "app/debt_managment/settle_debt/settledebt.cpp"
|
||||
|
||||
// Управление программными расходами через шасси expense.
|
||||
// capital — инициатор + получатель callback, шасси обслуживает весь flow.
|
||||
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/createpgexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/onpgexpdone.cpp"
|
||||
|
||||
// Управление расходами
|
||||
#include "app/expense_managment/create_expense/approveexpns.cpp"
|
||||
#include "app/expense_managment/create_expense/capauthexpns.cpp"
|
||||
|
||||
@@ -48,7 +48,6 @@ constexpr uint128_t CRPS_PRECISION_FACTOR = 100000000000000ULL; ///< Масшт
|
||||
#include <eosio/asset.hpp>
|
||||
#include "../lib/index.hpp"
|
||||
#include "domain/index.hpp"
|
||||
#include "../expense/expense.hpp" // ExpenseDomain::item / callback_handler — для inline-action в шасси
|
||||
|
||||
|
||||
using namespace eosio;
|
||||
@@ -323,38 +322,4 @@ public:
|
||||
[[eosio::action]]
|
||||
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
|
||||
|
||||
// Программные расходы через шасси expense.
|
||||
// capital — инициатор + получатель callback, шасси держит весь flow расхода.
|
||||
|
||||
[[eosio::action]]
|
||||
void topupprogexp(name coopname, eosio::asset amount);
|
||||
|
||||
/**
|
||||
* @brief Инициировать программный расход через шасси expense.
|
||||
* Резервирует amount в program_expense_pool, создаёт запись progexpense,
|
||||
* шлёт inline action expense::createexp с callback={capital, onpgexpdone}.
|
||||
* Шасси обслуживает весь flow (auth/pay/report/close|decline) и шлёт callback.
|
||||
*
|
||||
* items[] — позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
|
||||
* (item.mechanics: ADVANCE — аванс, DIRECT — прямая оплата).
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void createpgexp(name coopname, checksum256 expense_hash, name creator,
|
||||
std::vector<ExpenseDomain::item> items,
|
||||
std::string description, document2 statement);
|
||||
|
||||
/**
|
||||
* @brief Callback от шасси expense на финализацию program-расхода.
|
||||
* Шасси шлёт его inline (authority — _expense@active, require_auth(_expense))
|
||||
* после терминального перехода flow (CLOSED либо DECLINED).
|
||||
*
|
||||
* status == CLOSED → consume(total_actual) + release(остаток резерва);
|
||||
* при total_actual > reserved (перерасход) превышение
|
||||
* списывается напрямую из program_expense_pool.
|
||||
* status == DECLINED → release(reserved); decline возможен только до оплат.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
|
||||
eosio::asset total_actual, std::vector<char> data);
|
||||
|
||||
};
|
||||
|
||||
@@ -178,96 +178,8 @@ namespace Capital::Expenses {
|
||||
Capital::expense_index expenses(_capital, coopname.value);
|
||||
auto expense = expenses.find(expense_id);
|
||||
eosio::check(expense != expenses.end(), "Расход не найден");
|
||||
|
||||
|
||||
expenses.erase(expense);
|
||||
}
|
||||
|
||||
} // namespace Capital::Expenses
|
||||
|
||||
namespace Capital {
|
||||
|
||||
/**
|
||||
* @brief Таблица программных расходов — целевые списания «Благорост»,
|
||||
* не привязанные к конкретному проекту.
|
||||
* @par Область памяти (scope): coopname
|
||||
* @par Имя таблицы (table): progexpenses
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
|
||||
uint64_t id; ///< Внутренний ключ
|
||||
name coopname; ///< Имя кооператива
|
||||
name username; ///< Инициатор расхода (председатель / казначей)
|
||||
|
||||
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
|
||||
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
|
||||
uint64_t fund_id; ///< Идентификатор фонда списания
|
||||
eosio::asset amount; ///< Сумма расхода
|
||||
std::string description; ///< Описание (для отчётности)
|
||||
|
||||
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
|
||||
document2 approved_statement; ///< Записка, принятая председателем
|
||||
document2 authorization; ///< Решение совета
|
||||
|
||||
time_point_sec spended_at = current_time_point();
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_username() const { return username.value; }
|
||||
checksum256 by_expense_hash() const { return expense_hash; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"progexpenses"_n, program_expense,
|
||||
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
|
||||
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
|
||||
> program_expense_index;
|
||||
|
||||
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
|
||||
program_expense_index t(_capital, coopname.value);
|
||||
auto idx = t.get_index<"byhash"_n>();
|
||||
auto itr = idx.find(hash);
|
||||
if (itr == idx.end()) return std::nullopt;
|
||||
return program_expense(*itr);
|
||||
}
|
||||
|
||||
} // namespace Capital
|
||||
|
||||
namespace Capital::Expenses {
|
||||
|
||||
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
|
||||
auto v = Capital::get_program_expense(coopname, expense_hash);
|
||||
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
|
||||
return v.value();
|
||||
}
|
||||
|
||||
inline void create_program_expense(
|
||||
eosio::name coopname,
|
||||
const checksum256 &expense_hash,
|
||||
eosio::name username,
|
||||
const eosio::asset &amount,
|
||||
const std::string &description,
|
||||
const document2 &statement
|
||||
) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
|
||||
|
||||
t.emplace(coopname, [&](auto &e) {
|
||||
e.id = id;
|
||||
e.coopname = coopname;
|
||||
e.username = username;
|
||||
e.expense_hash = expense_hash;
|
||||
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
|
||||
e.status = Status::CREATED;
|
||||
e.spended_at = current_time_point();
|
||||
e.expense_statement = statement;
|
||||
e.amount = amount;
|
||||
e.description = description;
|
||||
});
|
||||
}
|
||||
|
||||
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
auto itr = t.find(id);
|
||||
eosio::check(itr != t.end(), "Программный расход не найден");
|
||||
t.erase(itr);
|
||||
}
|
||||
|
||||
} // namespace Capital::Expenses
|
||||
@@ -1,7 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/binary_extension.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
@@ -42,18 +40,7 @@ namespace Capital {
|
||||
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
|
||||
|
||||
config config; ///< Управляемая конфигурация контракта
|
||||
|
||||
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
|
||||
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
|
||||
// struct и только через binary_extension — иначе ломается десериализация
|
||||
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
|
||||
// перед config напрямую (без extension) и задеплоены на прод — таблица state
|
||||
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
|
||||
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
|
||||
// пустые), чтобы порядок хвостовых extension оставался консистентным.
|
||||
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
|
||||
};
|
||||
|
||||
@@ -63,16 +50,6 @@ namespace Capital {
|
||||
|
||||
|
||||
namespace State {
|
||||
|
||||
/**
|
||||
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
|
||||
* ещё не материализовано (старая запись, созданная до добавления
|
||||
* program_expense_* через binary_extension).
|
||||
*/
|
||||
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
|
||||
return v.has_value() ? v.value() : asset(0, sym);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Обновляет глобальное состояние новыми значениями.
|
||||
*
|
||||
@@ -101,7 +78,7 @@ inline global_state get_global_state(name coopname) {
|
||||
|
||||
/**
|
||||
* @brief Проверяет что контракт инициализирован.
|
||||
*
|
||||
*
|
||||
* @param coopname Имя кооператива.
|
||||
*/
|
||||
inline void is_initialized(name coopname) {
|
||||
@@ -109,116 +86,6 @@ inline void is_initialized(name coopname) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
|
||||
* Вызывается из action topupprogexp (председатель).
|
||||
*/
|
||||
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма пополнения должна быть положительной");
|
||||
eosio::check(itr->global_available_invest_pool >= amount,
|
||||
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
s.global_available_invest_pool -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Резервирует средства под approved/authorized программный расход:
|
||||
* program_expense_pool -= amount, program_expense_reserved += amount.
|
||||
*/
|
||||
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
reserved += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Освобождает зарезервированное при отклонении программного расхода:
|
||||
* program_expense_reserved -= amount, program_expense_pool += amount.
|
||||
*/
|
||||
inline void release_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Окончательное списание программного расхода (callback onpgexpdone, CLOSED):
|
||||
* program_expense_reserved -= amount. Деньги уже ушли через шасси expense,
|
||||
* ledger2-проводки сделаны на payexp/reportexp/returnexp.
|
||||
*/
|
||||
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Прямое списание из свободного пула программных расходов — превышение
|
||||
* факта над резервом при закрытии расхода с перерасходом (overspendexp
|
||||
* в шасси выдал доплату из BLAGOROST_FUND сверх зарезервированного).
|
||||
*/
|
||||
inline void spend_program_expense_pool(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
}// namespace State
|
||||
|
||||
}// namespace Capital
|
||||
@@ -1,10 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
project(expense)
|
||||
|
||||
find_package(cdt)
|
||||
|
||||
add_contract(expense expense expense.cpp)
|
||||
if(IS_TESTNET)
|
||||
target_compile_definitions(expense PUBLIC IS_TESTNET=1)
|
||||
endif()
|
||||
@@ -1,425 +0,0 @@
|
||||
// expense.cpp — шасси расходов (первый потребитель: Благорост).
|
||||
//
|
||||
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
|
||||
// Кошелёк-пул приходит в createexp параметром source_wallet; ledger2-коды всех
|
||||
// операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
|
||||
// (фабричная настройка — подключение нового пула не меняет этот контракт).
|
||||
// Механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
|
||||
// callback-handler сохраняется как переменная.
|
||||
|
||||
#include "expense.hpp"
|
||||
|
||||
namespace D = ExpenseDomain;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
asset zero_like(const asset& a) { return asset{0, a.symbol}; }
|
||||
|
||||
asset sum_planned(const std::vector<D::item>& items) {
|
||||
if (items.empty()) return asset{0, eosio::symbol{"RUB", 4}};
|
||||
asset total = zero_like(items.front().planned_amount);
|
||||
for (const auto& it : items) total += it.planned_amount;
|
||||
return total;
|
||||
}
|
||||
|
||||
// Набор ledger2-кодов операций шасси по кошельку-источнику СЗ. Один источник =
|
||||
// один набор (EXPENSE_OPERATION_SETS в ledger2) — в коде expense нет
|
||||
// захардкоженных operation_code, контракт настраивается на любой пул.
|
||||
const ExpenseOperationSet& expense_ops_for(eosio::name source_wallet) {
|
||||
const auto* set = find_expense_operation_set(source_wallet);
|
||||
eosio::check(set != nullptr,
|
||||
"Для кошелька-источника не настроен набор операций шасси расходов (EXPENSE_OPERATION_SETS)");
|
||||
return *set;
|
||||
}
|
||||
|
||||
D::proposals_index get_proposals(eosio::name self, eosio::name coopname) {
|
||||
return D::proposals_index(self, coopname.value);
|
||||
}
|
||||
|
||||
auto find_proposal(D::proposals_index& tbl, const eosio::checksum256& proposal_hash) {
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
return idx.find(proposal_hash);
|
||||
}
|
||||
|
||||
void send_callback_if_any(const D::callback_handler& cb,
|
||||
eosio::name coopname,
|
||||
const eosio::checksum256& proposal_hash,
|
||||
uint8_t status,
|
||||
const eosio::asset& amount,
|
||||
eosio::name self) {
|
||||
if (cb.contract.value == 0) return;
|
||||
// Payload: (coopname, proposal_hash, status, total_actual, data).
|
||||
// status = ExpenseDomain::ProposalStatus — инициатор различает CLOSED vs DECLINED.
|
||||
// coopname нужен инициатору для скоупа таблиц (capital::progexpenses scope=coopname).
|
||||
eosio::action(
|
||||
eosio::permission_level{self, "active"_n},
|
||||
cb.contract,
|
||||
cb.action,
|
||||
std::make_tuple(coopname, proposal_hash, status, amount, cb.data)
|
||||
).send();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
void expense::createexp(name coopname, name username,
|
||||
checksum256 proposal_hash,
|
||||
name source_wallet,
|
||||
std::vector<D::item> items,
|
||||
D::callback_handler callback,
|
||||
document2 statement) {
|
||||
// Прямой вызов backend'а — авторизация кооператива; inline от контракта-инициатора
|
||||
// (capital::createpgexp и т.п.) — авторизация по whitelist, как в ledger2::apply.
|
||||
if (!has_auth(coopname)) {
|
||||
check_auth_and_get_payer_or_fail(contracts_whitelist);
|
||||
}
|
||||
|
||||
verify_document_or_fail(statement, {username});
|
||||
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
|
||||
eosio::check(ledger2_is_known_wallet(source_wallet),
|
||||
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
|
||||
// Источник определяет ledger2-коды всех операций жизненного цикла —
|
||||
// отказываем сразу при создании, а не в момент оплаты.
|
||||
expense_ops_for(source_wallet);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
|
||||
"СЗ с таким proposal_hash уже существует");
|
||||
|
||||
// Механика оплаты задаётся на каждом item отдельно (ADVANCE | DIRECT) —
|
||||
// в одном СЗ допустимо смешение: ledger2-проводка payexp идёт по-позиционно
|
||||
// с кодом, выведенным из механики конкретного item'а.
|
||||
for (auto& it : items) {
|
||||
eosio::check(it.planned_amount.is_valid() && it.planned_amount.amount > 0,
|
||||
"planned_amount каждого item должен быть положительным");
|
||||
eosio::check(it.mechanics <= static_cast<uint8_t>(D::Mechanics::DIRECT),
|
||||
"Неизвестная mechanics item (0 = ADVANCE, 1 = DIRECT)");
|
||||
eosio::check(it.recipient_type <= static_cast<uint8_t>(D::RecipientType::ORG),
|
||||
"Неизвестный recipient_type item");
|
||||
|
||||
// Инвариант: пайщик (SELF/MEMBER) получает только аванс под отчёт на личные
|
||||
// реквизиты (расходом станет после его отчёта чеком); организация — только
|
||||
// прямую оплату по выставленным реквизитам, аванс ей не выдаётся.
|
||||
if (it.recipient_type == static_cast<uint8_t>(D::RecipientType::ORG)) {
|
||||
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::DIRECT),
|
||||
"Организации/ИП доступна только оплата по счёту (DIRECT)");
|
||||
} else {
|
||||
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::ADVANCE),
|
||||
"Пайщику средства выдаются только авансом под отчёт (ADVANCE)");
|
||||
eosio::check(it.recipient != name(),
|
||||
"recipient обязателен для получателя-пайщика");
|
||||
eosio::check(is_account(it.recipient),
|
||||
"Аккаунт получателя-пайщика не существует");
|
||||
}
|
||||
|
||||
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
|
||||
it.actual_amount = zero_like(it.planned_amount);
|
||||
}
|
||||
|
||||
const auto total = sum_planned(items);
|
||||
|
||||
tbl.emplace(get_self(), [&](auto& row) {
|
||||
row.id = tbl.available_primary_key();
|
||||
row.coopname = coopname;
|
||||
row.proposal_hash = proposal_hash;
|
||||
row.username = username;
|
||||
row.source_wallet = source_wallet;
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::CREATED);
|
||||
row.items = items;
|
||||
row.total_planned = total;
|
||||
row.total_actual = zero_like(total);
|
||||
row.callback = callback;
|
||||
row.statement_doc = statement;
|
||||
row.decision_doc = document2{};
|
||||
row.created_at = eosio::current_time_point();
|
||||
row.updated_at = row.created_at;
|
||||
});
|
||||
|
||||
// СЗ утверждает совет (как возвраты паевых взносов): ставим вопрос в повестку.
|
||||
// Утверждение председателем после голосования → callback authexp от _soviet;
|
||||
// отклонение (отрицательный консенсус / просрочка) → declexp с причиной.
|
||||
::Soviet::create_agenda(
|
||||
_expense,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("createexp"_n),
|
||||
proposal_hash,
|
||||
_expense,
|
||||
"authexp"_n,
|
||||
"declexp"_n,
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
|
||||
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED),
|
||||
"Авторизовать можно только СЗ в статусе CREATED");
|
||||
|
||||
verify_document_or_fail(decision);
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
row.decision_doc = decision;
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
void expense::declexp(name coopname, checksum256 proposal_hash, std::string reason) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
// Отклонение возможно только пока ни один item не оплачен: после payexp деньги
|
||||
// уже ушли через ledger2, и decline разъехался бы с учётом инициатора
|
||||
// (callback DECLINED возвращает инициатору весь резерв). Частично исполненный
|
||||
// СЗ завершается обычным путём: reportexp / returnexp → closeexp.
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED) ||
|
||||
it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED),
|
||||
"Отклонить можно только СЗ без оплат (статусы CREATED / AUTHORIZED)");
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// Callback инициатору (capital::onpgexpdone и т.п.); total_actual здесь всегда 0 —
|
||||
// оплат до decline не было (см. проверку статусов выше).
|
||||
send_callback_if_any(it->callback, coopname, proposal_hash,
|
||||
static_cast<uint8_t>(D::ProposalStatus::DECLINED),
|
||||
it->total_actual, get_self());
|
||||
}
|
||||
|
||||
void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset actual_amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED) ||
|
||||
it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Оплата возможна только из статусов AUTHORIZED / PARTIALLY_PAID");
|
||||
eosio::check(actual_amount.is_valid() && actual_amount.amount > 0,
|
||||
"actual_amount должен быть положительным");
|
||||
|
||||
bool item_found = false;
|
||||
uint8_t paid_mechanics = 0;
|
||||
name paid_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
bool all_reported = true;
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::APPROVED),
|
||||
"Item уже оплачен или закрыт");
|
||||
eosio::check(actual_amount.symbol == i.planned_amount.symbol,
|
||||
"Символ actual_amount не совпадает с планом item");
|
||||
eosio::check(actual_amount.amount <= i.planned_amount.amount,
|
||||
"Сумма оплаты не может превышать план item; доплата сверх плана — через overspendexp");
|
||||
i.actual_amount = actual_amount;
|
||||
// DIRECT не имеет фазы подотчёта (нет ADVANCE_HOLD): закрывающие документы
|
||||
// прикладывает сам кассир, item считается отчитанным сразу после оплаты.
|
||||
i.status = static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::DIRECT
|
||||
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
|
||||
: static_cast<uint8_t>(D::ItemStatus::PAID);
|
||||
paid_mechanics = i.mechanics;
|
||||
paid_recipient = i.recipient;
|
||||
item_found = true;
|
||||
}
|
||||
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
|
||||
all_reported = false;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
row.total_actual += actual_amount;
|
||||
// DIRECT-only СЗ становится готов к закрытию сразу после последней оплаты.
|
||||
row.status = all_reported
|
||||
? static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED)
|
||||
: static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2-проводка по механике оплаченного item'а — выдача аванса или прямая
|
||||
// оплата; коды выводятся из кошелька-источника СЗ. СЗ может смешивать механики.
|
||||
// username нужен ledger2 только для USER_SHARED-стороны (кошелёк-подотчёт) —
|
||||
// это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
|
||||
const auto& ops = expense_ops_for(it->source_wallet);
|
||||
const auto code = static_cast<D::Mechanics>(paid_mechanics) == D::Mechanics::ADVANCE
|
||||
? ops.advance
|
||||
: ops.direct;
|
||||
Ledger2::apply(get_self(), coopname, code, actual_amount,
|
||||
paid_recipient, proposal_hash, "expense:payexp");
|
||||
}
|
||||
|
||||
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Отчёт возможен только по СЗ в статусе PARTIALLY_PAID");
|
||||
|
||||
asset item_amount = asset{0, it->total_actual.symbol};
|
||||
bool item_found = false;
|
||||
name item_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
bool all_reported = true;
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
|
||||
"Отчёт возможен только для оплаченных items");
|
||||
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
|
||||
"reportexp применим только к ADVANCE-механике");
|
||||
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
|
||||
item_amount = i.actual_amount;
|
||||
item_recipient = i.recipient;
|
||||
item_found = true;
|
||||
}
|
||||
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
|
||||
all_reported = false;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
if (all_reported) {
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED);
|
||||
}
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2: BURN кошелька-подотчёта на остаточный actual item'а (выдано −
|
||||
// возвращено + доплачено), без бухпроводки — проводка уже сделана при выдаче.
|
||||
// Подотчёт числится на получателе аванса — его username и закрывает burn.
|
||||
// Полный возврат аванса (actual == 0) — burn не нужен, подотчёт уже пуст.
|
||||
if (item_amount.amount > 0) {
|
||||
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).report, item_amount,
|
||||
item_recipient, proposal_hash, "expense:reportexp");
|
||||
}
|
||||
}
|
||||
|
||||
void expense::closeexp(name coopname, checksum256 proposal_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED),
|
||||
"Закрыть можно только СЗ в статусе REPORT_SUBMITTED");
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::CLOSED);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// Callback на финализацию (capital::onpgexpdone и т.п.) — если был установлен при createexp.
|
||||
send_callback_if_any(it->callback, coopname, proposal_hash,
|
||||
static_cast<uint8_t>(D::ProposalStatus::CLOSED),
|
||||
it->total_actual, get_self());
|
||||
}
|
||||
|
||||
void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset return_amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
|
||||
|
||||
bool item_found = false;
|
||||
name item_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
|
||||
"Возврат возможен только из статуса PAID");
|
||||
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
|
||||
"returnexp применим только к ADVANCE-механике");
|
||||
eosio::check(return_amount.is_valid() &&
|
||||
return_amount.symbol == i.actual_amount.symbol,
|
||||
"Символ return_amount не совпадает с item");
|
||||
eosio::check(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
|
||||
"return_amount должен быть положительным и не превышать actual_amount");
|
||||
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — после
|
||||
// возврата остатка получатель штатно отчитывается reportexp по
|
||||
// фактически потраченной части (при полном возврате — actual == 0).
|
||||
i.actual_amount -= return_amount;
|
||||
item_recipient = i.recipient;
|
||||
item_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
row.total_actual -= return_amount;
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2: TRANSFER подотчёт → пул-источник (зеркало выдачи аванса).
|
||||
// Подотчёт снимается с получателя аванса.
|
||||
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).refund, return_amount,
|
||||
item_recipient, proposal_hash, "expense:returnexp");
|
||||
}
|
||||
|
||||
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset overspend_amount) {
|
||||
require_auth(coopname);
|
||||
eosio::check(overspend_amount.amount > 0, "overspend_amount должен быть положительным");
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
|
||||
|
||||
bool item_found = false;
|
||||
name item_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
|
||||
"Перерасход регистрируется только из статуса PAID");
|
||||
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
|
||||
"overspendexp применим только к ADVANCE-механике");
|
||||
eosio::check(overspend_amount.is_valid() &&
|
||||
overspend_amount.symbol == i.actual_amount.symbol,
|
||||
"Символ overspend_amount не совпадает с item");
|
||||
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — подотчёт
|
||||
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
|
||||
// который сделает burn ADVANCE_HOLD на итоговый actual.
|
||||
i.actual_amount += overspend_amount;
|
||||
item_recipient = i.recipient;
|
||||
item_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
row.total_actual += overspend_amount;
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2: доплата перерасхода — TRANSFER пул-источник → подотчёт получателя
|
||||
// (зеркало выдачи аванса на сумму доплаты). Закрытие на полную сумму — в reportexp.
|
||||
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).overspend, overspend_amount,
|
||||
item_recipient, proposal_hash, "expense:overspend");
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include "../lib/index.hpp"
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
/**
|
||||
* @defgroup public_expense Контракт EXPENSE
|
||||
* @brief Шасси расходов цифрового кооператива (MVP — Благорост).
|
||||
*
|
||||
* Универсальный контракт: principal storage = СЗ-расход с массивом items;
|
||||
* кошелёк-источник (пул) приходит параметром source_wallet, ledger2-коды всех
|
||||
* операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
|
||||
* (ledger2/operations.hpp) — подключение нового пула не меняет контракт;
|
||||
* механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
|
||||
* callback на финализацию (контракт+action+data) сохраняется при createexp
|
||||
* как переменная — `expense` агностичен к программе-получателю.
|
||||
*
|
||||
* See: components/desktop/extensions/expenses/NAMING-C28-28.md (v3).
|
||||
*/
|
||||
|
||||
namespace ExpenseDomain {
|
||||
|
||||
/**
|
||||
* @brief Способ оплаты item-а в СЗ.
|
||||
* ADVANCE — пайщик-получатель оплачивает, затем приносит чек.
|
||||
* DIRECT — кассир/председатель платит организации напрямую.
|
||||
*/
|
||||
enum class Mechanics : uint8_t {
|
||||
ADVANCE = 0,
|
||||
DIRECT = 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Получатель платежа в item-е.
|
||||
* SELF — сам создатель СЗ.
|
||||
* MEMBER — другой пайщик.
|
||||
* ORG — внешняя организация.
|
||||
*/
|
||||
enum class RecipientType : uint8_t {
|
||||
SELF = 0,
|
||||
MEMBER = 1,
|
||||
ORG = 2,
|
||||
};
|
||||
|
||||
enum class ProposalStatus : uint8_t {
|
||||
CREATED = 0, ///< СЗ создан, ждёт подписи signact1 создателя.
|
||||
AUTHORIZED = 1, ///< СЗ подписан signact2 (совет), готов к оплате.
|
||||
PARTIALLY_PAID = 2, ///< Часть item-ов оплачена.
|
||||
REPORT_SUBMITTED = 3, ///< Отчёт о расходе подан, ждёт авторизации совета.
|
||||
CLOSED = 4, ///< СЗ-отчёт авторизован — расход закрыт.
|
||||
DECLINED = 5, ///< СЗ отклонён до или после авторизации.
|
||||
};
|
||||
|
||||
enum class ItemStatus : uint8_t {
|
||||
APPROVED = 0, ///< Запланирован, ждёт оплаты.
|
||||
PAID = 1, ///< Оплачен (ADVANCE — выдан пайщику; DIRECT — оплачен организации).
|
||||
REPORTED = 2, ///< Закрыт отчётом (чек приложен). DIRECT-item получает его сразу при payexp.
|
||||
RETURNED = 3, ///< Зарезервировано, не используется: returnexp уменьшает actual_amount, статус остаётся PAID до отчёта.
|
||||
OVERSPENT = 4, ///< Зарезервировано, не используется: overspendexp увеличивает actual_amount, статус остаётся PAID до отчёта.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Callback на финализацию — устанавливается при createexp как переменная.
|
||||
*
|
||||
* Контракт expense не знает про capital/blagorost — он просто вызывает inline
|
||||
* action на (contract, action) и прокидывает data наружу. Пустой contract = нет callback.
|
||||
*
|
||||
* Пример: для расхода в РИД-проект Благороста UI/backend заполняет
|
||||
* { "capital"_n, "onexpreport"_n, packed(wip_project_hash) }.
|
||||
*/
|
||||
struct callback_handler {
|
||||
eosio::name contract;
|
||||
eosio::name action;
|
||||
std::vector<char> data;
|
||||
EOSLIB_SERIALIZE(callback_handler, (contract)(action)(data))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Item — одна строка СЗ-расхода.
|
||||
*/
|
||||
struct item {
|
||||
eosio::checksum256 item_hash;
|
||||
uint8_t mechanics; ///< Mechanics
|
||||
uint8_t recipient_type; ///< RecipientType
|
||||
eosio::name recipient; ///< username (для SELF/MEMBER), либо name-идентификатор (для ORG)
|
||||
std::string description;
|
||||
eosio::asset planned_amount;
|
||||
eosio::asset actual_amount; ///< 0 пока не оплачено / не отчиталось
|
||||
uint8_t status; ///< ItemStatus
|
||||
|
||||
EOSLIB_SERIALIZE(item, (item_hash)(mechanics)(recipient_type)(recipient)(description)(planned_amount)(actual_amount)(status))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Таблица СЗ-расходов. Scope = coopname.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract("expense")]] proposal {
|
||||
uint64_t id;
|
||||
eosio::name coopname; ///< кооператив (= scope таблицы; дублируем полем по канону)
|
||||
eosio::checksum256 proposal_hash;
|
||||
eosio::name username; ///< создатель
|
||||
eosio::name source_wallet; ///< семантически — из какого ЦПП/фонда (w.cap.blago, w.sov.expns, …)
|
||||
uint8_t status; ///< ProposalStatus
|
||||
std::vector<item> items;
|
||||
eosio::asset total_planned;
|
||||
eosio::asset total_actual;
|
||||
callback_handler callback; ///< опционально; contract.value == 0 = нет callback
|
||||
document2 statement_doc; ///< type=2010, signact1 создателя
|
||||
document2 decision_doc; ///< type=2011, signact2 совета
|
||||
eosio::time_point_sec created_at;
|
||||
eosio::time_point_sec updated_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
eosio::checksum256 by_hash() const { return proposal_hash; }
|
||||
uint64_t by_username() const { return username.value; }
|
||||
uint64_t by_status() const { return static_cast<uint64_t>(status); }
|
||||
|
||||
EOSLIB_SERIALIZE(proposal,
|
||||
(id)(coopname)(proposal_hash)(username)(source_wallet)(status)
|
||||
(items)(total_planned)(total_actual)(callback)(statement_doc)(decision_doc)
|
||||
(created_at)(updated_at))
|
||||
};
|
||||
|
||||
using proposals_index = eosio::multi_index<"proposals"_n, proposal,
|
||||
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<proposal, eosio::checksum256, &proposal::by_hash>>,
|
||||
eosio::indexed_by<"byusername"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_username>>,
|
||||
eosio::indexed_by<"bystatus"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_status>>
|
||||
>;
|
||||
|
||||
} // namespace ExpenseDomain
|
||||
|
||||
/**
|
||||
* @ingroup public_contracts
|
||||
* @brief Шасси расходов — 8 actions, MVP только Благорост.
|
||||
*/
|
||||
class [[eosio::contract("expense")]] expense : public contract {
|
||||
public:
|
||||
using contract::contract;
|
||||
|
||||
/**
|
||||
* @brief Создать и подать СЗ-расход.
|
||||
*
|
||||
* Создание и подача — одна транзакция (slug createexp). На входе: items + источник
|
||||
* (source_wallet) + опц. callback на финализацию.
|
||||
* Подписывает создатель (signact1 statement_doc, type=2010).
|
||||
*
|
||||
* Авторизация: coopname (прямой вызов backend'а) либо контракт-инициатор из
|
||||
* contracts_whitelist (inline, например capital::createpgexp).
|
||||
* Механика оплаты — per-item (item.mechanics: ADVANCE | DIRECT); ledger2-код
|
||||
* операции выводится из механики позиции в момент оплаты payexp.
|
||||
*
|
||||
* Ставит вопрос в повестку совета (soviet::createagenda, тип createexp):
|
||||
* утверждение совета → callback authexp, отклонение → declexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void createexp(name coopname, name username,
|
||||
checksum256 proposal_hash,
|
||||
name source_wallet,
|
||||
std::vector<ExpenseDomain::item> items,
|
||||
ExpenseDomain::callback_handler callback,
|
||||
document2 statement);
|
||||
|
||||
/**
|
||||
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
|
||||
* Callback решения совета — вызывается только контрактом soviet после
|
||||
* голосования и утверждения председателем. После этого расход доступен для оплаты.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
|
||||
|
||||
/**
|
||||
* @brief Отклонить СЗ. Callback решения совета (отрицательный консенсус
|
||||
* голосов «против» либо просрочка повестки) — вызывается только контрактом
|
||||
* soviet. Возможно только до первой оплаты (CREATED / AUTHORIZED):
|
||||
* после payexp средства уже ушли через ledger2, и СЗ завершается обычным
|
||||
* путём reportexp / returnexp → closeexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
|
||||
|
||||
/**
|
||||
* @brief Оплатить item — выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
|
||||
* Контракт зовёт Ledger2::apply с кодом из набора операций кошелька-источника
|
||||
* (EXPENSE_OPERATION_SETS: source_wallet + механика item'а → operation_code).
|
||||
* actual_amount не может превышать план item (доплата — через overspendexp).
|
||||
* DIRECT-item не имеет фазы подотчёта и помечается REPORTED сразу; когда все
|
||||
* items REPORTED — proposal переходит в REPORT_SUBMITTED прямо из payexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset actual_amount);
|
||||
|
||||
/**
|
||||
* @brief Отчёт о расходе (ADVANCE) — пайщик закрывает item чеком.
|
||||
* Зовёт Ledger2::apply(o.exp.advrpt) на остаточный actual item'а (при полном
|
||||
* возврате аванса burn пропускается). Когда все items reported — статус
|
||||
* proposal становится REPORT_SUBMITTED.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash);
|
||||
|
||||
/**
|
||||
* @brief Закрытие расхода советом — финальный signact2 СЗ-отчёта.
|
||||
* Если у proposal заполнен callback — отправляет inline action на (callback.contract, callback.action, callback.data).
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void closeexp(name coopname, checksum256 proposal_hash);
|
||||
|
||||
/**
|
||||
* @brief Возврат неиспользованного аванса (ADVANCE-остаток).
|
||||
* Settlement-запись: уменьшает actual_amount item'а, статус остаётся PAID —
|
||||
* фактически потраченная часть закрывается затем штатным reportexp.
|
||||
* Зовёт Ledger2::apply(o.exp.advret).
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset return_amount);
|
||||
|
||||
/**
|
||||
* @brief Доплата при перерасходе (ADVANCE).
|
||||
* Settlement-запись: увеличивает actual_amount item'а, статус остаётся PAID.
|
||||
* Зовёт Ledger2::apply(o.exp.over) — выдача доплаты; закрытие подотчёта на
|
||||
* полную сумму (выдано + доплата) делает последующий reportexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset overspend_amount);
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
#include <array>
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
* расширения для разовых исправлений состояния, которые можно провести
|
||||
@@ -71,74 +69,4 @@ void ledger2::migrate() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Разовая коррекция СЧЕТОВ под удаление фантомных пайщиков 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,12 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
|
||||
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
|
||||
|
||||
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
|
||||
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
|
||||
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
|
||||
|
||||
|
||||
static const std::set<eosio::name> soviet_actions = {
|
||||
"joincoop"_n, //регистрация пайщика
|
||||
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
|
||||
|
||||
|
||||
//MEET
|
||||
"creategm"_n,//предложение повестки планового общего собрание
|
||||
"completegm"_n, //решение общего собрания пайщиков
|
||||
@@ -103,10 +102,7 @@ static const std::set<eosio::name> soviet_actions = {
|
||||
|
||||
//WALLET
|
||||
"createwthd"_n, //создать заявление на возврат паевого взноса
|
||||
|
||||
//EXPENSE
|
||||
"createexp"_n, //служебная записка-смета о расходах (шасси расходов)
|
||||
|
||||
|
||||
//LEDGER
|
||||
"ledgerwthd"_n, //заявление на списание со счета через ledger
|
||||
|
||||
@@ -169,7 +165,6 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
static constexpr eosio::name _ledger = "ledger"_n;
|
||||
static constexpr eosio::name _ledger2 = "ledger2"_n;
|
||||
static constexpr eosio::name _apps = "apps"_n;
|
||||
static constexpr eosio::name _expense = "expense"_n;
|
||||
static constexpr eosio::name _power_account = "eosio.power"_n;
|
||||
static constexpr eosio::name _saving_account = "eosio.saving"_n;
|
||||
|
||||
@@ -198,8 +193,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
"ledger"_n,
|
||||
"ledger2"_n,
|
||||
"capital"_n,
|
||||
"apps"_n,
|
||||
"expense"_n
|
||||
"apps"_n
|
||||
// Добавьте остальные стандартные или пользовательские контракты по необходимости
|
||||
};
|
||||
|
||||
@@ -245,7 +239,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#ifdef IS_TESTNET
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
|
||||
#else
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
|
||||
#endif
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,11 +23,6 @@ enum class AccountType : uint8_t {
|
||||
ACTIVE_PASSIVE = 2,
|
||||
};
|
||||
|
||||
// Пересмотр 2026-06-06: добавлен счёт 76 «Расчёты с пайщиками» (А-П) —
|
||||
// суспенс для регистрационного взноса между приёмом денег кассой
|
||||
// (Dr 51 / Cr 76) и решением совета. На одобрении переносится на 80/86
|
||||
// (Dr 76 / Cr 80, Dr 76 / Cr 86), на отказе возвращается (Dr 76 / Cr 51).
|
||||
|
||||
/**
|
||||
* @brief План счетов ledger2 (MVP) со смещением *1000.
|
||||
*
|
||||
@@ -37,13 +32,12 @@ enum class AccountType : uint8_t {
|
||||
* состояния «принятый коммит» (паевой взнос имуществом в переходе РИД
|
||||
* в программу Благорост): commit → Dr 08 / Cr 80, accept → Dr 04 / Cr 08.
|
||||
*
|
||||
* Состав (7 счетов):
|
||||
* Состав (6 счетов):
|
||||
*
|
||||
* - 04 — Нематериальные активы (РИД, принятые в паевой фонд)
|
||||
* - 08 — Вложения во внеоборотные активы (промежуточное состояние)
|
||||
* - 51 — Расчётный счёт
|
||||
* - 58 — Финансовые вложения (выданные пайщикам беспроцентные займы)
|
||||
* - 76 — Расчёты с пайщиками (суспенс регистрационного взноса до решения совета)
|
||||
* - 80 — Паевой фонд (складочный капитал)
|
||||
* - 86 — Целевое финансирование (без субсчетов)
|
||||
*
|
||||
@@ -61,9 +55,6 @@ struct ledger2_accounts {
|
||||
static constexpr uint64_t BANK_ACCOUNT = 51 * 1000; ///< 51 — Расчётный счёт (А)
|
||||
static constexpr uint64_t FINANCIAL_INVESTMENTS = 58 * 1000; ///< 58 — Финансовые вложения (А)
|
||||
|
||||
// Активно-пассивные
|
||||
static constexpr uint64_t PARTICIPANT_SETTLEMENTS = 76 * 1000; ///< 76 — Расчёты с пайщиками (А-П): суспенс регистрационного взноса до решения совета
|
||||
|
||||
// Пассивы
|
||||
static constexpr uint64_t SHARE_FUND = 80 * 1000; ///< 80 — Паевой фонд (П)
|
||||
static constexpr uint64_t TARGET_RECEIPTS = 86 * 1000; ///< 86 — Целевое финансирование (П)
|
||||
@@ -88,12 +79,11 @@ struct Ledger2AccountMeta {
|
||||
* `constexpr std::array` + `string_view` — чтобы не было dynamic init
|
||||
* при загрузке контракта и тип был полностью заморожен на этапе сборки.
|
||||
*/
|
||||
inline constexpr std::array<Ledger2AccountMeta, 7> LEDGER2_ACCOUNT_MAP = {{
|
||||
inline constexpr std::array<Ledger2AccountMeta, 6> LEDGER2_ACCOUNT_MAP = {{
|
||||
{ ledger2_accounts::INTANGIBLE_ASSETS, "Нематериальные активы", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::NON_CURRENT_INVESTMENTS, "Вложения во внеоборотные активы", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::BANK_ACCOUNT, "Расчётный счёт", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::FINANCIAL_INVESTMENTS, "Финансовые вложения", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::PARTICIPANT_SETTLEMENTS, "Расчёты с пайщиками", AccountType::ACTIVE_PASSIVE },
|
||||
{ ledger2_accounts::SHARE_FUND, "Паевой фонд (складочный капитал)", AccountType::PASSIVE },
|
||||
{ ledger2_accounts::TARGET_RECEIPTS, "Целевое финансирование", AccountType::PASSIVE },
|
||||
}};
|
||||
|
||||
@@ -54,14 +54,8 @@ namespace operations {
|
||||
|
||||
// registrator
|
||||
namespace registrator {
|
||||
inline constexpr eosio::name PAY_ENTRANCE = "o.reg.payent"_n; ///< Оплата вступительного взноса (Dr 51 / Cr 86, ISSUE ENTRANCE_FEES). Одношаговый путь adduser (без совета).
|
||||
inline constexpr eosio::name PUT_MINSHARE = "o.reg.putmin"_n; ///< Внесение минимального паевого при регистрации (Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND). Одношаговый путь adduser (без совета).
|
||||
// Двухфазный путь через совет (reguser → confirmpay → confirmreg/declinereg):
|
||||
inline constexpr eosio::name RECEIVE_PAYMENT = "o.reg.inpay"_n; ///< Приём регистрационного взноса кассой в ожидание решения совета (Dr 51 / Cr 76, ISSUE REGISTRATION_PENDING).
|
||||
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
|
||||
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
|
||||
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
|
||||
inline constexpr eosio::name MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
|
||||
inline constexpr eosio::name PAY_ENTRANCE = "o.reg.payent"_n; ///< Оплата вступительного взноса (Dr 51 / Cr 86, ISSUE ENTRANCE_FEES).
|
||||
inline constexpr eosio::name PUT_MINSHARE = "o.reg.putmin"_n; ///< Внесение минимального паевого при регистрации (Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND).
|
||||
}
|
||||
|
||||
// wallet
|
||||
@@ -86,7 +80,6 @@ namespace operations {
|
||||
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
|
||||
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
|
||||
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
|
||||
inline constexpr eosio::name PROGRAM_EXPENSE_TOPUP = "o.cap.pgtop"_n; ///< Пополнение пула программных расходов из инвестиций программы (ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr — деньги уже на 51, выделяется кооперативный резерв под расходы; паевые L3-кошельки пайщиков не трогаются).
|
||||
}
|
||||
|
||||
// marketplace
|
||||
@@ -100,27 +93,6 @@ namespace operations {
|
||||
inline constexpr eosio::name CONVERT_AXN = "o.sov.axncnv"_n; ///< Трансляция паевого взноса в членский (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → DELEGATE_FEES).
|
||||
}
|
||||
|
||||
// expense — шасси расходов (MVP: только Благорост; хозрасходы из членских — отдельный эпик).
|
||||
//
|
||||
// Принципы (см. components/desktop/extensions/expenses/NAMING-C28-28.md):
|
||||
// - Контракт `expense` универсальный: operation_code передаётся в payload.
|
||||
// - Источник оплат — КООПЕРАТИВНЫЙ пул расходов (PROGRAM_EXPENSE_POOL),
|
||||
// пополняемый o.cap.pgtop; личные L3-кошельки пайщиков (w.cap.blago)
|
||||
// при оплатах СЗ не изменяются.
|
||||
// - При расходе из Благороста паевой фонд (80) НЕ трогается: меняется только форма
|
||||
// актива 51 → 08 (Дт 08 / Кт 51 для обеих механик).
|
||||
// - ADVANCE-отчёт (`o.exp.advrpt`) НЕ создаёт новой бухпроводки: проводка уже
|
||||
// сделана на `o.exp.blgadv` при выдаче.
|
||||
// - Callback на финализацию — переменная (`callback{contract, action, data}`),
|
||||
// заполняется при `expense::createexp`; expense ничего не знает про capital.
|
||||
namespace expense {
|
||||
inline constexpr eosio::name BLAGO_ADVANCE = "o.exp.blgadv"_n; ///< Выдача подотчётных из пула расходов (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51).
|
||||
inline constexpr eosio::name BLAGO_DIRECT = "o.exp.blgdir"_n; ///< Прямая оплата из пула расходов (BURN PROGRAM_EXPENSE_POOL, Dr 08 / Cr 51).
|
||||
inline constexpr eosio::name ADVANCE_REPORT = "o.exp.advrpt"_n; ///< Закрытие подотчёта пайщика (BURN ADVANCE_HOLD, без бухпроводки — canal 08/51 уже сделан на blgadv).
|
||||
inline constexpr eosio::name ADVANCE_RETURN = "o.exp.advret"_n; ///< Возврат неиспользованного подотчёта (TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL, Dr 51 / Cr 08).
|
||||
inline constexpr eosio::name OVERSPEND = "o.exp.over"_n; ///< Доплата сверх подотчёта (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51); сразу за ней expense вызывает ADVANCE_REPORT.
|
||||
}
|
||||
|
||||
// migration (только из migrate.cpp)
|
||||
//
|
||||
// В OPERATION_REGISTRY включены **только** те транзиты, которые проводятся
|
||||
@@ -216,42 +188,6 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Минимальный паевой взнос пайщика при регистрации" },
|
||||
|
||||
// 2a. Приём регистрационного взноса кассой (поток через совет): Dr 51 / Cr 76, ISSUE REGISTRATION_PENDING.
|
||||
// Деньги получены, но взнос ещё не признан — висит на расчётах с пайщиком (76)
|
||||
// до решения совета. Сумма = вступительный + минимальный паевой.
|
||||
{ operations::registrator::RECEIVE_PAYMENT, processes::registrator::ACCEPT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::REGISTRATION_PENDING,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::PARTICIPANT_SETTLEMENTS,
|
||||
"Приём регистрационного взноса в ожидание решения совета" },
|
||||
|
||||
// 2b. Зачисление минимального паевого по решению совета: Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND.
|
||||
{ operations::registrator::SETTLE_MINSHARE, processes::registrator::ACCEPT, WalletOp::TRANSFER,
|
||||
ledger2_wallets::REGISTRATION_PENDING, ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::SHARE_FUND,
|
||||
"Зачисление минимального паевого взноса по решению совета" },
|
||||
|
||||
// 2c. Зачисление вступительного по решению совета: Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES.
|
||||
{ operations::registrator::SETTLE_ENTRANCE, processes::registrator::ACCEPT, WalletOp::TRANSFER,
|
||||
ledger2_wallets::REGISTRATION_PENDING, ledger2_wallets::ENTRANCE_FEES,
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Зачисление вступительного взноса по решению совета" },
|
||||
|
||||
// 2d. Возврат регистрационного взноса при отказе совета: Dr 76 / Cr 51, BURN REGISTRATION_PENDING.
|
||||
// Отдельный процесс p.reg.refund: приём взноса прерывается, начинается возврат.
|
||||
// Деньги уходят из системы (банковский перевод кандидату), получателя на цепи нет.
|
||||
{ operations::registrator::REFUND, processes::registrator::REFUND, WalletOp::BURN,
|
||||
ledger2_wallets::REGISTRATION_PENDING, eosio::name{},
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат регистрационного взноса при отказе совета" },
|
||||
|
||||
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
|
||||
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
|
||||
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
|
||||
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
|
||||
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
|
||||
0, 0,
|
||||
"Перенос минимального паевого на главный при выходе из кооператива" },
|
||||
|
||||
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
|
||||
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
@@ -384,72 +320,6 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
0, 0,
|
||||
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
|
||||
|
||||
// 19a. Пополнение пула программных расходов: ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr.
|
||||
// Совет выделяет часть свободных инвестиций программы под целевые расходы:
|
||||
// деньги физически на 51 с момента взносов, здесь появляется кооперативный
|
||||
// резерв-кошелёк, из которого шасси expense оплачивает СЗ. Паевые L3-кошельки
|
||||
// пайщиков (w.cap.blago, счёт 80) не изменяются — права требования сохраняются.
|
||||
{ operations::capital::PROGRAM_EXPENSE_TOPUP, processes::capital::PGEXP, WalletOp::ISSUE,
|
||||
eosio::name{}, ledger2_wallets::PROGRAM_EXPENSE_POOL,
|
||||
0, 0,
|
||||
"Пополнение пула программных расходов ЦПП «Благорост»" },
|
||||
|
||||
// ----- Шасси расходов (o.exp.*) — вызываются из контракта expense -----
|
||||
//
|
||||
// Базовое состояние Благороста ДО расхода:
|
||||
// - Деньги физически на 51 с момента o.wal.depcpl (Dr 51 / Cr 80).
|
||||
// - 80 (паевой) наполнен; кошелёк w.cap.blago.
|
||||
// - 08 пустой (`o.cap.invest` — TRANSFER без проводок).
|
||||
//
|
||||
// Принцип: расход не уменьшает паевой фонд (80). Меняется только форма актива
|
||||
// 51 → 08 (банк уходит, появляется WIP-проект).
|
||||
|
||||
// 20. Выдача подотчётных из пула программных расходов: Dr 08 / Cr 51,
|
||||
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
|
||||
// Источник — КООПЕРАТИВНЫЙ пул расходов (пополняется o.cap.pgtop); личные
|
||||
// L3-кошельки пайщиков (w.cap.blago) при оплате СЗ не трогаются — их паевые
|
||||
// взносы в программе не уменьшаются. Деньги физически уходят пайщику (Cr 51),
|
||||
// стоимость капитализируется в WIP (Dr 08). ADVANCE_HOLD фиксирует
|
||||
// ответственность получателя аванса (USER_SHARED) до отчёта.
|
||||
{ operations::expense::BLAGO_ADVANCE, processes::expense::PROPOSAL, WalletOp::TRANSFER,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
|
||||
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Выдача подотчётных из пула расходов ЦПП «Благорост»" },
|
||||
|
||||
// 21. Прямая оплата из пула программных расходов (DIRECT): Dr 08 / Cr 51,
|
||||
// BURN PROGRAM_EXPENSE_POOL. Оплата организации по счёту; деньги уходят с 51,
|
||||
// стоимость капитализируется в 08. Кошелёк-резерв не задействован.
|
||||
{ operations::expense::BLAGO_DIRECT, processes::expense::PROPOSAL, WalletOp::BURN,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, eosio::name{},
|
||||
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Прямая оплата из пула расходов ЦПП «Благорост»" },
|
||||
|
||||
// 22. Закрытие подотчёта пайщика по отчёту: BURN ADVANCE_HOLD, БЕЗ бухпроводки.
|
||||
// Проводка Dr 08 / Cr 51 уже сделана на BLAGO_ADVANCE при выдаче. При отчёте только
|
||||
// снимается кошелёк-резерв пайщика — никакого canal 08/51 второй раз.
|
||||
{ operations::expense::ADVANCE_REPORT, processes::expense::PROPOSAL, WalletOp::BURN,
|
||||
ledger2_wallets::ADVANCE_HOLD, eosio::name{},
|
||||
0, 0,
|
||||
"Закрытие подотчёта пайщика по отчёту" },
|
||||
|
||||
// 23. Возврат неиспользованного подотчёта: Dr 51 / Cr 08,
|
||||
// TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL.
|
||||
// Зеркало BLAGO_ADVANCE: деньги возвращаются на 51, WIP-стоимость уменьшается,
|
||||
// остаток снова доступен пулу расходов.
|
||||
{ operations::expense::ADVANCE_RETURN, processes::expense::PROPOSAL, WalletOp::TRANSFER,
|
||||
ledger2_wallets::ADVANCE_HOLD, ledger2_wallets::PROGRAM_EXPENSE_POOL,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
"Возврат неиспользованного подотчёта в пул расходов" },
|
||||
|
||||
// 24. Доплата сверх подотчёта (перерасход): Dr 08 / Cr 51,
|
||||
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
|
||||
// Зеркало BLAGO_ADVANCE на сумму перерасхода. Контракт expense сразу за OVERSPEND
|
||||
// вызывает ADVANCE_REPORT — две последовательные записи в одной транзакции `expense::overspendexp`.
|
||||
{ operations::expense::OVERSPEND, processes::expense::PROPOSAL, WalletOp::TRANSFER,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
|
||||
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Доплата сверх подотчёта (перерасход)" },
|
||||
|
||||
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
|
||||
|
||||
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
|
||||
@@ -632,105 +502,3 @@ inline constexpr const OperationAdjustmentEntry* find_adjustment(eosio::name ope
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Наборы операций шасси расходов — фабричная настройка контракта expense.
|
||||
// =====================================================================
|
||||
//
|
||||
// Контракт `expense` агностичен к программе-источнику: кошелёк-пул приходит в
|
||||
// `createexp` параметром `source_wallet`, а ledger2-коды всех пяти операций
|
||||
// жизненного цикла (аванс / прямая оплата / отчёт / возврат / перерасход)
|
||||
// выводятся из этого кошелька через таблицу ниже — в коде expense нет ни
|
||||
// одного захардкоженного operation_code.
|
||||
//
|
||||
// Подключение шасси к новому пулу (например, кошельку членских взносов
|
||||
// кооперативного участка) = добавить 5 операций в OPERATION_REGISTRY и одну
|
||||
// строку здесь. Контракт expense при этом не меняется.
|
||||
struct ExpenseOperationSet {
|
||||
eosio::name source_wallet; ///< пул-источник средств (COOPERATIVE-кошелёк)
|
||||
eosio::name advance; ///< выдача аванса под отчёт (TRANSFER pool → подотчёт)
|
||||
eosio::name direct; ///< прямая оплата организации по счёту (BURN pool)
|
||||
eosio::name report; ///< закрытие подотчёта по отчёту (BURN подотчёта)
|
||||
eosio::name refund; ///< возврат неиспользованного аванса (TRANSFER подотчёт → pool)
|
||||
eosio::name overspend; ///< доплата при перерасходе (TRANSFER pool → подотчёт)
|
||||
};
|
||||
|
||||
static constexpr ExpenseOperationSet EXPENSE_OPERATION_SETS[] = {
|
||||
// Пул программных расходов ЦПП «Благорост» — source_wallet заполняет
|
||||
// capital::createpgexp при создании СЗ через inline expense::createexp.
|
||||
{ ledger2_wallets::PROGRAM_EXPENSE_POOL,
|
||||
operations::expense::BLAGO_ADVANCE,
|
||||
operations::expense::BLAGO_DIRECT,
|
||||
operations::expense::ADVANCE_REPORT,
|
||||
operations::expense::ADVANCE_RETURN,
|
||||
operations::expense::OVERSPEND },
|
||||
};
|
||||
|
||||
static constexpr size_t EXPENSE_OPERATION_SETS_SIZE =
|
||||
sizeof(EXPENSE_OPERATION_SETS) / sizeof(EXPENSE_OPERATION_SETS[0]);
|
||||
|
||||
// Compile-time валидация наборов: каждый код существует в OPERATION_REGISTRY,
|
||||
// тип wallet-операции и привязка кошельков соответствуют роли кода в наборе.
|
||||
namespace ledger2_expense_sets_detail {
|
||||
constexpr const OperationRegistryEntry* find_op(eosio::name code) {
|
||||
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
|
||||
if (OPERATION_REGISTRY[i].code == code) return &OPERATION_REGISTRY[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
constexpr bool source_wallets_unique() {
|
||||
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
|
||||
for (size_t j = i + 1; j < EXPENSE_OPERATION_SETS_SIZE; ++j) {
|
||||
if (EXPENSE_OPERATION_SETS[i].source_wallet == EXPENSE_OPERATION_SETS[j].source_wallet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
constexpr bool sets_consistent() {
|
||||
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
|
||||
const auto& s = EXPENSE_OPERATION_SETS[i];
|
||||
const auto* adv = find_op(s.advance);
|
||||
const auto* dir = find_op(s.direct);
|
||||
const auto* rep = find_op(s.report);
|
||||
const auto* ref = find_op(s.refund);
|
||||
const auto* ovr = find_op(s.overspend);
|
||||
if (!adv || !dir || !rep || !ref || !ovr) return false;
|
||||
// Аванс: пул → кошелёк-подотчёт.
|
||||
if (adv->wallet_op != WalletOp::TRANSFER || adv->wallet_from != s.source_wallet) return false;
|
||||
const eosio::name hold = adv->wallet_to;
|
||||
// Прямая оплата: сжигание с пула (подотчёт не задействован).
|
||||
if (dir->wallet_op != WalletOp::BURN || dir->wallet_from != s.source_wallet) return false;
|
||||
// Отчёт: сжигание подотчёта.
|
||||
if (rep->wallet_op != WalletOp::BURN || rep->wallet_from != hold) return false;
|
||||
// Возврат: подотчёт → пул.
|
||||
if (ref->wallet_op != WalletOp::TRANSFER ||
|
||||
ref->wallet_from != hold || ref->wallet_to != s.source_wallet) return false;
|
||||
// Перерасход: пул → подотчёт (зеркало аванса на сумму доплаты).
|
||||
if (ovr->wallet_op != WalletOp::TRANSFER ||
|
||||
ovr->wallet_from != s.source_wallet || ovr->wallet_to != hold) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace ledger2_expense_sets_detail
|
||||
|
||||
static_assert(ledger2_expense_sets_detail::source_wallets_unique(),
|
||||
"EXPENSE_OPERATION_SETS: source_wallet должен быть уникален");
|
||||
static_assert(ledger2_expense_sets_detail::sets_consistent(),
|
||||
"EXPENSE_OPERATION_SETS: набор операций не согласован с OPERATION_REGISTRY "
|
||||
"(коды/типы wallet-операций/привязка кошельков)");
|
||||
|
||||
/**
|
||||
* @brief Поиск набора операций шасси расходов по кошельку-источнику.
|
||||
*/
|
||||
inline const ExpenseOperationSet* find_expense_operation_set(eosio::name source_wallet) {
|
||||
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
|
||||
if (EXPENSE_OPERATION_SETS[i].source_wallet == source_wallet) {
|
||||
return &EXPENSE_OPERATION_SETS[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ namespace processes {
|
||||
|
||||
// registrator
|
||||
namespace registrator {
|
||||
inline constexpr eosio::name ACCEPT = "p.reg.accept"_n; ///< Приём пайщика в кооператив (o.reg.payent + o.reg.putmin; для потока через совет — o.reg.inpay + o.reg.setmin + o.reg.setent).
|
||||
inline constexpr eosio::name REFUND = "p.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (o.reg.refund). Отдельный процесс: приём взноса прерывается, начинается возврат.
|
||||
inline constexpr eosio::name ACCEPT = "p.reg.accept"_n; ///< Приём пайщика в кооператив (o.reg.payent + o.reg.putmin).
|
||||
}
|
||||
|
||||
// wallet
|
||||
@@ -54,7 +53,6 @@ namespace processes {
|
||||
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
|
||||
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
|
||||
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
|
||||
inline constexpr eosio::name PGEXP = "p.cap.pgexp"_n; ///< Пул программных расходов: пополнение из инвестиций программы (o.cap.pgtop, одноактовый).
|
||||
}
|
||||
|
||||
// marketplace
|
||||
@@ -62,11 +60,6 @@ namespace processes {
|
||||
inline constexpr eosio::name REQUEST = "p.mkt.reqst"_n; ///< Цикл запроса маркетплейса (o.mkt.supply + o.mkt.recv).
|
||||
}
|
||||
|
||||
// expense — шасси расходов (СЗ → авторизация → платёж → отчёт → закрытие)
|
||||
namespace expense {
|
||||
inline constexpr eosio::name PROPOSAL = "p.exp.expns"_n; ///< Цикл расхода по СЗ: o.exp.blgadv|blgdir (платёж) + опц. o.exp.over (перерасход) + o.exp.advrpt (отчёт ADVANCE) + опц. o.exp.advret (возврат). Анкер процесса — proposal_hash.
|
||||
}
|
||||
|
||||
// soviet
|
||||
namespace soviet {
|
||||
inline constexpr eosio::name AXN_CONVERT = "p.sov.axncnv"_n; ///< Конвертация паевого RUB → делегатский ЧВ (одноактовый).
|
||||
|
||||
@@ -49,10 +49,9 @@ struct ledger2_wallets {
|
||||
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.
|
||||
|
||||
// registrator — минимальный паевой + вступительные + суспенс
|
||||
// registrator — минимальный паевой + вступительные
|
||||
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
|
||||
static constexpr eosio::name ENTRANCE_FEES = "w.reg.entry"_n; ///< Вступительные взносы (Cr 86, COOPERATIVE)
|
||||
static constexpr eosio::name REGISTRATION_PENDING = "w.reg.pend"_n; ///< Регистрационный взнос в ожидании решения совета (суспенс счёта 76, USER_SHARED, без сверки соглашений — кандидат ещё не член). o.reg.inpay → сюда (Dr 51/Cr 76); o.reg.setmin/setent уносят на 80/86; o.reg.refund сжигает (Dr 76/Cr 51).
|
||||
|
||||
// soviet — членские (инфраструктура) + делегатские + хоз.расходы + использованные паевые
|
||||
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
|
||||
@@ -65,16 +64,9 @@ struct ledger2_wallets {
|
||||
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (COOPERATIVE — кооперативный пул, без L3-разреза по пайщику; L3-разрез из ADR-009 отменён из-за несовместимости с CRPS-перераспределением, см. wallets.hpp:107)
|
||||
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
|
||||
static constexpr eosio::name PROGRAM_EXPENSE_POOL = "w.cap.pgexp"_n; ///< Пул программных расходов ЦПП «Благорост» (COOPERATIVE) — кооперативный кошелёк, из которого шасси expense оплачивает СЗ; пополняется topupprogexp (o.cap.pgtop), паевые L3-кошельки пайщиков (w.cap.blago) при расходах не трогаются
|
||||
|
||||
// marketplace — выплаты
|
||||
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
|
||||
|
||||
// expense — шасси расходов (подотчёт пайщика, USER_SHARED)
|
||||
// Зеркало паттерна w.wal.wpend: кошелёк-резерв на пайщике-получателе ADVANCE-механики.
|
||||
// На момент выдачи аванса фиксирует ответственность пайщика; на отчёте — BURN без новой бухпроводки
|
||||
// (canal Дт 08 / Кт 51 уже сделан на o.exp.blgadv).
|
||||
static constexpr eosio::name ADVANCE_HOLD = "w.exp.adv"_n; ///< Подотчётные средства пайщика (USER_SHARED; резерв при ADVANCE-механике шасси расходов)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,17 +95,15 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (7) — L3-разрез по пайщику
|
||||
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::ADVANCE_HOLD, "Подотчётные средства пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, "Регистрационный взнос в ожидании решения совета", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3 (Generator + 9 «единых пулов»)
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
@@ -128,7 +118,6 @@ inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::PROGRAM_EXPENSE_POOL, "Пул программных расходов ЦПП «Благорост»", WalletKind::COOPERATIVE },
|
||||
}};
|
||||
|
||||
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
|
||||
@@ -241,46 +230,13 @@ struct Ledger2WalletProgramMapping {
|
||||
uint64_t required_program_id; // 0 = исключение (без проверки)
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 6> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, 0 /* w.reg.minshr — без проверки */ },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК */ },
|
||||
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК */ },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, 4 /* Благорост */ },
|
||||
{ ledger2_wallets::GENERATOR_FUND, 3 /* Генератор */ },
|
||||
{ ledger2_wallets::PREIMP_FUND, 0 /* w.cap.preimp — РИД-учёт до перехода на электронный учёт, без проверки */ },
|
||||
{ ledger2_wallets::ADVANCE_HOLD, 0 /* w.exp.adv — подотчёт пайщика по СЗ; программа-источник проверена контрактом expense, повторная gate не нужна */ },
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
|
||||
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
|
||||
*
|
||||
* Единый источник истины для суммы возврата. При одобрении выхода советом
|
||||
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
|
||||
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
|
||||
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
|
||||
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
|
||||
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
|
||||
* тем, что реально вернёт контракт.
|
||||
*
|
||||
* Состав — только паевые/возвратные USER_SHARED-кошельки:
|
||||
* w.reg.minshr — минимальный паевой взнос;
|
||||
* w.wal.share — целевой паевой взнос (ЦК);
|
||||
* w.cap.blago — паевой взнос в ЦПП «Благорост».
|
||||
*
|
||||
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
|
||||
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
|
||||
* w.cap.preimp (пред-импорт-учёт РИД).
|
||||
*
|
||||
* Каждому не-главному кошельку сета должна соответствовать операция переноса
|
||||
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
|
||||
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
|
||||
*/
|
||||
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
|
||||
ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_wallets::BLAGOROST_FUND,
|
||||
}};
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,10 +50,6 @@ namespace Names {
|
||||
// Коллбэки для расходов
|
||||
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
|
||||
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
|
||||
|
||||
// Программные расходы обслуживаются шасси expense; capital видит только
|
||||
// инициирование (createpgexp) и этот callback завершения flow.
|
||||
constexpr eosio::name ON_PROGRAM_EXPENSE_DONE = "onpgexpdone"_n;
|
||||
|
||||
// Коллбэки для долгов
|
||||
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
|
||||
@@ -84,13 +80,10 @@ namespace Names {
|
||||
// Внешние действия в других контрактах
|
||||
constexpr eosio::name CREATE_OUTPAY = "createoutpay"_n;
|
||||
constexpr eosio::name OPEN_PROGRAM_WALLET = "openprogwall"_n;
|
||||
|
||||
|
||||
// Действия для отправки в soviet
|
||||
constexpr eosio::name CREATE_APPROVAL = "createapprv"_n;
|
||||
constexpr eosio::name CREATE_AGENDA = "createagenda"_n;
|
||||
|
||||
// Шасси expense
|
||||
constexpr eosio::name CREATE_EXPENSE_PROPOSAL = "createexp"_n;
|
||||
}
|
||||
|
||||
namespace SovietActions {
|
||||
@@ -127,7 +120,6 @@ namespace Names {
|
||||
constexpr eosio::name CREATE_INVESTMENT = "createinvest"_n; // акцепт инвестиции
|
||||
constexpr eosio::name CREATE_PROGRAM_INVESTMENT = "createpinv"_n; // акцепт программной инвестиции
|
||||
constexpr eosio::name CREATE_EXPENSE = "createexpnse"_n; // акцепт расхода
|
||||
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n; // акцепт программного расхода
|
||||
constexpr eosio::name CREATE_WITHDRAW_1 = "createwthd1"_n; // акцепт возврата из задания
|
||||
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
|
||||
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
|
||||
|
||||
@@ -19,12 +19,6 @@ using createapprv_interface = void(CREATEAPPRV_SIGNATURE);
|
||||
#define DECLINE_CALLBACK_SIGNATURE name coopname, checksum256 hash, std::string reason
|
||||
using decline_callback_interface = void(DECLINE_CALLBACK_SIGNATURE);
|
||||
|
||||
// Общая ветка отказа решения совета: отправка коллбэка отклонения инициатору
|
||||
// (если решение пришло из внешнего процесса) + удаление решения из таблицы.
|
||||
// Используется при просрочке (cancelexprd) и при достижении отрицательного
|
||||
// консенсуса голосов «против» (voteagainst). Определение — cancelexprd.cpp.
|
||||
void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, const std::string &reason);
|
||||
|
||||
#define AUTHORIZE_CALLBACK_SIGNATURE name coopname, checksum256 hash, document2 authorization
|
||||
using authorize_callback_interface = void(AUTHORIZE_CALLBACK_SIGNATURE);
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include "table_registrator_verification.hpp"
|
||||
#include "table_registrator_candidates.hpp"
|
||||
#include "table_registrator_candidates_legacy.hpp"
|
||||
#include "table_registrator_exits.hpp"
|
||||
|
||||
// coops (soviet)
|
||||
#include "coops_access_helpers.hpp"
|
||||
|
||||
@@ -12,18 +12,3 @@ account get_account_or_fail(eosio::name username) {
|
||||
|
||||
return *account_row;
|
||||
}
|
||||
|
||||
// Возврат аккаунта к состоянию «можно подать заявку заново»: снимаем карточку
|
||||
// участника (type) и сторейджи, которые проставил reguser. Сам аккаунт (username,
|
||||
// ключи) сохраняется — повторная подача идёт на том же аккаунте. Вызывается при
|
||||
// терминальном закрытии кандидата (отказ совета + возврат взноса): после этого
|
||||
// reguser снова проходит проверку type=="" и принимает новое заявление.
|
||||
inline void reset_account_card(eosio::name username) {
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account_row = accounts.find(username.value);
|
||||
if (account_row == accounts.end()) return;
|
||||
accounts.modify(account_row, _registrator, [&](auto &a) {
|
||||
a.type = ""_n;
|
||||
a.storages.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "document_core.hpp"
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @ingroup public_tables
|
||||
* @ingroup public_registrator_tables
|
||||
* @par table: exits (registrator)
|
||||
*
|
||||
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
|
||||
* процесс возврата паевого взноса.
|
||||
*
|
||||
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
|
||||
* wallet::createwthd→authwthd→completewthd):
|
||||
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
|
||||
* - confirmexit → совет одобрил: статус `authorized`, консолидация
|
||||
* минимального паевого на главный (o.reg.mvmin), резерв
|
||||
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
|
||||
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
|
||||
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
|
||||
* запись стирается;
|
||||
* - declinexit → отказ совета или отклонение платежа: снятие резерва
|
||||
* (o.wal.wthdec, если он был сделан) и стирание записи.
|
||||
*
|
||||
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
|
||||
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
|
||||
* ledger2), а не доверяется клиенту.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
|
||||
name username;
|
||||
name coopname;
|
||||
name status; ///< pending | authorized
|
||||
time_point_sec created_at;
|
||||
document2 statement; ///< заявление о выходе (registry 200)
|
||||
document2 approved_statement; ///< решение совета о выходе (авторизация)
|
||||
checksum256 exit_hash;
|
||||
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
|
||||
|
||||
uint64_t primary_key() const { return username.value; }
|
||||
checksum256 by_hash() const { return exit_hash; }
|
||||
};
|
||||
|
||||
typedef multi_index<
|
||||
"exits"_n, exit,
|
||||
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
|
||||
exits_index;
|
||||
|
||||
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
|
||||
exits_index primary_index(_registrator, coopname.value);
|
||||
auto secondary_index = primary_index.get_index<"byhash"_n>();
|
||||
|
||||
auto itr = secondary_index.find(hash);
|
||||
if (itr == secondary_index.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return *itr;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -8,10 +8,10 @@
|
||||
# Источники правды в коде:
|
||||
# • cpp/registrator/registrator.hpp — actions
|
||||
# • cpp/registrator/src/user/{reguser,confirmpay,confirmreg,declinepay,declinereg}.cpp
|
||||
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.inpay, o.reg.setmin, o.reg.setent)
|
||||
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.payent, o.reg.putmin)
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::registrator::ACCEPT
|
||||
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.pend, w.reg.entry, w.reg.minshr
|
||||
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 76 / 80 / 86
|
||||
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.entry, w.reg.minshr
|
||||
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 80 / 86
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
|
||||
@@ -51,9 +51,8 @@ actions:
|
||||
role: progress
|
||||
purpose: >
|
||||
Кассир подтверждает зачисление взносов: карточка переходит в `payed`,
|
||||
повестка `joincoop` с колбэками отправлена в совет. Деньги получены,
|
||||
но взнос ещё не признан — он ставится на расчёты с пайщиком
|
||||
(Дт 51 / Кт 76) и ждёт решения совета.
|
||||
повестка `joincoop` с колбэками отправлена в совет. Учётных проводок
|
||||
ещё нет.
|
||||
|
||||
- name: registrator::declinepay
|
||||
human: Отклонить оплату
|
||||
@@ -61,27 +60,25 @@ actions:
|
||||
role: reject
|
||||
purpose: >
|
||||
Кассир отклоняет платёж: запись кандидата удаляется, операции в книге
|
||||
Ledger2 не создаются (деньги ещё не были приняты). Кандидат может
|
||||
подать заявление повторно.
|
||||
Ledger2 не создаются. Кандидат может подать заявление повторно.
|
||||
|
||||
- name: registrator::confirmreg
|
||||
human: Утвердить советом
|
||||
actor: soviet
|
||||
role: closer
|
||||
purpose: >
|
||||
Совет утверждает приём: статус карточки → `active`, взнос признаётся —
|
||||
сумма с расчётов с пайщиком (счёт 76) переносится на паевой фонд (80)
|
||||
и целевое финансирование (86) операциями `o.reg.setmin` и
|
||||
`o.reg.setent`. Кандидат добавлен в список участников.
|
||||
Совет утверждает приём: статус карточки → `active`, в книге Ledger2
|
||||
одновременно проводятся `o.reg.payent` и `o.reg.putmin`, кандидат
|
||||
добавлен в список участников.
|
||||
|
||||
- name: registrator::declinereg
|
||||
human: Отклонить советом
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Совет отказывает в приёме: процесс приёма взноса прерывается и
|
||||
начинается отдельный процесс возврата «Возврат регистрационного
|
||||
взноса» (p.reg.refund) — полученная сумма возвращается кандидату.
|
||||
Совет отказывает в приёме: запись кандидата удаляется, операции
|
||||
в книге Ledger2 не создаются. Возврат внесённых сумм оформляется
|
||||
отдельно.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
# Составная сущность: статус отслеживается в таблице `candidates` (created / payed),
|
||||
@@ -102,36 +99,23 @@ states:
|
||||
- name: payed
|
||||
human: Взносы оплачены
|
||||
description: >
|
||||
Деньги получены, карточка в `payed`, в совет ушла повестка `joincoop`.
|
||||
Полученная сумма стоит на расчётах с пайщиком (Дт 51 / Кт 76) и ждёт
|
||||
решения совета: при одобрении — признаётся взносом, при отказе —
|
||||
возвращается.
|
||||
Деньги получены, карточка в `payed`, в совет ушла повестка
|
||||
`joincoop`. Проводок ещё нет — они сработают на решении совета.
|
||||
kind: normal
|
||||
|
||||
- name: active
|
||||
human: Пайщик активен
|
||||
description: >
|
||||
`accounts.status` = `active`, карточка кандидата удалена, в книге
|
||||
Ledger2 взнос признан — сумма перенесена со счёта 76 на 80 и 86
|
||||
операциями `o.reg.setmin` и `o.reg.setent`. С этого момента доступны
|
||||
все остальные кооперативные процессы.
|
||||
Ledger2 проведены `o.reg.payent` и `o.reg.putmin`. С этого момента
|
||||
доступны все остальные кооперативные процессы.
|
||||
kind: final
|
||||
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Запись кандидата удалена на этапе оплаты (платёж не поступил/отклонён) —
|
||||
деньги не принимались, проводок не было.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
- name: refunding
|
||||
human: Возврат в процессе
|
||||
description: >
|
||||
Совет отказал кандидату, чьи деньги уже стоят на счёте 76. Процесс
|
||||
приёма прерывается, и управление переходит в отдельный процесс
|
||||
«Возврат регистрационного взноса» (p.reg.refund), где касса проводит
|
||||
возврат и подтверждает его.
|
||||
Запись кандидата удалена, проводок в книге Ledger2 не было.
|
||||
Если взносы успели поступить, возврат оформляется вне процесса.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -149,9 +133,6 @@ transitions:
|
||||
to: payed
|
||||
action: registrator::confirmpay
|
||||
actor: gateway_operator
|
||||
ledger_code: p.reg.accept
|
||||
operations:
|
||||
- o.reg.inpay
|
||||
guards:
|
||||
- Кассир подтвердил зачисление суммы = initial + minimum.
|
||||
|
||||
@@ -168,22 +149,17 @@ transitions:
|
||||
actor: soviet
|
||||
ledger_code: p.reg.accept
|
||||
operations:
|
||||
- o.reg.setmin
|
||||
- o.reg.setent
|
||||
- o.reg.payent
|
||||
- o.reg.putmin
|
||||
guards:
|
||||
- Совет принял положительное решение по повестке.
|
||||
|
||||
- from: payed
|
||||
to: refunding
|
||||
to: removed
|
||||
action: registrator::declinereg
|
||||
actor: soviet
|
||||
operations: []
|
||||
guards:
|
||||
- Совет принял отрицательное решение по повестке.
|
||||
- >
|
||||
Проводок здесь нет: процесс приёма прерывается и управление переходит
|
||||
в отдельный процесс «Возврат регистрационного взноса» (p.reg.refund),
|
||||
где возврат проводится на шаге подтверждения кассой (o.reg.refund).
|
||||
|
||||
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
|
||||
scenario:
|
||||
@@ -214,37 +190,33 @@ scenario:
|
||||
actor: gateway_operator
|
||||
action: registrator::confirmpay
|
||||
description: >
|
||||
Деньги пришли — встают на расчёты с пайщиком, карточка движется к совету.
|
||||
Деньги пришли — карточка движется к совету.
|
||||
|
||||
Кандидат оплачивает счёт (банковский перевод или криптоплатёж).
|
||||
Кассир, получив подтверждение зачисления от платёжной системы,
|
||||
вызывает `confirmpay`. Контракт переводит кандидата в статус
|
||||
`payed`, создаёт повестку в совете с колбэками
|
||||
`confirmreg` / `declinereg` и ставит полученную сумму на учёт
|
||||
операцией `o.reg.inpay` (Дт 51 / Кт 76) — деньги получены, но
|
||||
взнос ещё не признан и ждёт решения совета.
|
||||
`payed` и создаёт повестку в совете с колбэками
|
||||
`confirmreg` / `declinereg`. Учётных проводок ещё нет.
|
||||
pre:
|
||||
- Кандидат в статусе `created`.
|
||||
- На расчётный счёт получена сумма initial + minimum.
|
||||
post:
|
||||
- Кандидат в статусе `payed`.
|
||||
- В совете открыта повестка `joincoop`.
|
||||
- В книге Ledger2 проведена o.reg.inpay (Дт 51 / Кт 76).
|
||||
|
||||
- step: 3
|
||||
title: Утверждение советом
|
||||
actor: soviet
|
||||
action: registrator::confirmreg
|
||||
description: >
|
||||
Совет утверждает приём — взнос признаётся, кандидат становится пайщиком.
|
||||
Совет утверждает приём — два взноса встают на учёт, кандидат становится пайщиком.
|
||||
|
||||
Совет рассматривает повестку `joincoop`. При положительном
|
||||
решении контракт `confirmreg`: (1) меняет `accounts.status`
|
||||
на `active`; (2) добавляет нового пайщика в список участников
|
||||
совета (`soviet::addpartcpnt`); (3) признаёт взнос — переносит
|
||||
сумму с расчётов с пайщиком (76) на паевой фонд и целевое
|
||||
финансирование двумя операциями: `o.reg.setmin` (минимальный
|
||||
паевой, Дт 76 / Кт 80) и `o.reg.setent` (вступительный, Дт 76 / Кт 86).
|
||||
совета (`soviet::addpartcpnt`); (3) применяет в книге Ledger2
|
||||
две операции — `o.reg.payent` (вступительный взнос, Дт 51 / Кт 86)
|
||||
и `o.reg.putmin` (минимальный паевой взнос, Дт 51 / Кт 80).
|
||||
Запись кандидата удаляется.
|
||||
pre:
|
||||
- Кандидат в статусе `payed`.
|
||||
@@ -252,7 +224,7 @@ scenario:
|
||||
post:
|
||||
- accounts.status = `active` — пайщик стал активным членом кооператива.
|
||||
- Пайщик добавлен в soviet::participants.
|
||||
- В книге Ledger2 проведены o.reg.setmin + o.reg.setent.
|
||||
- В книге Ledger2 проведены o.reg.payent + o.reg.putmin.
|
||||
- Запись кандидата удалена из таблицы candidates.
|
||||
|
||||
alternatives:
|
||||
@@ -264,8 +236,8 @@ scenario:
|
||||
Платёж не прошёл или был отменён.
|
||||
|
||||
Кассир вызывает `declinepay`, кандидат удаляется, операции
|
||||
в книге Ledger2 не создаются (деньги ещё не приняты). Кандидат
|
||||
может повторить подачу заявления.
|
||||
в книге Ledger2 не создаются. Кандидат может повторить подачу
|
||||
заявления.
|
||||
|
||||
- branch: Отказ совета
|
||||
at_step: 3
|
||||
@@ -274,11 +246,9 @@ scenario:
|
||||
description: >
|
||||
Совет отказывает кандидату.
|
||||
|
||||
Процесс приёма взноса прерывается и начинается отдельный процесс
|
||||
«Возврат регистрационного взноса» (p.reg.refund): кандидат переводится
|
||||
в состояние возврата, кассе поручается вернуть деньги. Сама проводка
|
||||
Дт 76 / Кт 51 и удаление кандидата происходят там — на шаге, когда
|
||||
касса фактически проведёт возврат и подтвердит его.
|
||||
Запись кандидата удаляется, совершённый платёж подлежит возврату
|
||||
(возврат оформляется вне этого процесса). Операции в книге Ledger2
|
||||
не создаются.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -295,72 +265,39 @@ documents:
|
||||
stored_in: "(authorization — в параметре действия, не хранится в candidates после удаления записи)"
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
# Процесс мульти-операционный: три записи OPERATION_REGISTRY с process_type = p.reg.accept.
|
||||
# Двухфазный учёт: на приёме денег (confirmpay) сумма ставится на расчёты с
|
||||
# пайщиком (76), на одобрении совета (confirmreg) — признаётся взносом (80/86).
|
||||
#
|
||||
# ПЕРЕХОДНЫЙ ПЕРИОД (до 30.07.2026): кандидаты, чей платёж был принят ДО релиза
|
||||
# двухфазного учёта, не имеют суспенса на счёте 76. Для них одобрение совета
|
||||
# проводится по старому одношаговому пути — прямой ISSUE Дт 51 / Кт 80
|
||||
# (o.reg.putmin) и Дт 51 / Кт 86 (o.reg.payent), без переноса с 76. Признак —
|
||||
# отсутствие баланса кандидата на w.reg.pend (см. registration_migration.hpp).
|
||||
# Процесс мульти-операционный: две записи OPERATION_REGISTRY с process_type = p.reg.accept.
|
||||
# Обе срабатывают на закрывающем действии confirmreg.
|
||||
operations:
|
||||
- ledger_code: o.reg.inpay
|
||||
human_name: Приём регистрационного взноса в ожидание решения совета
|
||||
- ledger_code: o.reg.payent
|
||||
human_name: Вступительный взнос пайщика
|
||||
wallet_op: ISSUE
|
||||
wallet_from: ''
|
||||
wallet_to: w.reg.pend
|
||||
debit: 51
|
||||
credit: 76
|
||||
amount_ref: candidate.initial + candidate.minimum
|
||||
triggered_by: registrator::confirmpay
|
||||
description: >
|
||||
Постановка полученного регистрационного взноса на учёт. Сумма
|
||||
«вступительный + минимальный паевой» зачисляется на кошелёк
|
||||
«Регистрационный взнос в ожидании решения совета» (w.reg.pend);
|
||||
двойная запись Дт 51 / Кт 76 — деньги на расчётном счёте, обязательство
|
||||
перед пайщиком на счёте расчётов. Признание или возврат — позже.
|
||||
|
||||
- ledger_code: o.reg.setmin
|
||||
human_name: Зачисление минимального паевого взноса по решению совета
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.reg.pend
|
||||
wallet_to: w.reg.minshr
|
||||
debit: 76
|
||||
credit: 80
|
||||
amount_ref: candidate.minimum
|
||||
triggered_by: registrator::confirmreg
|
||||
description: >
|
||||
Признание минимального паевого взноса по решению совета. Сумма
|
||||
`candidate.minimum` переносится с кошелька ожидания (w.reg.pend) на
|
||||
«Минимальный паевой взнос» (w.reg.minshr); двойная запись Дт 76 / Кт 80 —
|
||||
стартовый вклад в паевой фонд.
|
||||
|
||||
- ledger_code: o.reg.setent
|
||||
human_name: Зачисление вступительного взноса по решению совета
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.reg.pend
|
||||
wallet_to: w.reg.entry
|
||||
debit: 76
|
||||
debit: 51
|
||||
credit: 86
|
||||
amount_ref: candidate.initial
|
||||
triggered_by: registrator::confirmreg
|
||||
description: >
|
||||
Признание вступительного взноса по решению совета. Сумма
|
||||
`candidate.initial` переносится с кошелька ожидания (w.reg.pend) на
|
||||
«Вступительные взносы» (w.reg.entry); двойная запись Дт 76 / Кт 86 —
|
||||
целевые поступления в кооперативный фонд.
|
||||
Постановка вступительного взноса на учёт. Сумма `candidate.initial`
|
||||
зачисляется на кошелёк «Вступительные взносы» (w.reg.entry); двойная
|
||||
запись Дт 51 / Кт 86 — целевые поступления в кооперативный фонд.
|
||||
|
||||
- ledger_code: o.reg.putmin
|
||||
human_name: Минимальный паевой взнос при регистрации
|
||||
wallet_op: ISSUE
|
||||
wallet_from: ''
|
||||
wallet_to: w.reg.minshr
|
||||
debit: 51
|
||||
credit: 80
|
||||
amount_ref: candidate.minimum
|
||||
triggered_by: registrator::confirmreg
|
||||
description: >
|
||||
Парная операция к `o.reg.payent`. Сумма `candidate.minimum`
|
||||
появляется на кошельке «Минимальный паевой взнос» (w.reg.minshr);
|
||||
двойная запись Дт 51 / Кт 80 — стартовый вклад в паевой фонд.
|
||||
|
||||
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
|
||||
related:
|
||||
- process_type: p.reg.refund
|
||||
id: public_registrator_refund_process
|
||||
relation: triggers
|
||||
note: >
|
||||
При отрицательном решении совета процесс приёма прерывается и
|
||||
запускается «Возврат регистрационного взноса» (p.reg.refund): сумма,
|
||||
стоящая на расчётах с пайщиком (счёт 76), возвращается кандидату.
|
||||
|
||||
- process_type: p.wal.depo
|
||||
id: public_wallet_deposit_process
|
||||
relation: triggers
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Стандарт «Возврат регистрационного взноса» — процесс возврата кандидату
|
||||
# полученного регистрационного взноса при отказе совета в приёме.
|
||||
#
|
||||
# Это самостоятельный процесс: приём пайщика (p.reg.accept) прерывается на
|
||||
# решении совета, и вместо признания взноса начинается его возврат. Возврат
|
||||
# идёт в два шага: совет выносит отказ и поручает кассе вернуть деньги, а
|
||||
# списание со счёта происходит только после того, как касса фактически проведёт
|
||||
# исходящий перевод кандидату и подтвердит это. До подтверждения деньги стоят
|
||||
# на расчётах с пайщиком (счёт 76).
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/registrator/registrator.hpp — действия declinereg / refundpay
|
||||
# • cpp/registrator/src/user/declinereg.cpp — отказ совета, создание исходящего возврата
|
||||
# • cpp/registrator/src/user/refundpay.cpp — подтверждение возврата кассой, проводка и закрытие
|
||||
# • cpp/registrator/src/user/declinerfnd.cpp — страж: отмена возврата невозможна
|
||||
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.refund)
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::registrator::REFUND
|
||||
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.pend
|
||||
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 76
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
|
||||
process_type: p.reg.refund
|
||||
id: public_registrator_refund_process
|
||||
title: Возврат регистрационного взноса
|
||||
slug: refund
|
||||
status: proposed
|
||||
contract: registrator
|
||||
summary: >
|
||||
Когда совет отказывает кандидату в приёме, полученный регистрационный
|
||||
взнос возвращается ему: совет поручает возврат, касса проводит перевод и
|
||||
подтверждает его, после чего запись кандидата закрывается.
|
||||
purpose: >
|
||||
«Возврат регистрационного взноса» — терминальный процесс приёма пайщика
|
||||
по отрицательному решению. Полученные кассой деньги стоят на расчётах
|
||||
с пайщиком (счёт 76) в ожидании решения; при отказе совета они
|
||||
возвращаются кандидату. Возврат денег не может быть отменён — права
|
||||
кандидата на свои средства безусловны.
|
||||
roles:
|
||||
- contributor # несостоявшийся пайщик (получатель возврата)
|
||||
- soviet # совет кооператива (выносит отказ и поручает возврат)
|
||||
- chairman # председатель / кассир (проводит и подтверждает возврат)
|
||||
|
||||
# ── Секция 2. Действия контракта (блокчейн-уровень) ─────────────────────────
|
||||
actions:
|
||||
- name: registrator::declinereg
|
||||
human: Отклонить советом
|
||||
actor: soviet
|
||||
role: opener
|
||||
purpose: >
|
||||
Совет отказывает кандидату в приёме. Процесс приёма прерывается:
|
||||
контракт переводит кандидата в состояние возврата и поручает кассе
|
||||
вернуть полученный взнос. Деньги остаются на расчётах с пайщиком
|
||||
(счёт 76) — со счёта они ещё не уходят.
|
||||
|
||||
- name: registrator::refundpay
|
||||
human: Подтвердить возврат
|
||||
actor: chairman
|
||||
role: closer
|
||||
purpose: >
|
||||
Касса фактически провела исходящий перевод денег кандидату и
|
||||
подтверждает это. Контракт проводит возврат обратной проводкой
|
||||
(Дт 76 / Кт 51) операцией `o.reg.refund` и закрывает запись кандидата.
|
||||
После этого аккаунт освобождается для повторной подачи заявления на
|
||||
том же аккаунте: несостоявшийся пайщик может подать заявку заново.
|
||||
|
||||
- name: registrator::declinerfnd
|
||||
human: Отклонить возврат (недопустимо)
|
||||
actor: chairman
|
||||
role: guard
|
||||
purpose: >
|
||||
Отмена возврата регистрационного взноса невозможна — действие всегда
|
||||
завершается ошибкой. Существует только как обязательная развилка
|
||||
процессинга; в интерфейсе кассы кнопки отклонения возврата нет.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: "registrator::candidate"
|
||||
entity_human: "Кандидат"
|
||||
entity_source: cpp/registrator/src/user/declinereg.cpp
|
||||
|
||||
states:
|
||||
- name: payed
|
||||
human: Взносы оплачены
|
||||
description: >
|
||||
Входное состояние процесса: деньги получены и стоят на расчётах
|
||||
с пайщиком (w.reg.pend, счёт 76), повестка `joincoop` рассмотрена
|
||||
советом с отрицательным решением.
|
||||
kind: normal
|
||||
|
||||
- name: refunding
|
||||
human: Возврат в процессе
|
||||
description: >
|
||||
Совет отказал и поручил кассе вернуть взнос. Создан исходящий платёж
|
||||
возврата; деньги по-прежнему стоят на расчётах с пайщиком (w.reg.pend,
|
||||
счёт 76) и ждут, пока касса фактически проведёт перевод и подтвердит его.
|
||||
kind: normal
|
||||
|
||||
- name: refunded
|
||||
human: Взнос возвращён
|
||||
description: >
|
||||
Касса подтвердила исходящий перевод. В книге Ledger2 проведена
|
||||
обратная операция `o.reg.refund` (Дт 76 / Кт 51) — обязательство перед
|
||||
кандидатом закрыто, деньги ушли со счёта. Запись кандидата удалена.
|
||||
kind: final
|
||||
|
||||
transitions:
|
||||
- from: payed
|
||||
to: refunding
|
||||
action: registrator::declinereg
|
||||
actor: soviet
|
||||
operations: []
|
||||
guards:
|
||||
- Совет принял отрицательное решение по повестке `joincoop`.
|
||||
- Кандидат в статусе `payed` (деньги были получены и стоят на счёте 76).
|
||||
|
||||
- from: refunding
|
||||
to: refunded
|
||||
action: registrator::refundpay
|
||||
actor: chairman
|
||||
ledger_code: p.reg.refund
|
||||
operations:
|
||||
- o.reg.refund
|
||||
guards:
|
||||
- Касса фактически провела исходящий перевод денег кандидату.
|
||||
- Кандидат в статусе `refunding`.
|
||||
|
||||
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
|
||||
scenario:
|
||||
steps:
|
||||
- step: 1
|
||||
title: Отказ совета и поручение на возврат
|
||||
actor: soviet
|
||||
action: registrator::declinereg
|
||||
description: >
|
||||
Совет рассматривает повестку `joincoop` и принимает отрицательное
|
||||
решение. Процесс приёма прерывается: кандидат переводится в состояние
|
||||
«возврат в процессе», и кассе поручается вернуть полученный
|
||||
регистрационный взнос. Деньги остаются на расчётах с пайщиком
|
||||
(w.reg.pend, счёт 76) — со счёта они ещё не уходят. Кандидат получает
|
||||
уведомление об отказе.
|
||||
pre:
|
||||
- Кандидат в статусе `payed` — деньги получены и стоят на счёте 76.
|
||||
- Совет принял отрицательное решение.
|
||||
post:
|
||||
- Кандидат в статусе `refunding`.
|
||||
- Создан исходящий платёж возврата на сумму взноса.
|
||||
- Деньги по-прежнему на счёте 76 (проводки ещё нет).
|
||||
|
||||
- step: 2
|
||||
title: Касса возвращает деньги и подтверждает
|
||||
actor: chairman
|
||||
action: registrator::refundpay
|
||||
description: >
|
||||
Касса проводит фактический перевод денег кандидату и подтверждает,
|
||||
что возврат выполнен. Контракт возвращает полученный регистрационный
|
||||
взнос обратной проводкой `o.reg.refund` (Дт 76 / Кт 51): сумма
|
||||
«вступительный + минимальный паевой» списывается с кошелька ожидания
|
||||
(w.reg.pend), обязательство перед кандидатом закрывается, деньги
|
||||
уходят со счёта. Запись кандидата удаляется. Отменить возврат нельзя.
|
||||
pre:
|
||||
- Кандидат в статусе `refunding`.
|
||||
- Касса фактически провела исходящий перевод кандидату.
|
||||
post:
|
||||
- В книге Ledger2 проведена o.reg.refund (Дт 76 / Кт 51).
|
||||
- Запись кандидата удалена из картотеки кандидатов.
|
||||
- Кандидат получил свои деньги.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
- action: registrator::declinereg
|
||||
title: Решение совета об отказе в приёме пайщика
|
||||
registry_id: 501
|
||||
signed_by: [ Совет ]
|
||||
stored_in: "(решение совета — в повестке joincoop)"
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
# Возврат проводится одной операцией o.reg.refund — на шаге подтверждения кассой
|
||||
# (registrator::refundpay), не на решении совета. Отмена возврата невозможна
|
||||
# (действие-страж declinerfnd всегда завершается ошибкой).
|
||||
#
|
||||
# ПЕРЕХОДНЫЙ ПЕРИОД (до 30.07.2026): кандидаты, чей платёж был принят ДО релиза
|
||||
# двухфазного учёта, суспенса на счёте 76 не имеют — для них on-chain проводки
|
||||
# возврата нет (как было раньше), деньги возвращает бэкенд банковским переводом,
|
||||
# а запись кандидата закрывается сразу при отказе совета. Операция o.reg.refund
|
||||
# проводится только при наличии баланса на w.reg.pend.
|
||||
operations:
|
||||
- ledger_code: o.reg.refund
|
||||
human_name: Возврат регистрационного взноса при отказе совета
|
||||
wallet_op: BURN
|
||||
wallet_from: w.reg.pend
|
||||
wallet_to: ''
|
||||
debit: 76
|
||||
credit: 51
|
||||
amount_ref: candidate.initial + candidate.minimum
|
||||
triggered_by: registrator::refundpay
|
||||
description: >
|
||||
Возврат полученного регистрационного взноса. Сумма «вступительный +
|
||||
минимальный паевой» списывается с кошелька «Регистрационный взнос
|
||||
в ожидании решения совета» (w.reg.pend); двойная запись Дт 76 / Кт 51 —
|
||||
закрытие обязательства перед кандидатом и уход денег с расчётного
|
||||
счёта. Получателя на цепи нет — деньги фактически переводит касса.
|
||||
|
||||
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
|
||||
related:
|
||||
- process_type: p.reg.accept
|
||||
id: public_registrator_accept_process
|
||||
relation: interrupts
|
||||
note: >
|
||||
Возврат — отрицательная развилка процесса «Приём пайщика»
|
||||
(p.reg.accept). Деньги, поставленные на расчёты с пайщиком операцией
|
||||
`o.reg.inpay` на этапе оплаты, при отказе совета не признаются
|
||||
взносом, а возвращаются этим процессом.
|
||||
@@ -8,16 +8,8 @@
|
||||
#include "src/user/confirmreg.cpp"
|
||||
#include "src/user/declinereg.cpp"
|
||||
#include "src/user/declinepay.cpp"
|
||||
#include "src/user/refundpay.cpp"
|
||||
#include "src/user/declinerfnd.cpp"
|
||||
#include "src/user/reguser.cpp"
|
||||
|
||||
#include "src/exit/exit_helpers.hpp"
|
||||
#include "src/exit/exitcoop.cpp"
|
||||
#include "src/exit/confirmexit.cpp"
|
||||
#include "src/exit/completexit.cpp"
|
||||
#include "src/exit/declinexit.cpp"
|
||||
|
||||
#include "src/account/createbranch.cpp"
|
||||
#include "src/account/newaccount.cpp"
|
||||
#include "src/account/updateaccnt.cpp"
|
||||
|
||||
@@ -85,17 +85,9 @@ public:
|
||||
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
|
||||
|
||||
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
|
||||
|
||||
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
|
||||
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
|
||||
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
|
||||
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
|
||||
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
|
||||
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void refundpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinerfnd(name coopname, checksum256 registration_hash, std::string reason);
|
||||
|
||||
|
||||
[[eosio::action]] void createbranch(eosio::name coopname, eosio::name braname);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @brief Завершение возврата паевого взноса при выходе из кооператива.
|
||||
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
|
||||
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
|
||||
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
|
||||
* Возврат назад невозможен — повторное участие только через новую регистрацию.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
|
||||
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
|
||||
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @brief Одобрение советом выхода пайщика из кооператива.
|
||||
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
|
||||
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
|
||||
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
|
||||
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
|
||||
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
|
||||
* без платежа.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param authorization Документ-решение совета о выходе
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
|
||||
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
|
||||
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
|
||||
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
|
||||
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
|
||||
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
|
||||
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
|
||||
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
|
||||
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
|
||||
if (balance.amount <= 0) continue;
|
||||
total_return += balance;
|
||||
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
|
||||
}
|
||||
|
||||
exits.modify(e, _soviet, [&](auto &row) {
|
||||
row.status = "authorized"_n;
|
||||
row.approved_statement = authorization;
|
||||
row.quantity = total_return;
|
||||
});
|
||||
|
||||
if (total_return.amount > 0) {
|
||||
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
|
||||
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
|
||||
total_return, username, exit_hash, memo_req);
|
||||
|
||||
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
|
||||
Action::send<createoutpay_interface>(
|
||||
_gateway,
|
||||
"createoutpay"_n,
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
exit_hash,
|
||||
total_return,
|
||||
_registrator,
|
||||
"completexit"_n,
|
||||
"declinexit"_n
|
||||
);
|
||||
} else {
|
||||
// Возвращать нечего — финализируем выход без платежа.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
exits.erase(e);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @brief Отказ в выходе из кооператива.
|
||||
* Вызывается либо советом при отклонении заявления (до резервирования средств),
|
||||
* либо gateway при отклонении исходящего платежа (после резервирования). Если
|
||||
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
|
||||
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
|
||||
*/
|
||||
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
|
||||
check_auth_and_get_payer_or_fail({_soviet, _gateway});
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
|
||||
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
|
||||
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
|
||||
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
|
||||
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
|
||||
if (e->status == "authorized"_n && e->quantity.amount > 0) {
|
||||
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../../../lib/index.hpp"
|
||||
|
||||
/**
|
||||
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
|
||||
*
|
||||
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
|
||||
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
|
||||
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
|
||||
* весь lib/index.hpp уже доступен.
|
||||
*/
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
|
||||
*
|
||||
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
|
||||
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
|
||||
* валюте управления.
|
||||
*/
|
||||
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
|
||||
userwallets_index userwallets(_ledger2, coopname.value);
|
||||
auto idx = userwallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(wallet_name.value, username.value));
|
||||
if (it == idx.end()) {
|
||||
return asset(0, _root_govern_symbol);
|
||||
}
|
||||
return it->available;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Консолидация доступного паевого кошелька пайщика на главный
|
||||
* (`w.wal.share`) перед резервом возврата при выходе.
|
||||
*
|
||||
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
|
||||
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
|
||||
* главный:
|
||||
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
|
||||
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
|
||||
*
|
||||
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
|
||||
* валит транзакцию с явным сообщением — защита от тихой потери средств.
|
||||
*/
|
||||
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
|
||||
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
|
||||
|
||||
eosio::name op;
|
||||
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
|
||||
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
|
||||
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
|
||||
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
|
||||
} else {
|
||||
eosio::check(false,
|
||||
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
|
||||
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
|
||||
}
|
||||
|
||||
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
|
||||
wallet_name.to_string() + ", username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
|
||||
* аккаунта в registrator.
|
||||
*
|
||||
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
|
||||
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
|
||||
* для пайщика начинает падать — он лишён права подавать заявления.
|
||||
*/
|
||||
inline void finalize_member_exit(name coopname, name username) {
|
||||
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
|
||||
action(
|
||||
permission_level{_registrator, "active"_n},
|
||||
_soviet,
|
||||
"delpartcpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
|
||||
// блокируем аккаунт в картотеке registrator
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account = accounts.find(username.value);
|
||||
eosio::check(account != accounts.end(), "Аккаунт не найден");
|
||||
accounts.modify(account, _registrator, [&](auto &a) {
|
||||
a.status = "blocked"_n;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @brief Подача заявления на выход из кооператива.
|
||||
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
|
||||
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
|
||||
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Имя пайщика, выходящего из кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param statement Подписанное заявление о выходе (registry 200)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
// выйти может только действующий пайщик (не заблокированный)
|
||||
get_participant_or_fail(coopname, username);
|
||||
|
||||
// повторная подача запрещена — у пайщика может быть только один процесс выхода
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto existing = exits.find(username.value);
|
||||
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
|
||||
|
||||
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
|
||||
|
||||
// проверяем подпись заявления о выходе
|
||||
verify_document_or_fail(statement);
|
||||
|
||||
exits.emplace(coopname, [&](auto &e) {
|
||||
e.username = username;
|
||||
e.coopname = coopname;
|
||||
e.status = "pending"_n;
|
||||
e.created_at = current_time_point();
|
||||
e.statement = statement;
|
||||
e.exit_hash = exit_hash;
|
||||
e.quantity = asset(0, _root_govern_symbol);
|
||||
});
|
||||
|
||||
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
|
||||
::Soviet::create_agenda(
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("leavecoop"_n),
|
||||
exit_hash,
|
||||
_registrator, // callback_contract
|
||||
"confirmexit"_n, // callback при одобрении
|
||||
"declinexit"_n, // callback при отказе
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#include <array>
|
||||
|
||||
/**
|
||||
* @brief Миграция данных контракта.
|
||||
* Обновляет счетчики активных пайщиков для всех кооперативов
|
||||
@@ -42,56 +40,4 @@
|
||||
coop.active_participants_count = active_count;
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Разовая очистка ФАНТОМНЫХ пайщиков кооператива fgrtejiwnynn (инцидент
|
||||
// 2026-06-04). Причина: install.interactor прогнан 3× (2 провала + успех);
|
||||
// каждый прогон минтил на цепи новые аккаунты совета (adduser) со случайным
|
||||
// username, а откат в catch чистил только off-chain. На цепи осели 6 дублей-
|
||||
// аккаунтов одних и тех же 3 учредителей (см. ~/gorozhane-dup-install-cleanup.md
|
||||
// и ~/fixes.md). Удаляем записи registrator::accounts и пересчитываем счётчик
|
||||
// активных пайщиков из soviet::participants (источник истины).
|
||||
//
|
||||
// Список username — единственный авторитет (дедуп off-chain по ФИО невозможен
|
||||
// on-chain — личных данных в registrator::accounts нет). ОДИН И ТОТ ЖЕ список
|
||||
// продублирован в soviet::migrate и ledger2::migrate — править синхронно.
|
||||
// Идемпотентно: повторный прогон не находит записей → no-op.
|
||||
{
|
||||
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
|
||||
const std::array<eosio::name, 6> PHANTOMS = {
|
||||
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
|
||||
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
|
||||
};
|
||||
|
||||
auto pcoop = coops2.find(PHANTOM_COOP.value);
|
||||
if (pcoop != coops2.end() && pcoop->is_cooperative) {
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
|
||||
for (const auto& u : PHANTOMS) {
|
||||
auto acc = accounts.find(u.value);
|
||||
if (acc == accounts.end()) continue; // уже удалён / отсутствует
|
||||
// Дедуп-гард: трогаем только аккаунты, зарегистрированные ЭТИМ коопом.
|
||||
if (acc->registrator != PHANTOM_COOP) continue;
|
||||
accounts.erase(acc);
|
||||
}
|
||||
|
||||
// Пересчитываем счётчик активных пайщиков из реестра soviet::participants
|
||||
// (источник истины), ИСКЛЮЧАЯ фантомов явно — счётчик сходится к верному
|
||||
// значению за ОДИН деплой независимо от порядка выполнения migrate
|
||||
// (registrator::migrate vs soviet::migrate). Если бы считали всех has_vote,
|
||||
// а soviet ещё не удалил фантомов — счётчик временно завысился бы.
|
||||
participants_index pparts(_soviet, PHANTOM_COOP.value);
|
||||
uint64_t active = 0;
|
||||
for (auto p = pparts.begin(); p != pparts.end(); ++p) {
|
||||
bool is_phantom = false;
|
||||
for (const auto& ph : PHANTOMS) {
|
||||
if (ph == p->username) { is_phantom = true; break; }
|
||||
}
|
||||
if (!is_phantom && p->has_vote) active++;
|
||||
}
|
||||
coops2.modify(pcoop, _registrator, [&](auto& coop) {
|
||||
coop.active_participants_count = active;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,7 @@ void registrator::confirmpay(name coopname, checksum256 registration_hash){
|
||||
candidates.modify(candidate, _registrator, [&](auto &c){
|
||||
c.status = "payed"_n;
|
||||
});
|
||||
|
||||
// Деньги получены кассой, но взнос ещё не признан — ставим на расчёты с
|
||||
// пайщиком (Dr 51 / Cr 76, ISSUE w.reg.pend) до решения совета. Сумма —
|
||||
// вступительный + минимальный паевой. Признание/возврат — в confirmreg/declinereg.
|
||||
eosio::asset registration_quantity = candidate -> initial + candidate -> minimum;
|
||||
std::string memo = "Приём регистрационного взноса в ожидание решения совета от username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::RECEIVE_PAYMENT, registration_quantity, candidate -> username, registration_hash, memo);
|
||||
|
||||
|
||||
action(permission_level{ _registrator, "active"_n}, _soviet, "createagenda"_n,
|
||||
std::make_tuple(
|
||||
coopname,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include "registration_migration.hpp"
|
||||
|
||||
/**
|
||||
* @brief Подтверждение регистрации пользователя.
|
||||
* Подтверждает регистрацию кандидата советом и добавляет его в кооператив
|
||||
@@ -47,28 +45,11 @@ void registrator::confirmreg(eosio::name coopname, checksum256 registration_hash
|
||||
)
|
||||
).send();
|
||||
|
||||
// Совет одобрил — признаём взнос. Гарды amount>0: у кооператива может не быть
|
||||
// вступительного (initial==0) — ledger2::apply падает на нулевой сумме.
|
||||
//
|
||||
// MIGRATION (снять условие после 30.07.2026, см. registration_migration.hpp):
|
||||
// для кандидатов, принятых ДО релиза двухфазного учёта (нет баланса на
|
||||
// w.reg.pend), признаём по старому пути — прямой ISSUE (Dr 51 / Cr 80|86);
|
||||
// для новых — переносим суспенс с 76 на 80/86 (TRANSFER w.reg.pend → ...).
|
||||
bool has_pending = Registrator::get_registration_pending_balance(coopname, candidate -> username).amount > 0;
|
||||
std::string memo = "Минимальный паевой взнос при вступлении пайщика с username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::PUT_MINSHARE, candidate -> minimum, candidate -> username, registration_hash, memo);
|
||||
|
||||
if (candidate -> minimum.amount > 0) {
|
||||
std::string memo = "Зачисление минимального паевого взноса по решению совета, username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname,
|
||||
has_pending ? operations::registrator::SETTLE_MINSHARE : operations::registrator::PUT_MINSHARE,
|
||||
candidate -> minimum, candidate -> username, registration_hash, memo);
|
||||
}
|
||||
|
||||
if (candidate -> initial.amount > 0) {
|
||||
std::string memo = "Зачисление вступительного взноса по решению совета, username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname,
|
||||
has_pending ? operations::registrator::SETTLE_ENTRANCE : operations::registrator::PAY_ENTRANCE,
|
||||
candidate -> initial, candidate -> username, registration_hash, memo);
|
||||
}
|
||||
memo = "Вступительный взнос при вступлении пайщика с username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::PAY_ENTRANCE, candidate -> initial, candidate -> username, registration_hash, memo);
|
||||
// Увеличиваем счетчик активных пайщиков
|
||||
cooperatives2_index cooperatives(_registrator, _registrator.value);
|
||||
auto coop_itr = cooperatives.find(coopname.value);
|
||||
|
||||
@@ -14,7 +14,7 @@ void registrator::declinepay(name coopname, checksum256 registration_hash, std::
|
||||
|
||||
auto exist_candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
|
||||
|
||||
eosio::check(exist_candidate.has_value(), "Кандидат не найден");
|
||||
eosio::check(!exist_candidate.has_value(), "Кандидат не найден");
|
||||
|
||||
Registrator::candidates_index candidates(_registrator, coopname.value);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include "registration_migration.hpp"
|
||||
|
||||
/**
|
||||
* @brief Отклонение регистрации пользователя.
|
||||
* Отклоняет регистрацию кандидата советом
|
||||
@@ -12,53 +10,5 @@
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::declinereg(name coopname, checksum256 registration_hash, std::string reason) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
|
||||
eosio::check(candidate.has_value(), "Кандидат не найден");
|
||||
|
||||
// оповещаем кандидата об отказе совета
|
||||
require_recipient(candidate -> username);
|
||||
|
||||
// Совет отказал — процесс приёма взноса прерывается, начинается процесс
|
||||
// возврата (p.reg.refund). Деньги НЕ списываем здесь: они стоят на расчётах
|
||||
// с пайщиком (счёт 76, кошелёк w.reg.pend) до тех пор, пока касса фактически
|
||||
// не проведёт исходящий возврат. Обратную проводку Дт 76 / Кт 51 выполняет
|
||||
// коллбэк refundpay по подтверждению кассы (gateway::outcomplete) — симметрично
|
||||
// возврату паевого в контракте 'wallet' (authwthd → createoutpay → completewthd).
|
||||
//
|
||||
// MIGRATION (снять условие после 30.07.2026, см. registration_migration.hpp):
|
||||
// кандидаты, принятые ДО релиза двухфазного учёта, суспенса на w.reg.pend
|
||||
// не имеют — для них on-chain возврата нет (как было раньше), банковский
|
||||
// возврат оформляет бэкенд, а запись кандидата закрываем сразу.
|
||||
eosio::asset pending = Registrator::get_registration_pending_balance(coopname, candidate -> username);
|
||||
|
||||
Registrator::candidates_index candidates(_registrator, coopname.value);
|
||||
auto it = candidates.find(candidate -> username.value);
|
||||
|
||||
if (pending.amount > 0) {
|
||||
// Новый путь: переводим кандидата в возврат и создаём исходящий платёж в
|
||||
// gateway с коллбэком после подтверждения кассой. Деньги остаются на счёте 76
|
||||
// вплоть до фактического проведения возврата кассой (refundpay).
|
||||
candidates.modify(it, _registrator, [&](auto &c){
|
||||
c.status = "refunding"_n;
|
||||
});
|
||||
|
||||
action(permission_level{ _registrator, "active"_n}, _gateway, "createoutpay"_n,
|
||||
std::make_tuple(
|
||||
coopname,
|
||||
candidate -> username,
|
||||
registration_hash,
|
||||
pending,
|
||||
_registrator,
|
||||
"refundpay"_n,
|
||||
"declinerfnd"_n
|
||||
)
|
||||
).send();
|
||||
} else {
|
||||
// Миграционный путь: возвращать на цепи нечего — закрываем кандидата и
|
||||
// освобождаем аккаунт для повторной подачи (снимаем карточку участника).
|
||||
if (it != candidates.end()) candidates.erase(it);
|
||||
reset_account_card(candidate -> username);
|
||||
}
|
||||
}
|
||||
//событие отклонения регистрации советом
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @brief Отклонение возврата регистрационного взноса — НЕДОПУСТИМО.
|
||||
* Возврат полученного регистрационного взноса при отказе совета не может быть
|
||||
* отменён: средства кандидата обязаны быть ему возвращены. Действие существует
|
||||
* только как обязательный decline-коллбэк gateway (createoutpay требует имя
|
||||
* decline-действия) и всегда завершается ошибкой. На фронте кнопки отклонения
|
||||
* возврата нет; этот страж защищает от прямого вызова gateway::outdecline.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param registration_hash Хэш регистрации (= outcome_hash исходящего платежа)
|
||||
* @param reason Причина отклонения (игнорируется)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::declinerfnd(name coopname, checksum256 registration_hash, std::string reason) {
|
||||
require_auth(_gateway);
|
||||
eosio::check(false, "Отмена возврата регистрационного взноса невозможна");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#include "registration_migration.hpp"
|
||||
|
||||
/**
|
||||
* @brief Подтверждение возврата регистрационного взноса.
|
||||
* Коллбэк gateway после фактического проведения кассой исходящего возврата
|
||||
* (gateway::outcomplete). Проводит обратную проводку Дт 76 / Кт 51 (сжигание
|
||||
* суспенса w.reg.pend) и закрывает запись кандидата. Деньги уходят со счёта 76
|
||||
* ровно в этот момент — не раньше, чем касса подтвердит исходящий перевод.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param registration_hash Хэш регистрации (= outcome_hash исходящего платежа)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::refundpay(name coopname, checksum256 registration_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
|
||||
eosio::check(candidate.has_value(), "Кандидат не найден");
|
||||
eosio::check(candidate -> status == "refunding"_n, "Кандидат не находится в процессе возврата");
|
||||
|
||||
// оповещаем кандидата о завершении возврата
|
||||
require_recipient(candidate -> username);
|
||||
|
||||
// Сжигаем фактический остаток на w.reg.pend — он равен полученной сумме
|
||||
// (вступительный + минимальный паевой). Обратная проводка Дт 76 / Кт 51.
|
||||
eosio::asset pending = Registrator::get_registration_pending_balance(coopname, candidate -> username);
|
||||
eosio::check(pending.amount > 0, "Нет средств на возврат");
|
||||
|
||||
std::string memo = "Возврат регистрационного взноса при отказе совета, username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::REFUND, pending, candidate -> username, registration_hash, memo);
|
||||
|
||||
// Возврат завершён — удаляем кандидата из картотеки.
|
||||
Registrator::candidates_index candidates(_registrator, coopname.value);
|
||||
auto it = candidates.find(candidate -> username.value);
|
||||
candidates.erase(it);
|
||||
|
||||
// Освобождаем аккаунт для повторной подачи на том же username: снимаем
|
||||
// карточку участника, иначе reguser упадёт «повторное получение невозможно».
|
||||
reset_account_card(candidate -> username);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ВРЕМЕННЫЙ МИГРАЦИОННЫЙ КОД — СНЯТЬ УСЛОВИЕ ПОСЛЕ 30.07.2026.
|
||||
//
|
||||
// На момент релиза двухфазного учёта регистрационного взноса (счёт 76,
|
||||
// кошелёк w.reg.pend) в системе могут быть «зависшие» кандидаты: их платёж
|
||||
// уже принят кассой (статус `payed`), но ПО СТАРОМУ пути — без операции
|
||||
// o.reg.inpay, то есть БЕЗ баланса на w.reg.pend.
|
||||
//
|
||||
// Когда совет вынесет по ним решение уже на новом контракте:
|
||||
// • confirmreg — нельзя делать TRANSFER с пустого w.reg.pend (упадёт
|
||||
// «недостаточно средств»); для таких кандидатов признаём взнос по-старому
|
||||
// прямым ISSUE (o.reg.putmin / o.reg.payent, Dr 51 / Cr 80|86).
|
||||
// • declinereg — возврата через ledger2 нет (как было раньше): on-chain
|
||||
// суспенса нет, банковский возврат оформляет бэкенд.
|
||||
//
|
||||
// Признак пути — наличие баланса кандидата на w.reg.pend (счёт 76):
|
||||
// > 0 → принят по новому пути (o.reg.inpay) → признание/возврат через 76;
|
||||
// == 0 → принят до релиза → старый путь.
|
||||
//
|
||||
// После 30.07.2026 (когда все зависшие на момент релиза кандидаты гарантированно
|
||||
// получат решение совета) этот файл и ветвления в confirmreg/declinereg убрать,
|
||||
// оставив только новый путь.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
/**
|
||||
* @brief Баланс кандидата на суспенс-кошельке регистрационного взноса
|
||||
* (w.reg.pend, счёт 76) из ledger2 L3 (userwallets). Запись существует только
|
||||
* пока available > 0 (auto-delete на нуле), поэтому отсутствие = нулевой баланс.
|
||||
*/
|
||||
inline eosio::asset get_registration_pending_balance(eosio::name coopname, eosio::name username) {
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(ledger2_wallets::REGISTRATION_PENDING.value, username.value));
|
||||
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -5,7 +5,7 @@
|
||||
# получить от совета согласие на выполнение действия. Поток:
|
||||
#
|
||||
# контракт-инициатор → newpackage / create_agenda → soviet::decisions
|
||||
# ↓ голосование (votefor / voteagainst)
|
||||
# ↓ голосование (votefor / voteagainst / cancelvote)
|
||||
# approved=true → authorize (председатель)
|
||||
# ↓
|
||||
# authorized=true → exec → callback в исходный контракт (confirmreg /
|
||||
@@ -19,8 +19,8 @@
|
||||
#
|
||||
# Источники в коде:
|
||||
# • cpp/soviet/src/doc/newpackage.cpp
|
||||
# • cpp/soviet/src/vote/{votefor,voteagainst}.cpp
|
||||
# • cpp/soviet/src/decision/{authorize,cancelexprd,declinedec,exec}.cpp
|
||||
# • cpp/soviet/src/vote/{votefor,voteagainst,cancelvote}.cpp
|
||||
# • cpp/soviet/src/decision/{authorize,cancelexprd,exec}.cpp
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
process_type: sov.authpkg
|
||||
@@ -74,9 +74,17 @@ actions:
|
||||
actor: Член совета
|
||||
role: reject
|
||||
purpose: >
|
||||
Член совета голосует «против». Голос фиксируется; решение само не
|
||||
закрывается. Когда против высказалось больше половины состава совета,
|
||||
председатель снимает решение явным действием (см. «Отклонить решение»).
|
||||
Член совета голосует «против». При достижении консенсуса по отказу
|
||||
решение помечается как rejected и при exec в исходный контракт
|
||||
уйдёт decline-callback.
|
||||
|
||||
- name: soviet::cancelvote
|
||||
human: Отозвать голос
|
||||
actor: Член совета
|
||||
role: progress
|
||||
purpose: >
|
||||
Член совета отзывает ранее поданный голос до перехода решения
|
||||
в финальный статус.
|
||||
|
||||
- name: soviet::authorize
|
||||
human: Утвердить протокол
|
||||
@@ -99,22 +107,11 @@ actions:
|
||||
|
||||
- name: soviet::cancelexprd
|
||||
human: Отменить просроченное
|
||||
actor: Кооператив
|
||||
role: reject
|
||||
purpose: >
|
||||
Если решение не было утверждено в срок, оно отменяется как
|
||||
просроченное: в исходный процесс уходит уведомление об отказе,
|
||||
решение закрывается. Снятие просроченных решений происходит
|
||||
автоматически по истечении срока.
|
||||
|
||||
- name: soviet::declinedec
|
||||
human: Отклонить решение
|
||||
actor: Председатель
|
||||
role: reject
|
||||
purpose: >
|
||||
Если против решения проголосовало больше половины состава совета,
|
||||
председатель явно отклоняет его, не дожидаясь истечения срока: в
|
||||
исходный процесс уходит уведомление об отказе, решение закрывается.
|
||||
Если решение не было утверждено в срок, оно отменяется как
|
||||
просроченное, в исходный контракт callback не уходит.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: soviet::decision
|
||||
@@ -153,9 +150,9 @@ states:
|
||||
- name: rejected
|
||||
human: Отклонено
|
||||
description: >
|
||||
Председатель отклонил решение по большинству «против» (declinedec) либо
|
||||
решение отменено как просроченное (cancelexprd, автоматически по сроку).
|
||||
В обоих случаях в исходный контракт уходит decline-callback.
|
||||
Совет проголосовал «против» либо решение отменено как просроченное.
|
||||
В исходный контракт уходит decline-callback (или callback
|
||||
не вызывается, если просрочка).
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -177,15 +174,15 @@ transitions:
|
||||
|
||||
- from: pending
|
||||
to: rejected
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
guards:
|
||||
- Большинство голосов «против».
|
||||
|
||||
- from: pending
|
||||
to: rejected
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Председатель
|
||||
guards:
|
||||
- Истёк срок принятия решения.
|
||||
|
||||
@@ -226,8 +223,8 @@ scenario:
|
||||
actor: Член совета
|
||||
action: soviet::votefor
|
||||
description: >
|
||||
Члены совета голосуют по решению (votefor / voteagainst).
|
||||
При достижении большинства «за» — approved=true.
|
||||
Члены совета голосуют по решению (votefor / voteagainst /
|
||||
cancelvote). При достижении большинства «за» — approved=true.
|
||||
pre:
|
||||
- Решение в `pending`.
|
||||
post:
|
||||
@@ -262,21 +259,18 @@ scenario:
|
||||
alternatives:
|
||||
- branch: Отказ совета
|
||||
at_step: 2
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
description: >
|
||||
Когда против высказалось больше половины состава совета, голос «против»
|
||||
лишь фиксируется. Председатель явно отклоняет решение (declinedec), не
|
||||
дожидаясь срока: в исходный контракт уходит decline-callback (например,
|
||||
registrator::declinereg), запись решения удаляется.
|
||||
Большинство «против» — при exec в исходный контракт уйдёт
|
||||
decline-callback (например, registrator::declinereg).
|
||||
|
||||
- branch: Просрочка
|
||||
at_step: 2
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Председатель
|
||||
description: >
|
||||
Решение не принято в срок — автоматически отменяется по истечении
|
||||
срока: в исходный контракт уходит decline-callback, запись удаляется.
|
||||
Решение не принято в срок, отменяется без вызова callback.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
# Документ повестки приходит из исходного процесса — конкретный registry_id
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
# • cpp/soviet/src/decision/freedecision.cpp
|
||||
# • cpp/soviet/src/vote/votefor.cpp
|
||||
# • cpp/soviet/src/vote/voteagainst.cpp
|
||||
# • cpp/soviet/src/vote/cancelvote.cpp
|
||||
# • cpp/soviet/src/decision/authorize.cpp
|
||||
# • cpp/soviet/src/decision/exec.cpp
|
||||
# • cpp/soviet/src/decision/cancelexprd.cpp
|
||||
# • cpp/soviet/src/decision/declinedec.cpp (внутри cancelexprd.cpp)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
process_type: sov.decision
|
||||
@@ -63,9 +63,17 @@ actions:
|
||||
actor: Член совета
|
||||
role: reject
|
||||
purpose: >
|
||||
Член совета голосует «против». Голос фиксируется; решение само не
|
||||
закрывается. Когда против высказалось больше половины состава совета,
|
||||
председатель снимает решение явным действием (см. «Отклонить решение»).
|
||||
Член совета голосует «против». При достижении консенсуса по отказу
|
||||
решение переходит в статус rejected; протокол не оформляется,
|
||||
исполнения не происходит.
|
||||
|
||||
- name: soviet::cancelvote
|
||||
human: Отозвать голос
|
||||
actor: Член совета
|
||||
role: progress
|
||||
purpose: >
|
||||
Член совета отзывает ранее поданный голос (за или против) до того,
|
||||
как решение перешло в финальный статус.
|
||||
|
||||
- name: soviet::authorize
|
||||
human: Утвердить протокол
|
||||
@@ -88,21 +96,11 @@ actions:
|
||||
|
||||
- name: soviet::cancelexprd
|
||||
human: Отменить просроченное
|
||||
actor: Кооператив
|
||||
actor: Член совета
|
||||
role: reject
|
||||
purpose: >
|
||||
Если решение не было утверждено в срок, оно отменяется как
|
||||
просроченное. Запись удаляется без исполнения. Снятие просроченных
|
||||
решений происходит автоматически по истечении срока.
|
||||
|
||||
- name: soviet::declinedec
|
||||
human: Отклонить решение
|
||||
actor: Председатель
|
||||
role: reject
|
||||
purpose: >
|
||||
Если против решения проголосовало больше половины состава совета,
|
||||
председатель явно отклоняет его, не дожидаясь истечения срока.
|
||||
Запись удаляется без исполнения.
|
||||
Если решение не было утверждено в срок, оно может быть отменено
|
||||
как просроченное. Запись удаляется без исполнения.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: soviet::decision
|
||||
@@ -167,15 +165,15 @@ transitions:
|
||||
|
||||
- from: created
|
||||
to: rejected
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
guards:
|
||||
- Большинство голосов «против».
|
||||
|
||||
- from: created
|
||||
to: rejected
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Член совета
|
||||
guards:
|
||||
- Истёк срок принятия решения.
|
||||
|
||||
@@ -216,7 +214,7 @@ scenario:
|
||||
actor: Член совета
|
||||
action: soviet::votefor
|
||||
description: >
|
||||
Члены совета поочерёдно голосуют (votefor / voteagainst).
|
||||
Члены совета поочерёдно голосуют (votefor / voteagainst / cancelvote).
|
||||
При достижении большинства «за» решение помечается как approved.
|
||||
pre:
|
||||
- Решение ещё не закрыто (created).
|
||||
@@ -250,22 +248,21 @@ scenario:
|
||||
- События newresolved/newdecision разосланы.
|
||||
|
||||
alternatives:
|
||||
- branch: Отклонение советом
|
||||
- branch: Отклонение голосованием
|
||||
at_step: 2
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
description: >
|
||||
Когда против высказалось большинство состава совета, голос «против»
|
||||
лишь фиксируется. Председатель явно отклоняет решение (declinedec) —
|
||||
запись удаляется, протокол не оформляется.
|
||||
Большинство голосов «против» — решение помечается как rejected
|
||||
и не утверждается председателем.
|
||||
|
||||
- branch: Просрочка
|
||||
at_step: 2
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Член совета
|
||||
description: >
|
||||
Если решение не было принято в срок, оно автоматически отменяется
|
||||
по истечении срока — запись удаляется как просроченная.
|
||||
Если решение не было принято в срок, любой член совета может
|
||||
вызвать cancelexprd — запись удаляется как просроченная.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
#include "src/fund/fundwithdraw.cpp"
|
||||
|
||||
#include "src/participant/addparticipant.cpp"
|
||||
#include "src/participant/delparticipant.cpp"
|
||||
#include "src/participant/setminamt.cpp"
|
||||
#include "src/participant/block.cpp"
|
||||
#include "src/participant/cancelreg.cpp"
|
||||
|
||||
@@ -139,9 +139,6 @@ public:
|
||||
//regaccount.cpp
|
||||
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
|
||||
|
||||
//delparticipant.cpp
|
||||
[[eosio::action]] void delpartcpnt(eosio::name coopname, eosio::name username);
|
||||
|
||||
//setminamt.cpp
|
||||
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
|
||||
|
||||
@@ -204,7 +201,6 @@ public:
|
||||
//decisions
|
||||
[[eosio::action]] void withdraw(eosio::name coopname, eosio::name username, uint64_t withdraw_id, document2 statement);
|
||||
[[eosio::action]] void cancelexprd(eosio::name coopname, uint64_t decision_id);
|
||||
[[eosio::action]] void declinedec(eosio::name coopname, uint64_t decision_id);
|
||||
|
||||
void withdraw_effect(eosio::name executer, eosio::name coopname, uint64_t decision_id, uint64_t batch_id);
|
||||
|
||||
|
||||
@@ -9,14 +9,10 @@
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
* @note Авторизация требуется от аккаунта: @p chairman
|
||||
*/
|
||||
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
|
||||
// Авторизует кооператив (действие проводится через бэкенд ключом кооператива).
|
||||
// Согласие председателя при этом не теряется: оно подтверждается криптографически
|
||||
// его личной подписью на документе (verify_document_or_fail ниже) и проверкой
|
||||
// is_valid_chairman — без валидного подписанного председателем документа авторизация невозможна.
|
||||
require_auth(coopname);
|
||||
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
|
||||
require_auth(chairman);
|
||||
|
||||
boards_index boards(_soviet, coopname.value);
|
||||
autosigner_index autosigner(_soviet, coopname.value);
|
||||
@@ -31,9 +27,7 @@ void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t deci
|
||||
|
||||
verify_document_or_fail(document);
|
||||
|
||||
// RAM-плательщик — кооператив: транзакцию авторизует coopname (require_auth выше),
|
||||
// поэтому увеличивать RAM аккаунта председателя нельзя (он tx не подписывал).
|
||||
decisions.modify(decision, coopname, [&](auto &d){
|
||||
decisions.modify(decision, chairman, [&](auto &d){
|
||||
d.authorized_by = chairman;
|
||||
d.authorized = !decision -> authorized;
|
||||
d.authorization = document;
|
||||
|
||||
@@ -9,23 +9,30 @@
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
// Общая ветка отказа решения: при наличии полей коллбэка отправляет инициатору
|
||||
// коллбэк отклонения, затем удаляет решение из оперативной таблицы. Вызывается
|
||||
// из cancelexprd (просрочка) и voteagainst (отрицательный консенсус).
|
||||
void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, const std::string &reason) {
|
||||
void soviet::cancelexprd(eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
eosio::check(decision != decisions.end(), "Решение не найдено");
|
||||
|
||||
|
||||
// Проверка истечения срока
|
||||
bool expired = true;
|
||||
if ( decision -> expired_at.has_value() ) {
|
||||
expired = (eosio::time_point_sec(eosio::current_time_point()) > decision->expired_at.value());
|
||||
}
|
||||
|
||||
eosio::check(expired, "Срок действия решения ещё не истёк");
|
||||
|
||||
// Проверка наличия всех необходимых полей для отправки коллбэка
|
||||
if (decision->callback_contract.has_value() &&
|
||||
decision->decline_callback.has_value() &&
|
||||
decision->hash.has_value() &&
|
||||
decision->callback_contract.value() != ""_n &&
|
||||
decision->confirm_callback.value() != ""_n &&
|
||||
if (decision->callback_contract.has_value() &&
|
||||
decision->decline_callback.has_value() &&
|
||||
decision->hash.has_value() &&
|
||||
decision->callback_contract.value() != ""_n &&
|
||||
decision->confirm_callback.value() != ""_n &&
|
||||
decision->decline_callback.value() != ""_n
|
||||
) {
|
||||
|
||||
|
||||
// Отправка коллбэка отклонения
|
||||
Action::send<decline_callback_interface>(
|
||||
decision->callback_contract.value(),
|
||||
@@ -34,61 +41,10 @@ void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, cons
|
||||
_soviet,
|
||||
coopname,
|
||||
decision->hash.value(),
|
||||
reason
|
||||
std::string("Отклонение по истечению срока")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Удаление решения
|
||||
decisions.erase(decision);
|
||||
}
|
||||
|
||||
void soviet::cancelexprd(eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
eosio::check(decision != decisions.end(), "Решение не найдено");
|
||||
|
||||
// Проверка истечения срока
|
||||
bool expired = true;
|
||||
if ( decision -> expired_at.has_value() ) {
|
||||
expired = (eosio::time_point_sec(eosio::current_time_point()) > decision->expired_at.value());
|
||||
}
|
||||
|
||||
eosio::check(expired, "Срок действия решения ещё не истёк");
|
||||
|
||||
decline_and_erase_decision(coopname, decision_id, std::string("Отклонение по истечению срока"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Явное отклонение решения советом по отрицательному консенсусу
|
||||
* Проводится председателем (ключом кооператива), когда против решения
|
||||
* проголосовало большинство состава совета, до истечения срока. Развязано с
|
||||
* cancelexprd: просрочка гасится автоматически по сроку, а отрицательно принятое
|
||||
* решение снимается с повестки только этим явным действием.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param decision_id Идентификатор решения для отклонения
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void soviet::declinedec(eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
eosio::check(decision != decisions.end(), "Решение не найдено");
|
||||
|
||||
// Отрицательный консенсус: против должно высказаться больше половины состава
|
||||
// совета (порог зеркален votefor — 50%, строгое «больше»).
|
||||
auto board = get_board_by_type_or_fail(coopname, "soviet"_n);
|
||||
auto [votes_for_count, votes_against_count] = decision->get_votes_count();
|
||||
uint64_t total_members = board.get_members_count();
|
||||
uint64_t consensus_percent = 50;
|
||||
|
||||
eosio::check(votes_against_count * 100 > total_members * consensus_percent,
|
||||
"Решение не отклонено советом: нет большинства голосов против");
|
||||
|
||||
decline_and_erase_decision(coopname, decision_id, std::string("Отклонено советом: большинство голосов против"));
|
||||
}
|
||||
@@ -10,14 +10,11 @@
|
||||
* @param coopname Имя кооператива
|
||||
* @param decision_id Идентификатор решения для исполнения
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
* @note Авторизация требуется от аккаунта: @p executer
|
||||
*/
|
||||
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
|
||||
// Исполнение проводится через бэкенд ключом кооператива. Параметр executer остаётся
|
||||
// в payload как аудит-метка (кто инициировал исполнение) и не используется для авторизации
|
||||
// inline-эффектов — те выполняются под собственными правами контракта (_soviet/_gateway/_fund).
|
||||
require_auth(coopname);
|
||||
|
||||
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(executer);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @brief Удаление пайщика из кооператива при выходе
|
||||
* Стирает запись пайщика из реестра совета по завершении процедуры выхода
|
||||
* (после возврата паевого взноса). Зеркало `addpartcpnt`. Если выбывающий
|
||||
* пайщик был активным и имел право голоса — уменьшает счётчик активных
|
||||
* пайщиков в registrator (как это делает `block`).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Наименование выбывающего пайщика
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта в белом списке контрактов
|
||||
*/
|
||||
void soviet::delpartcpnt(eosio::name coopname, eosio::name username) {
|
||||
check_auth_and_get_payer_or_fail(contracts_whitelist);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
participants_index participants(_soviet, coopname.value);
|
||||
auto participant = participants.find(username.value);
|
||||
eosio::check(participant != participants.end(), "Пайщик не найден в кооперативе");
|
||||
|
||||
// Если пайщик был активным и имел право голоса — уменьшаем счётчик активных
|
||||
// пайщиков (счётчик живёт в registrator, см. block.cpp / decparticpnt.cpp).
|
||||
bool had_vote = participant->has_vote;
|
||||
bool was_active = participant->status == "accepted"_n;
|
||||
|
||||
if (was_active && had_vote) {
|
||||
action(
|
||||
permission_level{_soviet, "active"_n},
|
||||
_registrator,
|
||||
"decparticpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
}
|
||||
|
||||
participants.erase(participant);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
|
||||
row.type = "user"_n;
|
||||
row.coopname = coopname;
|
||||
row.program_id = 0;
|
||||
row.draft_id = 4;
|
||||
row.draft_id = 3;
|
||||
});
|
||||
|
||||
//создаём соглашение для шаблона политики конфиденциальности
|
||||
@@ -132,7 +132,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
|
||||
row.type = "privacy"_n;
|
||||
row.coopname = coopname;
|
||||
row.program_id = 0;
|
||||
row.draft_id = 3;
|
||||
row.draft_id = 4;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include <array>
|
||||
|
||||
/**
|
||||
* @brief Миграция данных системы
|
||||
* Выполняет миграцию данных системы при обновлении контракта.
|
||||
@@ -12,62 +10,14 @@
|
||||
void soviet::migrate() {
|
||||
require_auth(_soviet); // Проверяем авторизацию
|
||||
|
||||
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
|
||||
// Предыдущая версия этой миграции переименовывала programs.program_type в
|
||||
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
|
||||
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
|
||||
// 'capital' — каноничное значение (используется coagreements.type, contract-
|
||||
// константой _capital_program и всем controller-кодом); эта миграция приводит
|
||||
// programs.program_type к нему же. Идемпотентно.
|
||||
{
|
||||
eosio::name coopname = "voskhod"_n;
|
||||
programs_index programs(_soviet, coopname.value);
|
||||
auto program_it = programs.find(4);
|
||||
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
|
||||
programs.modify(program_it, _soviet, [&](auto &pr) {
|
||||
pr.program_type = "capital"_n;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Разовая очистка ФАНТОМНЫХ участников кооператива fgrtejiwnynn (инцидент
|
||||
// 2026-06-04). Удаляем строки soviet::participants для 6 дублей-аккаунтов,
|
||||
// оставшихся на цепи после 3-кратного прогона install.interactor (см.
|
||||
// ~/gorozhane-dup-install-cleanup.md). Эти участники не голосовали и не
|
||||
// входят в совет (createboard для них не выполнялся).
|
||||
//
|
||||
// Список username синхронизирован с registrator::migrate и ledger2::migrate.
|
||||
// Дедуп-гард: удаляем фантомов ТОЛЬКО если в коопе остаётся хотя бы один
|
||||
// НЕ-фантомный участник (никогда не опустошаем реестр). Идемпотентно.
|
||||
{
|
||||
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
|
||||
const std::array<eosio::name, 6> PHANTOMS = {
|
||||
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
|
||||
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
|
||||
};
|
||||
|
||||
auto is_phantom = [&](eosio::name u) {
|
||||
for (const auto& p : PHANTOMS) if (p == u) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
participants_index participants(_soviet, PHANTOM_COOP.value);
|
||||
|
||||
// Гарантируем, что после удаления останется ≥1 реальный участник.
|
||||
uint64_t real_participants = 0;
|
||||
for (auto it = participants.begin(); it != participants.end(); ++it) {
|
||||
if (!is_phantom(it->username)) real_participants++;
|
||||
}
|
||||
|
||||
if (real_participants > 0) {
|
||||
for (const auto& u : PHANTOMS) {
|
||||
auto it = participants.find(u.value);
|
||||
if (it != participants.end()) {
|
||||
participants.erase(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
|
||||
eosio::name coopname = "voskhod"_n;
|
||||
programs_index programs(_soviet, coopname.value);
|
||||
auto program_it = programs.find(4);
|
||||
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
|
||||
programs.modify(program_it, _soviet, [&](auto &pr) {
|
||||
pr.program_type = "blagorost"_n;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,12 +60,8 @@ void soviet::voteagainst(
|
||||
auto board = get_board_by_type_or_fail(coopname, "soviet"_n);
|
||||
eosio::check(board.is_voting_member(username), "У вас нет права голоса");
|
||||
|
||||
decision -> check_for_any_vote_exist(username);
|
||||
decision -> check_for_any_vote_exist(username);
|
||||
|
||||
// Голос «против» только фиксируется. Отрицательный консенсус НЕ затирает
|
||||
// решение автоматически: отказ при достигнутом большинстве «против» проводит
|
||||
// председатель явным действием declinedec (развязано с авто-отменой по сроку
|
||||
// cancelexprd). Так принятое отрицательно решение не исчезает само из повестки.
|
||||
add_vote_against(coopname, username, decision->id);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"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"
|
||||
|
||||
@@ -34,13 +34,6 @@ SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM="'КО-ПОЧТАЛЬОН' <mail@coopenomics.world>"
|
||||
|
||||
# Напоминатель об отчёте по авансу под отчёт (extensions/expenses).
|
||||
# Интервал тика сканера зеркала расходов (мс). Дефолт 6 ч. Письмо пайщику —
|
||||
# не чаще раза в неделю (троттлит идемпотентность Центра уведомлений).
|
||||
EXPENSE_ADVANCE_REMINDER_INTERVAL_MS=21600000
|
||||
# Сколько дней аванс может висеть без отчёта до первого напоминания. Дефолт 3.
|
||||
EXPENSE_ADVANCE_REMINDER_GRACE_DAYS=3
|
||||
|
||||
# BLOCKHAIN
|
||||
BLOCKCHAIN_RPC=http://127.0.0.1:8888
|
||||
CHAIN_ID=cae86058a6d8698833afb474ab8a5ad8599c6cf54f9ebcf275dbac7055c16fe1
|
||||
@@ -78,6 +71,12 @@ SENTRY_DSN=
|
||||
GITHUB_TOKEN=
|
||||
|
||||
|
||||
# OpenSearch (поисковая система документов, опционально)
|
||||
OPENSEARCH_NODE=https://opensearch:9200
|
||||
OPENSEARCH_ENABLED=false
|
||||
OPENSEARCH_USERNAME=admin
|
||||
OPENSEARCH_PASSWORD=
|
||||
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -472,7 +472,7 @@ BaseExtModule
|
||||
- `ChairmanBlockchainAdapter` — взаимодействие с контрактом `soviet`
|
||||
- **Резолверы**: `ApprovalResolver`, `ChairmanOnboardingResolver`
|
||||
- **Крон**: Проверка решений каждые N минут (`checkInterval`)
|
||||
- **Конфигурация**: `checkInterval`, `onboarding_*` — флаги прохождения мастера настройки
|
||||
- **Конфигурация**: `checkInterval`, `cancelApprovedDecisions`, `onboarding_*` — флаги прохождения мастера настройки
|
||||
|
||||
### `capital` — Благорост (целевая программа)
|
||||
- **Структура**: `domain/`, `infrastructure/`, `application/` — самое крупное расширение
|
||||
|
||||
@@ -27,19 +27,11 @@ RUN apt-get update && apt-get install -y \
|
||||
libharfbuzz-dev \
|
||||
libcairo2 \
|
||||
libpango1.0-dev \
|
||||
fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m venv /venv \
|
||||
&& /venv/bin/pip install WeasyPrint==67 \
|
||||
&& rm -rf /root/.cache/pip
|
||||
|
||||
# Шрифт Arial устанавливаем СИСТЕМНО (а не встраиваем base64 в каждый html).
|
||||
# Иначе weasyprint парсил бы ~270 КБ TTF на каждый рендер (≈1.3 сек/документ).
|
||||
# Системный шрифт fontconfig парсит один раз и кеширует. Файл тот же —
|
||||
# глифы/вёрстка идентичны прежним.
|
||||
COPY assets/fonts/arial.ttf /usr/share/fonts/truetype/arial/arial.ttf
|
||||
RUN fc-cache -f && fc-match Arial
|
||||
|
||||
# Установка пути к виртуальному окружению в PATH
|
||||
ENV PATH="/venv/bin:$PATH"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,126 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import mongoose from 'mongoose';
|
||||
import config from '~/config/config';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
const CAPITAL_DOC_REGISTRY_ID = 994;
|
||||
const TARGET_COOPNAME = 'voskhod';
|
||||
const COLLECTION_NAME = 'doc_private_data';
|
||||
|
||||
const capitalProgramPrivateData = {
|
||||
generator_program_purpose:
|
||||
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
|
||||
eoap_definition:
|
||||
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
|
||||
generator_task_goal:
|
||||
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
|
||||
idea_unit_cost:
|
||||
"50",
|
||||
idea_unit_cost_words:
|
||||
"пятьдесят",
|
||||
blagorost_goal_expansion:
|
||||
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
|
||||
blagorost_goal_reason:
|
||||
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
|
||||
blagorost_task_expansion:
|
||||
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
|
||||
blagorost_task_development:
|
||||
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
|
||||
return_source_description:
|
||||
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
|
||||
return_additional_source:
|
||||
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
|
||||
const entries = Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const v = (value as Record<string, unknown>)[key];
|
||||
return `${JSON.stringify(key)}:${stableStringify(v)}`;
|
||||
});
|
||||
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
function calculateSha256(value: unknown): string {
|
||||
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Backfill capital_program_doc_data_hash for voskhod',
|
||||
validUntil: new Date('2027-05-01T00:00:00Z'),
|
||||
|
||||
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
if (config.coopname !== TARGET_COOPNAME) {
|
||||
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const hash = calculateSha256(capitalProgramPrivateData);
|
||||
let mongo: mongoose.Connection | null = null;
|
||||
|
||||
try {
|
||||
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
|
||||
const db = mongo.db;
|
||||
if (!db) {
|
||||
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
|
||||
}
|
||||
const collection = db.collection(COLLECTION_NAME);
|
||||
await collection.createIndex({ hash: 1 }, { unique: true });
|
||||
await collection.updateOne(
|
||||
{ hash },
|
||||
{
|
||||
$setOnInsert: {
|
||||
hash,
|
||||
registry_id: CAPITAL_DOC_REGISTRY_ID,
|
||||
payload: capitalProgramPrivateData,
|
||||
_created_at: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
const result = await dataSource.query(
|
||||
`UPDATE extensions
|
||||
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
|
||||
updated_at = NOW()
|
||||
WHERE name = 'capital'`,
|
||||
[hash]
|
||||
);
|
||||
|
||||
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
|
||||
return false;
|
||||
} finally {
|
||||
await mongo?.close();
|
||||
}
|
||||
},
|
||||
|
||||
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
if (config.coopname !== TARGET_COOPNAME) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await dataSource.query(
|
||||
`UPDATE extensions
|
||||
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
|
||||
updated_at = NOW()
|
||||
WHERE name = 'capital'`
|
||||
);
|
||||
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.25-3",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -97,7 +97,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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ const { spawnSync } = require('child_process');
|
||||
|
||||
const controllerRoot = path.resolve(__dirname, '..');
|
||||
const schemaPath = path.join(controllerRoot, 'schema.gql');
|
||||
const zeusConfigPath = path.join(controllerRoot, 'graphql-zeus.json');
|
||||
const sdkSrc = path.resolve(controllerRoot, '..', 'sdk', 'src');
|
||||
const localZeusDir = path.join(controllerRoot, 'zeus');
|
||||
const sdkZeusDir = path.join(sdkSrc, 'zeus');
|
||||
@@ -41,12 +40,6 @@ if (!fs.existsSync(sdkSrc)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// graphql-zeus 7.x: yargs кладёт path в argv.path, а CLIClass читает args._[0].
|
||||
// Без graphql-zeus.json config-maker уходит в inquirer и «зависает» без TTY.
|
||||
if (!fs.existsSync(zeusConfigPath)) {
|
||||
fs.writeFileSync(zeusConfigPath, `${JSON.stringify({ urlOrPath: './schema.gql' }, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
let zeusBin;
|
||||
let zeusIsWin;
|
||||
try {
|
||||
@@ -58,7 +51,6 @@ try {
|
||||
|
||||
const zeusArgs = ['./schema.gql', '../sdk/src', '--subscriptions', 'graphql-ws'];
|
||||
const startedAt = Date.now();
|
||||
console.log('generate-client: graphql-zeus… (5–30 с без вывода — норма, не прерывайте)');
|
||||
const result = spawnSync(zeusBin, zeusArgs, {
|
||||
stdio: 'inherit',
|
||||
cwd: controllerRoot,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
// Infrastructure modules
|
||||
@@ -12,9 +11,11 @@ import config from '~/config/config';
|
||||
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
|
||||
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 +36,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 +58,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 +67,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';
|
||||
@@ -76,10 +76,8 @@ import { UserModule } from './application/user/user.module';
|
||||
import { TokenApplicationModule } from './application/token/token-application.module';
|
||||
import { SettingsApplicationModule } from './application/settings/settings.module';
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { MembershipExitModule } from './application/membership-exit/membership-exit.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({
|
||||
@@ -93,7 +91,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
limit: 50,
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(), // @Interval/@Cron — нужен outbox-worker'у Центра уведомлений
|
||||
// Infrastructure modules
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
@@ -101,6 +98,8 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
NovuModule,
|
||||
SearchInfrastructureModule,
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
@@ -132,6 +131,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MeetDomainModule,
|
||||
GatewayDomainModule,
|
||||
VaultDomainModule,
|
||||
NotificationDomainModule,
|
||||
LedgerDomainModule,
|
||||
ProcessRegistryDomainModule,
|
||||
ParserDomainModule,
|
||||
@@ -142,7 +142,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
AccountModule,
|
||||
AgreementModule,
|
||||
AgendaModule,
|
||||
DecisionAuthorizeModule,
|
||||
AppStoreModule,
|
||||
AuthModule,
|
||||
DesktopModule,
|
||||
@@ -160,7 +159,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
GatewayModule,
|
||||
WalletModule,
|
||||
NotificationModule,
|
||||
NotificationCenterModule,
|
||||
LedgerModule,
|
||||
Ledger2Module,
|
||||
ProcessRegistryModule,
|
||||
@@ -170,10 +168,8 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
TokenApplicationModule,
|
||||
SettingsApplicationModule,
|
||||
RegistrationModule,
|
||||
MembershipExitModule,
|
||||
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
|
||||
|
||||
-119
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user