[C28-29][@ant] feat(contracts): новый контракт expense — 8 actions, шасси расходов
Typecheck / desktop (pull_request) Successful in 13m4s
Typecheck / controller (pull_request) Successful in 12m44s

components/contracts/cpp/expense/:
- expense.hpp: 1 таблица proposals (scope=coopname; secondary by_hash/by_username/by_status),
  типы item, callback_handler, enum Mechanics/RecipientType/ProposalStatus/ItemStatus.
- expense.cpp: 8 actions (createexp/authexp/declexp/payexp/reportexp/closeexp/returnexp/overspendexp).
- CMakeLists.txt: стандартный add_contract(expense expense expense.cpp).

components/contracts/cpp/lib/consts.hpp:
- _expense = expense_n + добавлен в contracts_whitelist.

components/contracts/CMakeLists.txt:
- add_contract_build(expense).

Принцип v3:
- Один процесс p.exp.expns, 5 ledger2 operation_codes (o.exp.{blgadv,blgdir,advrpt,advret,over}).
- createexp принимает operation_code+source_wallet+callback в payload — контракт агностичен к источнику.
- payexp на DIRECT-механике сразу помечает item REPORTED (BURN blago = один полный шаг).
- payexp на ADVANCE — отдельный reportexp пайщиком после чека.
- closeexp шлёт inline action на сохранённый callback (contract,action,data) если он непустой.
- overspendexp — две последовательные Ledger2::apply (OVERSPEND + ADVANCE_REPORT) в одной транзакции.
This commit is contained in:
coopops
2026-06-02 18:17:12 +00:00
parent 42165ec3e8
commit 8897a93e83
5 changed files with 548 additions and 1 deletions
+1
View File
@@ -58,6 +58,7 @@ add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(expense)
add_contract_build(system)
# Опции для тестов
@@ -0,0 +1,10 @@
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()
@@ -0,0 +1,322 @@
// expense.cpp — шасси расходов (MVP: Благорост).
//
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
// operation_code передаётся в createexp; callback-handler сохраняется как переменная;
// все ledger2-проводки идут через Ledger2::apply на коды из OPERATION_REGISTRY.
#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;
}
void check_operation_code(eosio::name code) {
// На MVP принимаем только зарегистрированные коды expense из ledger2 OPERATION_REGISTRY.
eosio::check(
code == operations::expense::BLAGO_ADVANCE ||
code == operations::expense::BLAGO_DIRECT,
"operation_code должен быть o.exp.blgadv или o.exp.blgdir на этапе payexp/createexp"
);
}
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,
const eosio::checksum256& proposal_hash,
const eosio::asset& amount,
eosio::name self) {
if (cb.contract.value == 0) return;
eosio::action(
eosio::permission_level{self, "active"_n},
cb.contract,
cb.action,
std::make_tuple(proposal_hash, amount, cb.data)
).send();
}
} // namespace
// ── actions ──────────────────────────────────────────────────────────────────
void expense::createexp(name coopname, name username,
checksum256 proposal_hash,
name operation_code,
name source_wallet,
std::vector<D::item> items,
D::callback_handler callback,
document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement, {username});
check_operation_code(operation_code);
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
eosio::check(ledger2_is_known_wallet(source_wallet),
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
auto tbl = get_proposals(get_self(), coopname);
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
"СЗ с таким proposal_hash уже существует");
const auto total = sum_planned(items);
for (auto& it : items) {
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
it.actual_amount = zero_like(it.planned_amount);
}
tbl.emplace(get_self(), [&](auto& row) {
row.id = tbl.available_primary_key();
row.proposal_hash = proposal_hash;
row.username = username;
row.operation_code = operation_code;
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;
});
}
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
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::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(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::CLOSED),
"Нельзя отклонить закрытый СЗ");
idx.modify(it, get_self(), [&](auto& row) {
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
row.updated_at = eosio::current_time_point();
});
}
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");
bool item_found = false;
D::Mechanics mech = D::Mechanics::ADVANCE;
asset total_after = it->total_actual;
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::APPROVED),
"Item уже оплачен или закрыт");
i.actual_amount = actual_amount;
i.status = static_cast<uint8_t>(D::ItemStatus::PAID);
mech = static_cast<D::Mechanics>(i.mechanics);
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += actual_amount;
row.status = static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
row.updated_at = eosio::current_time_point();
total_after = row.total_actual;
});
// ledger2 проводка по operation_code из proposal — выдача аванса или прямая оплата.
Ledger2::apply(get_self(), coopname, it->operation_code, actual_amount,
it->username, proposal_hash, "expense:payexp");
// DIRECT — фоновый reportexp (закрываем подотчёт нулевым BURN, без чека от пайщика).
if (mech == D::Mechanics::DIRECT) {
// Для DIRECT нет ADVANCE_HOLD-кошелька, поэтому advrpt-BURN не применим.
// DIRECT считается «закрытым» сразу после payexp: помечаем item REPORTED.
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
if (i.item_hash == item_hash) {
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
break;
}
}
row.updated_at = eosio::current_time_point();
});
}
}
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(), "СЗ не найден");
asset item_amount = asset{0, it->total_actual.symbol};
bool item_found = false;
bool all_reported = true;
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),
"Отчёт возможен только для оплаченных 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_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED) &&
i.status != static_cast<uint8_t>(D::ItemStatus::RETURNED)) {
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 ADVANCE_HOLD, без бухпроводки — canal 08/51 уже сделан на blgadv.
Ledger2::apply(get_self(), coopname, operations::expense::ADVANCE_REPORT, item_amount,
it->username, 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::onexpreport и т.п.) — если был установлен при createexp.
send_callback_if_any(it->callback, proposal_hash, 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(), "СЗ не найден");
bool item_found = false;
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(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
"return_amount должен быть положительным и не превышать actual_amount");
i.actual_amount -= return_amount;
i.status = static_cast<uint8_t>(D::ItemStatus::RETURNED);
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual -= return_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: TRANSFER ADVANCE_HOLD → BLAGOROST_FUND, Dr 51 / Cr 08.
Ledger2::apply(get_self(), coopname, operations::expense::ADVANCE_RETURN, return_amount,
it->username, 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(), "СЗ не найден");
bool item_found = false;
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-механике");
i.actual_amount += overspend_amount;
i.status = static_cast<uint8_t>(D::ItemStatus::OVERSPENT);
item_found = true;
break;
}
}
eosio::check(item_found, "item с заданным hash не найден в СЗ");
row.total_actual += overspend_amount;
row.updated_at = eosio::current_time_point();
});
// ledger2: 1) OVERSPEND (выдача доплаты) → 2) ADVANCE_REPORT (закрытие подотчёта).
Ledger2::apply(get_self(), coopname, operations::expense::OVERSPEND, overspend_amount,
it->username, proposal_hash, "expense:overspend");
Ledger2::apply(get_self(), coopname, operations::expense::ADVANCE_REPORT, overspend_amount,
it->username, proposal_hash, "expense:overspend-report");
}
@@ -0,0 +1,212 @@
#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;
* operation_code из ledger2 OPERATION_REGISTRY передаётся в payload payexp;
* 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, ///< Закрыт отчётом (чек приложен).
RETURNED = 3, ///< Неиспользованный остаток вернули в пул.
OVERSPENT = 4, ///< Был перерасход — задокументирован отдельной OVERSPEND op.
};
/**
* @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::checksum256 proposal_hash;
eosio::name username; ///< создатель
eosio::name operation_code; ///< из ledger2 OPERATION_REGISTRY (например, o.exp.blgadv)
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)(proposal_hash)(username)(operation_code)(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 + operation_code из OPERATION_REGISTRY) + опц. callback на финализацию.
* Подписывает создатель (signact1 statement_doc, type=2010).
*/
[[eosio::action]]
void createexp(name coopname, name username,
checksum256 proposal_hash,
name operation_code,
name source_wallet,
std::vector<ExpenseDomain::item> items,
ExpenseDomain::callback_handler callback,
document2 statement);
/**
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
* После этого расход доступен для оплаты.
*/
[[eosio::action]]
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
/**
* @brief Отклонить СЗ (до или после авторизации).
*/
[[eosio::action]]
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
/**
* @brief Оплатить item — выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
* Контракт зовёт Ledger2::apply(operation_code, actual_amount, ...).
* Для DIRECT-механики сразу за payexp вызывает reportexp (фоном).
*/
[[eosio::action]]
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset actual_amount);
/**
* @brief Отчёт о расходе (ADVANCE) — пайщик закрывает item чеком.
* Зовёт Ledger2::apply(o.exp.advrpt). Когда все 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-остаток).
* Зовёт Ledger2::apply(o.exp.advret).
*/
[[eosio::action]]
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset return_amount);
/**
* @brief Доплата при перерасходе (ADVANCE).
* Сначала Ledger2::apply(o.exp.over) — выдача доплаты;
* сразу за ней Ledger2::apply(o.exp.advrpt) — закрытие подотчёта.
* Две последовательные записи в одной транзакции.
*/
[[eosio::action]]
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
asset overspend_amount);
};
+3 -1
View File
@@ -165,6 +165,7 @@ 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;
@@ -193,7 +194,8 @@ static constexpr uint64_t _capital_program_id = 4;
"ledger"_n,
"ledger2"_n,
"capital"_n,
"apps"_n
"apps"_n,
"expense"_n
// Добавьте остальные стандартные или пользовательские контракты по необходимости
};