fix(capital+loan): review PR#402 round 2 — изоляция loan, счётчик займов в сегменте, бездокументарные роли

Round 2 ревью PR #402 — 8 замечаний:

LOAN-контракт независим от прикладных (capital/marketplace/...):
- loan.debts: убран binary_extension<checksum256> project_hash и индекс byuserproj
- loan.debts: добавлен binary_extension<name> source_contract (контракт-источник)
- loan::createdebt(): убран параметр project_hash, payer пишется в source_contract
- Loan::create_debt(): убран project_hash из сигнатуры и хелпера
- Loan::count_user_project_debts(): удалена — capital считает у себя
- Локальный контекст (project_hash и т.п.) остаётся в Capital::Debts

CAPITAL — счётчик активных займов на сегменте через binary_extension:
- Segment::active_debts_count (binary_extension<uint32_t>, хвостом)
- increase/decrease/get_active_debts_count хелперы
- debtpaycnfrm: после confirm_paid +1 счётчика сегмента
- settledebt: после mark_settled -1 счётчика
- signact2: лимит «10 займов на проект в одном паевом взносе» проверяется
  по счётчику (не через обход byprojhash); сам перебор ограничен счётчиком,
  ранний выход после нужного числа активных записей

role_requests — бездокументарная схема и строгая типизация:
- namespace Role { CREATOR, AUTHOR, MASTER, NONE } в role_requests.hpp
- удалены поля statement и master_decision из role_request struct
- create()/approve() — без параметров document2
- validate_role_or_fail и apply_role_to_project — через Role:: namespace
- requestrole/inviterole/approverole/acceptinvite — без statement/master_decision
- requestrateu — role = Role::NONE (не маркер "rate"_n), request_type = RATE_UPDATE
- decline сохраняет только reason; решения подписываются coopname-транзакцией

prerequisite fix:
- names.hpp: убран дубль Names::Capital::CREATE_PROGRAM_EXPENSE (был объявлен
  и в callback-блоке line 55, и в акцепт-блоке line 130 — оставлен один)

Замечания закрыты:
- PR comment id=3264976591 (фу-фу byuserproj) — индекс удалён
- id=3264980890 (count_user_project_debts) — функция удалена
- id=3265021918 (loan независим от capital) — изоляция выполнена
- id=3265072380 (rate как name через namespace) — RequestType::RATE_UPDATE + Role::NONE
- id=3265080990 (счётчик в binary_extension вместо перебора) — active_debts_count
- id=3265088096 (namespace типа роли) — Role::{CREATOR,AUTHOR,MASTER}
- id=3265101459 (без statement/master_decision) — поля удалены
- id=3265104196 (через Namespace проверка) — validate_role_or_fail через Role::

Сборка: cdt-cpp v4.1.0 capital.wasm + loan.wasm без ошибок.
Тесты: capital-blagorost.test.ts — формулировки A2/D обновлены под счётчик
       и бездокументарную схему.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-19 09:40:09 +00:00
parent 07f35ea084
commit c185b0167f
16 changed files with 189 additions and 125 deletions
@@ -60,30 +60,38 @@ describe('CAPITAL — расширения «Благорост» (Эпик 1)',
)
})
describe('A2 — Централизованный учёт займов (loan-контракт)', () => {
describe('A2 — Централизованный учёт займов (loan-контракт + счётчик сегмента)', () => {
it.todo(
'Story 1.12: debtpaycnfrm создаёт запись в loan.debts через inline createdebt (с project_hash)'
'Story 1.12: loan-контракт изолирован — loan.debts хранит только (coopname, username, debt_hash, amount, repaid_at) + source_contract; project_hash отсутствует'
)
it.todo(
'Story 1.12: settledebt в capital удаляет запись из loan.debts через inline settledebt'
'Story 1.12: debtpaycnfrm создаёт запись в loan.debts через inline createdebt и поднимает loan.summaries.total пайщика'
)
it.todo(
'Story 1.12: signact2 с N≤10 займами на проекте — все погашаются inline-actions в loan'
'Story 1.12: settledebt в capital удаляет запись из loan.debts через inline settledebt и опускает loan.summaries.total'
)
it.todo(
'Story 1.12: signact2 с >10 займами на проекте — fail с "Превышен лимит займов на проект для одного паевого взноса"'
'Story 1.12: debt в loan.debts помечается source_contract = вызвавший контракт (capital для capital→loan)'
)
it.todo(
'Story 1.12: debtpaycnfrm увеличивает active_debts_count на сегменте (binary_extension), settledebt — уменьшает'
)
it.todo(
'Story 1.12: signact2 с N≤10 займами на проекте — все погашаются inline-actions в loan; active_debts_count = 0'
)
it.todo(
'Story 1.12: signact2 с >10 займами на проекте (active_debts_count > 10) — fail сразу по счётчику, без обхода byprojhash'
)
it.todo(
'Story 1.12: loan.createdebt от аккаунта вне contracts_whitelist — fail (только capital/marketplace/...)'
)
it.todo(
'Story 1.12: count_user_project_debts возвращает корректное число активных займов пайщика на проекте'
)
})
describe('B — Расходы программы', () => {
@@ -108,9 +116,17 @@ describe('CAPITAL — расширения «Благорост» (Эпик 1)',
)
})
describe('D — Двухуровневые допуски и approved-ставка', () => {
describe('D — Двухуровневые допуски и approved-ставка (бездокументарная схема)', () => {
it.todo(
'Story 1.7: requestrole создаёт rolerequests со статусом PENDING; approverole с 1200 фиксирует approved_rate'
'Story 1.7: requestrole без statement создаёт rolerequests со статусом PENDING; approverole без master_decision фиксирует approved_rate'
)
it.todo(
'Story 1.7: requestrole с role вне Role::{CREATOR, AUTHOR, MASTER} — fail "Допустимые роли заявки..."'
)
it.todo(
'Story 1.7: requestrateu создаёт запись с role = Role::NONE и request_type = RequestType::RATE_UPDATE'
)
it.todo(
@@ -122,7 +138,7 @@ describe('CAPITAL — расширения «Благорост» (Эпик 1)',
)
it.todo(
'Story 1.7: inviterole → acceptinvite применяет инвайт-ставку к сегменту; declinvite оставляет сегмент без approved-rate'
'Story 1.7: inviterole без statement → acceptinvite применяет инвайт-ставку к сегменту; declinvite оставляет сегмент без approved-rate'
)
it.todo(
@@ -130,7 +146,7 @@ describe('CAPITAL — расширения «Благорост» (Эпик 1)',
)
it.todo(
'Story 1.7: requestrateu + approverole с новой ставкой обновляет approved_rate в сегменте'
'Story 1.7: requestrateu + approverole с новой ставкой обновляет approved_rate в сегменте; role.apply_role_to_project не вызывается'
)
})
@@ -32,11 +32,16 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
// Увеличиваем долг contributor (теперь долг активен и должен быть погашен через внесение результата)
Capital::Contributors::increase_debt_amount(coopname, contributor->id, exist_debt.amount);
// Централизованный учёт займа: дублируем запись в контракт loan через inline action.
// loan.debts становится единым реестром выданных займов для всех контрактов
// (capital, marketplace и др.) — общий механизм погашения и проверки лимитов.
// Локальный счётчик займов пайщика на проекте этого сегмента (+1).
// Используется в signact2 как лимит «10 займов на проект» без обхода byprojhash.
Capital::Segments::increase_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// Централизованный учёт займа в контракте loan — loan-контракт независим от capital,
// знает только (coopname, username, debt_hash, amount, repaid_at) + source_contract
// (заполняется самим loan::createdebt из payer-авторизации). Локальная привязка к
// проекту/программе остаётся в Capital::Debts (поле project_hash).
// due_at уже проставлен в confirm_paid, читаем обновлённое значение.
auto fresh_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
Loan::create_debt(_capital, coopname, exist_debt.username, exist_debt.project_hash,
Loan::create_debt(_capital, coopname, exist_debt.username,
debt_hash, fresh_debt.due_at, exist_debt.amount);
};
@@ -45,7 +45,10 @@ void capital::settledebt(name coopname, checksum256 debt_hash, eosio::asset amou
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, amount);
Capital::Debts::mark_settled(coopname, exist_debt.id, memo, _capital);
// Централизованный учёт: удаляем запись из loan.debts через inline action.
// Локальный счётчик займов пайщика на сегменте этого проекта (-1).
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// Централизованный учёт: уменьшаем сводную и удаляем запись из loan.debts через inline action.
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, amount);
// event ridge: и должник, и председатель видят факт погашения.
@@ -16,7 +16,7 @@ void capital::acceptinvite(name coopname, checksum256 request_hash) {
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
"Принять можно только инвайт");
Capital::RoleRequests::approve(coopname, req.id, req.rate_per_hour, req.hours_per_day, document2{});
Capital::RoleRequests::approve(coopname, req.id, req.rate_per_hour, req.hours_per_day);
Capital::Segments::set_approved_rate(
coopname, req.project_hash, req.username, req.rate_per_hour, req.hours_per_day
);
@@ -6,27 +6,27 @@
* Сегмент должен уже существовать (создаётся при подписании приложения договора УХД
* на этот проект через apprvappndx — L1-допуск).
*
* Бездокументарная схема: решение фиксируется только подписью транзакции
* (require_auth coopname), документ-решение мастера не передаётся.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (requestrole либо requestrateu)
* @param approved_rate Утверждённая мастером ставка часа (≠ запрошенной — допустимо)
* @param approved_hours Утверждённая норма часов
* @param master_decision Документ-решение мастера
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::approverole(name coopname, checksum256 request_hash,
eosio::asset approved_rate, uint64_t approved_hours,
document2 master_decision) {
eosio::asset approved_rate, uint64_t approved_hours) {
require_auth(coopname);
verify_document_or_fail(master_decision);
Wallet::validate_asset(approved_rate);
eosio::check(approved_rate.amount > 0, "Утверждённая ставка должна быть положительной");
eosio::check(approved_hours > 0 && approved_hours <= 8, "Норма часов — от 1 до 8");
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
Capital::RoleRequests::approve(coopname, req.id, approved_rate, approved_hours, master_decision);
Capital::RoleRequests::approve(coopname, req.id, approved_rate, approved_hours);
Capital::Segments::set_approved_rate(
coopname, req.project_hash, req.username, approved_rate, approved_hours
@@ -3,6 +3,9 @@
* приглашает кандидата на роль. Обратный поток к requestrole.
* Кандидат далее acceptinvite (с применением приглашённой ставки) либо declinvite.
*
* Бездокументарная схема: приглашение и принятие/отказ оформляются только
* подписью транзакции (require_auth coopname). Юридический документ-инвайт не нужен.
*
* Ставка часа в приглашении — это ставка, на которую кандидат соглашается через
* acceptinvite. Не путать с contributors.rate_per_hour (личная глобальная ставка).
*
@@ -11,22 +14,20 @@
* @param project_hash Проект / компонент
* @param candidate Приглашаемый пайщик (потенциальный участник)
* @param master Мастер-приглашающий (или координатор/председатель для роли master)
* @param role Предлагаемая роль: creator | author | master
* @param role Предлагаемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
* @param rate_per_hour Предлагаемая ставка часа
* @param hours_per_day Предлагаемая норма часов в день
* @param description Текст приглашения (может быть пустым)
* @param statement Инвайт-документ (RoleInviteStatement — Эпик 3)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
name candidate, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description, document2 statement) {
std::string description) {
require_auth(coopname);
Capital::RoleRequests::validate_role_or_fail(role);
verify_document_or_fail(statement);
Wallet::validate_asset(rate_per_hour);
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов — от 1 до 8");
@@ -36,7 +37,7 @@ void capital::inviterole(name coopname, checksum256 request_hash, checksum256 pr
rate_per_hour, hours_per_day,
Capital::RoleRequests::Direction::INVITE,
Capital::RoleRequests::RequestType::ROLE,
description, statement
description
);
// event ridge: кандидат и мастер видят инвайт.
@@ -1,8 +1,12 @@
/**
* @brief Заявка пайщика на обновление approved-ставки на сегменте.
* Тот же flow что и requestrole, но RequestType::RATE_UPDATE.
* Тот же flow что и requestrole, но через RequestType::RATE_UPDATE.
* approverole с новой ставкой → обновление сегмента; declinerole — без изменений.
*
* Бездокументарная схема: заявление-документ не передаётся, утверждение
* фиксируется только транзакцией. role в записи остаётся Role::NONE
* (валидация роли для RATE_UPDATE не применяется).
*
* @param coopname Кооператив
* @param request_hash Хеш заявки
* @param project_hash Проект / компонент
@@ -11,28 +15,26 @@
* @param new_rate Желаемая новая ставка
* @param new_hours Желаемая новая норма часов
* @param description Текст заявки на обновление ставки (может быть пустым)
* @param statement Заявление (RateUpdateStatement — Эпик 3)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master,
eosio::asset new_rate, uint64_t new_hours,
std::string description, document2 statement) {
std::string description) {
require_auth(coopname);
verify_document_or_fail(statement);
Wallet::validate_asset(new_rate);
eosio::check(new_rate.amount > 0, "Новая ставка должна быть положительной");
eosio::check(new_hours > 0 && new_hours <= 8, "Норма часов — от 1 до 8");
// role не значим для RATE_UPDATE — кладём "rate"_n как маркер (валидацию ролей не запускаем).
Capital::RoleRequests::create(
coopname, request_hash, project_hash, username, master, "rate"_n,
coopname, request_hash, project_hash, username, master,
Capital::RoleRequests::Role::NONE,
new_rate, new_hours,
Capital::RoleRequests::Direction::REQUEST,
Capital::RoleRequests::RequestType::RATE_UPDATE,
description, statement
description
);
// event ridge: заявитель и мастер компонента видят запрос на обновление ставки.
@@ -5,6 +5,10 @@
* (или председатель — для роли master, когда мастера в проекте ещё нет)
* решает: approverole (с фиксацией approved-ставки в сегменте) или declinerole.
*
* Бездокументарная схема: подача и решение оформляются только подписью транзакции
* (require_auth coopname). Юридическое заявление при приёме на роль не требуется —
* допуск даётся подписью действия на стороне кооператива.
*
* Ставка часа подаётся явно — это ставка пайщика именно под этот проект.
* По дефолту фронт подставляет contributors.rate_per_hour, но пайщик может изменить.
*
@@ -13,22 +17,20 @@
* @param project_hash Проект (или компонент — на уровне хэша одно)
* @param username Заявитель
* @param master Мастер компонента (ожидаемый одобряющий)
* @param role Запрашиваемая роль: creator | author | master
* @param role Запрашиваемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
* @param rate_per_hour Заявленная ставка часа под этот проект
* @param hours_per_day Заявленная норма часов в день
* @param description Текст заявки (может быть пустым)
* @param statement Заявление (документ RoleRequestStatement — Эпик 3)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description, document2 statement) {
std::string description) {
require_auth(coopname);
Capital::RoleRequests::validate_role_or_fail(role);
verify_document_or_fail(statement);
Wallet::validate_asset(rate_per_hour);
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов в день — от 1 до 8");
@@ -38,7 +40,7 @@ void capital::requestrole(name coopname, checksum256 request_hash, checksum256 p
rate_per_hour, hours_per_day,
Capital::RoleRequests::Direction::REQUEST,
Capital::RoleRequests::RequestType::ROLE,
description, statement
description
);
// event ridge: заявитель и мастер компонента видят новую заявку.
@@ -66,28 +66,35 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
if (result -> debt_amount.amount > 0){
Ledger2::apply(_capital, coopname, operations::capital::REPAY, result -> debt_amount, result -> username, result_hash, memo);
// Централизованное погашение конкретных займов в loan-контракте.
// Собираем все активные (PAID/OVERDUE) займы пайщика на этом проекте,
// лимит 10 — иначе одна транзакция EOSIO с 11+ inline actions не уложится в лимит.
// Лимит «10 займов на проект в одном паевом взносе» проверяем по счётчику
// active_debts_count на сегменте (binary_extension) — без обхода byprojhash.
// Сам счётчик инкрементируется в debtpaycnfrm и декрементируется в mark_settled.
uint32_t active_cnt = Capital::Segments::get_active_debts_count(coopname, result->project_hash, result->username);
eosio::check(active_cnt <= 10,
"Превышен лимит займов на проект для одного паевого взноса (10)");
// Перебираем долги пайщика по проекту, но не больше известного счётчика —
// ранний выход сразу после нужного числа активных записей.
Capital::Debts::debts_index debts(_capital, coopname.value);
auto byproj = debts.get_index<"byprojhash"_n>();
std::vector<std::tuple<uint64_t, checksum256, eosio::asset>> to_settle;
to_settle.reserve(active_cnt);
for (auto it = byproj.find(result->project_hash);
it != byproj.end() && it->project_hash == result->project_hash; ++it) {
it != byproj.end() && it->project_hash == result->project_hash
&& to_settle.size() < active_cnt; ++it) {
if (it->username == result->username
&& (it->status == Capital::Debts::Status::PAID
|| it->status == Capital::Debts::Status::OVERDUE)) {
to_settle.emplace_back(it->id, it->debt_hash, it->amount);
}
}
eosio::check(to_settle.size() <= 10,
"Превышен лимит займов на проект для одного паевого взноса (10)");
for (auto& tup : to_settle) {
auto id = std::get<0>(tup);
auto dh = std::get<1>(tup);
auto amt = std::get<2>(tup);
Loan::settle_debt(_capital, coopname, result->username, dh, amt);
Capital::Debts::mark_settled(coopname, id, memo, _capital);
Capital::Segments::decrease_active_debts_count(coopname, result->project_hash, result->username);
}
}
+6 -6
View File
@@ -348,17 +348,17 @@ public:
[[eosio::action]]
void declpgexp(name coopname, checksum256 expense_hash, std::string reason);
// L2-роли и approved-ставки сегмента (Story 1.7)
// L2-роли и approved-ставки сегмента (Story 1.7) — бездокументарная схема,
// решения фиксируются только подписью транзакции (require_auth coopname).
[[eosio::action]]
void requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description, document2 statement);
std::string description);
[[eosio::action]]
void approverole(name coopname, checksum256 request_hash,
eosio::asset approved_rate, uint64_t approved_hours,
document2 master_decision);
eosio::asset approved_rate, uint64_t approved_hours);
[[eosio::action]]
void declinerole(name coopname, checksum256 request_hash, std::string reason);
@@ -367,7 +367,7 @@ public:
void inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
name candidate, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description, document2 statement);
std::string description);
[[eosio::action]]
void acceptinvite(name coopname, checksum256 request_hash);
@@ -379,6 +379,6 @@ public:
void requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master,
eosio::asset new_rate, uint64_t new_hours,
std::string description, document2 statement);
std::string description);
};
@@ -33,8 +33,24 @@ namespace RequestType {
constexpr name RATE_UPDATE = "rateupdate"_n;
}
/**
* @brief Допустимые роли заявки на L2-допуск.
* Контрибьюторы и инвесторы — L1, через role_requests не идут.
*/
namespace Role {
constexpr name CREATOR = "creator"_n;
constexpr name AUTHOR = "author"_n;
constexpr name MASTER = "master"_n;
constexpr name NONE = name{}; ///< Для RATE_UPDATE: поле role не значимо
}
/**
* @brief Очередь заявок на L2-допуск/инвайт и на обновление approved-ставки.
*
* Бездокументарная схема: подача и решение оформляются только транзакцией
* блокчейна (require_auth coopname). Юридическое заявление при приёме на роль
* не требуется — допуск даётся через подпись действия на стороне кооператива.
*
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): rolerequests
*/
@@ -45,19 +61,17 @@ struct [[eosio::table, eosio::contract(CAPITAL)]] role_request {
checksum256 project_hash; ///< Проект / компонент
eosio::name username; ///< Заявитель (REQUEST) или кандидат (INVITE)
eosio::name master; ///< Мастер компонента (одобряющий) — при invite на роль master это координатор/председатель
eosio::name role; ///< Заявляемая роль: creator | author | master (контрибьютор — L1, сюда не попадает)
eosio::asset rate_per_hour = asset(0, _root_govern_symbol); ///< Заявленная пайщиком/мастером ставка часа (всегда явная, не из contributors)
uint64_t hours_per_day = 0; ///< Заявленная норма часов
eosio::asset approved_rate = asset(0, _root_govern_symbol); ///< Утверждённая ставка (≠ requested при approve)
uint64_t approved_hours = 0; ///< Утверждённая норма часов
eosio::name direction = Direction::REQUEST; ///< request | invite
eosio::name request_type = RequestType::ROLE; ///< role | rateupdate
eosio::name status = Status::PENDING; ///< pending | approved | declined
eosio::name role = Role::NONE; ///< Role::CREATOR | Role::AUTHOR | Role::MASTER (для RequestType::RATE_UPDATE — Role::NONE)
eosio::asset rate_per_hour = asset(0, _root_govern_symbol); ///< Заявленная пайщиком/мастером ставка часа (всегда явная, не из contributors)
uint64_t hours_per_day = 0; ///< Заявленная норма часов
eosio::asset approved_rate = asset(0, _root_govern_symbol); ///< Утверждённая ставка (≠ requested при approve)
uint64_t approved_hours = 0; ///< Утверждённая норма часов
eosio::name direction = Direction::REQUEST; ///< request | invite
eosio::name request_type = RequestType::ROLE; ///< role | rateupdate
eosio::name status = Status::PENDING; ///< pending | approved | declined
std::string description; ///< Текст заявки / приглашения (может быть пустым)
document2 statement; ///< Заявление пайщика / инвайт-документ мастера
document2 master_decision; ///< Решение мастера (approve/decline)
std::string decline_reason; ///< Причина отказа (если DECLINED)
time_point_sec created_at = current_time_point();
time_point_sec created_at = current_time_point();
uint64_t primary_key() const { return id; }
checksum256 by_request_hash() const { return request_hash; }
@@ -97,8 +111,7 @@ inline void create(
uint64_t hours_per_day,
eosio::name direction,
eosio::name request_type,
const std::string &description,
const document2 &statement
const std::string &description
) {
role_requests_index t(_capital, coopname.value);
eosio::check(!get_role_request(coopname, request_hash).has_value(),
@@ -119,25 +132,25 @@ inline void create(
r.request_type = request_type;
r.status = Status::PENDING;
r.description = description;
r.statement = statement;
r.created_at = current_time_point();
});
}
/**
* @brief Проверяет, что role входит в допустимый набор {creator, author, master}.
* @brief Проверяет, что role входит в допустимый набор Role::{CREATOR, AUTHOR, MASTER}.
* Контрибьюторы и инвесторы — это L1-роли и через role_requests не идут.
* Не применяется к заявкам RequestType::RATE_UPDATE (там role = Role::NONE).
*/
inline void validate_role_or_fail(eosio::name role) {
eosio::check(
role == "creator"_n || role == "author"_n || role == "master"_n,
role == Role::CREATOR || role == Role::AUTHOR || role == Role::MASTER,
"Допустимые роли заявки: creator, author, master"
);
}
/**
* @brief Применяет заявленную роль к проекту через inline action:
* author → addauthor, master → setmaster, creator → no-op
* Role::AUTHOR → addauthor, Role::MASTER → setmaster, Role::CREATOR → no-op
* (creator фиксируется фактически при первом createcmmt).
*
* Вызывается из approverole/acceptinvite после фиксации решения, чтобы
@@ -150,14 +163,14 @@ inline void apply_role_to_project(
eosio::name username,
eosio::name role
) {
if (role == "author"_n) {
if (role == Role::AUTHOR) {
eosio::action(
eosio::permission_level{coopname, "active"_n},
_capital,
"addauthor"_n,
std::make_tuple(coopname, project_hash, username)
).send();
} else if (role == "master"_n) {
} else if (role == Role::MASTER) {
eosio::action(
eosio::permission_level{coopname, "active"_n},
_capital,
@@ -171,8 +184,7 @@ inline void approve(
eosio::name coopname,
uint64_t request_id,
const eosio::asset &approved_rate,
uint64_t approved_hours,
const document2 &master_decision
uint64_t approved_hours
) {
role_requests_index t(_capital, coopname.value);
auto itr = t.find(request_id);
@@ -182,7 +194,6 @@ inline void approve(
r.status = Status::APPROVED;
r.approved_rate = approved_rate;
r.approved_hours = approved_hours;
r.master_decision = master_decision;
});
}
@@ -120,6 +120,12 @@ namespace Capital::Segments {
eosio::binary_extension<eosio::asset> approved_rate_per_hour; ///< Утверждённая мастером ставка часа на этом сегменте
eosio::binary_extension<uint64_t> approved_hours_per_day; ///< Утверждённое мастером количество часов в день на этом сегменте
// Счётчик активных (PAID/OVERDUE) займов пайщика на проекте этого сегмента.
// Инкрементируется в Capital::Debts::confirm_paid, декрементируется в mark_settled.
// Используется как лимит «10 займов на проект в одном паевом взносе» (signact2),
// чтобы не перебирать byprojhash и не упираться в 30-сек лимит транзакции.
eosio::binary_extension<uint32_t> active_debts_count;
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
checksum256 by_project_hash() const { return project_hash; } ///< Индекс по хэшу проекта (2)
@@ -449,14 +455,57 @@ inline void increase_debt_amount(eosio::name coopname, uint64_t segment_id, eosi
inline void decrease_debt_amount(eosio::name coopname, uint64_t segment_id, eosio::asset amount) {
Capital::Segments::segments_index segments(_capital, coopname.value);
auto segment = segments.find(segment_id);
eosio::check(segment->debt_amount >= amount, "Пайщик не может погасить долг больше, чем должен");
segments.modify(segment, coopname, [&](auto &s) {
s.debt_amount -= amount;
});
}
/**
* @brief Увеличивает счётчик активных займов пайщика на сегменте на +1.
* Вызывается в Capital::Debts::confirm_paid после перехода долга в PAID.
*/
inline void increase_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
s.active_debts_count.emplace(cur + 1);
});
}
/**
* @brief Уменьшает счётчик активных займов пайщика на сегменте на -1.
* Вызывается в Capital::Debts::mark_settled (settledebt и signact2-погашение).
*/
inline void decrease_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
eosio::check(cur > 0, "Счётчик активных займов уже на нуле");
s.active_debts_count.emplace(cur - 1);
});
}
/**
* @brief Текущее значение счётчика активных займов пайщика на сегменте.
* Используется в signact2 как лимит обхода долгов проекта (≤10).
*/
inline uint32_t get_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
auto seg = get_segment(coopname, project_hash, username);
if (!seg.has_value()) return 0;
return seg->active_debts_count.has_value() ? *seg->active_debts_count : 0;
}
/**
* @brief Удаляет сегмент участника
* @param coopname Имя кооператива
@@ -12,7 +12,7 @@
using namespace eosio;
using std::string;
#define CREATEDEBT_SIGNATURE name coopname, name username, checksum256 project_hash, checksum256 debt_hash, time_point_sec repaid_at, asset quantity
#define CREATEDEBT_SIGNATURE name coopname, name username, checksum256 debt_hash, time_point_sec repaid_at, asset quantity
#define SETTLEDEBT_SIGNATURE name coopname, name username, checksum256 debt_hash, asset quantity
using createdebt_interface = void(CREATEDEBT_SIGNATURE);
@@ -51,33 +51,9 @@ inline void assert_no_expired_debts(name coopname, name username) {
}
}
/**
* @brief Кол-во активных займов пайщика на конкретном проекте.
* Используется в capital::signact2 для лимита 10 займов на проект
* при автоматическом погашении из паевого взноса.
*/
inline uint64_t count_user_project_debts(
name coopname, const checksum256 &project_hash, name username
) {
debts_index debts(_loan, coopname.value);
auto idx = debts.get_index<"byuserproj"_n>();
auto p_bytes = project_hash.extract_as_byte_array();
uint64_t hi = (uint64_t)p_bytes[0] << 56 | (uint64_t)p_bytes[1] << 48
| (uint64_t)p_bytes[2] << 40 | (uint64_t)p_bytes[3] << 32
| (uint64_t)p_bytes[4] << 24 | (uint64_t)p_bytes[5] << 16
| (uint64_t)p_bytes[6] << 8 | (uint64_t)p_bytes[7];
uint128_t key = ((uint128_t)hi << 64) | username.value;
uint64_t cnt = 0;
for (auto it = idx.lower_bound(key); it != idx.end() && it->by_user_project() == key; ++it) {
++cnt;
}
return cnt;
}
inline void create_debt(name calling_contract, CREATEDEBT_SIGNATURE) {
Action::send<createdebt_interface>(_loan, Names::Loan::CREATE_DEBT, calling_contract, coopname, username,
project_hash, debt_hash, repaid_at, quantity);
debt_hash, repaid_at, quantity);
}
inline void settle_debt(name calling_contract, SETTLEDEBT_SIGNATURE) {
+1 -1
View File
@@ -52,7 +52,7 @@ namespace Names {
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
// Коллбэки для программных расходов
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n;
// (CREATE_PROGRAM_EXPENSE объявлен ниже в блоке "акцептов" — общий для обоих контекстов)
constexpr eosio::name AUTHORIZE_PROGRAM_EXPENSE = "authpgexp"_n;
constexpr eosio::name DECLINE_PROGRAM_EXPENSE = "declpgexp"_n;
constexpr eosio::name CONFIRM_PROGRAM_EXPENSE_PAYMENT = "pgexppay"_n;
@@ -20,25 +20,17 @@ struct [[eosio::table, eosio::contract(LOAN)]] debt {
time_point_sec created_at;
time_point_sec repaid_at;
// Привязка займа к проекту/программе вызывающего контракта (для паевого взноса
// нужно знать, какие займы пайщика гасятся именно по этому проекту).
// binary_extension: таблица debts уже в продакшене (но пустая), новые поля только хвостом.
binary_extension<checksum256> project_hash;
// Контракт-источник, инициировавший выдачу займа (capital / marketplace / ...).
// loan не знает доменного контекста (проекта, программы) — это ответственность
// вызывающего контракта; ему же принадлежит и локальная привязка debt_hash.
// binary_extension: таблица в продакшене (пустая), новые поля только хвостом.
binary_extension<name> source_contract;
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_debt_hash() const { return debt_hash; }
uint64_t by_created() const { return created_at.sec_since_epoch(); }
uint64_t by_repaid() const { return repaid_at.sec_since_epoch(); }
uint128_t by_user_project() const {
checksum256 ph = project_hash.has_value() ? *project_hash : checksum256{};
auto p_bytes = ph.extract_as_byte_array();
uint64_t hi = (uint64_t)p_bytes[0] << 56 | (uint64_t)p_bytes[1] << 48
| (uint64_t)p_bytes[2] << 40 | (uint64_t)p_bytes[3] << 32
| (uint64_t)p_bytes[4] << 24 | (uint64_t)p_bytes[5] << 16
| (uint64_t)p_bytes[6] << 8 | (uint64_t)p_bytes[7];
return ((uint128_t)hi << 64) | username.value;
}
};
typedef multi_index<
@@ -46,8 +38,7 @@ typedef multi_index<
indexed_by<"byusername"_n, const_mem_fun<debt, uint64_t, &debt::by_username>>,
indexed_by<"bydebthash"_n, const_mem_fun<debt, checksum256, &debt::by_debt_hash>>,
indexed_by<"bycreated"_n, const_mem_fun<debt, uint64_t, &debt::by_created>>,
indexed_by<"byrepaid"_n, const_mem_fun<debt, uint64_t, &debt::by_repaid>>,
indexed_by<"byuserproj"_n, const_mem_fun<debt, uint128_t, &debt::by_user_project>>>
indexed_by<"byrepaid"_n, const_mem_fun<debt, uint64_t, &debt::by_repaid>>>
debts_index;
} // namespace Loan
@@ -2,12 +2,13 @@ using namespace Loan;
/**
* @brief Создание долгового обязательства.
* Создает новое долговое обязательство для пользователя с указанным сроком погашения
* и привязкой к проекту вызывающего контракта.
* Создает новое долговое обязательство пайщика. loan-контракт независим от
* прикладных контрактов — здесь нет привязки к проекту/программе/компоненту.
* Локальный контекст (project_hash и т.п.) хранится на стороне вызывающего
* контракта в его собственной таблице долгов.
* @param coopname Наименование кооператива
* @param username Имя пользователя-должника
* @param project_hash Хэш проекта/программы (для лимита займов на проект при паевом взносе)
* @param debt_hash Хэш долгового обязательства
* @param debt_hash Хэш долгового обязательства (идентификатор)
* @param repaid_at Срок погашения долга
* @param quantity Сумма долга
* @ingroup public_actions
@@ -15,7 +16,7 @@ using namespace Loan;
* @note Авторизация требуется от аккаунта: @p contracts_whitelist
*/
void loan::createdebt(name coopname, name username, checksum256 project_hash, checksum256 debt_hash, time_point_sec repaid_at, asset quantity) {
void loan::createdebt(name coopname, name username, checksum256 debt_hash, time_point_sec repaid_at, asset quantity) {
name payer = check_auth_and_get_payer_or_fail(contracts_whitelist);
check(quantity.amount > 0, "Сумма должна быть положительной");
@@ -35,7 +36,7 @@ void loan::createdebt(name coopname, name username, checksum256 project_hash, ch
d.amount = quantity;
d.created_at = time_point_sec(current_time_point());
d.repaid_at = repaid_at;
d.project_hash.emplace(project_hash);
d.source_contract.emplace(payer);
});
Loan::summaries_index summaries(_loan, coopname.value);