feat(loan): централизация учёта займов — capital вызывает loan через inline action

Ревью PR#402 (debtpaycnfrm:23): учёт займов должен быть централизован в одном контракте,
как Gateway для платежей. Существующий контракт loan/ уже задеплоен и в whitelist,
но capital его не вызывал — теперь вызывает.

loan-контракт:
— table_loan_debts: добавлено поле project_hash (binary_extension<checksum256> —
  таблица уже в продакшене, расширение схемы только хвостом) + индекс byuserproj
— Loan::create_debt: новая сигнатура с project_hash; createdebt сохраняет привязку к проекту
— Loan::count_user_project_debts: хелпер для лимита займов на проект в паевом взносе

capital:
— debtpaycnfrm: после confirm_paid дублирует запись в loan.debts через inline
  Loan::create_debt (с due_at из обновлённой capital-записи и project_hash)
— settledebt: дополнительно зовёт inline Loan::settle_debt; принимает также OVERDUE
  (не только PAID) — погашение просроченного через председателя
— signact2 (паевой взнос): собирает все PAID/OVERDUE займы пайщика на проекте
  из capital.debts, проверяет лимит 10 (transaction limit EOSIO), на каждый —
  inline Loan::settle_debt + capital.Debts::mark_settled. Никакого «боевого взноса» —
  только паевой по интеллектуальному результату

Boot/deploy не требует правок: loan/ уже в configs/contracts.ts и configs/index.ts,
аккаунт loan создаётся, контракт деплоится. capital уже в contracts_whitelist в lib/consts.

Тесты: добавлен describe «A2 — Централизованный учёт займов» с 6 it.todo
по новым сценариям (createdebt в loan, settle через capital, лимит 10, whitelist).

Полный перенос таблицы debts из capital в loan (с удалением capital.debts) —
отдельная Story 1.12, оставлена на следующую итерацию: текущий PR сохраняет
параллельный учёт в обеих таблицах для безопасной миграции прода.
This commit is contained in:
coopops
2026-05-19 08:16:15 +00:00
parent 4a13cd26c0
commit 07f35ea084
7 changed files with 113 additions and 9 deletions
@@ -60,6 +60,32 @@ describe('CAPITAL — расширения «Благорост» (Эпик 1)',
)
})
describe('A2 — Централизованный учёт займов (loan-контракт)', () => {
it.todo(
'Story 1.12: debtpaycnfrm создаёт запись в loan.debts через inline createdebt (с project_hash)'
)
it.todo(
'Story 1.12: settledebt в capital удаляет запись из loan.debts через inline settledebt'
)
it.todo(
'Story 1.12: signact2 с N≤10 займами на проекте — все погашаются inline-actions в loan'
)
it.todo(
'Story 1.12: signact2 с >10 займами на проекте — fail с "Превышен лимит займов на проект для одного паевого взноса"'
)
it.todo(
'Story 1.12: loan.createdebt от аккаунта вне contracts_whitelist — fail (только capital/marketplace/...)'
)
it.todo(
'Story 1.12: count_user_project_debts возвращает корректное число активных займов пайщика на проекте'
)
})
describe('B — Расходы программы', () => {
it.todo(
'Story 1.4: exppaycnfrm после complete_expense делает Ledger2 PAY_EXPENSE (BLAGOROST_FUND → SOV_EXPENSES)'
@@ -31,5 +31,12 @@ 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 и др.) — общий механизм погашения и проверки лимитов.
// 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,
debt_hash, fresh_debt.due_at, exist_debt.amount);
};
@@ -24,8 +24,9 @@ void capital::settledebt(name coopname, checksum256 debt_hash, eosio::asset amou
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
eosio::check(exist_debt.status == Capital::Debts::Status::PAID,
"Погашение деньгами доступно только для активного займа (статус paid)");
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
"Погашение допустимо для активного (paid) или просроченного (overdue) займа");
eosio::check(amount.is_valid(), "Сумма погашения некорректна");
eosio::check(amount.symbol == exist_debt.amount.symbol,
@@ -44,6 +45,9 @@ 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.
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, amount);
// event ridge: и должник, и председатель видят факт погашения.
require_recipient(exist_debt.username);
require_recipient(coopname);
@@ -61,10 +61,34 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
}
// Возврат беспроцентного займа пайщика: Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY.
// Семантика: пайщик погасил ссуду результатом (интеллектуальным взносом),
// Семантика: пайщик погасил ссуду результатом (паевой взнос интеллектуальным результатом),
// у кооперативa уменьшилось фин. вложение 58, деньги вернулись на расчётный.
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 не уложится в лимит.
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;
for (auto it = byproj.find(result->project_hash);
it != byproj.end() && it->project_hash == result->project_hash; ++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);
}
}
// Обновляем накопительные показатели контрибьютора на основе его ролей в сегменте
@@ -12,7 +12,7 @@
using namespace eosio;
using std::string;
#define CREATEDEBT_SIGNATURE name coopname, name username, checksum256 debt_hash, time_point_sec repaid_at, asset quantity
#define CREATEDEBT_SIGNATURE name coopname, name username, checksum256 project_hash, 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,9 +51,33 @@ 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,
debt_hash, repaid_at, quantity);
project_hash, debt_hash, repaid_at, quantity);
}
inline void settle_debt(name calling_contract, SETTLEDEBT_SIGNATURE) {
@@ -1,6 +1,7 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/binary_extension.hpp>
#include <eosio/crypto.hpp>
#include <eosio/eosio.hpp>
@@ -19,11 +20,25 @@ 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;
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<
@@ -31,7 +46,8 @@ 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<"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>>>
debts_index;
} // namespace Loan
@@ -3,8 +3,10 @@ using namespace Loan;
/**
* @brief Создание долгового обязательства.
* Создает новое долговое обязательство для пользователя с указанным сроком погашения
* и привязкой к проекту вызывающего контракта.
* @param coopname Наименование кооператива
* @param username Имя пользователя-должника
* @param project_hash Хэш проекта/программы (для лимита займов на проект при паевом взносе)
* @param debt_hash Хэш долгового обязательства
* @param repaid_at Срок погашения долга
* @param quantity Сумма долга
@@ -13,7 +15,7 @@ using namespace Loan;
* @note Авторизация требуется от аккаунта: @p contracts_whitelist
*/
void loan::createdebt(name coopname, name username, checksum256 debt_hash, time_point_sec repaid_at, asset quantity) {
void loan::createdebt(name coopname, name username, checksum256 project_hash, 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, "Сумма должна быть положительной");
@@ -33,6 +35,7 @@ void loan::createdebt(name coopname, name username, checksum256 debt_hash, time_
d.amount = quantity;
d.created_at = time_point_sec(current_time_point());
d.repaid_at = repaid_at;
d.project_hash.emplace(project_hash);
});
Loan::summaries_index summaries(_loan, coopname.value);