feat(exit): возврат при выходе — сет из 3 паевых кошельков (minshr+share+blago)
Typecheck / desktop (pull_request) Failing after 9m37s
Typecheck / controller (pull_request) Successful in 12m46s

confirmexit теперь обходит сет LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
w.wal.share + w.cap.blago): собирает доступные L3-балансы каждого (>0),
консолидирует на главный паевой (w.reg.minshr→o.reg.mvmin, w.cap.blago→
o.cap.wthcap) и ставит полную сумму на возврат единым платежом. Раньше
хардкодил только minshr+share — паевой в Благоросте оставался висеть.

Сет задан один раз в wallets.hpp (источник истины), генерируется gen:from-cpp
в cooptypes (LEDGER2_EXIT_REFUND_WALLETS / EXIT_REFUND_WALLET_NAMES) и обходится
backend-preview (getReturnPreview) — расчёт на фронте всегда совпадает с тем,
что реально вернёт контракт. Подпись «планируемая сумма / итог фиксирует Совет»
убрана из ExitButton и ExitOverlay (сумма теперь авторитетна).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-16 14:36:52 +00:00
parent 95891b2319
commit 1f68169b94
11 changed files with 197 additions and 58 deletions
@@ -252,6 +252,37 @@ inline constexpr std::array<Ledger2WalletProgramMapping, 8> LEDGER2_USER_SHARED_
{ 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,
}};
/**
* @brief Возвращает required program_id для USER_SHARED-кошелька.
*
@@ -1,10 +1,11 @@
/**
* @brief Одобрение советом выхода пайщика из кооператива.
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата по
* L3-балансам ledger2 (минимальный + целевой паевой), консолидирует
* минимальный паевой на главный (o.reg.mvmin), резервирует сумму возврата
* (o.wal.wthreq) и создаёт исходящий платёж в gateway. Если возвращать нечего
* — выход завершается сразу без платежа.
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
* обходит сет паевых кошельков 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 Документ-решение совета о выходе
@@ -29,11 +30,18 @@ void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, docum
require_recipient(username);
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
// клиенту. Возвращается минимальный (w.reg.minshr) + целевой (w.wal.share)
// паевой взнос пайщика.
eosio::asset min_share = Registrator::get_user_wallet_available(coopname, ledger2_wallets::MIN_SHARE_FUND, username);
eosio::asset share = Registrator::get_user_wallet_available(coopname, ledger2_wallets::SHARE_FUND_PAY, username);
eosio::asset total_return = min_share + share;
// клиенту. Обходим сет паевых («боевых») кошельков 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;
@@ -41,15 +49,6 @@ void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, docum
row.quantity = total_return;
});
// Консолидируем минимальный паевой на главный (o.reg.mvmin: w.reg.minshr →
// w.wal.share), чтобы единым платежом вернуть весь паевой через механику
// возврата паевого взноса (o.wal.*).
if (min_share.amount > 0) {
std::string memo = "Консолидация минимального паевого взноса при выходе, username=" + username.to_string();
Ledger2::apply(_registrator, coopname, operations::registrator::MOVE_MINSHARE,
min_share, username, exit_hash, memo);
}
if (total_return.amount > 0) {
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
@@ -34,6 +34,38 @@ inline asset get_user_wallet_available(name coopname, name wallet_name, name use
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.
@@ -273,26 +273,38 @@ export class MembershipExitService {
}
/**
* Предварительный расчёт суммы возврата паевого взноса при выходе пайщика
* минимальный (w.reg.minshr) + целевой (w.wal.share) паевой по L3-балансам.
* Предварительный расчёт суммы возврата паевого взноса при выходе пайщика.
*
* Считается обходом сета паевых кошельков Ledger2.EXIT_REFUND_WALLET_NAMES
* (w.reg.minshr + w.wal.share + w.cap.blago) по L3-балансам — тот же сет, что
* обходит контракт `confirmexit` при одобрении выхода советом, поэтому preview
* совпадает с суммой, которую реально вернёт контракт.
*/
async getReturnPreview(coopname: string, username: string): Promise<MembershipExitReturnPreviewDTO> {
const [shareRow, minRow] = await Promise.all([
this.userWalletRepository.findByWalletAndUsername(coopname, Ledger2.SHARE_WALLET_NAME, username),
this.userWalletRepository.findByWalletAndUsername(coopname, Ledger2.MIN_SHARE_WALLET_NAME, username),
]);
const wallets = Ledger2.EXIT_REFUND_WALLET_NAMES;
const rows = await Promise.all(
wallets.map((wallet) => this.userWalletRepository.findByWalletAndUsername(coopname, wallet, username)),
);
const share = shareRow?.present !== false ? shareRow?.available : undefined;
const minimum = minRow?.present !== false ? minRow?.available : undefined;
// available учитываем только для существующих (present) кошельков
const balances = rows.map((row) => (row?.present !== false ? row?.available : undefined));
const template = await this.resolveAssetTemplate(coopname, share ?? minimum);
const shareAsset = share ?? this.zeroAssetLike(template);
const minimumAsset = minimum ?? this.zeroAssetLike(template);
const template = await this.resolveAssetTemplate(coopname, balances.find((b) => !!b));
const zero = this.zeroAssetLike(template);
const assets = balances.map((b) => b ?? zero);
const total = assets.reduce((acc, a) => this.sumAssets(acc, a), zero);
// индивидуальные паевые отдаём для информации; фронт показывает total
const balanceOf = (wallet: string): string => {
const idx = wallets.indexOf(wallet);
return idx >= 0 ? assets[idx] : zero;
};
return {
total: this.sumAssets(shareAsset, minimumAsset),
share_contribution: shareAsset,
minimum_contribution: minimumAsset,
total,
share_contribution: balanceOf(Ledger2.SHARE_WALLET_NAME),
minimum_contribution: balanceOf(Ledger2.MIN_SHARE_WALLET_NAME),
};
}
+36 -1
View File
@@ -106,6 +106,29 @@ const programMapping: ProgramMappingEntry[] = []
}
}
// ── 5b. LEDGER2_EXIT_REFUND_WALLETS ──────────────────────────────────────
// Сет паевых кошельков, возвращаемых пайщику при выходе. Простой массив имён
// `ledger2_wallets::NAME` — единый источник для контракта (confirmexit) и
// backend-preview.
const exitRefundWallets: string[] = []
{
const body = extractArrayBody('LEDGER2_EXIT_REFUND_WALLETS')
const re = /ledger2_wallets::(\w+)/g
for (const m of body.matchAll(re)) {
const wallet_name = nameMap.get(m[1]!)
if (!wallet_name) throw new Error(`gen-from-cpp: ledger2_wallets::${m[1]} не найден среди имён`)
exitRefundWallets.push(wallet_name)
}
if (exitRefundWallets.length === 0) throw new Error('gen-from-cpp: LEDGER2_EXIT_REFUND_WALLETS пуст')
const sizeMatch = /std::array<eosio::name,\s*(\d+)>\s+LEDGER2_EXIT_REFUND_WALLETS/.exec(hpp)
if (sizeMatch && Number(sizeMatch[1]) !== exitRefundWallets.length)
throw new Error(`gen-from-cpp: распарсено ${exitRefundWallets.length} exit-refund-кошельков, объявлено ${sizeMatch[1]}`)
const known = new Set(walletRegistry.map(w => w.name))
for (const w of exitRefundWallets) {
if (!known.has(w)) throw new Error(`gen-from-cpp: exit-refund ссылается на отсутствующий в реестре кошелёк "${w}"`)
}
}
// ── 6. emit TS ───────────────────────────────────────────────────────────
const lines: string[] = []
lines.push('// AUTO-GENERATED by cooptypes/scripts/gen-from-cpp.ts — DO NOT EDIT.')
@@ -153,6 +176,18 @@ for (const m of programMapping) {
}
lines.push('] as const')
lines.push('')
lines.push('/**')
lines.push(' * Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.')
lines.push(' * Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит')
lines.push(' * этот сет, собирает доступные балансы и ставит их на возврат; backend-preview')
lines.push(' * считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.')
lines.push(' */')
lines.push('export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [')
for (const w of exitRefundWallets) {
lines.push(` ${JSON.stringify(w)},`)
}
lines.push('] as const')
lines.push('')
writeFileSync(OUT_PATH, lines.join('\n'), 'utf8')
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length})`)
console.log(`gen-from-cpp: записано ${OUT_PATH} (wallets=${walletRegistry.length}, mapping=${programMapping.length}, exitRefund=${exitRefundWallets.length})`)
@@ -62,3 +62,15 @@ export const LEDGER2_USER_SHARED_PROGRAM_MAPPING: readonly ProgramWalletMapping[
{ wallet_name: "w.exp.adv", required_program_id: 0, program_label: null },
{ wallet_name: "w.reg.pend", required_program_id: 0, program_label: null },
] as const
/**
* Сет паевых («боевых») кошельков пайщика, возвращаемых при выходе из кооператива.
* Точная копия `LEDGER2_EXIT_REFUND_WALLETS` из C++. Контракт `confirmexit` обходит
* этот сет, собирает доступные балансы и ставит их на возврат; backend-preview
* считает по нему же — расчёт на фронте всегда совпадает с тем, что вернёт контракт.
*/
export const LEDGER2_EXIT_REFUND_WALLETS: readonly IName[] = [
"w.reg.minshr",
"w.wal.share",
"w.cap.blago",
] as const
@@ -18,6 +18,7 @@
import type { IName } from '../interfaces/ledger2'
export {
LEDGER2_EXIT_REFUND_WALLETS,
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
LEDGER2_WALLET_REGISTRY,
} from './wallets.generated'
@@ -28,6 +29,7 @@ export type {
} from './wallets.generated'
import {
LEDGER2_EXIT_REFUND_WALLETS,
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
LEDGER2_WALLET_REGISTRY,
} from './wallets.generated'
@@ -75,6 +77,15 @@ export const SHARE_WALLET_NAME = 'w.wal.share'
*/
export const MIN_SHARE_WALLET_NAME = 'w.reg.minshr'
/**
* Сет паевых («боевых») кошельков, возвращаемых пайщику при выходе из кооператива
* (`w.reg.minshr` + `w.wal.share` + `w.cap.blago`). Источник истины — контракт
* (`LEDGER2_EXIT_REFUND_WALLETS` в wallets.hpp), сгенерирован в wallets.generated.
* Контракт `confirmexit` пылесосит эти кошельки на возврат; backend-preview
* суммирует их же — расчёт всегда совпадает с тем, что вернёт контракт.
*/
export const EXIT_REFUND_WALLET_NAMES: readonly string[] = LEDGER2_EXIT_REFUND_WALLETS
/**
* Все wallet_name'ы, привязанные к какой-либо программе (program_id > 0).
* Исключает кошельки с required_program_id == 0 (например, `w.reg.minshr`).
@@ -32,6 +32,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_USER_SHARED_PRO
"required_program_id": 0,
"wallet_name": "w.cap.preimp",
},
{
"program_label": null,
"required_program_id": 0,
"wallet_name": "w.exp.adv",
},
{
"program_label": null,
"required_program_id": 0,
@@ -67,6 +72,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
"kind": "USER_SHARED",
"name": "w.cap.preimp",
},
{
"human_name": "Подотчётные средства пайщика",
"kind": "USER_SHARED",
"name": "w.exp.adv",
},
{
"human_name": "Регистрационный взнос в ожидании решения совета",
"kind": "USER_SHARED",
@@ -122,5 +132,10 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
"kind": "COOPERATIVE",
"name": "w.mkt.payout",
},
{
"human_name": "Пул программных расходов ЦПП «Благорост»",
"kind": "COOPERATIVE",
"name": "w.cap.pgexp",
},
]
`;
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
EXIT_REFUND_WALLET_NAMES,
LEDGER2_EXIT_REFUND_WALLETS,
LEDGER2_USER_SHARED_PROGRAM_MAPPING,
LEDGER2_WALLET_REGISTRY,
programIdForWallet,
@@ -35,4 +37,13 @@ describe('ledger2 wallets registry (generated from C++)', () => {
it('ЦК split: program_id=1 → share + member', () => {
expect(walletNamesForProgram(1).sort()).toEqual(['w.wal.member', 'w.wal.share'])
})
it('exit-refund сет: ровно три паевых кошелька (minshr + share + blago)', () => {
expect(LEDGER2_EXIT_REFUND_WALLETS).toEqual(['w.reg.minshr', 'w.wal.share', 'w.cap.blago'])
// алиас-обёртка ссылается на тот же сет
expect(EXIT_REFUND_WALLET_NAMES).toEqual(LEDGER2_EXIT_REFUND_WALLETS)
// каждый кошелёк сета зарегистрирован в реестре
const known = new Set(LEDGER2_WALLET_REGISTRY.map(w => w.name))
for (const w of LEDGER2_EXIT_REFUND_WALLETS) expect(known.has(w)).toBe(true)
})
})
@@ -64,11 +64,10 @@ div
DocumentHtmlReader(:html='document.html')
//- Итог к возврату — soft-панель под документом, читается как подбивка
//- к заявлению (а не «висящий» текст снизу).
//- к заявлению (а не «висящий» текст снизу). Сумма авторитетная: собирается
//- по сету паевых кошельков (тот же, что обходит контракт при возврате).
div.exit-summary(v-if='preview')
div.exit-summary__text
span.exit-summary__label Сумма к возврату
span.exit-summary__hint Планируемая сумма; итог фиксирует Совет
span.exit-summary__label Сумма к возврату
span.exit-summary__value {{ formatAsset2Digits(preview.total) }}
</template>
@@ -253,21 +252,11 @@ const handlerSubmit = async (): Promise<void> => {
background: var(--p-surface-2);
border-radius: var(--p-r-md);
}
.exit-summary__text {
display: flex;
flex-direction: column;
gap: var(--p-1);
min-width: 0;
}
.exit-summary__label {
font-size: var(--p-fs-body);
font-weight: 600;
color: var(--p-ink);
}
.exit-summary__hint {
font-size: var(--p-fs-meta);
line-height: var(--p-lh-meta);
color: var(--p-ink-3);
min-width: 0;
}
.exit-summary__value {
font-family: var(--p-mono);
@@ -25,7 +25,6 @@ BaseDialog(
:variant='paymentChip.variant',
size='sm'
) {{ paymentChip.label }}
span.exit-amount__hint.t-sm.t-faint(v-if='view.plannedHint') Планируемая сумма; итог фиксирует Совет.
p.exit-note.t-sm.t-muted(v-if='view.canCancel') Пока вы не перешли по ссылке, заявление можно отменить.
//- Футер: действия (отделён бордером внутри AuthCard).
@@ -107,7 +106,6 @@ const view = computed(() => {
title: 'Вы вышли из кооператива',
body: 'Возврат паевого взноса оплачен — дождитесь поступления средств на указанные реквизиты. Аккаунт заблокирован. Благодарим за участие в кооперативе.',
amountLabel: 'Сумма возврата',
plannedHint: false,
canCancel: false,
};
}
@@ -117,8 +115,7 @@ const view = computed(() => {
tone: 'primary',
title: 'Подтвердите выход по ссылке из письма',
body: 'Мы отправили письмо на вашу электронную почту — перейдите по ссылке в нём, чтобы подтвердить выход и запустить возврат паевого взноса. Письмо могло попасть в папку «Спам».',
amountLabel: 'Планируемая сумма к возврату',
plannedHint: false,
amountLabel: 'Сумма к возврату',
canCancel: true,
};
}
@@ -129,7 +126,6 @@ const view = computed(() => {
title: 'Совет одобрил выход',
body: 'Возврат паевого взноса будет совершён в срок, установленный Уставом кооператива. Аккаунт заблокирован — дождитесь поступления средств.',
amountLabel: 'Сумма к возврату',
plannedHint: false,
canCancel: false,
};
}
@@ -139,7 +135,6 @@ const view = computed(() => {
title: 'Заявление на рассмотрении Совета',
body: 'Ваше заявление о выходе передано в Совет кооператива. Кабинет заблокирован на время процедуры — дождитесь решения Совета.',
amountLabel: 'Сумма к возврату',
plannedHint: true,
canCancel: false,
};
});
@@ -238,9 +233,6 @@ const onLogout = async (): Promise<void> => {
.exit-amount__chip {
margin-top: var(--p-2);
}
.exit-amount__hint {
margin-top: var(--p-1);
}
.exit-note {
margin: var(--p-3) 0 0;