[C28-38][@ant] merge: dev → feat/ku-selforg — разрешение конфликтов + фикс desktop typecheck
Typecheck / desktop (pull_request) Successful in 13m56s
Typecheck / controller (pull_request) Successful in 13m29s

Конфликты:
- extensions.module.ts: оба модуля (Ku + Expenses)
- notifications/workflows/index.ts: оба набора воркфлоу (branch + expense)
- UserSearchSelector.vue: оба пропа (exclude + types)
- zeus (sdk+controller): перегенерирован из объединённой схемы
  (rebuild stale dist: cooptypes, factory, inter, notifications)

Фикс vue-tsc (упавший desktop-typecheck на PR #130):
- BaseInput: добавлен type 'datetime-local' (q-input его поддерживает)
- KuMeetingDetailsPage: AgendaNumberAvatar number-коэрс (?? ""),
  docTarget/openMeetingDoc типизированы IDocumentAggregate,
  preview-агрегат неподписанного документа — каст
- KuBranchDetailsWidget: documentTarget/authorityTarget типизированы
  IDocumentAggregate, preview-агрегат — каст

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-15 13:48:26 +00:00
288 changed files with 21619 additions and 281 deletions
+1
View File
@@ -26,6 +26,7 @@
"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",
+40
View File
@@ -0,0 +1,40 @@
#!/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)."
+5
View File
@@ -128,4 +128,9 @@ export default [
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'expense',
path: path.join(userBase, 'expense'),
target: 'expense',
},
]
+4
View File
@@ -154,6 +154,10 @@ export default {
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'expense',
code_permissions_to: ['expense'],
},
// {
// name: provider_chairman,
// },
@@ -0,0 +1,33 @@
{
"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"
}
+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,65 @@
/**
* @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();
}
@@ -0,0 +1,65 @@
/**
* @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);
}
@@ -0,0 +1,33 @@
/**
* @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,6 +95,12 @@
#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,6 +48,7 @@ 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;
@@ -322,4 +323,38 @@ 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,8 +178,96 @@ 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
@@ -39,8 +39,11 @@ namespace Capital {
asset program_membership_distributed = asset(0, _root_govern_symbol); ///< Распределенная сумма членских взносов по программе
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
asset program_expense_pool = asset(0, _root_govern_symbol); ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
asset program_expense_reserved = asset(0, _root_govern_symbol); ///< Сумма, зарезервированная под approved/authorized программные расходы
config config; ///< Управляемая конфигурация контракта
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -78,7 +81,7 @@ inline global_state get_global_state(name coopname) {
/**
* @brief Проверяет что контракт инициализирован.
*
*
* @param coopname Имя кооператива.
*/
inline void is_initialized(name coopname) {
@@ -86,6 +89,96 @@ 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) {
s.global_available_invest_pool -= amount;
s.program_expense_pool += amount;
});
}
/**
* @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(itr->program_expense_pool >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
s.program_expense_reserved += amount;
});
}
/**
* @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(itr->program_expense_reserved >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
s.program_expense_pool += amount;
});
}
/**
* @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(itr->program_expense_reserved >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved -= amount;
});
}
/**
* @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(itr->program_expense_pool >= amount,
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool -= amount;
});
}
}// namespace State
}// namespace Capital
@@ -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,425 @@
// 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");
}
@@ -0,0 +1,233 @@
#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);
};
+7 -2
View File
@@ -106,7 +106,10 @@ static const std::set<eosio::name> soviet_actions = {
//WALLET
"createwthd"_n, //создать заявление на возврат паевого взноса
//EXPENSE
"createexp"_n, //служебная записка-смета о расходах (шасси расходов)
//LEDGER
"ledgerwthd"_n, //заявление на списание со счета через ledger
@@ -169,6 +172,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;
@@ -197,7 +201,8 @@ static constexpr uint64_t _capital_program_id = 4;
"ledger"_n,
"ledger2"_n,
"capital"_n,
"apps"_n
"apps"_n,
"expense"_n
// Добавьте остальные стандартные или пользовательские контракты по необходимости
};
@@ -85,6 +85,7 @@ 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
@@ -98,6 +99,27 @@ 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 включены **только** те транзиты, которые проводятся
@@ -352,6 +374,72 @@ 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
@@ -534,3 +622,105 @@ 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;
}
@@ -54,6 +54,7 @@ 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
@@ -61,6 +62,11 @@ 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 → делегатский ЧВ (одноактовый).
@@ -65,9 +65,16 @@ 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-механике шасси расходов)
};
/**
@@ -96,16 +103,17 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (7) — 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
// COOPERATIVE (10) — единый кооперативный баланс, без L3 (Generator + 9 «единых пулов»)
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
// CRPS-распределение между сегментами проекта не поддерживает per-user
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
@@ -120,6 +128,7 @@ inline constexpr std::array<Ledger2WalletMeta, 16> 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();
@@ -232,13 +241,14 @@ struct Ledger2WalletProgramMapping {
uint64_t required_program_id; // 0 = исключение (без проверки)
};
inline constexpr std::array<Ledger2WalletProgramMapping, 7> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
inline constexpr std::array<Ledger2WalletProgramMapping, 8> 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 — кандидат ещё не член, соглашения нет, без проверки */ },
}};
+9 -1
View File
@@ -50,6 +50,10 @@ 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;
@@ -80,10 +84,13 @@ 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 {
@@ -120,6 +127,7 @@ 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; // акцепт возврата из программы
+7
View File
@@ -34,6 +34,13 @@ 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
@@ -53,3 +53,47 @@ export function createPaginationResult<T>(ItemType: new (...args: any[]) => T, n
}
return PaginationResult;
}
/**
* Сборка `PaginationResult<U>` для consumer'ов, читающих через inter-порт
* (нет своего Repository.findAndCount, доступен только raw `{items, totalCount}`).
*
* Зачем: вычисление `totalPages`/`currentPage` из `PaginationInputDTO` — общая
* детерминированная логика; дублировать её в каждом consumer'е = canon-долг.
*/
export function buildPaginationResult<T, U>(
raw: { items: T[]; totalCount: number },
options: PaginationInputDTO | undefined,
mapItem: (it: T) => U,
): PaginationResult<U> {
const limit = options?.limit;
const page = options?.page != null ? Math.max(1, options.page) : 1;
const totalPages = limit != null && limit > 0 ? Math.max(1, Math.ceil(raw.totalCount / limit)) : 1;
return {
items: raw.items.map(mapItem),
totalCount: raw.totalCount,
totalPages,
currentPage: page,
};
}
/**
* Канон-конверсия `PaginationInputDTO` → `{limit, offset, sortBy, sortOrder}`
* для адаптеров inter-портов / внешних read API, не принимающих page-form.
*/
export function paginationInputToOffset(options?: PaginationInputDTO): {
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} {
const limit = options?.limit;
const page = options?.page != null ? Math.max(1, options.page) : 1;
const offset = limit != null ? (page - 1) * limit : undefined;
return {
limit,
offset,
sortBy: options?.sortBy,
sortOrder: options?.sortOrder,
};
}
@@ -0,0 +1,133 @@
import { InputType, Field, IntersectionType, OmitType, Int } from '@nestjs/graphql';
import { IsString, IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
type Action = Cooperative.Registry.ExpenseProposalDecision.Action;
type ItemAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseItem;
type HeaderAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseProposalHeader;
type DecisionBody = Cooperative.Registry.ExpenseProposalDecision.IExpenseProposalDecisionBody;
@InputType('ExpenseProposalDecisionItemInput')
class ExpenseProposalDecisionItemInputDTO implements ItemAction {
@Field()
@IsString()
number!: string;
@Field()
@IsString()
description!: string;
@Field()
@IsString()
amount!: string;
@Field()
@IsString()
recipient_type!: 'SELF' | 'MEMBER' | 'ORG';
@Field()
@IsString()
mechanics!: 'ADVANCE' | 'DIRECT';
}
@InputType('ExpenseProposalDecisionHeaderInput')
class ExpenseProposalDecisionHeaderInputDTO implements HeaderAction {
@Field()
@IsString()
description!: string;
@Field()
@IsString()
total_amount!: string;
@Field(() => Int)
@IsInt()
items_count!: number;
@Field()
@IsString()
source_wallet!: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
deadline?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
fund_name?: string;
}
@InputType('ExpenseProposalDecisionBodyInput')
class ExpenseProposalDecisionBodyInputDTO implements DecisionBody {
@Field({ description: 'Род решения (approve / decline)' })
@IsString()
kind!: 'approve' | 'decline';
@Field({ description: 'Причина отказа (для decline)', nullable: true })
@IsOptional()
@IsString()
reason?: string;
}
@InputType('BaseExpenseProposalDecisionMetaDocumentInput')
class BaseExpenseProposalDecisionMetaDocumentInputDTO implements Omit<Action, 'coopname' | 'username' | 'registry_id' | 'block_num' | 'lang' | 'title' | 'generator' | 'version' | 'created_at' | 'timezone' | 'links'> {
@Field({ description: 'Хеш сметы расхода' })
@IsString()
@IsNotEmpty()
proposal_hash!: string;
@Field(() => Int, { description: 'Идентификатор решения совета (повестка) — источник данных голосования' })
@IsInt()
decision_id!: number;
@Field(() => ExpenseProposalDecisionHeaderInputDTO, { description: 'Шапка СЗ' })
@ValidateNested()
@Type(() => ExpenseProposalDecisionHeaderInputDTO)
proposal!: ExpenseProposalDecisionHeaderInputDTO;
@Field(() => [ExpenseProposalDecisionItemInputDTO], { description: 'Позиции расхода' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseProposalDecisionItemInputDTO)
items!: ExpenseProposalDecisionItemInputDTO[];
@Field(() => ExpenseProposalDecisionBodyInputDTO, { description: 'Резолюция совета (утвердить / отказать)' })
@ValidateNested()
@Type(() => ExpenseProposalDecisionBodyInputDTO)
resolution!: ExpenseProposalDecisionBodyInputDTO;
}
/**
* Input генерации документа решения по СЗ (registry 2011).
*/
@InputType('ExpenseProposalDecisionGenerateDocumentInput')
export class ExpenseProposalDecisionGenerateDocumentInputDTO extends IntersectionType(
BaseExpenseProposalDecisionMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
}
@InputType('ExpenseProposalDecisionSignedMetaDocumentInput')
export class ExpenseProposalDecisionSignedMetaDocumentInputDTO extends IntersectionType(
BaseExpenseProposalDecisionMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
/**
* Подписанный документ-решение по СЗ (registry 2011).
*/
@InputType('ExpenseProposalDecisionSignedDocumentInput')
export class ExpenseProposalDecisionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => ExpenseProposalDecisionSignedMetaDocumentInputDTO, {
description: 'Метаинформация решения по СЗ',
})
public readonly meta!: ExpenseProposalDecisionSignedMetaDocumentInputDTO;
}
@@ -0,0 +1,212 @@
import { InputType, Field, IntersectionType, OmitType, Int } from '@nestjs/graphql';
import { IsString, IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
type Action = Cooperative.Registry.ExpenseProposalStatement.Action;
type ItemAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseItem;
type HeaderAction = Cooperative.Registry.ExpenseProposalStatement.IExpenseProposalHeader;
/**
* Позиция-вход генерации СЗ-документа. Приватные поля (имя/реквизиты/назначение)
* — это вход для фабрики: сервер сохраняет их off-chain в doc_data и публикует
* в meta только `doc_data_hash`. На on-chain эти поля НЕ попадают.
*/
@InputType('ExpenseProposalItemInput')
class ExpenseProposalItemInputDTO {
@Field({ description: 'Порядковый номер строки' })
@IsString()
number!: string;
@Field({ description: 'Описание расхода' })
@IsString()
description!: string;
@Field({ description: 'Сумма строки' })
@IsString()
amount!: string;
@Field({ description: 'Тип получателя (SELF / MEMBER / ORG)' })
@IsString()
recipient_type!: 'SELF' | 'MEMBER' | 'ORG';
@Field({ description: 'Способ оплаты (ADVANCE / DIRECT)' })
@IsString()
mechanics!: 'ADVANCE' | 'DIRECT';
@Field({ description: 'Имя получателя', nullable: true })
@IsOptional()
@IsString()
recipient_name?: string;
@Field({ description: 'Реквизиты получателя', nullable: true })
@IsOptional()
@IsString()
requisites?: string;
@Field({ description: 'Назначение платежа — отдельной строкой после реквизитов', nullable: true })
@IsOptional()
@IsString()
payment_purpose?: string;
@Field({
description:
'Идентификатор сохранённых реквизитов получателя-пайщика — сервер подставит полные реквизиты в документ.',
nullable: true,
})
@IsOptional()
@IsString()
payment_method_id?: string;
@Field({
description: 'Имя аккаунта получателя-пайщика (владелец реквизитов).',
nullable: true,
})
@IsOptional()
@IsString()
recipient_username?: string;
}
/**
* Публичная позиция подписанной meta — ровно то, что публикуется on-chain.
* Без имени/реквизитов/назначения платежа (они off-chain в doc_data).
*/
@InputType('ExpenseProposalSignedItemInput')
class ExpenseProposalSignedItemInputDTO implements ItemAction {
@Field({ description: 'Порядковый номер строки' })
@IsString()
number!: string;
@Field({ description: 'Описание расхода' })
@IsString()
description!: string;
@Field({ description: 'Сумма строки' })
@IsString()
amount!: string;
@Field({ description: 'Тип получателя (SELF / MEMBER / ORG)' })
@IsString()
recipient_type!: 'SELF' | 'MEMBER' | 'ORG';
@Field({ description: 'Способ оплаты (ADVANCE / DIRECT)' })
@IsString()
mechanics!: 'ADVANCE' | 'DIRECT';
}
@InputType('ExpenseProposalHeaderInput')
class ExpenseProposalHeaderInputDTO implements HeaderAction {
@Field({ description: 'Описание цели расходов' })
@IsString()
description!: string;
@Field({ description: 'Итоговая сумма расходов' })
@IsString()
total_amount!: string;
@Field(() => Int, { description: 'Количество позиций' })
@IsInt()
items_count!: number;
@Field({ description: 'Кошелёк-источник' })
@IsString()
source_wallet!: string;
@Field({ description: 'Срок исполнения («в срок до»), формат DD.MM.YYYY', nullable: true })
@IsOptional()
@IsString()
deadline?: string;
@Field({
description: 'Фонд списания — подставляется сервером из параметров шасси расходов, передавать не нужно',
nullable: true,
})
@IsOptional()
@IsString()
fund_name?: string;
}
/**
* База ВХОДА генерации — богатые позиции (приватные поля уйдут в doc_data на
* сервере). `doc_data_hash` здесь нет: его вычисляет сервер при генерации.
*/
@InputType('BaseExpenseProposalStatementGenerateMetaDocumentInput')
class BaseExpenseProposalStatementGenerateMetaDocumentInputDTO {
@Field({ description: 'Хеш сметы расхода (детерминированный)' })
@IsString()
@IsNotEmpty()
proposal_hash!: string;
@Field(() => ExpenseProposalHeaderInputDTO, { description: 'Шапка СЗ' })
@ValidateNested()
@Type(() => ExpenseProposalHeaderInputDTO)
proposal!: ExpenseProposalHeaderInputDTO;
@Field(() => [ExpenseProposalItemInputDTO], { description: 'Позиции расхода' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseProposalItemInputDTO)
items!: ExpenseProposalItemInputDTO[];
}
/**
* База ПОДПИСАННОЙ meta — ровно то, что подписывается и едет on-chain:
* публичные позиции + `doc_data_hash` (реквизиты off-chain).
*/
@InputType('BaseExpenseProposalStatementSignedMetaDocumentInput')
class BaseExpenseProposalStatementSignedMetaDocumentInputDTO implements Omit<Action, 'coopname' | 'username' | 'registry_id' | 'block_num' | 'lang' | 'title' | 'generator' | 'version' | 'created_at' | 'timezone' | 'links'> {
@Field({ description: 'Хеш сметы расхода (детерминированный)' })
@IsString()
@IsNotEmpty()
proposal_hash!: string;
@Field(() => ExpenseProposalHeaderInputDTO, { description: 'Шапка СЗ' })
@ValidateNested()
@Type(() => ExpenseProposalHeaderInputDTO)
proposal!: ExpenseProposalHeaderInputDTO;
@Field(() => [ExpenseProposalSignedItemInputDTO], { description: 'Публичные позиции расхода (без реквизитов)' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseProposalSignedItemInputDTO)
items!: ExpenseProposalSignedItemInputDTO[];
@Field({ description: 'Идентификатор приватных данных документа off-chain (реквизиты/имя/назначение)' })
@IsString()
@IsNotEmpty()
doc_data_hash!: string;
}
/**
* Input генерации документа СЗ-заявления (registry 2010).
* Backend через factory собирает PDF, возвращает `IGeneratedDocument` (без подписей).
*/
@InputType('ExpenseProposalStatementGenerateDocumentInput')
export class ExpenseProposalStatementGenerateDocumentInputDTO extends IntersectionType(
BaseExpenseProposalStatementGenerateMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
}
@InputType('ExpenseProposalStatementSignedMetaDocumentInput')
export class ExpenseProposalStatementSignedMetaDocumentInputDTO extends IntersectionType(
BaseExpenseProposalStatementSignedMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
/**
* Подписанный документ СЗ-заявления (registry 2010).
*/
@InputType('ExpenseProposalStatementSignedDocumentInput')
export class ExpenseProposalStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => ExpenseProposalStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация СЗ-заявления',
})
public readonly meta!: ExpenseProposalStatementSignedMetaDocumentInputDTO;
}
@@ -28,4 +28,10 @@ export class PaymentFiltersInputDTO implements PaymentFiltersDomainInterface {
@Field(() => String, { nullable: true, description: 'Хэш платежа' })
hash?: string;
@Field(() => String, {
nullable: true,
description: 'Хэш расхода (служебной записки): вернёт все платежи, связанные с этим расходом',
})
proposal_hash?: string;
}
@@ -26,6 +26,7 @@ import { AccountType } from '~/application/account/enum/account-type.enum';
import { PaymentMethodRepository, PAYMENT_METHOD_REPOSITORY } from '~/domain/common/repositories/payment-method.repository';
import type { PaymentDetailsDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
import { AccountDomainPort, ACCOUNT_DOMAIN_PORT } from '~/domain/account/ports/account-domain.port';
import { INTER_EXPENSE_CHASSIS, type InterExpenseChassisPort } from '@coopenomics/inter';
/**
* Интерактор домена gateway для управления платежами (просмотр, изменение статуса и создание)
@@ -54,7 +55,9 @@ export class GatewayInteractor {
@Inject(ACCOUNT_DOMAIN_SERVICE)
private readonly accountDomainService: AccountDomainService,
@Inject(PAYMENT_METHOD_REPOSITORY)
private readonly paymentMethodRepository: PaymentMethodRepository
private readonly paymentMethodRepository: PaymentMethodRepository,
@Inject(INTER_EXPENSE_CHASSIS)
private readonly expenseChassis: InterExpenseChassisPort
) {}
/**
@@ -185,6 +188,25 @@ export class GatewayInteractor {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Пользователь ${payment.username} внес паевой взнос на сумму ${blockchainFormattedQuantity}`);
} else if (payment.type === PaymentTypeEnum.EXPENSE_RETURN) {
// Возврат неиспользованного аванса под отчёт (недорасход): пайщик отчитался
// о факте меньше выданного аванса и вернул разницу на расчётный счёт.
// Подтверждение приёма средств кассиром проводит on-chain
// expense::returnexp (TRANSFER подотчёт → пул на сумму возврата), затем
// expense::reportexp закрывает позицию на фактически потраченную часть.
// Это НЕ паевой взнос — completeIncome здесь не вызывается (другая ledger2-
// семантика). proposal_hash и item_hash — в blockchain_data.
const bc = payment.blockchain_data as { proposal_hash?: string; item_hash?: string } | undefined;
if (!bc?.proposal_hash || !bc?.item_hash) {
throw new Error(`У платежа возврата ${payment.hash} нет proposal_hash/item_hash в blockchain_data`);
}
const returnAmount = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
await this.expenseChassis.returnItem(payment.coopname, bc.proposal_hash, bc.item_hash, returnAmount);
await this.expenseChassis.reportItem(payment.coopname, bc.proposal_hash, bc.item_hash);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Возврат недорасхода ${payment.hash} (СЗ ${bc.proposal_hash}) проведён on-chain (returnexp + reportexp)`);
}
} catch (e: any) {
if (payment.id) {
@@ -246,6 +268,62 @@ export class GatewayInteractor {
return;
}
// Оплата позиции СЗ-расхода: подтверждение кассой проводит on-chain
// expense::payexp (ledger2-проводка по механике позиции — аванс/оплата по
// счёту). Реквизиты и назначение платежа в чейн не уходят — они живут
// только в этом платеже и снимке шасси. proposal_hash — в blockchain_data
// (hash платежа = item_hash, его одного payexp недостаточно).
if (payment.type === PaymentTypeEnum.EXPENSE) {
const proposalHash = (payment.blockchain_data as { proposal_hash?: string } | undefined)?.proposal_hash;
try {
if (!proposalHash) {
throw new Error(`У платежа расхода ${payment.hash} отсутствует proposal_hash в blockchain_data`);
}
const actualAmount = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
await this.expenseChassis.payItem(payment.coopname, proposalHash, payment.hash, actualAmount);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Оплата позиции расхода ${payment.hash} (СЗ ${proposalHash}) проведена on-chain (payexp)`);
} catch (e: any) {
const message = e?.message ?? String(e);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.FAILED, message });
}
this.logger.error(`Ошибка оплаты позиции расхода ${payment.hash}: ${message}`, e);
}
return;
}
// Доплата при перерасходе аванса под отчёт: пайщик отчитался о факте больше
// выданного аванса, кооператив доплачивает разницу. Подтверждение выплаты
// кассиром проводит on-chain expense::overspendexp (TRANSFER пул → подотчёт на
// сумму доплаты), затем expense::reportexp закрывает позицию на полный факт.
// proposal_hash и item_hash — в blockchain_data (hash платежа уникальный, не
// равен item_hash, чтобы не пересекаться с платежом выдачи аванса).
if (payment.type === PaymentTypeEnum.EXPENSE_OVERSPEND) {
const bc = payment.blockchain_data as { proposal_hash?: string; item_hash?: string } | undefined;
try {
if (!bc?.proposal_hash || !bc?.item_hash) {
throw new Error(`У платежа доплаты ${payment.hash} нет proposal_hash/item_hash в blockchain_data`);
}
const overspendAmount = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
await this.expenseChassis.overspendItem(payment.coopname, bc.proposal_hash, bc.item_hash, overspendAmount);
await this.expenseChassis.reportItem(payment.coopname, bc.proposal_hash, bc.item_hash);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Доплата перерасхода ${payment.hash} (СЗ ${bc.proposal_hash}) проведена on-chain (overspendexp + reportexp)`);
} catch (e: any) {
const message = e?.message ?? String(e);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.FAILED, message });
}
this.logger.error(`Ошибка доплаты перерасхода ${payment.hash}: ${message}`, e);
}
return;
}
try {
const completeOutcomeData: CompleteOutcomeDomainInterface = {
coopname: payment.coopname,
@@ -55,9 +55,10 @@ export class PaymentNotificationService implements OnModuleInit {
// Получаем отображаемое имя пользователя
const userName = await this.accountPort.getDisplayName(payment.username);
// Выбираем workflow по статусу И направлению платежа: исходящий PAID — это
// возврат взноса пайщику, у него своё уведомление «Возврат выполнен», а не
// «Платёж принят» (иначе пайщик при возврате получает письмо как на приём).
// Выбираем workflow по статусу И направлению — но БЕЗ привязки к типу платежа
// (мы не всегда знаем, это возврат взноса, аванс под отчёт или доплата).
// Исходящий PAID → пайщик получил деньги → «Платёж выполнен»; входящий PAID →
// кооператив принял платёж пайщика → «Платёж принят». Оба текста универсальны.
const isOutgoing = payment.direction === PaymentDirectionEnum.OUTGOING;
const workflowId =
payment.status === PaymentStatusEnum.PAID
@@ -16,6 +16,10 @@ export interface GeneratorPort {
list<T = any>(collection: string, filter?: Record<string, any>): Promise<Cooperative.Document.IGetResponse<T>>;
getHistory<T = any>(collection: string, filter: Record<string, any>): Promise<T[]>;
search(query: string): Promise<ISearchResult[]>;
saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }>;
getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null>;
}
export const GENERATOR_PORT = Symbol('GeneratorPort');
@@ -25,6 +25,14 @@ export class DocumentDomainService {
return await this.generatorInfrastructureService.generateDocument(data);
}
public async saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }> {
return await this.generatorInfrastructureService.saveDocData(payload, registry_id);
}
public async getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null> {
return await this.generatorInfrastructureService.getDocData<P>(hash);
}
public async getDocumentByHash(hash: string): Promise<DocumentDomainEntity | null> {
const document = await this.documentRepository.findByHash(hash);
return document;
@@ -113,6 +113,9 @@ export class PaymentDomainEntity implements PaymentDomainInterface {
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат взноса',
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
};
return typeLabels[this.type] || this.type;
}
@@ -9,6 +9,20 @@ export enum PaymentTypeEnum {
// Исходящий возврат вступительного и мин. паевого взносов при отказе совета
// в приёме. Отдельный тип, не WITHDRAWAL — чтобы не путать с возвратом паевого.
REGISTRATION_REFUND = 'registration_refund',
// Исходящая оплата позиции служебной записки-сметы (шасси expense). Создаётся
// автоматически после авторизации СЗ советом; подтверждение кассиром проводит
// on-chain оплату expense::payexp по позиции.
EXPENSE = 'expense',
// Входящий возврат неиспользованного аванса под отчёт (недорасход). Создаётся,
// когда пайщик отчитался о факте меньше выданного аванса: он возвращает разницу
// на расчётный счёт кооператива; подтверждение кассиром (приём средств)
// проводит on-chain expense::returnexp и закрывает позицию expense::reportexp.
EXPENSE_RETURN = 'expense_return',
// Исходящая доплата при перерасходе аванса под отчёт. Создаётся, когда пайщик
// отчитался о факте больше выданного аванса: кооператив доплачивает разницу;
// подтверждение кассиром (выплата) проводит on-chain expense::overspendexp и
// закрывает позицию expense::reportexp.
EXPENSE_OVERSPEND = 'expense_overspend',
}
/**
@@ -27,6 +41,9 @@ export const PAYMENT_TYPE_LABELS: Record<PaymentTypeEnum, string> = {
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат паевого взноса',
[PaymentTypeEnum.REGISTRATION_REFUND]: 'Возврат вступит. и мин.паевого взноса',
[PaymentTypeEnum.EXPENSE]: 'Оплата расхода по служебной записке',
[PaymentTypeEnum.EXPENSE_RETURN]: 'Возврат неиспользованного аванса под отчёт',
[PaymentTypeEnum.EXPENSE_OVERSPEND]: 'Доплата по перерасходу аванса',
};
/**
@@ -49,17 +66,24 @@ export const PAYMENT_DIRECTION_LABELS: Record<PaymentDirectionEnum, string> = {
* Определяет направление платежа по его типу
*/
export function getPaymentDirection(type: PaymentTypeEnum): PaymentDirectionEnum {
const incomingTypes = [PaymentTypeEnum.REGISTRATION, PaymentTypeEnum.DEPOSIT];
return incomingTypes.includes(type) ? PaymentDirectionEnum.INCOMING : PaymentDirectionEnum.OUTGOING;
return INCOMING_PAYMENT_TYPES.includes(type) ? PaymentDirectionEnum.INCOMING : PaymentDirectionEnum.OUTGOING;
}
/**
* Входящие типы платежей
*/
export const INCOMING_PAYMENT_TYPES = [PaymentTypeEnum.REGISTRATION, PaymentTypeEnum.DEPOSIT];
export const INCOMING_PAYMENT_TYPES = [
PaymentTypeEnum.REGISTRATION,
PaymentTypeEnum.DEPOSIT,
PaymentTypeEnum.EXPENSE_RETURN,
];
/**
* Исходящие типы платежей
*/
export const OUTGOING_PAYMENT_TYPES = [PaymentTypeEnum.WITHDRAWAL, PaymentTypeEnum.REGISTRATION_REFUND];
export const OUTGOING_PAYMENT_TYPES = [
PaymentTypeEnum.WITHDRAWAL,
PaymentTypeEnum.REGISTRATION_REFUND,
PaymentTypeEnum.EXPENSE,
PaymentTypeEnum.EXPENSE_OVERSPEND,
];
@@ -13,6 +13,7 @@ export interface PaymentFiltersDomainInterface {
direction?: PaymentDirectionEnum;
provider?: string;
hash?: string;
proposal_hash?: string;
}
/**
@@ -119,6 +119,16 @@ export const PROCESS_HASH_LOCATOR: Readonly<Record<string, HashLocation[]>> = Ob
// Сущностная таблица — `capital::prgwithdraws.withdraw_hash`.
'p.cap.wthcap': [{ code: 'capital', table: 'prgwithdraws', field: 'withdraw_hash' }],
// p.cap.pgexp — пополнение пула программных расходов (o.cap.pgtop, ISSUE
// w.cap.pgexp). Одноактовый, process_hash синтетический (sha256 в контракте) —
// entity-таблицы нет, данные из blockchain_actions.
'p.cap.pgexp': [],
// p.exp.expns — расход по СЗ (шасси expense): o.exp.blgadv/blgdir (оплата) +
// опц. o.exp.over + o.exp.advrpt (отчёт) + опц. o.exp.advret (возврат).
// Анкер процесса — proposal_hash в `expense::proposals`.
'p.exp.expns': [{ code: 'expense', table: 'proposals', field: 'proposal_hash' }],
// marketplace::requests.hash — поле так и называется `hash`.
'p.mkt.reqst': [{ code: 'marketplace', table: 'requests', field: 'hash' }],
@@ -76,6 +76,18 @@ export class StateOutputDTO extends BaseOutputDTO {
})
global_available_invest_pool!: string;
@Field(() => String, {
nullable: true,
description: 'Пул программных расходов — средства, переведённые под целевые расходы программы',
})
program_expense_pool?: string;
@Field(() => String, {
nullable: true,
description: 'Зарезервировано в пуле программных расходов под активные расходы (служебные записки в работе)',
})
program_expense_reserved?: string;
@Field(() => String, {
description: 'Общая сумма членских взносов по программе',
})
@@ -0,0 +1,47 @@
import { Field, InputType } from '@nestjs/graphql';
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ExpenseItemInputDTO } from '~/extensions/expenses/application/dto/expense-item.input';
import { ExpenseProposalStatementSignedDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-statement-document.dto';
/**
* Создание программного расхода капитала через шасси `expense`.
* Capital резервирует `program_expense_pool` на сумму items и шлёт inline
* action `expense::createexp` с callback `{capital, onpgexpdone}`. Дальше
* lifecycle обслуживает шасси; capital получает callback на финализацию.
*/
@InputType('CapitalCreateProgramExpenseInput')
export class CreateProgramExpenseInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хэш СЗ-расхода (детерминированный, из UI). Он же станет proposal_hash в шасси.' })
@IsNotEmpty()
@IsString()
expense_hash!: string;
@Field(() => String, { description: 'Имя пайщика-создателя СЗ (председатель).' })
@IsNotEmpty()
@IsString()
creator!: string;
@Field(() => [ExpenseItemInputDTO], { description: 'Строки расхода. Способ оплаты (аванс под отчёт / оплата по счёту) задаётся на каждой строке отдельно.' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseItemInputDTO)
items!: ExpenseItemInputDTO[];
@Field(() => String, { description: 'Описание программного расхода.' })
@IsString()
description!: string;
@Field(() => ExpenseProposalStatementSignedDocumentInputDTO, {
description: 'Подписанная СЗ-смета (document2, registry 2010).',
})
@ValidateNested()
@Type(() => ExpenseProposalStatementSignedDocumentInputDTO)
statement!: ExpenseProposalStatementSignedDocumentInputDTO;
}
@@ -0,0 +1,51 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ExpenseProposalStatus } from '~/extensions/expenses/domain/enums/expense-proposal-status.enum';
import { ExpenseMechanics } from '~/extensions/expenses/domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '~/extensions/expenses/domain/enums/expense-recipient-type.enum';
import { ExpenseItemStatus } from '~/extensions/expenses/domain/enums/expense-item-status.enum';
/** Owner-callback из шасси (зеркало InterExpenseCallbackHandler). */
@ObjectType('CapitalProgramExpenseCallback')
export class ProgramExpenseCallbackOutputDTO {
@Field(() => String) contract!: string;
@Field(() => String) action!: string;
@Field(() => String) data!: string;
}
/** Item в выводе (зеркало InterExpenseItem). */
@ObjectType('CapitalProgramExpenseItem')
export class ProgramExpenseItemOutputDTO {
@Field(() => String) item_hash!: string;
@Field(() => ExpenseMechanics) mechanics!: ExpenseMechanics;
@Field(() => ExpenseRecipientType) recipient_type!: ExpenseRecipientType;
@Field(() => String) recipient!: string;
@Field(() => String, { description: 'Имя получателя (ФИО пайщика или название организации)' })
recipient_name!: string;
@Field(() => String) description!: string;
@Field(() => String) planned_amount!: string;
@Field(() => String) actual_amount!: string;
@Field(() => ExpenseItemStatus) status!: ExpenseItemStatus;
}
/**
* Программный расход в проекции Капитала — те же данные шасси, спроецированные
* под consumer-extension. Capital НЕ хранит свой mirror items/total — берёт через
* inter-порт INTER_EXPENSE_CHASSIS.
*/
@ObjectType('CapitalProgramExpense')
export class ProgramExpenseOutputDTO {
@Field(() => String) coopname!: string;
@Field(() => String) expense_hash!: string;
@Field(() => String) creator!: string;
@Field(() => String, { description: 'Имя инициатора (ФИО пайщика или название организации)' })
creator_name!: string;
@Field(() => String) source_wallet!: string;
@Field(() => ExpenseProposalStatus) status!: ExpenseProposalStatus;
@Field(() => ProgramExpenseCallbackOutputDTO, { nullable: true })
callback?: ProgramExpenseCallbackOutputDTO;
@Field(() => [ProgramExpenseItemOutputDTO]) items!: ProgramExpenseItemOutputDTO[];
@Field(() => String) total_planned!: string;
@Field(() => String) total_actual!: string;
@Field(() => String) created_at!: string;
@Field(() => String) updated_at!: string;
}
@@ -0,0 +1,19 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Пополнение пула программных расходов кооператива (председатель).
* Перевод суммы из `global_available_invest_pool` в `program_expense_pool`.
*/
@InputType('CapitalTopupProgramExpenseInput')
export class TopupProgramExpenseInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Сумма пополнения (asset, eg "10000.0000 RUB").' })
@IsNotEmpty()
@IsString()
amount!: string;
}
@@ -0,0 +1,80 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import {
createPaginationResult,
PaginationInputDTO,
PaginationResult,
} from '~/application/common/dto/pagination.dto';
import { ProgramExpensesManagementService } from '../services/program-expenses-management.service';
import { CreateProgramExpenseInputDTO } from '../dto/program_expenses/create-program-expense.input';
import { TopupProgramExpenseInputDTO } from '../dto/program_expenses/topup-program-expense.input';
import { ProgramExpenseOutputDTO } from '../dto/program_expenses/program-expense.output';
const paginatedProgramExpensesResult = createPaginationResult(
ProgramExpenseOutputDTO,
'PaginatedCapitalProgramExpenses',
);
/**
* GraphQL-резолвер программных расходов капитала. Председатель/совет.
* Write → on-chain capital; read → шасси через inter-порт.
*/
@Resolver()
export class ProgramExpensesResolver {
constructor(private readonly service: ProgramExpensesManagementService) {}
@Mutation(() => TransactionDTO, {
name: 'capitalCreateProgramExpense',
description: 'Создание программного расхода капитала через шасси expense.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async createProgramExpense(
@Args('data', { type: () => CreateProgramExpenseInputDTO }) data: CreateProgramExpenseInputDTO,
): Promise<TransactionDTO> {
return this.service.createProgramExpense(data);
}
@Mutation(() => TransactionDTO, {
name: 'capitalTopupProgramExpensePool',
description: 'Пополнение пула программных расходов капитала из инвестиционного пула.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async topupProgramExpense(
@Args('data', { type: () => TopupProgramExpenseInputDTO }) data: TopupProgramExpenseInputDTO,
): Promise<TransactionDTO> {
return this.service.topupProgramExpense(data);
}
@Query(() => paginatedProgramExpensesResult, {
name: 'capitalProgramExpenses',
description: 'Список программных расходов капитала (через шасси expense).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async listProgramExpenses(
@Args('coopname', { type: () => String }) coopname: string,
@Args('options', { type: () => PaginationInputDTO, nullable: true }) options?: PaginationInputDTO,
): Promise<PaginationResult<ProgramExpenseOutputDTO>> {
return this.service.listProgramExpenses(coopname, options);
}
@Query(() => ProgramExpenseOutputDTO, {
name: 'capitalProgramExpense',
description: 'Программный расход по expense_hash.',
nullable: true,
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getProgramExpense(
@Args('coopname') coopname: string,
@Args('expense_hash') expenseHash: string,
): Promise<ProgramExpenseOutputDTO | null> {
return this.service.getProgramExpense(coopname, expenseHash);
}
}
@@ -0,0 +1,239 @@
import { Injectable, Inject } from '@nestjs/common';
import { CapitalContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
import { config } from '~/config';
import {
INTER_EXPENSE_CHASSIS,
type InterExpenseChassisPort,
type InterExpenseItem,
type InterExpenseProposalRead,
type InterExpenseProposalStatus,
type InterExpenseRequisiteItemInput,
} from '@coopenomics/inter';
import { AccountDataPort, ACCOUNT_DATA_PORT } from '~/domain/account/ports/account-data.port';
import { CapitalBlockchainPort, CAPITAL_BLOCKCHAIN_PORT } from '../../domain/interfaces/capital-blockchain.port';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import {
buildPaginationResult,
PaginationInputDTO,
paginationInputToOffset,
type PaginationResult,
} from '~/application/common/dto/pagination.dto';
import type { CreateProgramExpenseInputDTO } from '../dto/program_expenses/create-program-expense.input';
import type { TopupProgramExpenseInputDTO } from '../dto/program_expenses/topup-program-expense.input';
import type {
ProgramExpenseCallbackOutputDTO,
ProgramExpenseItemOutputDTO,
ProgramExpenseOutputDTO,
} from '../dto/program_expenses/program-expense.output';
import { ExpenseProposalStatus } from '~/extensions/expenses/domain/enums/expense-proposal-status.enum';
import { ExpenseMechanics } from '~/extensions/expenses/domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '~/extensions/expenses/domain/enums/expense-recipient-type.enum';
import { ExpenseItemStatus } from '~/extensions/expenses/domain/enums/expense-item-status.enum';
/**
* Управление программными расходами Капитала.
*
* Тонкое расширение: write — `capital::createpgexp` / `topupprogexp` через
* `CapitalBlockchainPort`; read — через inter-порт `INTER_EXPENSE_CHASSIS`
* (шасси-extension отвечает за хранение proposals).
*/
@Injectable()
export class ProgramExpensesManagementService {
constructor(
@Inject(CAPITAL_BLOCKCHAIN_PORT)
private readonly capitalBlockchainPort: CapitalBlockchainPort,
@Inject(INTER_EXPENSE_CHASSIS)
private readonly expenseChassis: InterExpenseChassisPort,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountDataPort: AccountDataPort,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
) {}
async createProgramExpense(data: CreateProgramExpenseInputDTO): Promise<TransactResult> {
// Реквизиты получателей: валидация ДО on-chain заявки, снимок в шасси —
// ПОСЛЕ (фиксация «куда платить» на момент создания СЗ).
const requisiteItems: InterExpenseRequisiteItemInput[] = data.items.map((it) => ({
proposalHash: data.expense_hash,
itemHash: it.item_hash,
recipient: it.recipient,
isOrganization: it.recipient_type === ExpenseRecipientType.ORG,
mechanics: it.mechanics === ExpenseMechanics.DIRECT ? 'DIRECT' : 'ADVANCE',
paymentMethodId: it.payment_method_id,
requisites: it.requisites,
paymentPurpose: it.payment_purpose,
}));
await this.expenseChassis.validateRequisites(data.coopname, requisiteItems);
const zeroAmount = `0.0000 ${config.blockchain.root_govern_symbol}`;
const blockchainData: CapitalContract.Actions.CreateProgramExpense.ICreateProgramExpense = {
coopname: data.coopname,
expense_hash: data.expense_hash,
creator: data.creator,
items: data.items.map((it) => ({
item_hash: it.item_hash,
mechanics: it.mechanics === ExpenseMechanics.DIRECT ? 1 : 0,
recipient_type:
it.recipient_type === ExpenseRecipientType.SELF
? 0
: it.recipient_type === ExpenseRecipientType.MEMBER
? 1
: 2,
recipient: it.recipient,
description: it.description,
planned_amount: it.planned_amount,
actual_amount: zeroAmount,
status: 0,
})),
description: data.description,
statement: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.statement),
};
const result = await this.capitalBlockchainPort.createProgramExpense(blockchainData);
await this.expenseChassis.snapshotRequisites(data.coopname, requisiteItems);
return result;
}
async topupProgramExpense(data: TopupProgramExpenseInputDTO): Promise<TransactResult> {
return this.capitalBlockchainPort.topupProgramExpense({
coopname: data.coopname,
amount: data.amount,
});
}
async listProgramExpenses(
coopname: string,
options?: PaginationInputDTO,
): Promise<PaginationResult<ProgramExpenseOutputDTO>> {
const { limit, offset, sortBy, sortOrder } = paginationInputToOffset(options);
const allowedSort = sortBy === 'createdAt' || sortBy === 'updatedAt' ? sortBy : undefined;
const result = await this.expenseChassis.listProposalsByOwner(coopname, 'capital', 'onpgexpdone', {
limit,
offset,
sortBy: allowedSort,
sortOrder,
});
const names = await this.resolveDisplayNames(
result.items.flatMap((p) => [p.creator, ...this.memberRecipients(p)]),
);
return buildPaginationResult(result, options, (p) => this.toOutput(p, names));
}
async getProgramExpense(coopname: string, expenseHash: string): Promise<ProgramExpenseOutputDTO | null> {
const proposal = await this.expenseChassis.readProposalByHash(coopname, expenseHash);
if (!proposal) return null;
const names = await this.resolveDisplayNames([proposal.creator, ...this.memberRecipients(proposal)]);
return this.toOutput(proposal, names);
}
/** Получатели-пайщики СЗ (у ORG-строк recipient — уже название организации). */
private memberRecipients(p: InterExpenseProposalRead): string[] {
return p.items
.filter((it) => this.mapRecipientType(it.recipientType) !== ExpenseRecipientType.ORG)
.map((it) => it.recipient);
}
/** ФИО/название организации по username; при ошибке резолва остаётся username. */
private async resolveDisplayNames(usernames: string[]): Promise<Map<string, string>> {
const unique = [...new Set(usernames.filter(Boolean))];
const entries = await Promise.all(
unique.map(async (username): Promise<[string, string]> => {
try {
const displayName = await this.accountDataPort.getDisplayName(username);
return [username, displayName || username];
} catch {
return [username, username];
}
}),
);
return new Map(entries);
}
private toOutput(p: InterExpenseProposalRead, names: Map<string, string>): ProgramExpenseOutputDTO {
return {
coopname: p.coopname,
expense_hash: p.proposalHash,
creator: p.creator,
creator_name: names.get(p.creator) ?? p.creator,
source_wallet: p.sourceWalletCode,
status: this.mapStatus(p.status),
callback: p.callback ? this.toCallbackOutput(p.callback) : undefined,
items: p.items.map((it) => this.toItemOutput(it, names)),
total_planned: p.totalPlanned,
total_actual: p.totalActual,
created_at: p.createdAt,
updated_at: p.updatedAt,
};
}
private toItemOutput(it: InterExpenseItem, names: Map<string, string>): ProgramExpenseItemOutputDTO {
const recipientType = this.mapRecipientType(it.recipientType);
return {
item_hash: it.itemHash,
mechanics: this.mapMechanics(it.mechanics),
recipient_type: recipientType,
recipient: it.recipient,
recipient_name:
recipientType === ExpenseRecipientType.ORG
? it.recipient
: (names.get(it.recipient) ?? it.recipient),
description: it.description,
planned_amount: it.plannedAmount,
actual_amount: it.actualAmount,
status: this.mapItemStatus(it.status),
};
}
private toCallbackOutput(cb: { contract: string; action: string; data: string }): ProgramExpenseCallbackOutputDTO {
return { contract: cb.contract, action: cb.action, data: cb.data };
}
private mapStatus(status: InterExpenseProposalStatus): ExpenseProposalStatus {
switch (status) {
case 'CREATED':
return ExpenseProposalStatus.CREATED;
case 'AUTHORIZED':
return ExpenseProposalStatus.AUTHORIZED;
case 'PARTIALLY_PAID':
return ExpenseProposalStatus.PARTIALLY_PAID;
case 'REPORT_SUBMITTED':
return ExpenseProposalStatus.REPORT_SUBMITTED;
case 'CLOSED':
return ExpenseProposalStatus.CLOSED;
case 'DECLINED':
return ExpenseProposalStatus.DECLINED;
default:
return ExpenseProposalStatus.UNDEFINED;
}
}
private mapMechanics(raw: number): ExpenseMechanics {
return raw === 1 ? ExpenseMechanics.DIRECT : ExpenseMechanics.ADVANCE;
}
private mapRecipientType(raw: number): ExpenseRecipientType {
if (raw === 2) return ExpenseRecipientType.ORG;
if (raw === 1) return ExpenseRecipientType.MEMBER;
return ExpenseRecipientType.SELF;
}
private mapItemStatus(raw: number): ExpenseItemStatus {
switch (raw) {
case 0:
return ExpenseItemStatus.APPROVED;
case 1:
return ExpenseItemStatus.PAID;
case 2:
return ExpenseItemStatus.REPORTED;
case 3:
return ExpenseItemStatus.RETURNED;
case 4:
return ExpenseItemStatus.OVERSPENT;
default:
return ExpenseItemStatus.UNDEFINED;
}
}
}
@@ -52,6 +52,8 @@ export class ContractManagementInteractor {
return {
coopname: blockchainState.coopname,
global_available_invest_pool: blockchainState.global_available_invest_pool,
program_expense_pool: blockchainState.program_expense_pool,
program_expense_reserved: blockchainState.program_expense_reserved,
program_membership_funded: blockchainState.program_membership_funded,
program_membership_available: blockchainState.program_membership_available,
program_membership_distributed: blockchainState.program_membership_distributed,
@@ -374,6 +374,7 @@ import { VotingService } from './application/services/voting.service';
import { ResultSubmissionService } from './application/services/result-submission.service';
import { DistributionManagementService } from './application/services/distribution-management.service';
import { ExpensesManagementService } from './application/services/expenses-management.service';
import { ProgramExpensesManagementService } from './application/services/program-expenses-management.service';
import { ContributorAccountSyncService } from './application/services/contributor-account-sync.service';
import { CommitSyncService } from './application/syncers/commit-sync.service';
import { CommitDeltaMapper } from './infrastructure/blockchain/mappers/commit-delta.mapper';
@@ -426,6 +427,7 @@ import { VotingResolver } from './application/resolvers/voting.resolver';
import { ResultSubmissionResolver } from './application/resolvers/result-submission.resolver';
import { DistributionManagementResolver } from './application/resolvers/distribution-management.resolver';
import { ExpensesManagementResolver } from './application/resolvers/expenses-management.resolver';
import { ProgramExpensesResolver } from './application/resolvers/program-expenses.resolver';
import { SegmentsResolver } from './application/resolvers/segments.resolver';
import { LogResolver } from './application/resolvers/log.resolver';
import { MutationLogMapperService } from './application/services/mutation-log-mapper.service';
@@ -710,6 +712,7 @@ IssueIdGenerationService,
ResultSubmissionService,
DistributionManagementService,
ExpensesManagementService,
ProgramExpensesManagementService,
ContributorAccountSyncService,
SegmentsService,
SegmentMapper,
@@ -769,6 +772,7 @@ IssueIdGenerationService,
ResultSubmissionResolver,
DistributionManagementResolver,
ExpensesManagementResolver,
ProgramExpensesResolver,
SegmentsResolver,
TimeTrackerResolver,
LogResolver,
@@ -224,6 +224,22 @@ export interface CapitalBlockchainPort {
*/
createExpense(data: CapitalContract.Actions.CreateExpense.ICreateExpense): Promise<TransactResult>;
/**
* Программный расход через шасси: capital резервирует program_expense_pool
* и шлёт inline action в expense::createexp с callback `{capital, onpgexpdone}`.
*/
createProgramExpense(
data: CapitalContract.Actions.CreateProgramExpense.ICreateProgramExpense,
): Promise<TransactResult>;
/**
* Пополнение пула программных расходов из доступного остатка
* `global_available_invest_pool` (председатель).
*/
topupProgramExpense(
data: CapitalContract.Actions.TopupProgramExpense.ITopupProgramExpense,
): Promise<TransactResult>;
/**
* Редактирование участника CAPITAL контракта
*/
@@ -746,6 +746,38 @@ export class CapitalBlockchainAdapter implements CapitalBlockchainPort {
});
}
async createProgramExpense(
data: CapitalContract.Actions.CreateProgramExpense.ICreateProgramExpense,
): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateProgramExpense.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async topupProgramExpense(
data: CapitalContract.Actions.TopupProgramExpense.ITopupProgramExpense,
): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.TopupProgramExpense.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
/**
* Редактирование участника CAPITAL контракта
*/
@@ -0,0 +1,141 @@
# Expenses Extension — шасси расходов (C28-31)
**Статус:** implemented (8 actions полного lifecycle, sync-сервис, GraphQL).
## Скоуп расширения
Backend-сторона **шасси системы расходов** (MVP-SINGLE) для Цифрового Кооператива. Расширение:
- хранит и отдаёт **проектные сущности** (`expense_proposals`, `expense_items`) — синхронизация с контрактом `expenses` (TBA в C28-29);
- хранит и отдаёт **первичные файлы** (`expense_files`) — payment proof, отчёт-чеки, return proof. Файлы — в MinIO через готовую `~/infrastructure/file-storage` инфру;
- агрегирует document2 (2010 СЗ-смета, 2011 протокол-1 — см. cooptypes registry 2010/2011) и `expense_files` в единый ответ `expenseProposalDetails` для UI;
- триггерит capital extension при переходе расхода в финальный статус (capitalization of WIP).
## Зависимости (порядок сборки)
1. **C28-28** — P0 коды в `OPERATION_REGISTRY` (`o.exp.*`) — БЛОКИРУЕТ контракт.
2. **C28-29** — контракт `expenses` + cooptypes types: `Expenses.payexp`, `Expenses.reportexp`, `Expenses.returnexp` etc.
3. **C28-30** — ✅ document2 2010/2011 (готово, commit `1fa41b2b39a`).
4. **C28-31** (этот модуль) — TypeORM-entities + sync + GraphQL.
5. **C28-32** — UI шасси (потребляет GraphQL отсюда).
## Планируемая структура файлов
```
extensions/expenses/
├── README.md ← этот файл
├── expenses-extension.module.ts ← NestJS root module (forwardRef в Capital, File-storage)
├── application/
│ ├── dto/
│ │ ├── pay-expense-item.input.ts ← { proposal_hash, item_hash, actual_amount, payment_proof: Upload }
│ │ ├── upload-report-file.input.ts ← { proposal_hash, item_hash?, kind, file: Upload }
│ │ ├── submit-report.input.ts ← { proposal_hash, total_actual_amount }
│ │ └── return-expense.input.ts ← { proposal_hash, item_hash, return_proof: Upload }
│ └── resolvers/
│ ├── expense-proposal.resolver.ts ← Query: expenseProposalDetails(proposal_hash)
│ ├── expense-files.resolver.ts ← Query: expenseFiles(proposal_hash), Mutation: uploadExpenseFile
│ └── expense-mutations.resolver.ts ← Mutation: payExpenseItem / submitReport / returnExpense / authorizeReport
├── domain/
│ ├── entities/
│ │ ├── expense-proposal.entity.ts ← domain model (proposal_hash, status, items[], document2_refs)
│ │ ├── expense-item.entity.ts ← domain model (item_hash, recipient_type, mechanics, amounts)
│ │ └── expense-file.entity.ts ← domain model (file_id, proposal_hash, item_hash?, kind, minio_key, checksum, mime, size, uploaded_at, uploaded_by)
│ ├── enums/
│ │ ├── expense-proposal-status.enum.ts ← DRAFT | APPROVED | PARTIALLY_PAID | REPORTED | AUTHORIZED | CLOSED | DECLINED
│ │ ├── expense-item-status.enum.ts ← APPROVED | PAID | REPORTED | RETURNED | OVERSPENT
│ │ ├── expense-recipient-type.enum.ts ← SELF | MEMBER | ORG (зеркалит cooptypes ExpenseItemRecipientType)
│ │ ├── expense-payment-mechanics.enum.ts ← ADVANCE | DIRECT (зеркалит cooptypes ExpenseItemPaymentMechanics)
│ │ └── expense-file-kind.enum.ts ← PAYMENT_PROOF | REPORT_FILE | RETURN_PROOF
│ ├── interfaces/
│ │ ├── expense-blockchain.interface.ts ← IExpenseProposalBlockchainData / IExpenseItemBlockchainData
│ │ └── expense-database.interface.ts ← IExpenseProposalDatabaseData / IExpenseItemDatabaseData / IExpenseFileDatabaseData
│ └── repositories/
│ ├── expense-proposal.repository.ts ← interface
│ ├── expense-item.repository.ts ← interface
│ └── expense-file.repository.ts ← interface
├── infrastructure/
│ ├── database/
│ │ └── expenses-database.module.ts ← TypeOrmModule.forFeature([...])
│ ├── entities/
│ │ ├── expense-proposal.typeorm-entity.ts ← coopname, proposal_hash, username, status, total_amount, statement_doc, decision_doc, _created_at, _updated_at
│ │ ├── expense-item.typeorm-entity.ts ← proposal_hash, item_hash, recipient_type, mechanics, planned_amount, actual_amount, recipient_account, status
│ │ └── expense-file.typeorm-entity.ts ← file_id (uuid), proposal_hash, item_hash (nullable), kind, minio_key, checksum, mime, size_bytes, uploaded_by, uploaded_at
│ ├── mappers/
│ │ ├── expense-proposal.mapper.ts ← domain <-> typeorm
│ │ ├── expense-item.mapper.ts
│ │ └── expense-file.mapper.ts
│ ├── repositories/
│ │ ├── expense-proposal.typeorm-repository.ts ← extends BaseBlockchainRepository
│ │ ├── expense-item.typeorm-repository.ts ← extends BaseBlockchainRepository
│ │ └── expense-file.typeorm-repository.ts ← plain TypeORM (нет блокчейн-якоря)
│ ├── blockchain/
│ │ └── mappers/
│ │ ├── expense-proposal-delta.mapper.ts ← parser2 delta → IExpenseProposalBlockchainData
│ │ └── expense-item-delta.mapper.ts
│ └── services/
│ ├── expense-files.service.ts ← @UseBucket('expenses:files'), put/getReadUrl/delete
│ ├── document-aggregation.service.ts ← собирает 2010+2011 document2 + expense_files в expenseProposalDetails
│ └── capital-trigger.service.ts ← вызывает capital extension при authorizeReport → capitalization
└── constants/
└── expenses-bucket.ts ← { name: 'expenses:files', maxBytes, allowedMime, metadataSchema }
```
## MinIO bucket spec
```ts
{
name: 'expenses:files',
maxBytes: 20 * 1024 * 1024, // 20 МБ на файл (чеки/PDF/фото)
allowedMime: [
'image/jpeg', 'image/png', 'image/webp', 'image/heic',
'application/pdf',
],
metadataSchema: {
proposalHash: 'required',
itemHash: 'optional',
kind: 'required', // PAYMENT_PROOF | REPORT_FILE | RETURN_PROOF
uploadedBy: 'required', // username
},
defaultUrlTtlSeconds: 600, // 10 мин на signed read URL
}
```
Ключ объекта: `{coopname}/expenses/{proposal_hash}/{item_hash|_proposal}/{kind}/{checksum}.{ext}`.
## GraphQL-схема (предварительная)
```graphql
type Query {
expenseProposalDetails(coopname: String!, proposal_hash: String!): ExpenseProposalDetails!
expenseFiles(coopname: String!, proposal_hash: String!): [ExpenseFile!]!
}
type Mutation {
payExpenseItem(input: PayExpenseItemInput!): ExpenseItem!
submitExpenseReport(input: SubmitReportInput!): ExpenseProposal!
authorizeExpenseReport(coopname: String!, proposal_hash: String!): ExpenseProposal!
declineExpenseReport(coopname: String!, proposal_hash: String!, reason: String!): ExpenseProposal!
returnExpenseItem(input: ReturnExpenseInput!): ExpenseItem!
uploadExpenseFile(input: UploadExpenseFileInput!): ExpenseFile!
}
type ExpenseProposalDetails {
proposal: ExpenseProposal!
items: [ExpenseItem!]!
files: [ExpenseFile!]!
documents: ExpenseProposalDocuments! # signed 2010 + signed 2011
events: [ExpenseProposalEvent!]! # таймлайн
}
```
## Когда расшивается C28-28
Каркас выше можно класть в код **только после** согласования имён `o.exp.*` action-codes и моделей wallets/счетов с пользователем (вопросы заданы в issue C28-28). До этого любой код в `infrastructure/blockchain/mappers/*` опирается на несуществующие cooptypes-типы.
## Ссылки
- PRD шасси: `13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/requirements/f8-prd-shassi-sistemy-raskhodov-tsifrovogo-kooperativa-v12.md`
- Issue C28-31: `13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/issues/C28-31-shassi-raskhodov-epik-3-backend-extension-expenses.md`
- File-storage инфра: `~/infrastructure/file-storage/README.md`
- Образец extension с blockchain-sync: `~/extensions/capital/`
- Document2 templates: `components/cooptypes/src/cooperative/registry/2010.ExpenseProposalStatement/`, `2011.ExpenseProposalDecision/`
@@ -0,0 +1,60 @@
import { Field, InputType } from '@nestjs/graphql';
import { ArrayMinSize, IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ExpenseItemInputDTO } from './expense-item.input';
import { ExpenseCallbackInputDTO } from './expense-callback.input';
import { ExpenseProposalStatementSignedDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-statement-document.dto';
/**
* Input создания и подачи СЗ-расхода (создатель сметы — пайщик / председатель).
*
* Сабмит: `expense::createexp` через `ExpensesBlockchainPort.createExp`.
* Подпись `statement` (registry 2010) сделана на стороне UI через `Classes.Document`.
*/
@InputType('CreateExpenseProposalInput')
export class CreateExpenseProposalInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Имя пайщика-создателя СЗ.' })
@IsNotEmpty()
@IsString()
username!: string;
@Field(() => String, { description: 'Хеш сметы расхода (детерминированный, из UI).' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, {
description: 'Источник средств (eosio::name кошелька-источника, eg "w.cap.blago").',
})
@IsNotEmpty()
@IsString()
source_wallet!: string;
@Field(() => [ExpenseItemInputDTO], { description: 'Строки расхода (массив items).' })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ExpenseItemInputDTO)
items!: ExpenseItemInputDTO[];
@Field(() => ExpenseCallbackInputDTO, {
nullable: true,
description: 'Callback на финализацию closeexp (опционально).',
})
@IsOptional()
@ValidateNested()
@Type(() => ExpenseCallbackInputDTO)
callback?: ExpenseCallbackInputDTO;
@Field(() => ExpenseProposalStatementSignedDocumentInputDTO, {
description: 'Подписанная СЗ-смета (document2, registry 2010).',
})
@ValidateNested()
@Type(() => ExpenseProposalStatementSignedDocumentInputDTO)
statement!: ExpenseProposalStatementSignedDocumentInputDTO;
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
/**
* Callback на финализацию `expense::closeexp`. Пустой `contract` (`""`) = нет callback.
* Зеркалит `ExpenseDomain::callback_handler` из `expense.hpp`.
*/
@InputType('ExpenseCallbackInput')
export class ExpenseCallbackInputDTO {
@Field(() => String, { nullable: true, description: 'Контракт-целевой' })
@IsOptional()
@IsString()
contract?: string;
@Field(() => String, { nullable: true, description: 'Action-метод' })
@IsOptional()
@IsString()
action?: string;
@Field(() => String, { nullable: true, description: 'Payload (hex bytes)' })
@IsOptional()
@IsString()
data?: string;
}
@@ -0,0 +1,69 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { ExpenseFileKind } from '../../domain/enums/expense-file-kind.enum';
import type { IExpenseFileDatabaseData } from '../../domain/interfaces/expense-file-database.interface';
/**
* Output DTO записи о первичном файле расхода в MinIO-бакете.
*
* `read_url` — короткоживущий HMAC-signed URL (TTL = `defaultUrlTtlSeconds` бакета).
* Доменный код выдаёт URL уже после проверки ACL; держатель URL может скачать до истечения TTL.
*/
@ObjectType('ExpenseFile', { description: 'Запись о первичном файле расхода (платёжка/чек/возврат).' })
export class ExpenseFileOutputDTO {
@Field(() => Int, { description: 'Внутренний ID записи.' })
id!: number;
@Field(() => String, { description: 'Имя кооператива (scope).' })
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода.' })
proposal_hash!: string;
@Field(() => String, { nullable: true, description: 'Хеш строки расхода (если файл уровня item).' })
item_hash?: string;
@Field(() => ExpenseFileKind, { description: 'Назначение файла.' })
kind!: ExpenseFileKind;
@Field(() => String, { description: 'SHA-256 содержимого, hex-lowercase.' })
checksum_sha256!: string;
@Field(() => String, { description: 'MIME-тип содержимого.' })
mime_type!: string;
@Field(() => Int, { description: 'Размер файла в байтах.' })
size_bytes!: number;
@Field(() => String, { description: 'MinIO-ключ внутри бакета.' })
storage_key!: string;
@Field(() => String, { nullable: true, description: 'Оригинальное имя загруженного файла.' })
original_filename?: string;
@Field(() => String, { description: 'Кто загрузил (username).' })
uploaded_by_username!: string;
@Field(() => Date, { description: 'Когда загружено.' })
uploaded_at!: Date;
@Field(() => String, { nullable: true, description: 'Короткоживущий URL на скачивание (HMAC-signed).' })
read_url?: string;
static fromDomain(data: IExpenseFileDatabaseData, readUrl?: string): ExpenseFileOutputDTO {
const dto = new ExpenseFileOutputDTO();
dto.id = data.id ?? 0;
dto.coopname = data.coopname;
dto.proposal_hash = data.proposal_hash;
dto.item_hash = data.item_hash ?? undefined;
dto.kind = data.kind;
dto.checksum_sha256 = data.checksum_sha256;
dto.mime_type = data.mime_type;
dto.size_bytes = data.size_bytes;
dto.storage_key = data.storage_key;
dto.original_filename = data.original_filename ?? undefined;
dto.uploaded_by_username = data.uploaded_by_username;
dto.uploaded_at = data.uploaded_at;
dto.read_url = readUrl;
return dto;
}
}
@@ -0,0 +1,62 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator';
import { ExpenseMechanics } from '../../domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '../../domain/enums/expense-recipient-type.enum';
@InputType('ExpenseItemInput')
export class ExpenseItemInputDTO {
@Field(() => String, { description: 'Хеш строки расхода (детерминированный, из UI).' })
@IsNotEmpty()
@IsString()
item_hash!: string;
@Field(() => ExpenseMechanics, { description: 'Способ оплаты (ADVANCE / DIRECT).' })
@IsEnum(ExpenseMechanics)
mechanics!: ExpenseMechanics;
@Field(() => ExpenseRecipientType, { description: 'Тип получателя.' })
@IsEnum(ExpenseRecipientType)
recipient_type!: ExpenseRecipientType;
@Field(() => String, {
description: 'Получатель: username пайщика; для организации — пустая строка (аккаунта в кооперативе нет).',
})
@ValidateIf((o) => o.recipient_type !== ExpenseRecipientType.ORG)
@IsNotEmpty()
@IsString()
recipient!: string;
@Field(() => String, { description: 'Описание назначения расхода.' })
@IsString()
description!: string;
@Field(() => String, { description: 'Планируемая сумма (asset, eg "1000.0000 RUB").' })
@IsNotEmpty()
@IsString()
planned_amount!: string;
@Field(() => String, {
nullable: true,
description:
'Идентификатор сохранённых реквизитов получателя-пайщика — реквизиты снимаются в момент создания и прикладываются к платежу.',
})
@IsOptional()
@IsString()
payment_method_id?: string;
@Field(() => String, {
nullable: true,
description: 'Реквизиты получателя-организации (вводятся вручную).',
})
@IsOptional()
@IsString()
requisites?: string;
@Field(() => String, {
nullable: true,
description: 'Назначение платежа (для оплаты по счёту) — фиксируется в снимке для кассира.',
})
@IsOptional()
@IsString()
payment_purpose?: string;
}
@@ -0,0 +1,34 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ExpenseItemStatus } from '../../domain/enums/expense-item-status.enum';
import { ExpenseMechanics } from '../../domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '../../domain/enums/expense-recipient-type.enum';
/**
* Output DTO строки СЗ-расхода. Зеркалит `IExpenseItemBlockchainData`.
*/
@ObjectType('ExpenseItem', { description: 'Строка сметы расхода.' })
export class ExpenseItemOutputDTO {
@Field(() => String, { description: 'Хеш строки расхода.' })
item_hash!: string;
@Field(() => ExpenseMechanics, { description: 'Способ оплаты.' })
mechanics!: ExpenseMechanics;
@Field(() => ExpenseRecipientType, { description: 'Тип получателя платежа.' })
recipient_type!: ExpenseRecipientType;
@Field(() => String, { nullable: true, description: 'Идентификатор получателя.' })
recipient?: string;
@Field(() => String, { description: 'Назначение/описание строки.' })
description!: string;
@Field(() => String, { description: 'Планируемая сумма.' })
planned_amount!: string;
@Field(() => String, { nullable: true, description: 'Фактическая сумма (после оплаты/отчёта).' })
actual_amount?: string;
@Field(() => ExpenseItemStatus, { description: 'Статус строки.' })
status!: ExpenseItemStatus;
}
@@ -0,0 +1,147 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { BaseOutputDTO } from '~/shared/dto/base.dto';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
import { ExpenseMechanics } from '../../domain/enums/expense-mechanics.enum';
import { ExpenseRecipientType } from '../../domain/enums/expense-recipient-type.enum';
import { ExpenseItemStatus } from '../../domain/enums/expense-item-status.enum';
import { ExpenseItemOutputDTO } from './expense-item.output';
import type { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import type { IExpenseItemBlockchainData } from '../../domain/interfaces/expense-proposal-blockchain.interface';
import type { ExpenseProposalDocumentAggregates } from '../services/expenses-management.service';
/**
* Output DTO СЗ-расхода. Сборка из доменной сущности через `fromDomain`.
*
* `statement_doc` / `decision_doc` — полные `DocumentAggregateDTO` (заявление
* пайщика + решение совета), собираются `DocumentAggregationService` из chain-mirror
* `signed_document`-структур. Резолвер вызывает `ExpensesManagementService
* .buildProposalDocumentAggregates(entity)` и передаёт результат в `fromDomain`.
* Если агрегаты не переданы — поля остаются `undefined` (например в spec'ах /
* test-фикстурах, чтобы не тащить полный document-stack).
*/
@ObjectType('ExpenseProposal', { description: 'Смета расхода (СЗ).' })
export class ExpenseProposalOutputDTO extends BaseOutputDTO {
@Field(() => Int, { nullable: true, description: 'ID в блокчейне.' })
id?: number;
@Field(() => String, { description: 'Хеш сметы расхода.' })
proposal_hash!: string;
@Field(() => String, { description: 'Имя кооператива (scope).' })
coopname!: string;
@Field(() => ExpenseProposalStatus, { description: 'Доменный статус сметы.' })
status!: ExpenseProposalStatus;
@Field(() => String, { nullable: true, description: 'Создатель сметы (username).' })
username?: string;
@Field(() => String, { nullable: true, description: 'Кошелёк-источник средств.' })
source_wallet?: string;
@Field(() => Int, { nullable: true, description: 'Сырой статус из блокчейна (uint8).' })
blockchain_status?: number;
@Field(() => [ExpenseItemOutputDTO], { description: 'Строки сметы.' })
items!: ExpenseItemOutputDTO[];
@Field(() => String, { nullable: true, description: 'Сумма всех строк (план).' })
total_planned?: string;
@Field(() => String, { nullable: true, description: 'Сумма всех строк (факт).' })
total_actual?: string;
@Field(() => String, { nullable: true, description: 'Время создания (chain).' })
created_at?: string;
@Field(() => String, { nullable: true, description: 'Время последнего обновления (chain).' })
updated_at?: string;
@Field(() => DocumentAggregateDTO, {
nullable: true,
description: 'Заявление пайщика о расходе (DocumentAggregate).',
})
statement_doc?: DocumentAggregateDTO | null;
@Field(() => DocumentAggregateDTO, {
nullable: true,
description: 'Решение совета о расходе (DocumentAggregate).',
})
decision_doc?: DocumentAggregateDTO | null;
static fromDomain(
entity: ExpenseProposalDomainEntity,
aggregates?: ExpenseProposalDocumentAggregates
): ExpenseProposalOutputDTO {
const dto = new ExpenseProposalOutputDTO();
dto._id = (entity as unknown as { _id: string })._id;
dto._created_at = (entity as unknown as { _created_at: Date })._created_at;
dto._updated_at = (entity as unknown as { _updated_at: Date })._updated_at;
dto.present = entity.present ?? false;
dto.block_num = entity.block_num;
dto.id = entity.id;
dto.proposal_hash = entity.proposal_hash;
dto.coopname = entity.coopname;
dto.status = entity.status;
dto.username = entity.username;
dto.source_wallet = entity.source_wallet;
dto.blockchain_status = entity.blockchain_status;
dto.items = (entity.items ?? []).map((i) => ExpenseProposalOutputDTO.itemFromBlockchain(i));
dto.total_planned = entity.total_planned;
dto.total_actual = entity.total_actual;
dto.created_at = entity.created_at;
dto.updated_at = entity.updated_at;
dto.statement_doc = aggregates?.statement_doc
? new DocumentAggregateDTO(aggregates.statement_doc)
: aggregates?.statement_doc === null
? null
: undefined;
dto.decision_doc = aggregates?.decision_doc
? new DocumentAggregateDTO(aggregates.decision_doc)
: aggregates?.decision_doc === null
? null
: undefined;
return dto;
}
private static itemFromBlockchain(item: IExpenseItemBlockchainData): ExpenseItemOutputDTO {
const dto = new ExpenseItemOutputDTO();
dto.item_hash = String(item.item_hash).toLowerCase();
dto.mechanics = ExpenseProposalOutputDTO.mapMechanics(item.mechanics);
dto.recipient_type = ExpenseProposalOutputDTO.mapRecipientType(item.recipient_type);
dto.recipient = item.recipient || undefined;
dto.description = item.description;
dto.planned_amount = item.planned_amount;
dto.actual_amount = item.actual_amount || undefined;
dto.status = ExpenseProposalOutputDTO.mapItemStatus(item.status);
return dto;
}
private static mapMechanics(raw: number): ExpenseMechanics {
return raw === 1 ? ExpenseMechanics.DIRECT : ExpenseMechanics.ADVANCE;
}
private static mapRecipientType(raw: number): ExpenseRecipientType {
if (raw === 2) return ExpenseRecipientType.ORG;
if (raw === 1) return ExpenseRecipientType.MEMBER;
return ExpenseRecipientType.SELF;
}
private static mapItemStatus(raw: number): ExpenseItemStatus {
switch (raw) {
case 0:
return ExpenseItemStatus.APPROVED;
case 1:
return ExpenseItemStatus.PAID;
case 2:
return ExpenseItemStatus.REPORTED;
case 3:
return ExpenseItemStatus.RETURNED;
case 4:
return ExpenseItemStatus.OVERSPENT;
default:
return ExpenseItemStatus.UNDEFINED;
}
}
}
@@ -0,0 +1,48 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-type-json';
import type { ExpenseRequisiteSnapshotTypeormEntity } from '../../infrastructure/entities/expense-requisite-snapshot.typeorm-entity';
/**
* Снимок реквизитов получателя по строке расхода. Персональные данные —
* хранятся только в БД шасси (в блокчейн не пишутся), читаются советом
* для сверки «куда и за что платим».
*/
@ObjectType('ExpenseRequisite')
export class ExpenseRequisiteOutputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода (proposal).' })
proposal_hash!: string;
@Field(() => String, { description: 'Хеш строки расхода (item).' })
item_hash!: string;
@Field(() => String, { description: 'Получатель платежа (аккаунт пайщика или название организации).' })
recipient!: string;
@Field(() => String, { nullable: true, description: 'Тип платёжного метода пайщика (СБП / банковский перевод).' })
method_type?: string | null;
@Field(() => GraphQLJSON, { nullable: true, description: 'Снимок данных платёжного метода на момент подачи СЗ.' })
data?: Record<string, unknown> | null;
@Field(() => String, { description: 'Реквизиты строкой — как в документе служебной записки.' })
requisites!: string;
@Field(() => String, { nullable: true, description: 'Назначение платежа для поручения кассиру.' })
payment_purpose?: string | null;
static fromEntity(entity: ExpenseRequisiteSnapshotTypeormEntity): ExpenseRequisiteOutputDTO {
const dto = new ExpenseRequisiteOutputDTO();
dto.coopname = entity.coopname;
dto.proposal_hash = entity.proposal_hash;
dto.item_hash = entity.item_hash;
dto.recipient = entity.recipient;
dto.method_type = entity.method_type;
dto.data = entity.data;
dto.requisites = entity.requisites;
dto.payment_purpose = entity.payment_purpose;
return dto;
}
}
@@ -0,0 +1,35 @@
import { Field, InputType } from '@nestjs/graphql'
import { IsNotEmpty, IsString, Matches } from 'class-validator'
/**
* Input для action `expense::overspendexp` — доплата при перерасходе (ADVANCE-механика).
*
* Контракт: `Ledger2::apply(OVERSPEND_COMPENSATION)` → сразу `Ledger2::apply(ADVANCE_REPORTED)`
* одной транзакцией. Item переходит из `PAID/REPORTED` в `OVERSPENT`.
*/
@InputType('OverspendExpenseItemInput')
export class OverspendExpenseItemInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string
@Field(() => String, { description: 'Хеш сметы расхода (proposal).' })
@IsNotEmpty()
@IsString()
proposal_hash!: string
@Field(() => String, { description: 'Хеш строки расхода (item).' })
@IsNotEmpty()
@IsString()
item_hash!: string
@Field(() => String, {
description: 'Сумма доплаты сверх planned_amount (asset, например "200.0000 RUB").',
})
@IsNotEmpty()
@Matches(/^\d+\.\d{1,8} [A-Z]{1,7}$/, {
message: 'overspend_amount должен быть в формате asset (например "200.0000 RUB").',
})
overspend_amount!: string
}
@@ -0,0 +1,30 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
/**
* Input для action `expense::payexp` — выдача аванса (ADVANCE) или прямая оплата (DIRECT).
*
* `actual_amount` — `asset` (`"100.0000 RUB"`), формат проверяется регуляркой.
*/
@InputType('PayExpenseItemInput')
export class PayExpenseItemInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода (proposal).' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, { description: 'Хеш строки расхода (item).' })
@IsNotEmpty()
@IsString()
item_hash!: string;
@Field(() => String, { description: 'Фактическая сумма оплаты (asset, например "100.0000 RUB").' })
@IsNotEmpty()
@Matches(/^\d+\.\d{1,8} [A-Z]{1,7}$/, { message: 'actual_amount должен быть в формате asset (например "100.0000 RUB").' })
actual_amount!: string;
}
@@ -0,0 +1,40 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
/**
* Input для отчёта пайщика по строке-авансу (ADVANCE).
*
* `actual_amount` — фактически потраченная сумма по чекам:
* - не указана либо равна выданному авансу → позиция закрывается сразу
* (`expense::reportexp`);
* - меньше аванса (недорасход) → заводится входящая платёжка возврата разницы
* (пайщик возвращает на расчётный счёт), `reportexp` отложен до её приёма;
* - больше аванса (перерасход) → заводится исходящая платёжка-доплата разницы,
* `reportexp` отложен до её выплаты.
*/
@InputType('ReportExpenseItemInput')
export class ReportExpenseItemInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода (proposal).' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, { description: 'Хеш строки расхода (item).' })
@IsNotEmpty()
@IsString()
item_hash!: string;
@Field(() => String, {
nullable: true,
description: 'Фактически потраченная сумма по чекам (asset, например "800.0000 RUB"). Не указана — равна выданному авансу.',
})
@IsOptional()
@IsString()
@Matches(/^\d+\.\d{1,8} [A-Z]{1,7}$/, { message: 'actual_amount должен быть в формате asset (например "800.0000 RUB").' })
actual_amount?: string;
}
@@ -0,0 +1,43 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
/**
* Исход отчёта пайщика по строке-авансу: совпал ли факт с выданным авансом или
* требуется расчёт разницы через платёжку.
*/
export enum ExpenseReportOutcome {
/** Факт равен авансу — позиция закрыта on-chain (`expense::reportexp`). */
CLOSED = 'CLOSED',
/** Недорасход — заведена входящая платёжка возврата разницы, ждёт приёма кассиром. */
RETURN_PENDING = 'RETURN_PENDING',
/** Перерасход — заведена исходящая платёжка-доплата разницы, ждёт выплаты кассиром. */
OVERSPEND_PENDING = 'OVERSPEND_PENDING',
}
registerEnumType(ExpenseReportOutcome, {
name: 'ExpenseReportOutcome',
description: 'Исход отчёта по строке-авансу: закрыто либо ожидается расчёт разницы (возврат/доплата).',
});
/**
* Результат мутации `reportExpenseItem`.
*
* При `CLOSED` заполнен `transaction` (on-chain reportexp). При `RETURN_PENDING` /
* `OVERSPEND_PENDING` chain-транзакции ещё нет — заполнены `settlement_amount`
* (раз­ница) и `settlement_payment_hash` (хэш заведённой платёжки расчёта);
* `reportexp` пройдёт автоматически после подтверждения платёжки кассиром.
*/
@ObjectType('ExpenseReportResult')
export class ExpenseReportResultDTO {
@Field(() => ExpenseReportOutcome, { description: 'Исход отчёта.' })
outcome!: ExpenseReportOutcome;
@Field(() => TransactionDTO, { nullable: true, description: 'Транзакция закрытия позиции (только при CLOSED).' })
transaction?: TransactionDTO;
@Field(() => String, { nullable: true, description: 'Сумма разницы к расчёту (asset), при недо-/перерасходе.' })
settlement_amount?: string;
@Field(() => String, { nullable: true, description: 'Хэш заведённой платёжки расчёта (возврат/доплата).' })
settlement_payment_hash?: string;
}
@@ -0,0 +1,28 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
/**
* Input для action `expense::returnexp` — возврат неиспользованного аванса (ADVANCE).
*/
@InputType('ReturnExpenseItemInput')
export class ReturnExpenseItemInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода (proposal).' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, { description: 'Хеш строки расхода (item).' })
@IsNotEmpty()
@IsString()
item_hash!: string;
@Field(() => String, { description: 'Возвращаемая сумма (asset, например "50.0000 RUB").' })
@IsNotEmpty()
@Matches(/^\d+\.\d{1,8} [A-Z]{1,7}$/, { message: 'return_amount должен быть в формате asset (например "50.0000 RUB").' })
return_amount!: string;
}
@@ -0,0 +1,40 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
/**
* Input для финализации СЗ-отчёта по смете расхода.
*
* Пайщик/председатель закрывают смету, когда все items уже reported/returned.
* На-цепи это эквивалент `expense::closeexp` (см. `contracts/cpp/expense/expense.hpp`).
* Поле `total_actual_amount` опционально — backend в Phase 2 сверит его с суммой
* фактических item-сумм перед submit'ом (защита от UI-расхождения).
*/
@InputType('SubmitExpenseReportInput')
export class SubmitExpenseReportInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода.' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, {
nullable: true,
description: 'Итоговая сумма фактических расходов (asset, например "1500.0000 RUB").',
})
@IsOptional()
@Matches(/^\d+\.\d{1,8} [A-Z]{1,7}$/)
total_actual_amount?: string;
@Field(() => String, {
nullable: true,
description: 'Комментарий пайщика к финализации отчёта (свободный текст, до 1000 символов).',
})
@IsOptional()
@IsString()
@MaxLength(1000)
comment?: string;
}
@@ -0,0 +1,56 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBase64, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
import { ExpenseFileKind } from '../../domain/enums/expense-file-kind.enum';
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'application/pdf'] as const;
/**
* Input для `uploadExpenseFile` — загрузка платёжки / чека / доказательства возврата.
*
* MVP-передача: base64-содержимое внутри GraphQL-mutation. Размер ограничен `maxBytes` бакета
* (20 MB) — для типичных фото/PDF этого хватает. В Phase 2 при необходимости заменим на
* pre-signed upload URL (для крупных файлов / лучшего UX).
*/
@InputType('UploadExpenseFileInput')
export class UploadExpenseFileInputDTO {
@Field(() => String, { description: 'Имя кооператива.' })
@IsNotEmpty()
@IsString()
coopname!: string;
@Field(() => String, { description: 'Хеш сметы расхода.' })
@IsNotEmpty()
@IsString()
proposal_hash!: string;
@Field(() => String, { nullable: true, description: 'Хеш строки расхода (пусто — файл уровня сметы).' })
@IsOptional()
@IsString()
item_hash?: string;
@Field(() => ExpenseFileKind, { description: 'Назначение файла (платёжка/чек/возврат).' })
kind!: ExpenseFileKind;
@Field(() => String, { description: 'MIME-тип содержимого.' })
@IsIn([...ALLOWED_MIME], { message: 'mime_type должен быть одним из allowed.' })
mime_type!: string;
@Field(() => String, { nullable: true, description: 'Оригинальное имя файла — для отображения и поиска.' })
@IsOptional()
@IsString()
original_filename?: string;
@Field(() => Number, { description: 'Размер файла в байтах (для серверной валидации).' })
@IsInt()
@Min(1)
@Max(20 * 1024 * 1024)
size_bytes!: number;
@Field(() => String, { description: 'SHA-256 содержимого, hex-lowercase (64 hex-символа).' })
@Matches(/^[a-f0-9]{64}$/, { message: 'checksum_sha256 должен быть 64-символьным lowercase hex.' })
checksum_sha256!: string;
@Field(() => String, { description: 'Содержимое файла, base64 без префикса data:.' })
@IsBase64()
content_base64!: string;
}
@@ -0,0 +1,76 @@
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { ExpenseFilesService } from '../services/expense-files.service';
import { UploadExpenseFileInputDTO } from '../dto/upload-expense-file.input';
import { ExpenseFileOutputDTO } from '../dto/expense-file.output';
/**
* GraphQL для файлов расхода: загрузка (mutation) + чтение списков (queries).
*
* - Загружают пайщик и председатель (всем трём ролям нужно для разных сценариев:
* пайщик — чек/возврат, председатель — платёжка).
* - Списки видят те же роли, при необходимости ACL уточнится после E2E.
*/
@Resolver(() => ExpenseFileOutputDTO)
export class ExpenseFilesResolver {
constructor(private readonly expenseFiles: ExpenseFilesService) {}
@Mutation(() => ExpenseFileOutputDTO, {
name: 'uploadExpenseFile',
description: 'Загрузить первичный файл расхода (платёжка/чек/возврат) в бакет expenses:files.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async uploadExpenseFile(
@Args('data', { type: () => UploadExpenseFileInputDTO }) data: UploadExpenseFileInputDTO,
@CurrentUser() user: MonoAccountDomainInterface
): Promise<ExpenseFileOutputDTO> {
const { data: saved, readUrl } = await this.expenseFiles.uploadFile(data, user.username);
return ExpenseFileOutputDTO.fromDomain(saved, readUrl);
}
@Query(() => ExpenseFileOutputDTO, {
name: 'expenseFile',
description: 'Получить запись о файле + свежий короткоживущий read-URL.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async getExpenseFile(@Args('id', { type: () => Int }) id: number): Promise<ExpenseFileOutputDTO> {
const { data, readUrl } = await this.expenseFiles.getReadUrl(id);
return ExpenseFileOutputDTO.fromDomain(data, readUrl);
}
@Query(() => [ExpenseFileOutputDTO], {
name: 'expenseFilesByProposal',
description: 'Список файлов сметы расхода (без read-URL — запрос отдельно по id).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async listByProposal(
@Args('coopname', { type: () => String }) coopname: string,
@Args('proposal_hash', { type: () => String }) proposalHash: string
): Promise<ExpenseFileOutputDTO[]> {
const items = await this.expenseFiles.listByProposal(coopname, proposalHash);
return items.map((d) => ExpenseFileOutputDTO.fromDomain(d));
}
@Query(() => [ExpenseFileOutputDTO], {
name: 'expenseFilesByItem',
description: 'Список файлов строки расхода (без read-URL — запрос отдельно по id).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async listByItem(
@Args('coopname', { type: () => String }) coopname: string,
@Args('proposal_hash', { type: () => String }) proposalHash: string,
@Args('item_hash', { type: () => String }) itemHash: string
): Promise<ExpenseFileOutputDTO[]> {
const items = await this.expenseFiles.listByItem(coopname, proposalHash, itemHash);
return items.map((d) => ExpenseFileOutputDTO.fromDomain(d));
}
}
@@ -0,0 +1,173 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { ForbiddenException, UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { ExpenseProposalStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-statement-document.dto';
import { ExpenseProposalDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-decision-document.dto';
import { ExpensesMutationsService } from '../services/expenses-mutations.service';
import { ExpensesManagementService } from '../services/expenses-management.service';
import { CreateExpenseProposalInputDTO } from '../dto/create-expense-proposal.input';
import { PayExpenseItemInputDTO } from '../dto/pay-expense-item.input';
import { ReportExpenseItemInputDTO } from '../dto/report-expense-item.input';
import { ExpenseReportResultDTO } from '../dto/report-expense-item.output';
import { ReturnExpenseItemInputDTO } from '../dto/return-expense-item.input';
import { OverspendExpenseItemInputDTO } from '../dto/overspend-expense-item.input';
import { SubmitExpenseReportInputDTO } from '../dto/submit-expense-report.input';
/**
* GraphQL Mutation-резолвер контракта `expense`.
*
* createExpenseProposal / payExpenseItem / reportExpenseItem / returnExpenseItem /
* overspendExpenseItem / submitExpenseReport. Все маршрутизируются в
* `ExpensesMutationsService`, который сабмитит через `ExpensesBlockchainPort`.
* Авторизация и отклонение СЗ — решение совета (повестка), не мутации backend'а.
*/
@Resolver()
export class ExpenseMutationsResolver {
constructor(
private readonly expensesMutations: ExpensesMutationsService,
private readonly expensesManagement: ExpensesManagementService
) {}
/**
* Контракт проверяет только статус/механику строки, но не личность
* отчитывающегося — поэтому отчёт по чужому авансу отсекается здесь:
* пайщик может отчитаться только по своей строке, совет — по любой.
*/
private async assertCanReportItem(
user: MonoAccountDomainInterface,
proposalHash: string,
itemHash: string
): Promise<void> {
if (user.role === 'chairman' || user.role === 'member') return;
const proposal = await this.expensesManagement.getProposalByHash(proposalHash);
const item = proposal?.items?.find(
(i) => i.item_hash?.toLowerCase() === itemHash.toLowerCase()
);
if (!item || item.recipient !== user.username) {
throw new ForbiddenException('Отчитаться по авансу может только его получатель');
}
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'generateExpenseProposalStatementDocument',
description: 'Сгенерировать документ СЗ-заявления (registry 2010) для последующей подписи.',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateExpenseProposalStatementDocument(
@Args('data', { type: () => ExpenseProposalStatementGenerateDocumentInputDTO })
data: ExpenseProposalStatementGenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO,
): Promise<GeneratedDocumentDTO> {
return this.expensesMutations.generateExpenseProposalStatementDocument(data, options);
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'generateExpenseProposalDecisionDocument',
description: 'Сгенерировать документ-решение по СЗ (registry 2011) для последующей подписи.',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async generateExpenseProposalDecisionDocument(
@Args('data', { type: () => ExpenseProposalDecisionGenerateDocumentInputDTO })
data: ExpenseProposalDecisionGenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO,
): Promise<GeneratedDocumentDTO> {
return this.expensesMutations.generateExpenseProposalDecisionDocument(data, options);
}
@Mutation(() => TransactionDTO, {
name: 'createExpenseProposal',
description: 'Подать СЗ-расход (создать смету с подписью пайщика/председателя).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async createExpenseProposal(
@Args('data', { type: () => CreateExpenseProposalInputDTO }) data: CreateExpenseProposalInputDTO,
@CurrentUser() user: MonoAccountDomainInterface
): Promise<TransactionDTO> {
// RolesGuard пропускает запрос при data.username === user.username независимо
// от роли — здесь это сломало бы ограничение «СЗ подаёт совет», поэтому роль
// проверяется явно.
if (user.role !== 'chairman' && user.role !== 'member') {
throw new ForbiddenException('Создавать служебные записки на расход могут только председатель и члены совета');
}
return this.expensesMutations.createExpenseProposal(data);
}
@Mutation(() => TransactionDTO, {
name: 'payExpenseItem',
description: 'Оплатить строку расхода (выдача аванса ADVANCE или прямая оплата DIRECT).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async payExpenseItem(
@Args('data', { type: () => PayExpenseItemInputDTO }) data: PayExpenseItemInputDTO
): Promise<TransactionDTO> {
return this.expensesMutations.payExpenseItem(data);
}
@Mutation(() => ExpenseReportResultDTO, {
name: 'reportExpenseItem',
description: 'Отчитаться по строке-авансу: при совпадении факта с авансом — закрыть позицию; при недо-/перерасходе — завести платёжку расчёта разницы.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async reportExpenseItem(
@Args('data', { type: () => ReportExpenseItemInputDTO }) data: ReportExpenseItemInputDTO,
@CurrentUser() user: MonoAccountDomainInterface
): Promise<ExpenseReportResultDTO> {
await this.assertCanReportItem(user, data.proposal_hash, data.item_hash);
return this.expensesMutations.reportExpenseItem(data);
}
@Mutation(() => TransactionDTO, {
name: 'returnExpenseItem',
description: 'Вернуть неиспользованный аванс по строке расхода (ADVANCE-остаток).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async returnExpenseItem(
@Args('data', { type: () => ReturnExpenseItemInputDTO }) data: ReturnExpenseItemInputDTO
): Promise<TransactionDTO> {
return this.expensesMutations.returnExpenseItem(data);
}
@Mutation(() => TransactionDTO, {
name: 'overspendExpenseItem',
description: 'Доплатить сумму перерасхода по строке расхода (ADVANCE-механика).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async overspendExpenseItem(
@Args('data', { type: () => OverspendExpenseItemInputDTO }) data: OverspendExpenseItemInputDTO
): Promise<TransactionDTO> {
return this.expensesMutations.overspendExpenseItem(data);
}
@Mutation(() => TransactionDTO, {
name: 'submitExpenseReport',
description: 'Финализировать СЗ-отчёт по смете расхода (все items закрыты — оплата/чек/возврат).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
// Закрытие расхода — финализация СЗ-отчёта советом: председатель / член совета.
@AuthRoles(['chairman', 'member'])
async submitExpenseReport(
@Args('data', { type: () => SubmitExpenseReportInputDTO }) data: SubmitExpenseReportInputDTO
): Promise<TransactionDTO> {
return this.expensesMutations.submitExpenseReport(data);
}
}
@@ -0,0 +1,111 @@
import { Args, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import {
createPaginationResult,
PaginationInputDTO,
PaginationResult,
} from '~/application/common/dto/pagination.dto';
import { ExpensesManagementService } from '../services/expenses-management.service';
import { ExpenseRequisiteSnapshotsService } from '../services/expense-requisite-snapshots.service';
import { ExpenseProposalOutputDTO } from '../dto/expense-proposal.output';
import { ExpenseRequisiteOutputDTO } from '../dto/expense-requisite.output';
import type { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
const paginatedExpenseProposalsResult = createPaginationResult(
ExpenseProposalOutputDTO,
'PaginatedExpenseProposals'
);
/**
* GraphQL-резолвер чтения смет расходов (контракт `expense`).
*
* Read-only: read-path тянется из PG-зеркала (см. ADR-011 в controller/CLAUDE.md).
* Mutation-резолвер (payexp/reportexp/returnexp/submitReport) — `ExpenseMutationsResolver`.
*
* Пагинация — единый паттерн controller'а (см. controller/CLAUDE.md «Пагинация — единый паттерн»):
* input `PaginationInputDTO`, выход `PaginationResult<T>` через `createPaginationResult`.
*
* Документы (заявление + решение совета) выгружаются как полные `DocumentAggregate`
* через `ExpensesManagementService.buildProposalDocumentAggregates(entity)`.
*/
@Resolver(() => ExpenseProposalOutputDTO)
export class ExpenseProposalResolver {
constructor(
private readonly expenses: ExpensesManagementService,
private readonly requisiteSnapshots: ExpenseRequisiteSnapshotsService
) {}
@Query(() => [ExpenseRequisiteOutputDTO], {
name: 'expenseRequisitesByProposal',
description:
'Снимки реквизитов получателей по строкам СЗ (персональные данные — только совету; в блокчейн не пишутся).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getExpenseRequisitesByProposal(
@Args('coopname', { type: () => String }) coopname: string,
@Args('proposal_hash', { type: () => String }) proposalHash: string
): Promise<ExpenseRequisiteOutputDTO[]> {
const items = await this.requisiteSnapshots.listByProposal(coopname, proposalHash);
return items.map((e) => ExpenseRequisiteOutputDTO.fromEntity(e));
}
@Query(() => ExpenseProposalOutputDTO, {
name: 'expenseProposal',
description: 'Получить смету расхода по хешу.',
nullable: true,
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async getExpenseProposal(
@Args('proposal_hash', { type: () => String }) proposalHash: string
): Promise<ExpenseProposalOutputDTO | null> {
const entity = await this.expenses.getProposalByHash(proposalHash);
if (!entity) return null;
const aggregates = await this.expenses.buildProposalDocumentAggregates(entity);
return ExpenseProposalOutputDTO.fromDomain(entity, aggregates);
}
@Query(() => paginatedExpenseProposalsResult, {
name: 'expenseProposalsByCooperative',
description: 'Список смет расходов кооператива (paginated).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async listByCooperative(
@Args('coopname', { type: () => String }) coopname: string,
@Args('options', { type: () => PaginationInputDTO, nullable: true }) options?: PaginationInputDTO
): Promise<PaginationResult<ExpenseProposalOutputDTO>> {
const result = await this.expenses.listProposalsByCooperativePaginated(coopname, options);
return { ...result, items: await this.mapItemsWithDocuments(result.items) };
}
@Query(() => paginatedExpenseProposalsResult, {
name: 'expenseProposalsByMember',
description: 'Список смет расходов пайщика (свои/созданные им, paginated).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async listByMember(
@Args('coopname', { type: () => String }) coopname: string,
@Args('username', { type: () => String }) username: string,
@Args('options', { type: () => PaginationInputDTO, nullable: true }) options?: PaginationInputDTO
): Promise<PaginationResult<ExpenseProposalOutputDTO>> {
const result = await this.expenses.listProposalsByMemberPaginated(coopname, username, options);
return { ...result, items: await this.mapItemsWithDocuments(result.items) };
}
private async mapItemsWithDocuments(
entities: ExpenseProposalDomainEntity[]
): Promise<ExpenseProposalOutputDTO[]> {
return Promise.all(
entities.map(async (e) => {
const aggregates = await this.expenses.buildProposalDocumentAggregates(e);
return ExpenseProposalOutputDTO.fromDomain(e, aggregates);
})
);
}
}
@@ -0,0 +1,184 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { Workflows } from '@coopenomics/notifications';
import { NotificationSenderService } from '~/application/notification/services/notification-sender.service';
import config from '~/config/config';
import {
EXPENSE_PROPOSAL_REPOSITORY,
type ExpenseProposalRepository,
} from '../../domain/repositories/expense-proposal.repository';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
import type { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import type { IExpenseItemBlockchainData } from '../../domain/interfaces/expense-proposal-blockchain.interface';
// Зеркала числовых enum контракта `expense` (cpp/expense/expense.hpp).
const MECHANICS_ADVANCE = 0; // Mechanics::ADVANCE — пайщик-получатель отчитывается чеком.
const RECIPIENT_ORG = 2; // RecipientType::ORG — у организации нет личного кабинета.
const ITEM_STATUS_PAID = 1; // ItemStatus::PAID — аванс выдан, отчёт ещё не подан.
// Параметры воркера — ENV-sourced с дефолтами (канон: не hardcode magic numbers,
// тюнинг воркера читаем из process.env как outbox-worker Центра уведомлений).
// Тик опроса зеркала расходов. Дайджест шлётся не чаще раза в неделю — частоту
// тика троттлит идемпотентность Центра (поле `period` в payload), поэтому тик
// можно держать редким: он лишь определяет задержку первого письма после грейса.
const TICK_MS = Number(process.env.EXPENSE_ADVANCE_REMINDER_INTERVAL_MS) || 6 * 60 * 60 * 1000;
// Сколько дней аванс может «висеть» без отчёта до первого напоминания.
const GRACE_DAYS = Number(process.env.EXPENSE_ADVANCE_REMINDER_GRACE_DAYS) || 3;
const DAY_MS = 24 * 60 * 60 * 1000;
interface OutstandingAdvance {
description: string;
amount: string;
proposal_hash: string;
}
/**
* Фоновый напоминатель об отчёте по авансу под отчёт.
*
* Каждый тик сканирует зеркало расходов кооператива и находит ADVANCE-item-ы в
* статусе PAID (аванс выдан пайщику, отчёт не подан). По каждому пайщику-получателю
* шлёт один агрегированный дайджест со ссылками на расходы. Кассиру/совету ничего
* не шлётся — только получателю аванса.
*
* Троттлинг «раз в неделю» — через идемпотентность Центра уведомлений: поле `period`
* (ISO-неделя) делает payload стабильным внутри недели, поэтому повторные тики гасятся,
* а на новой неделе уходит ровно одно письмо. Своя таблица состояния не нужна.
*
* Точка отсчёта грейса — `proposal.updated_at` (последняя операция со СЗ; для PAID-не-
* REPORTED item-а это фактически момент выплаты), с фолбэком на `created_at`. Срок из
* заявления пайщика (deadline) сейчас не доезжает до цепи — это осознанный fallback,
* точная семантика «просрочен по дате» — отдельный апгрейд контракта (см. README).
*/
@Injectable()
export class ExpenseAdvanceReminderService implements OnModuleInit {
private readonly logger = new Logger(ExpenseAdvanceReminderService.name);
// Гард от наложения тиков (single-instance): длинный тик не запускается повторно.
private isRunning = false;
constructor(
@Inject(EXPENSE_PROPOSAL_REPOSITORY)
private readonly proposals: ExpenseProposalRepository,
private readonly notificationSender: NotificationSenderService
) {}
onModuleInit(): void {
this.logger.log(
`Напоминатель об отчёте по авансу запущен: тик ${Math.round(TICK_MS / 60000)} мин, грейс ${GRACE_DAYS} дн`
);
}
@Interval('expense-advance-report-reminder', TICK_MS)
async tick(): Promise<void> {
if (this.isRunning) return;
this.isRunning = true;
try {
await this.processCoop(config.coopname);
} catch (error: any) {
this.logger.error(`Ошибка тика напоминателя авансов: ${error.message}`, error.stack);
} finally {
this.isRunning = false;
}
}
private async processCoop(coopname: string): Promise<void> {
const proposals = await this.proposals.findByCoopname(coopname);
const now = Date.now();
// Группируем «висящие» авансы по пайщику-получателю.
const byUser = new Map<string, OutstandingAdvance[]>();
for (const proposal of proposals) {
if (!this.isPayablePhase(proposal.status)) continue;
const anchorMs = this.anchorMs(proposal);
if (anchorMs === null || now - anchorMs < GRACE_DAYS * DAY_MS) continue;
for (const item of proposal.items ?? []) {
if (!this.isOutstandingAdvance(item)) continue;
const username = item.recipient || proposal.username;
if (!username) continue;
const list = byUser.get(username) ?? [];
list.push({
// Выданная сумма аванса — то, по чему пайщик отчитывается. actual_amount
// дрейфует от возвратов/перерасходов (settlement), поэтому не используем.
description: item.description,
amount: item.planned_amount,
proposal_hash: proposal.proposal_hash,
});
byUser.set(username, list);
}
}
if (byUser.size === 0) return;
this.logger.debug(`Напоминания об авансах: пайщиков к уведомлению ${byUser.size}`);
for (const [username, advances] of byUser) {
await this.sendDigest(coopname, username, advances);
}
}
private async sendDigest(coopname: string, username: string, advances: OutstandingAdvance[]): Promise<void> {
const base = `${config.frontend_url}/${coopname}/expenses`;
// Один аванс — ведём прямо на сам расход; несколько — на личную страницу
// «Платежи» пайщика (там строки авансов с панелью «приложить чек/отчитаться»).
const link =
advances.length === 1
? `${base}/${advances[0].proposal_hash}`
: `${config.frontend_url}/${coopname}/user/payments`;
const payload: Workflows.ExpenseAdvanceReportReminder.IPayload = {
coopName: coopname,
period: isoWeekBucket(new Date()),
count: advances.length,
link,
advances: advances.map((advance) => ({
description: advance.description,
amount: advance.amount,
url: `${base}/${advance.proposal_hash}`,
})),
};
try {
await this.notificationSender.sendNotificationToUser(
username,
Workflows.ExpenseAdvanceReportReminder.id,
payload
);
} catch (error: any) {
// Нет subscriber_id/email у аккаунта (системный/служебный) — не валим цикл.
this.logger.warn(`Не удалось отправить напоминание об авансе пайщику ${username}: ${error.message}`);
}
}
private isPayablePhase(status: ExpenseProposalStatus): boolean {
// Аванс мог быть выдан в AUTHORIZED (одна позиция) или PARTIALLY_PAID (часть позиций).
// REPORT_SUBMITTED/CLOSED/DECLINED — расход уже без открытого подотчёта.
return status === ExpenseProposalStatus.AUTHORIZED || status === ExpenseProposalStatus.PARTIALLY_PAID;
}
private isOutstandingAdvance(item: IExpenseItemBlockchainData): boolean {
return (
item.mechanics === MECHANICS_ADVANCE &&
item.status === ITEM_STATUS_PAID &&
item.recipient_type !== RECIPIENT_ORG
);
}
private anchorMs(proposal: ExpenseProposalDomainEntity): number | null {
const raw = proposal.updated_at || proposal.created_at;
if (!raw) return null;
const ms = Date.parse(raw);
return Number.isNaN(ms) ? null : ms;
}
}
/**
* ISO-8601 номер недели в формате `YYYY-Www` — техническая «корзина» троттлинга.
* Один пайщик + одна неделя → стабильный payload → идемпотентность Центра гасит повтор.
*/
function isoWeekBucket(d: Date): string {
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const dayNum = date.getUTCDay() || 7; // Пн=1…Вс=7
date.setUTCDate(date.getUTCDate() + 4 - dayNum); // ближайший четверг определяет ISO-год недели
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / DAY_MS + 1) / 7);
return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
@@ -0,0 +1,159 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'crypto';
import type { InterFileStorageBucket } from '@coopenomics/inter';
import { InjectBucket, UseBucket } from '~/infrastructure/file-storage';
import { PAYMENT_REPOSITORY, type PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { ExpenseFileKind } from '../../domain/enums/expense-file-kind.enum';
import {
EXPENSE_FILE_REPOSITORY,
type ExpenseFileRepository,
} from '../../domain/repositories/expense-file.repository';
import type { IExpenseFileDatabaseData } from '../../domain/interfaces/expense-file-database.interface';
import { EXPENSES_BUCKET } from '../../constants/expenses-bucket';
import { UploadExpenseFileInputDTO } from '../dto/upload-expense-file.input';
const EXTENSION_BY_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/heic': 'heic',
'application/pdf': 'pdf',
};
/**
* Сервис файлов расхода: загрузка в `expenses:files`, выдача read-URL, удаление + БД-метаданные.
*
* Ключ MinIO детерминирован: `{coopname}/{proposal_hash}/{item_hash|_proposal}/{kind}/{checksum}.{ext}`.
* Поэтому повторная загрузка того же содержимого с тем же scope'ом идемпотентна на стороне бакета,
* а в БД защищаемся явным `findByChecksum` (409 при дубле).
*/
@UseBucket(EXPENSES_BUCKET)
@Injectable()
export class ExpenseFilesService {
constructor(
@InjectBucket() private readonly bucket: InterFileStorageBucket,
@Inject(EXPENSE_FILE_REPOSITORY) private readonly files: ExpenseFileRepository,
@Inject(PAYMENT_REPOSITORY) private readonly payments: PaymentRepository
) {}
async uploadFile(
input: UploadExpenseFileInputDTO,
uploadedByUsername: string
): Promise<{ data: IExpenseFileDatabaseData; readUrl: string }> {
const body = Buffer.from(input.content_base64, 'base64');
if (body.byteLength !== input.size_bytes) {
throw new BadRequestException(
`size_bytes (${input.size_bytes}) не совпадает с фактическим размером base64-контента (${body.byteLength}).`
);
}
const actualChecksum = createHash('sha256').update(body).digest('hex');
if (actualChecksum !== input.checksum_sha256.toLowerCase()) {
throw new BadRequestException(
`checksum_sha256 не совпадает с реальным SHA-256 содержимого.`
);
}
const existing = await this.files.findByChecksum(input.coopname, actualChecksum);
if (existing) {
throw new ConflictException(
`Файл с таким SHA-256 уже зарегистрирован в этом кооперативе (id=${existing.id}).`
);
}
const storageKey = this.buildKey({
coopname: input.coopname,
proposalHash: input.proposal_hash,
itemHash: input.item_hash ?? null,
kind: input.kind,
checksum: actualChecksum,
mimeType: input.mime_type,
});
await this.bucket.put(storageKey, new Uint8Array(body), {
contentType: input.mime_type,
});
const saved = await this.files.create({
coopname: input.coopname,
proposal_hash: input.proposal_hash.toLowerCase(),
item_hash: input.item_hash ? input.item_hash.toLowerCase() : null,
kind: input.kind,
checksum_sha256: actualChecksum,
mime_type: input.mime_type,
size_bytes: body.byteLength,
storage_key: storageKey,
original_filename: input.original_filename ?? null,
uploaded_by_username: uploadedByUsername,
uploaded_at: new Date(),
});
await this.syncPaymentProofMark(saved);
const readUrl = await this.bucket.getReadUrl(storageKey);
return { data: saved, readUrl };
}
/**
* Зеркалит число приложенных платёжек в исходящий платёж реестра
* (`blockchain_data.proof_count`) — реестр платежей показывает по нему отметку
* «платёжка приложена», не дёргая хранилище файлов на каждую строку.
* Статус платежа не трогаем — им управляет gateway.
*/
private async syncPaymentProofMark(file: Pick<IExpenseFileDatabaseData, 'coopname' | 'proposal_hash' | 'item_hash' | 'kind'>): Promise<void> {
if (file.kind !== ExpenseFileKind.PAYMENT_PROOF || !file.item_hash) return;
const payment = await this.payments.findByHash(file.item_hash);
if (!payment?.id) return;
const proofs = (await this.files.findByItem(file.coopname, file.proposal_hash, file.item_hash)).filter(
(f) => f.kind === ExpenseFileKind.PAYMENT_PROOF
);
await this.payments.update(payment.id, {
blockchain_data: { ...(payment.blockchain_data ?? {}), proof_count: proofs.length },
});
}
async getReadUrl(fileId: number): Promise<{ data: IExpenseFileDatabaseData; readUrl: string }> {
const file = await this.files.findById(fileId);
if (!file) throw new NotFoundException(`Файл расхода #${fileId} не найден.`);
const readUrl = await this.bucket.getReadUrl(file.storage_key);
return { data: file, readUrl };
}
async listByProposal(coopname: string, proposalHash: string): Promise<IExpenseFileDatabaseData[]> {
return this.files.findByProposal(coopname, proposalHash.toLowerCase());
}
async listByItem(
coopname: string,
proposalHash: string,
itemHash: string
): Promise<IExpenseFileDatabaseData[]> {
return this.files.findByItem(coopname, proposalHash.toLowerCase(), itemHash.toLowerCase());
}
async deleteFile(fileId: number): Promise<void> {
const file = await this.files.findById(fileId);
if (!file) throw new NotFoundException(`Файл расхода #${fileId} не найден.`);
await this.bucket.delete(file.storage_key);
await this.files.delete(fileId);
await this.syncPaymentProofMark(file);
}
private buildKey(params: {
coopname: string;
proposalHash: string;
itemHash: string | null;
kind: ExpenseFileKind;
checksum: string;
mimeType: string;
}): string {
const ext = EXTENSION_BY_MIME[params.mimeType] ?? 'bin';
const item = params.itemHash ? params.itemHash.toLowerCase() : '_proposal';
return `${params.coopname}/${params.proposalHash.toLowerCase()}/${item}/${params.kind}/${params.checksum}.${ext}`;
}
}
@@ -0,0 +1,137 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentTypeEnum, PaymentDirectionEnum } from '~/domain/gateway/enums/payment-type.enum';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
import { QuantityUtils } from '~/shared/utils/quantity.utils';
import { generateUniqueHash } from '~/utils/generate-hash.util';
import { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
import { ExpenseRequisiteSnapshotTypeormEntity } from '../../infrastructure/entities/expense-requisite-snapshot.typeorm-entity';
import { EXPENSES_CHASSIS_CONFIG } from '../../domain/expenses-chassis.config';
/** Зеркало ExpenseDomain::RecipientType контракта expense. */
const RECIPIENT_ORG = 2;
/**
* Заводит исходящие платежи в реестр платежей после авторизации СЗ советом.
*
* Подписан на `entitysynced::expense::proposals` (после save зеркала в PG).
* При переходе СЗ в AUTHORIZED создаёт по одному OUTGOING-платежу на каждую
* позицию расхода: реквизиты и назначение платежа берутся из снимка
* `expense_requisite_snapshots` (зафиксирован при создании СЗ) и НЕ проходят
* через блокчейн — это персональные данные. Кассир видит платёж в реестре
* (PENDING), подтверждает оплату — gateway.interactor по типу EXPENSE проводит
* on-chain `expense::payexp` (см. processOutgoingPayment).
*
* Идемпотентность: hash платежа = item_hash; повторный sync (replay/форк)
* платежей-дублей не создаёт.
*/
@Injectable()
export class ExpensePaymentsListener {
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly payments: PaymentRepository,
@InjectRepository(ExpenseRequisiteSnapshotTypeormEntity)
private readonly snapshots: Repository<ExpenseRequisiteSnapshotTypeormEntity>,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(ExpensePaymentsListener.name);
}
@OnEvent('entitysynced::expense::proposals')
async handleProposalSynced(payload: { entity: ExpenseProposalDomainEntity; blockNum: number }): Promise<void> {
const { entity } = payload;
if (entity.status !== ExpenseProposalStatus.AUTHORIZED) return;
if (!entity.items?.length) return;
for (const item of entity.items) {
const itemHash = item.item_hash.toLowerCase();
const existing = await this.payments.findByHash(itemHash);
if (existing) continue;
const snapshot = await this.snapshots.findOne({
where: {
coopname: entity.coopname,
proposal_hash: entity.proposal_hash,
item_hash: itemHash,
},
});
if (!snapshot) {
// СЗ авторизована, а реквизитов нет — кассир не сможет оплатить позицию.
this.logger.error(
`КРИТИЧНО: СЗ ${entity.proposal_hash} авторизована, но снимок реквизитов позиции ${itemHash} не найден — платёж не создан, нужна ручная сверка`
);
continue;
}
const { amount, symbol } = QuantityUtils.parseQuantityString(item.planned_amount);
const isOrganization = item.recipient_type === RECIPIENT_ORG;
const now = new Date();
const payment: PaymentDomainInterface = {
id: '',
coopname: entity.coopname,
// Владелец платежа в реестре = тот, к кому платёж относится ЛИЧНО:
// аванс под отчёт — пайщик-получатель (платёж виден в его личном реестре,
// его реквизиты/сертификат). Оплата организации — это платёж самого
// КООПЕРАТИВА (получатель-организация в реквизитах), а НЕ инициатора СЗ:
// инициатор лишь предложил расход, деньги идут не к нему — на его столе
// пайщика такой платёж появляться не должен. Поэтому org-платёж
// принадлежит кооперативу (coopname): в личных реестрах пайщиков он не
// виден (там фильтр по username), кассир видит его в общем реестре
// платежей кооператива (без фильтра по username). На on-chain payexp это
// не влияет — проводка берёт coopname/proposal_hash/item_hash, не username.
username: isOrganization ? entity.coopname : item.recipient,
quantity: amount,
symbol,
type: PaymentTypeEnum.EXPENSE,
direction: PaymentDirectionEnum.OUTGOING,
// Совет уже решил — платёж сразу готов к выплате кассиром.
status: PaymentStatusEnum.PENDING,
// Назначение платежа из снимка: пайщику — всегда «Аванс под отчёт»,
// организации — введённый при создании СЗ текст. Кассир копирует as-is.
memo: snapshot.payment_purpose ?? EXPENSES_CHASSIS_CONFIG.advancePaymentPurpose,
secret: generateUniqueHash(),
payment_method_id: snapshot.method_id ?? undefined,
payment_details: {
// Для пайщика — снимок данных платёжного метода (банк/СБП — рендерится
// в реестре как обычные реквизиты); для организации — свободные
// реквизиты строкой + имя получателя.
data: snapshot.data ?? {
recipient_name: snapshot.recipient,
requisites: snapshot.requisites,
},
amount_plus_fee: String(amount),
amount_without_fee: String(amount),
fee_amount: '0',
fee_percent: 0,
fact_fee_percent: 0,
tolerance_percent: 0,
},
// Связь с СЗ для processOutgoingPayment (payexp требует proposal_hash)
// и для кассира (что оплачиваем).
blockchain_data: {
proposal_hash: entity.proposal_hash,
item_hash: itemHash,
description: item.description,
},
expired_at: undefined, // бессрочный — как прочие исходящие платежи
created_at: now,
updated_at: now,
hash: itemHash,
};
const created = await this.payments.create(payment);
this.logger.log(
`СЗ ${entity.proposal_hash}: создан исходящий платёж ${created.id} по позиции ${itemHash} на ${item.planned_amount} (${payment.memo})`
);
}
}
}
@@ -0,0 +1,154 @@
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import type { InterExpenseRequisiteItemInput } from '@coopenomics/inter'
import { PAYMENT_METHOD_REPOSITORY, PaymentMethodRepository } from '~/domain/common/repositories/payment-method.repository'
import { EXPENSES_CHASSIS_CONFIG } from '../../domain/expenses-chassis.config'
import { ExpenseRequisiteSnapshotTypeormEntity } from '../../infrastructure/entities/expense-requisite-snapshot.typeorm-entity'
import { formatPaymentMethodRequisites } from '../../domain/utils/format-requisites.util'
/**
* Снимки реквизитов получателей по строкам СЗ. Канон gateway prepareWithdraw →
* persistWithdraw: `validate` зовётся ДО on-chain заявки (метод существует),
* `snapshot` — ПОСЛЕ (данные метода копируются на момент создания СЗ).
* Реквизиты в чейн не пишутся — только в БД шасси и в документ 2010.
*/
@Injectable()
export class ExpenseRequisiteSnapshotsService {
private readonly logger = new Logger(ExpenseRequisiteSnapshotsService.name)
constructor(
@Inject(PAYMENT_METHOD_REPOSITORY)
private readonly paymentMethods: PaymentMethodRepository,
@InjectRepository(ExpenseRequisiteSnapshotTypeormEntity)
private readonly repository: Repository<ExpenseRequisiteSnapshotTypeormEntity>
) {}
async validate(coopname: string, items: InterExpenseRequisiteItemInput[]): Promise<void> {
await this.resolve(coopname, items)
}
async snapshot(coopname: string, items: InterExpenseRequisiteItemInput[]): Promise<void> {
const snapshots = await this.resolve(coopname, items)
if (snapshots.length === 0) return
try {
await this.repository.save(snapshots)
} catch (error: any) {
// On-chain заявка уже создана — без снимка бухгалтер не получит реквизиты
// для оплаты. Требуется ручная сверка.
this.logger.error(
`КРИТИЧНО: СЗ ${items[0]?.proposalHash} создана on-chain, но снимок реквизитов не сохранён: ${error.message}`,
error
)
throw error
}
}
/** Снимки реквизитов всех строк СЗ — для сверки советом на странице расхода. */
async listByProposal(coopname: string, proposalHash: string): Promise<ExpenseRequisiteSnapshotTypeormEntity[]> {
return this.repository.find({
where: { coopname, proposal_hash: proposalHash.toLowerCase() },
order: { id: 'ASC' },
})
}
/** Полные реквизиты платёжного метода пайщика строкой — для документов. */
async formatForOwner(username: string, methodId: string): Promise<string> {
const method = await this.paymentMethods.get({ username, method_id: methodId })
return formatPaymentMethodRequisites(method)
}
/**
* Снимок реквизитов пайщика по позиции СЗ — куда кассир платит при доплате
* перерасхода (тот же платёжный метод, что и при выдаче аванса).
*/
async getItemRequisiteData(
coopname: string,
proposalHash: string,
itemHash: string
): Promise<{ data: Record<string, unknown> | null; requisites: string } | null> {
const snapshot = await this.repository.findOne({
where: { coopname, proposal_hash: proposalHash.toLowerCase(), item_hash: itemHash.toLowerCase() },
})
if (!snapshot) return null
return { data: snapshot.data ?? null, requisites: snapshot.requisites ?? '' }
}
/**
* Банковские реквизиты самого кооператива — куда пайщик возвращает
* неиспользованный аванс (недорасход). Берём дефолтный (или первый) платёжный
* метод аккаунта кооператива.
*/
async getCooperativeRequisiteData(
coopname: string
): Promise<{ data: Record<string, unknown> | null; requisites: string } | null> {
const list = await this.paymentMethods.list({ username: coopname, page: 1, limit: 50, sortOrder: 'ASC' })
const methods = list.items ?? []
const method = methods.find((m) => m.is_default) ?? methods[0]
if (!method) return null
return {
data: method.data as unknown as Record<string, unknown>,
requisites: formatPaymentMethodRequisites(method),
}
}
private async resolve(
coopname: string,
items: InterExpenseRequisiteItemInput[]
): Promise<ExpenseRequisiteSnapshotTypeormEntity[]> {
return Promise.all(
items.map(async (it) => {
// Инвариант шасси: пайщику — только аванс под отчёт (средства на личные
// реквизиты, расходом станут после отчёта чеком); организации — только
// прямая оплата по выставленным реквизитам.
if (it.isOrganization && it.mechanics !== 'DIRECT') {
throw new BadRequestException(
'Организации/ИП доступна только оплата по счёту (DIRECT) — аванс под отчёт ей не выдаётся'
)
}
if (!it.isOrganization && it.mechanics !== 'ADVANCE') {
throw new BadRequestException(
`Пайщику ${it.recipient} средства передаются только авансом под отчёт (ADVANCE) — оплата по счёту доступна только организации/ИП`
)
}
const snapshot = this.repository.create({
coopname,
proposal_hash: it.proposalHash.toLowerCase(),
item_hash: it.itemHash.toLowerCase(),
recipient: it.recipient,
method_id: null,
method_type: null,
data: null,
requisites: it.requisites ?? '',
payment_purpose: it.paymentPurpose ?? null,
})
if (it.isOrganization) {
return snapshot
}
// Пайщику назначение платежа фиксированное — кассир в платёжке всегда
// указывает «Аванс под отчёт» (суть расхода — в описании позиции)
snapshot.payment_purpose = EXPENSES_CHASSIS_CONFIG.advancePaymentPurpose
if (!it.paymentMethodId) {
throw new BadRequestException(
`Не указаны реквизиты получателя ${it.recipient} (платёжный метод) по строке расхода`
)
}
const method = await this.paymentMethods.get({
username: it.recipient,
method_id: it.paymentMethodId,
})
snapshot.method_id = method.method_id
snapshot.method_type = method.method_type
snapshot.data = method.data as unknown as Record<string, unknown>
snapshot.requisites = formatPaymentMethodRequisites(method)
return snapshot
})
)
}
}
@@ -0,0 +1,117 @@
import type { EventEmitter2 } from '@nestjs/event-emitter';
import {
ExpensesCapitalTriggerService,
EXPENSE_CAPITALIZATION_REQUIRED,
} from './expenses-capital-trigger.service';
import { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
import type { ExpenseProposalRepository } from '../../domain/repositories/expense-proposal.repository';
import type { WinstonLoggerService } from '~/application/logger/logger-app.service';
function makeProposal(
status: ExpenseProposalStatus,
total_actual?: string
): ExpenseProposalDomainEntity {
return {
proposal_hash: '0xabc',
coopname: 'voskhod',
username: 'creator',
status,
total_actual: total_actual ?? '1500.0000 RUB',
} as unknown as ExpenseProposalDomainEntity;
}
function makeProposalWithoutTotal(status: ExpenseProposalStatus): ExpenseProposalDomainEntity {
return {
proposal_hash: '0xabc',
coopname: 'voskhod',
status,
} as unknown as ExpenseProposalDomainEntity;
}
function makeLogger(): WinstonLoggerService & {
log: jest.Mock;
warn: jest.Mock;
setContext: jest.Mock;
} {
return {
log: jest.fn(),
warn: jest.fn(),
setContext: jest.fn(),
} as unknown as WinstonLoggerService & {
log: jest.Mock;
warn: jest.Mock;
setContext: jest.Mock;
};
}
describe('ExpensesCapitalTriggerService', () => {
let service: ExpensesCapitalTriggerService;
let logger: ReturnType<typeof makeLogger>;
let proposals: ExpenseProposalRepository;
let eventEmitter: EventEmitter2 & { emit: jest.Mock };
beforeEach(() => {
logger = makeLogger();
proposals = {} as ExpenseProposalRepository;
eventEmitter = { emit: jest.fn() } as unknown as EventEmitter2 & { emit: jest.Mock };
service = new ExpensesCapitalTriggerService(proposals, eventEmitter, logger);
});
it('ничего не делает для не-CLOSED статуса', async () => {
await service.handleProposalSynced({
entity: makeProposal(ExpenseProposalStatus.REPORT_SUBMITTED),
blockNum: 100,
});
expect(logger.log).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('ничего не делает для AUTHORIZED (не финальный статус)', async () => {
await service.handleProposalSynced({
entity: makeProposal(ExpenseProposalStatus.AUTHORIZED),
blockNum: 100,
});
expect(logger.log).not.toHaveBeenCalled();
});
it('warn без total_actual при CLOSED', async () => {
await service.handleProposalSynced({
entity: makeProposalWithoutTotal(ExpenseProposalStatus.CLOSED),
blockNum: 100,
});
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('capitalization не запускается'));
expect(logger.log).not.toHaveBeenCalled();
});
it('эмитит EXPENSE_CAPITALIZATION_REQUIRED для CLOSED + total_actual', async () => {
await service.handleProposalSynced({
entity: makeProposal(ExpenseProposalStatus.CLOSED, '1500.0000 RUB'),
blockNum: 100,
});
expect(logger.log).toHaveBeenCalledTimes(1);
const message = logger.log.mock.calls[0][0] as string;
expect(message).toContain('0xabc');
expect(message).toContain('1500.0000 RUB');
expect(message).toContain('100');
expect(eventEmitter.emit).toHaveBeenCalledWith(
EXPENSE_CAPITALIZATION_REQUIRED,
expect.objectContaining({
coopname: 'voskhod',
proposal_hash: '0xabc',
username: 'creator',
total_actual: '1500.0000 RUB',
block_num: 100,
})
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('игнорирует DECLINED (capitalization не запускается)', async () => {
await service.handleProposalSynced({
entity: makeProposal(ExpenseProposalStatus.DECLINED),
blockNum: 100,
});
expect(logger.log).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
import {
ExpenseProposalRepository,
EXPENSE_PROPOSAL_REPOSITORY,
} from '../../domain/repositories/expense-proposal.repository';
/**
* Payload события `expense::capitalization::required`. Эмитит при переходе сметы
* в CLOSED — подписчик в capital extension выполнит capital::createrid через
* WriteMutationPool под подпись кооператива.
*/
export interface ExpenseCapitalizationRequiredPayload {
coopname: string;
proposal_hash: string;
username: string;
total_actual: string;
block_num: number;
}
export const EXPENSE_CAPITALIZATION_REQUIRED = 'expense::capitalization::required';
/**
* Триггер capitalization при закрытии сметы расхода (CLOSED).
*
* Подписан на `entitysynced::expense::proposals` (emit'ит `ExpenseProposalSyncService`
* после save в PG). При переходе в CLOSED — эмитит платформенное событие
* `expense::capitalization::required` с полным payload.
*
* Capital extension подписывается на это событие и выполняет capital::createrid
* через WriteMutationPool — coupling двух extensions через event-bus, без прямой
* инъекции CapitalExtensionModule.
*
* Идемпотентность: подписан на `entitysynced` (после save в PG), повторная
* capitalization защищается на уровне capital через sync_key dedup в pool.
*/
@Injectable()
export class ExpensesCapitalTriggerService {
constructor(
@Inject(EXPENSE_PROPOSAL_REPOSITORY)
private readonly proposals: ExpenseProposalRepository,
private readonly eventEmitter: EventEmitter2,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(ExpensesCapitalTriggerService.name);
}
@OnEvent('entitysynced::expense::proposals')
async handleProposalSynced(payload: { entity: ExpenseProposalDomainEntity; blockNum: number }): Promise<void> {
const { entity, blockNum } = payload;
if (entity.status !== ExpenseProposalStatus.CLOSED) {
return;
}
if (!entity.total_actual) {
this.logger.warn(
`СЗ ${entity.proposal_hash} закрыта без total_actual — capitalization не запускается.`
);
return;
}
if (!entity.username) {
this.logger.warn(
`СЗ ${entity.proposal_hash} закрыта без username — capitalization не запускается.`
);
return;
}
const event: ExpenseCapitalizationRequiredPayload = {
coopname: entity.coopname,
proposal_hash: entity.proposal_hash,
username: entity.username,
total_actual: entity.total_actual,
block_num: blockNum,
};
this.logger.log(
`СЗ ${entity.proposal_hash} закрыта в блоке ${blockNum} (total_actual=${entity.total_actual}). ` +
`Эмит ${EXPENSE_CAPITALIZATION_REQUIRED} для capital extension.`
);
this.eventEmitter.emit(EXPENSE_CAPITALIZATION_REQUIRED, event);
void this.proposals;
}
}
@@ -0,0 +1,104 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ExpenseProposalRepository,
EXPENSE_PROPOSAL_REPOSITORY,
} from '../../domain/repositories/expense-proposal.repository';
import { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import {
PaginationInputDTO,
PaginationResult,
} from '~/application/common/dto/pagination.dto';
import { DocumentAggregationService } from '~/domain/document/services/document-aggregation.service';
import type { DocumentDomainAggregate } from '~/domain/document/aggregates/document-domain.aggregate';
/**
* Агрегаты подписанных документов сметы расхода: заявление + решение совета.
*
* Используется и в одиночном fetch'е, и в paginated-листе (агрегаты собираются
* через batched Promise.all, на типичную страницу 10–20 смет это OK).
*/
export interface ExpenseProposalDocumentAggregates {
statement_doc?: DocumentDomainAggregate | null;
decision_doc?: DocumentDomainAggregate | null;
}
/**
* Read-сервис расходов: список и просмотр смет.
*
* Mutation-операции (`payexp`/`reportexp`/`returnexp`) — отдельные use-case-сервисы;
* этот сервис только читает зеркало из PG (никаких RPC-fallback'ов, см. ADR-011).
*/
@Injectable()
export class ExpensesManagementService {
constructor(
@Inject(EXPENSE_PROPOSAL_REPOSITORY)
private readonly proposals: ExpenseProposalRepository,
private readonly documentAggregation: DocumentAggregationService
) {}
/**
* Собрать агрегаты обоих документов сметы (заявление + решение совета).
*
* Возвращает `undefined` для отсутствующих доков, `null` — если хэш пустой
* (см. `DocumentAggregationService.EMPTY_HASH` контракт). Это разные состояния:
* - `undefined` → доку не приложен / зеркало ещё не пришло;
* - `null` → доку приложен, но пустой хэш (пограничный случай chain-mirror).
*/
async buildProposalDocumentAggregates(
entity: ExpenseProposalDomainEntity
): Promise<ExpenseProposalDocumentAggregates> {
const [statement_doc, decision_doc] = await Promise.all([
entity.statement_doc
? this.documentAggregation.buildDocumentAggregate(entity.statement_doc)
: Promise.resolve(undefined),
entity.decision_doc
? this.documentAggregation.buildDocumentAggregate(entity.decision_doc)
: Promise.resolve(undefined),
]);
return { statement_doc, decision_doc };
}
async getProposalByHash(proposalHash: string): Promise<ExpenseProposalDomainEntity | null> {
return this.proposals.findByProposalHash(proposalHash);
}
async listProposalsByCooperative(coopname: string): Promise<ExpenseProposalDomainEntity[]> {
return this.proposals.findByCoopname(coopname);
}
async listProposalsByMember(coopname: string, username: string): Promise<ExpenseProposalDomainEntity[]> {
return this.proposals.findByUsername(coopname, username);
}
async listProposalsByCooperativePaginated(
coopname: string,
options?: PaginationInputDTO
): Promise<PaginationResult<ExpenseProposalDomainEntity>> {
const { items, totalCount } = await this.proposals.findByCoopnamePaginated(coopname, options);
return this.toPaginationResult(items, totalCount, options);
}
async listProposalsByMemberPaginated(
coopname: string,
username: string,
options?: PaginationInputDTO
): Promise<PaginationResult<ExpenseProposalDomainEntity>> {
const { items, totalCount } = await this.proposals.findByUsernamePaginated(coopname, username, options);
return this.toPaginationResult(items, totalCount, options);
}
private toPaginationResult<T>(
items: T[],
totalCount: number,
options?: PaginationInputDTO
): PaginationResult<T> {
const page = Math.max(1, options?.page ?? 1);
const limit = Math.max(1, options?.limit ?? 10);
return {
items,
totalCount,
totalPages: limit > 0 ? Math.ceil(totalCount / limit) : 0,
currentPage: page,
};
}
}
@@ -0,0 +1,401 @@
import { ExpensesMutationsService } from './expenses-mutations.service'
import type { ExpensesBlockchainPort } from '../../domain/interfaces/expenses-blockchain.port'
import type { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service'
import type { ExpenseProposalRepository } from '../../domain/repositories/expense-proposal.repository'
import type { PaymentRepository } from '~/domain/gateway/repositories/payment.repository'
import { PaymentDirectionEnum, PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum'
import config from '~/config/config'
import type { PayExpenseItemInputDTO } from '../dto/pay-expense-item.input'
import type { ReportExpenseItemInputDTO } from '../dto/report-expense-item.input'
import { ExpenseReportOutcome } from '../dto/report-expense-item.output'
import type { ReturnExpenseItemInputDTO } from '../dto/return-expense-item.input'
import type { OverspendExpenseItemInputDTO } from '../dto/overspend-expense-item.input'
import type { SubmitExpenseReportInputDTO } from '../dto/submit-expense-report.input'
import type { CreateExpenseProposalInputDTO } from '../dto/create-expense-proposal.input'
import { ExpenseMechanics } from '../../domain/enums/expense-mechanics.enum'
import { ExpenseRecipientType } from '../../domain/enums/expense-recipient-type.enum'
import { ExpenseReportState } from '../../domain/enums/expense-report-state.enum'
// Символ/precision берём из конфига ноды — тесты расчёта разницы не зависят от
// того, какой именно root_govern_symbol сконфигурирован в окружении CI.
const SYM = config.blockchain.root_govern_symbol
const PREC = config.blockchain.root_govern_precision
const asset = (n: number) => `${n.toFixed(PREC)} ${SYM}`
/**
* Контракт-тест: backend-mutations пробрасывают payload в `ExpensesBlockchainPort`
* с корректным action-mapping. createExpenseProposal раскладывает подписанный
* document2 через `.toDocument()` и снимает реквизиты получателей-пайщиков.
* authexp/declexp — callbacks решения совета, у backend'а их нет.
*/
describe('ExpensesMutationsService', () => {
let service: ExpensesMutationsService
let chain: jest.Mocked<ExpensesBlockchainPort>
let generator: jest.Mocked<Pick<GeneratorInfrastructureService, 'generateDocument'>>
let requisiteSnapshots: {
validate: jest.Mock
snapshot: jest.Mock
formatForOwner: jest.Mock
getItemRequisiteData: jest.Mock
getCooperativeRequisiteData: jest.Mock
}
let proposals: { findByProposalHash: jest.Mock }
let payments: { findByHash: jest.Mock; create: jest.Mock; update: jest.Mock }
const fakeResult = { response: { transaction_id: 'tx_abc' } } as never
beforeEach(() => {
chain = {
createExp: jest.fn().mockResolvedValue(fakeResult),
payExp: jest.fn().mockResolvedValue(fakeResult),
reportExp: jest.fn().mockResolvedValue(fakeResult),
returnExp: jest.fn().mockResolvedValue(fakeResult),
overspendExp: jest.fn().mockResolvedValue(fakeResult),
closeExp: jest.fn().mockResolvedValue(fakeResult),
} as unknown as jest.Mocked<ExpensesBlockchainPort>
generator = {
generateDocument: jest.fn().mockResolvedValue({} as never),
}
requisiteSnapshots = {
validate: jest.fn().mockResolvedValue(undefined),
snapshot: jest.fn().mockResolvedValue(undefined),
formatForOwner: jest.fn().mockResolvedValue('Банковский перевод: счёт 40817810000000000000, Банк ВТБ (ПАО)'),
getItemRequisiteData: jest.fn().mockResolvedValue({ data: { bank_name: 'ВТБ' }, requisites: 'счёт пайщика' }),
getCooperativeRequisiteData: jest.fn().mockResolvedValue({ data: { bank_name: 'Банк кооператива' }, requisites: 'счёт кооператива' }),
}
proposals = { findByProposalHash: jest.fn().mockResolvedValue(null) }
payments = {
findByHash: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation((p) => Promise.resolve({ ...p, id: 'pay-1' })),
update: jest.fn().mockResolvedValue(undefined),
}
service = new ExpensesMutationsService(
chain,
generator as unknown as GeneratorInfrastructureService,
requisiteSnapshots as never,
proposals as unknown as ExpenseProposalRepository,
payments as unknown as PaymentRepository
)
})
// СЗ-зеркало с одной строкой-авансом на пайщика, выданный аванс = 1000.
const mockProposalWithAdvance = (advance = 1000) =>
proposals.findByProposalHash.mockResolvedValue({
proposal_hash: '0xabc',
items: [
{
// Зеркало хранит mechanics/recipient_type числами (ExpenseDomain enum):
// ADVANCE=0, MEMBER=1. PAID=1.
item_hash: '0xdef',
mechanics: 0,
recipient_type: 1,
recipient: 'petrov',
description: 'Закупка кормов',
planned_amount: asset(advance),
actual_amount: asset(advance),
status: 1,
},
],
} as never)
const makeSignedDoc = (overrides: Partial<{ hash: string; doc_hash: string; meta_hash: string }> = {}) => ({
version: '1',
hash: overrides.hash ?? '0xhash',
doc_hash: overrides.doc_hash ?? '0xdoc',
meta_hash: overrides.meta_hash ?? '0xmeta',
meta: { title: 'Заявление', registry_id: 2010 },
signatures: [
{
id: 0,
signed_hash: '0xsignedhash',
signer: 'ivanov',
public_key: 'EOS6...',
signature: 'SIG_K1_...',
signed_at: '2026-06-02T10:00:00',
meta: '',
},
],
toDocument(): any {
return {
version: '1',
hash: overrides.hash ?? '0xhash',
doc_hash: overrides.doc_hash ?? '0xdoc',
meta_hash: overrides.meta_hash ?? '0xmeta',
meta: JSON.stringify({ title: 'Заявление', registry_id: 2010 }),
signatures: this.signatures,
}
},
})
it('createExpenseProposal → chain.createExp с раскладкой items + document2', async () => {
const input: CreateExpenseProposalInputDTO = {
coopname: 'voskhod',
username: 'ivanov',
proposal_hash: '0xabc',
source_wallet: 'w.cap.blago',
items: [
{
item_hash: '0xitem1',
mechanics: ExpenseMechanics.ADVANCE,
recipient_type: ExpenseRecipientType.MEMBER,
recipient: 'petrov',
description: 'Закупка кормов',
planned_amount: '5000.0000 RUB',
payment_method_id: 'pm-1',
},
],
statement: makeSignedDoc() as any,
} as CreateExpenseProposalInputDTO
await service.createExpenseProposal(input)
expect(chain.createExp).toHaveBeenCalledTimes(1)
const call = chain.createExp.mock.calls[0][0]
expect(call.coopname).toBe('voskhod')
expect(call.username).toBe('ivanov')
expect(call.proposal_hash).toBe('0xabc')
expect(call.source_wallet).toBe('w.cap.blago')
expect(call.items).toHaveLength(1)
expect(call.items[0].item_hash).toBe('0xitem1')
expect(call.items[0].mechanics).toBe(0)
expect(call.items[0].recipient_type).toBe(1)
expect(call.callback).toEqual({ contract: '', action: '', data: '' })
expect(call.statement.doc_hash).toBe('0xdoc')
// Реквизиты: валидация до блокчейна, снимок — после.
expect(requisiteSnapshots.validate).toHaveBeenCalledTimes(1)
expect(requisiteSnapshots.snapshot).toHaveBeenCalledTimes(1)
const snapItems = requisiteSnapshots.snapshot.mock.calls[0][1]
expect(snapItems[0]).toMatchObject({
proposalHash: '0xabc',
itemHash: '0xitem1',
recipient: 'petrov',
isOrganization: false,
paymentMethodId: 'pm-1',
})
})
it('createExpenseProposal: ошибка валидации реквизитов не доходит до блокчейна', async () => {
requisiteSnapshots.validate.mockRejectedValue(new Error('Не указаны реквизиты получателя'))
const input: CreateExpenseProposalInputDTO = {
coopname: 'voskhod',
username: 'ivanov',
proposal_hash: '0xabc',
source_wallet: 'w.cap.blago',
items: [
{
item_hash: '0xitem1',
mechanics: ExpenseMechanics.ADVANCE,
recipient_type: ExpenseRecipientType.SELF,
recipient: 'ivanov',
description: 'Канцелярия',
planned_amount: '1000.0000 RUB',
},
],
statement: makeSignedDoc() as any,
} as CreateExpenseProposalInputDTO
await expect(service.createExpenseProposal(input)).rejects.toThrow('Не указаны реквизиты')
expect(chain.createExp).not.toHaveBeenCalled()
})
it('payExpenseItem → chain.payExp({coopname, proposal_hash, item_hash, actual_amount})', async () => {
const input = {
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: '100.0000 RUB',
} as PayExpenseItemInputDTO
await service.payExpenseItem(input)
expect(chain.payExp).toHaveBeenCalledWith({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: '100.0000 RUB',
})
})
it('reportExpenseItem → chain.reportExp({coopname, proposal_hash, item_hash})', async () => {
const input = {
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
} as ReportExpenseItemInputDTO
const result = await service.reportExpenseItem(input)
expect(chain.reportExp).toHaveBeenCalledWith({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
})
expect(result.outcome).toBe(ExpenseReportOutcome.CLOSED)
expect(payments.create).not.toHaveBeenCalled()
})
it('reportExpenseItem: факт == аванс → CLOSED, платёжка расчёта не заводится', async () => {
mockProposalWithAdvance(1000)
// Платёж выдачи аванса (hash = item_hash) — для зеркалирования report_state.
payments.findByHash.mockResolvedValue({ id: 'adv-1', hash: '0xdef', blockchain_data: { proposal_hash: '0xabc' } })
const result = await service.reportExpenseItem({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: asset(1000),
} as ReportExpenseItemInputDTO)
expect(result.outcome).toBe(ExpenseReportOutcome.CLOSED)
expect(chain.reportExp).toHaveBeenCalledTimes(1)
expect(payments.create).not.toHaveBeenCalled()
// Отчёт принят → зеркалим CLOSED в платёж аванса.
expect(payments.update).toHaveBeenCalledWith('adv-1', {
blockchain_data: { proposal_hash: '0xabc', report_state: ExpenseReportState.CLOSED },
})
})
it('reportExpenseItem: недорасход → входящая платёжка EXPENSE_RETURN на разницу, reportexp отложен', async () => {
mockProposalWithAdvance(1000)
// Платёжка расчёта (хэш settlement) ещё не заведена → null (чтобы create сработал);
// платёж выдачи аванса (item_hash '0xdef') существует → для зеркалирования report_state.
payments.findByHash.mockImplementation((h: string) =>
Promise.resolve(h === '0xdef' ? { id: 'adv-1', hash: '0xdef', blockchain_data: {} } : null),
)
const result = await service.reportExpenseItem({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: asset(800),
} as ReportExpenseItemInputDTO)
expect(result.outcome).toBe(ExpenseReportOutcome.RETURN_PENDING)
expect(result.settlement_amount).toBe(asset(200))
expect(chain.reportExp).not.toHaveBeenCalled()
expect(payments.create).toHaveBeenCalledTimes(1)
// Отчёт подан, ждём расчёт → зеркалим SETTLEMENT_PENDING + заявленный факт
// (reported_amount) в платёж аванса; факт нужен реестру вместо выданного аванса.
expect(payments.update).toHaveBeenCalledWith('adv-1', {
blockchain_data: {
report_state: ExpenseReportState.SETTLEMENT_PENDING,
reported_amount: asset(800),
},
})
const payment = payments.create.mock.calls[0][0]
expect(payment.type).toBe(PaymentTypeEnum.EXPENSE_RETURN)
expect(payment.direction).toBe(PaymentDirectionEnum.INCOMING)
expect(payment.username).toBe('petrov')
expect(payment.quantity).toBe(200)
expect(payment.blockchain_data).toMatchObject({ proposal_hash: '0xabc', item_hash: '0xdef' })
// Недорасход: реквизиты для оплаты — банк кооператива (пайщик возвращает ему).
expect(requisiteSnapshots.getCooperativeRequisiteData).toHaveBeenCalledWith('voskhod')
expect(payment.payment_details?.data).toMatchObject({ bank_name: 'Банк кооператива' })
})
it('reportExpenseItem: перерасход → исходящая платёжка EXPENSE_OVERSPEND на разницу, reportexp отложен', async () => {
mockProposalWithAdvance(1000)
const result = await service.reportExpenseItem({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: asset(1200),
} as ReportExpenseItemInputDTO)
expect(result.outcome).toBe(ExpenseReportOutcome.OVERSPEND_PENDING)
expect(result.settlement_amount).toBe(asset(200))
expect(chain.reportExp).not.toHaveBeenCalled()
expect(payments.create).toHaveBeenCalledTimes(1)
const payment = payments.create.mock.calls[0][0]
expect(payment.type).toBe(PaymentTypeEnum.EXPENSE_OVERSPEND)
expect(payment.direction).toBe(PaymentDirectionEnum.OUTGOING)
expect(payment.quantity).toBe(200)
// Перерасход: реквизиты для выплаты — снимок реквизитов пайщика по позиции.
expect(requisiteSnapshots.getItemRequisiteData).toHaveBeenCalledWith('voskhod', '0xabc', '0xdef')
expect(payment.payment_details?.data).toMatchObject({ bank_name: 'ВТБ' })
})
it('reportExpenseItem: повторный отчёт по той же позиции платёжку не дублирует (идемпотентность)', async () => {
mockProposalWithAdvance(1000)
payments.findByHash.mockResolvedValue({ hash: 'existing-hash' })
const result = await service.reportExpenseItem({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: asset(800),
} as ReportExpenseItemInputDTO)
expect(result.outcome).toBe(ExpenseReportOutcome.RETURN_PENDING)
expect(result.settlement_payment_hash).toBe('existing-hash')
expect(payments.create).not.toHaveBeenCalled()
})
it('reportExpenseItem: повторный отчёт после поданного (SETTLEMENT_PENDING) запрещён', async () => {
mockProposalWithAdvance(1000)
// Платёж аванса уже несёт report_state SETTLEMENT_PENDING — отчёт подан ранее,
// позиция on-chain ещё PAID (расчёт не подтверждён). Повторный отчёт обязан
// отбиться на бэке, иначе плодятся дубль-платёжки расчёта.
payments.findByHash.mockResolvedValue({
id: 'adv-1',
hash: '0xdef',
blockchain_data: { proposal_hash: '0xabc', report_state: ExpenseReportState.SETTLEMENT_PENDING },
})
await expect(
service.reportExpenseItem({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: asset(800),
} as ReportExpenseItemInputDTO),
).rejects.toThrow('Отчёт по этой позиции уже подан')
expect(payments.create).not.toHaveBeenCalled()
})
it('returnExpenseItem → chain.returnExp({coopname, proposal_hash, item_hash, return_amount})', async () => {
const input = {
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
return_amount: '50.0000 RUB',
} as ReturnExpenseItemInputDTO
await service.returnExpenseItem(input)
expect(chain.returnExp).toHaveBeenCalledWith({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
return_amount: '50.0000 RUB',
})
})
it('overspendExpenseItem → chain.overspendExp({coopname, proposal_hash, item_hash, overspend_amount})', async () => {
const input = {
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
overspend_amount: '200.0000 RUB',
} as OverspendExpenseItemInputDTO
await service.overspendExpenseItem(input)
expect(chain.overspendExp).toHaveBeenCalledWith({
coopname: 'voskhod',
proposal_hash: '0xabc',
item_hash: '0xdef',
overspend_amount: '200.0000 RUB',
})
})
it('submitExpenseReport → chain.closeExp({coopname, proposal_hash})', async () => {
const input = {
coopname: 'voskhod',
proposal_hash: '0xabc',
total_actual_amount: '1500.0000 RUB',
comment: 'пиар-кампания апрель',
} as SubmitExpenseReportInputDTO
await service.submitExpenseReport(input)
expect(chain.closeExp).toHaveBeenCalledWith({ coopname: 'voskhod', proposal_hash: '0xabc' })
})
})
@@ -0,0 +1,424 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'
import type { TransactResult } from '@wharfkit/session'
import type { InterExpenseRequisiteItemInput } from '@coopenomics/inter'
import { Cooperative } from 'cooptypes'
import { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service'
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity'
import { ExpenseProposalStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-statement-document.dto'
import { ExpenseProposalDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/expense-proposal-decision-document.dto'
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository'
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface'
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum'
import { PaymentDirectionEnum, PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum'
import { QuantityUtils } from '~/shared/utils/quantity.utils'
import { generateHashFromString, generateUniqueHash } from '~/utils/generate-hash.util'
import { CreateExpenseProposalInputDTO } from '../dto/create-expense-proposal.input'
import type { ExpenseItemInputDTO } from '../dto/expense-item.input'
import { PayExpenseItemInputDTO } from '../dto/pay-expense-item.input'
import { ReportExpenseItemInputDTO } from '../dto/report-expense-item.input'
import { ExpenseReportOutcome, ExpenseReportResultDTO } from '../dto/report-expense-item.output'
import { ReturnExpenseItemInputDTO } from '../dto/return-expense-item.input'
import { OverspendExpenseItemInputDTO } from '../dto/overspend-expense-item.input'
import { SubmitExpenseReportInputDTO } from '../dto/submit-expense-report.input'
import {
EXPENSES_BLOCKCHAIN_PORT,
ExpensesBlockchainPort,
} from '../../domain/interfaces/expenses-blockchain.port'
import {
EXPENSE_PROPOSAL_REPOSITORY,
ExpenseProposalRepository,
} from '../../domain/repositories/expense-proposal.repository'
import type { IExpenseItemBlockchainData } from '../../domain/interfaces/expense-proposal-blockchain.interface'
import { ExpenseMechanics } from '../../domain/enums/expense-mechanics.enum'
import { ExpenseRecipientType } from '../../domain/enums/expense-recipient-type.enum'
import { ExpenseReportState } from '../../domain/enums/expense-report-state.enum'
import { EXPENSES_CHASSIS_CONFIG } from '../../domain/expenses-chassis.config'
import { ExpenseRequisiteSnapshotsService } from './expense-requisite-snapshots.service'
/** Зеркало ExpenseDomain::Mechanics::ADVANCE контракта expense. */
const MECHANICS_ADVANCE = 0
/**
* Write-сервис расходов.
*
* Подписывает ключом кооператива (`active`), `account = expense`. Для пайщик-actions
* (`reportexp` / `returnexp`) — тот же канон, что в capital (`createCommit` пайщика
* сервисно подписан кооперативом). Авторизация / отклонение СЗ идут через решение
* совета (callbacks authexp/declexp от контракта soviet) — здесь их нет.
*/
@Injectable()
export class ExpensesMutationsService {
constructor(
@Inject(EXPENSES_BLOCKCHAIN_PORT)
private readonly chain: ExpensesBlockchainPort,
private readonly generator: GeneratorInfrastructureService,
private readonly requisiteSnapshots: ExpenseRequisiteSnapshotsService,
@Inject(EXPENSE_PROPOSAL_REPOSITORY)
private readonly proposals: ExpenseProposalRepository,
@Inject(PAYMENT_REPOSITORY)
private readonly payments: PaymentRepository
) {}
async generateExpenseProposalStatementDocument(
data: ExpenseProposalStatementGenerateDocumentInputDTO,
options: Cooperative.Document.IGenerationOptions
): Promise<DocumentDomainEntity> {
const registry_id = Cooperative.Registry.ExpenseProposalStatement.registry_id
// Фонд списания — параметр шасси, фронт его не передаёт
const proposal = { ...data.proposal, fund_name: EXPENSES_CHASSIS_CONFIG.fundNameDative }
// Приватная часть позиций — имя/реквизиты/назначение платежа. Полные реквизиты
// получателей подставляет сервер по payment_method_id (фронт знает только
// сокращённое представление). Эти данные НЕ публикуются в блокчейн: они
// сохраняются off-chain в doc_data фабрики, а в meta документа едет только
// doc_data_hash. Корреляция с публичной позицией — по number.
const privateItems = await Promise.all(
data.items.map(async (item) => {
let requisites = item.requisites
if (item.payment_method_id) {
const owner = item.recipient_username || data.username
requisites = await this.requisiteSnapshots.formatForOwner(owner, item.payment_method_id)
}
return {
number: item.number,
recipient_name: item.recipient_name,
requisites,
// Пайщику назначение платежа не вводится — всегда «Аванс под отчёт»
payment_purpose:
item.recipient_type === 'ORG' ? item.payment_purpose : EXPENSES_CHASSIS_CONFIG.advancePaymentPurpose,
}
})
)
const privatePayload: Cooperative.Registry.ExpenseProposalStatement.PrivateData = { items: privateItems }
const { hash: doc_data_hash } = await this.generator.saveDocData(
privatePayload as unknown as Record<string, unknown>,
registry_id
)
// Публичные позиции — без реквизитов/имени/назначения (только структура и суммы).
const items: Cooperative.Registry.ExpenseProposalStatement.IExpenseItem[] = data.items.map((item) => ({
number: item.number,
description: item.description,
amount: item.amount,
recipient_type: item.recipient_type,
mechanics: item.mechanics,
}))
const action = {
...data,
registry_id,
proposal,
items,
doc_data_hash,
} as unknown as Cooperative.Registry.ExpenseProposalStatement.Action
return this.generator.generateDocument({ data: action, options: options || {} })
}
async generateExpenseProposalDecisionDocument(
data: ExpenseProposalDecisionGenerateDocumentInputDTO,
options: Cooperative.Document.IGenerationOptions
): Promise<DocumentDomainEntity> {
data.registry_id = Cooperative.Registry.ExpenseProposalDecision.registry_id
return this.generator.generateDocument({ data: data as unknown as Cooperative.Registry.ExpenseProposalDecision.Action, options: options || {} })
}
async createExpenseProposal(input: CreateExpenseProposalInputDTO): Promise<TransactResult> {
// Валидация реквизитов ДО блокчейна, снимок — ПОСЛЕ (канон gateway
// prepareWithdraw → persistWithdraw). Реквизиты в чейн не пишутся.
const requisiteItems = toRequisiteItems(input.proposal_hash, input.items)
await this.requisiteSnapshots.validate(input.coopname, requisiteItems)
const items = input.items.map((it) => ({
item_hash: it.item_hash,
mechanics: it.mechanics === ExpenseMechanics.DIRECT ? 1 : 0,
recipient_type:
it.recipient_type === ExpenseRecipientType.SELF
? 0
: it.recipient_type === ExpenseRecipientType.MEMBER
? 1
: 2,
recipient: it.recipient,
description: it.description,
planned_amount: it.planned_amount,
actual_amount: it.planned_amount,
status: 0,
}))
const statement = input.statement.toDocument()
const result = await this.chain.createExp({
coopname: input.coopname,
username: input.username,
proposal_hash: input.proposal_hash,
source_wallet: input.source_wallet,
items,
callback: {
contract: input.callback?.contract ?? '',
action: input.callback?.action ?? '',
data: input.callback?.data ?? '',
},
statement: {
version: statement.version,
hash: statement.hash,
doc_hash: statement.doc_hash,
meta_hash: statement.meta_hash,
meta: statement.meta,
signatures: statement.signatures,
} as any,
})
await this.requisiteSnapshots.snapshot(input.coopname, requisiteItems)
return result
}
async payExpenseItem(input: PayExpenseItemInputDTO): Promise<TransactResult> {
return this.chain.payExp({
coopname: input.coopname,
proposal_hash: input.proposal_hash,
item_hash: input.item_hash,
actual_amount: input.actual_amount,
})
}
/**
* Отчёт пайщика по строке-авансу. Если факт совпал с выданным авансом (или не
* указан) — закрываем позицию on-chain (`reportexp`). Если факт расходится —
* заводим платёжку расчёта разницы (возврат/доплата) и откладываем `reportexp`
* до её подтверждения кассиром (контракт принимает returnexp/overspendexp
* только пока позиция в статусе PAID, поэтому расчёт обязан пройти ДО закрытия).
*/
async reportExpenseItem(input: ReportExpenseItemInputDTO): Promise<ExpenseReportResultDTO> {
// Защита от повторного отчёта. Позиция on-chain остаётся PAID до подтверждения
// расчётной платёжки кассой (reportexp отложен), поэтому без этого guard'а
// пайщик/кассир могли отчитываться многократно на разные суммы, плодя дубль-
// платёжки расчёта (идемпотентность createSettlementPayment — лишь по виду
// возврат/доплата, не по факту «отчёт уже подан»). Источник истины —
// report_state в зеркале платежа выдачи аванса.
const reportState = await this.getAdvanceReportState(input.item_hash)
if (reportState === ExpenseReportState.SETTLEMENT_PENDING || reportState === ExpenseReportState.CLOSED) {
throw new BadRequestException('Отчёт по этой позиции уже подан — повторный отчёт недоступен')
}
if (!input.actual_amount) {
const transaction = await this.chain.reportExp({
coopname: input.coopname,
proposal_hash: input.proposal_hash,
item_hash: input.item_hash,
})
await this.markAdvanceReportState(input.item_hash, ExpenseReportState.CLOSED)
return { outcome: ExpenseReportOutcome.CLOSED, transaction: transaction as any }
}
const proposal = await this.proposals.findByProposalHash(input.proposal_hash)
const item = proposal?.items?.find(
(i) => i.item_hash?.toLowerCase() === input.item_hash.toLowerCase()
)
if (!proposal || !item) {
throw new NotFoundException('Строка расхода не найдена')
}
if (item.mechanics !== MECHANICS_ADVANCE) {
throw new BadRequestException('Отчёт о фактической сумме применим только к авансу под отчёт')
}
// База расчёта — фактически ВЫДАННЫЙ аванс (item.actual_amount после payexp),
// а не план: устойчиво к частичной выдаче аванса кассиром.
const advance = parseAssetToMinor(item.actual_amount)
const factual = parseAssetToMinor(input.actual_amount)
if (advance.symbol !== factual.symbol) {
throw new BadRequestException('Символ фактической суммы не совпадает с символом аванса')
}
const deltaMinor = factual.minor - advance.minor
if (deltaMinor === 0) {
const transaction = await this.chain.reportExp({
coopname: input.coopname,
proposal_hash: input.proposal_hash,
item_hash: input.item_hash,
})
await this.markAdvanceReportState(input.item_hash, ExpenseReportState.CLOSED)
return { outcome: ExpenseReportOutcome.CLOSED, transaction: transaction as any }
}
const isUnderspend = deltaMinor < 0
const diffAmount = Math.abs(deltaMinor) / 10 ** advance.precision
const diffAsset = QuantityUtils.formatQuantityForBlockchain(diffAmount, advance.symbol)
const paymentHash = await this.createSettlementPayment(
input.coopname,
input.proposal_hash,
item,
isUnderspend,
diffAmount,
advance.symbol
)
// Отчёт подан, но позиция закроется (reportexp) только после подтверждения
// платёжки расчёта кассой — фиксируем промежуточное состояние для реестра.
// reported_amount храним рядом, чтобы реестр показывал заявленный факт
// (а не выданный аванс) и не предлагал отчитаться повторно.
await this.markAdvanceReportState(input.item_hash, ExpenseReportState.SETTLEMENT_PENDING, {
reported_amount: input.actual_amount,
})
return {
outcome: isUnderspend ? ExpenseReportOutcome.RETURN_PENDING : ExpenseReportOutcome.OVERSPEND_PENDING,
settlement_amount: diffAsset,
settlement_payment_hash: paymentHash,
}
}
/**
* Зеркалит состояние отчёта в платёж выдачи аванса (hash платежа = item_hash) —
* реестр платежей показывает «Требуется отчёт / подан / принят» рядом со
* статусом платежа, не дёргая цепь. Статус платежа при этом не трогаем.
*/
private async markAdvanceReportState(
itemHash: string,
state: ExpenseReportState,
extra?: Record<string, unknown>
): Promise<void> {
const payment = await this.payments.findByHash(itemHash.toLowerCase())
if (!payment?.id) return
await this.payments.update(payment.id, {
blockchain_data: { ...(payment.blockchain_data ?? {}), report_state: state, ...(extra ?? {}) },
})
}
/**
* Текущее состояние отчёта по строке-авансу — из зеркала в платеже выдачи
* (`blockchain_data.report_state`). AWAITING по умолчанию: отчёт ещё не подан.
* Нужен guard'у reportExpenseItem против повторной подачи.
*/
private async getAdvanceReportState(itemHash: string): Promise<ExpenseReportState> {
const payment = await this.payments.findByHash(itemHash.toLowerCase())
const state = (payment?.blockchain_data as { report_state?: ExpenseReportState } | undefined)?.report_state
return state ?? ExpenseReportState.AWAITING
}
/**
* Заводит manual-confirm платёжку расчёта разницы в общем реестре платежей
* (как платёж выдачи аванса) — кассир подтверждает по факту движения денег, и
* gateway проводит on-chain returnexp/overspendexp + reportexp. Идемпотентно по
* детерминированному хэшу: повторный отчёт по той же позиции платёжку не плодит.
*/
private async createSettlementPayment(
coopname: string,
proposalHash: string,
item: IExpenseItemBlockchainData,
isUnderspend: boolean,
amount: number,
symbol: string
): Promise<string> {
const itemHash = item.item_hash.toLowerCase()
const kind = isUnderspend ? 'return' : 'overspend'
const hash = generateHashFromString(`expense-settlement:${coopname}:${itemHash}:${kind}`)
const existing = await this.payments.findByHash(hash)
if (existing) return existing.hash
// Реквизиты для платёжки: при недорасходе пайщик платит КООПЕРАТИВУ (его банк),
// при перерасходе кооператив платит ПАЙЩИКУ (снимок реквизитов позиции — тот
// же метод, что при выдаче аванса). Кассир/пайщик видят их в деталях платежа.
const requisite = isUnderspend
? await this.requisiteSnapshots.getCooperativeRequisiteData(coopname)
: await this.requisiteSnapshots.getItemRequisiteData(coopname, proposalHash, itemHash)
const now = new Date()
const amountStr = amount.toFixed(2)
const payment: PaymentDomainInterface = {
id: '',
coopname,
// Позиция-аванс всегда оформлена на пайщика-получателя — расчёт разницы
// относится лично к нему (виден в его личном реестре платежей).
username: item.recipient,
quantity: amount,
symbol,
type: isUnderspend ? PaymentTypeEnum.EXPENSE_RETURN : PaymentTypeEnum.EXPENSE_OVERSPEND,
direction: isUnderspend ? PaymentDirectionEnum.INCOMING : PaymentDirectionEnum.OUTGOING,
status: PaymentStatusEnum.PENDING,
// Назначение фиксированное (суть расхода — в описании позиции, поле
// blockchain_data.description показывается отдельно как «Что оплачиваем»).
memo: isUnderspend
? 'Возврат неиспользованных средств, выданных авансом под отчёт'
: 'Доплата по перерасходу аванса под отчёт',
secret: generateUniqueHash(),
payment_method_id: undefined,
payment_details: {
// Полные реквизиты (банк/СБП) — те же поля, что у платежа выдачи аванса;
// деталь платежа рендерит их строками с копированием.
data: requisite?.data ?? { requisites: requisite?.requisites ?? '' },
amount_plus_fee: amountStr,
amount_without_fee: amountStr,
fee_amount: '0',
fee_percent: 0,
fact_fee_percent: 0,
tolerance_percent: 0,
},
// proposal_hash + item_hash нужны gateway для returnexp/overspendexp; hash
// платёжки уникальный (не item_hash) — не пересекается с платежом выдачи.
blockchain_data: {
proposal_hash: proposalHash.toLowerCase(),
item_hash: itemHash,
description: item.description,
},
expired_at: undefined,
created_at: now,
updated_at: now,
hash,
}
const created = await this.payments.create(payment)
return created.hash
}
async returnExpenseItem(input: ReturnExpenseItemInputDTO): Promise<TransactResult> {
return this.chain.returnExp({
coopname: input.coopname,
proposal_hash: input.proposal_hash,
item_hash: input.item_hash,
return_amount: input.return_amount,
})
}
async overspendExpenseItem(input: OverspendExpenseItemInputDTO): Promise<TransactResult> {
return this.chain.overspendExp({
coopname: input.coopname,
proposal_hash: input.proposal_hash,
item_hash: input.item_hash,
overspend_amount: input.overspend_amount,
})
}
async submitExpenseReport(input: SubmitExpenseReportInputDTO): Promise<TransactResult> {
return this.chain.closeExp({
coopname: input.coopname,
proposal_hash: input.proposal_hash,
})
}
}
/**
* Парсит asset-строку ("800.0000 RUB") в целые минорные единицы по precision
* символа — сравнение/вычитание сумм без ошибок плавающей точки.
*/
function parseAssetToMinor(asset: string): { minor: number; symbol: string; precision: number } {
const { amount, symbol } = QuantityUtils.parseQuantityString(asset)
const precision = QuantityUtils.getPrecisionForSymbol(symbol)
return { minor: Math.round(amount * 10 ** precision), symbol, precision }
}
function toRequisiteItems(proposalHash: string, items: ExpenseItemInputDTO[]): InterExpenseRequisiteItemInput[] {
return items.map((it) => ({
proposalHash,
itemHash: it.item_hash,
recipient: it.recipient,
isOrganization: it.recipient_type === ExpenseRecipientType.ORG,
mechanics: it.mechanics === ExpenseMechanics.DIRECT ? 'DIRECT' : 'ADVANCE',
paymentMethodId: it.payment_method_id,
requisites: it.requisites,
paymentPurpose: it.payment_purpose,
}))
}
@@ -0,0 +1,90 @@
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
import type { ISyncResult } from '~/shared/interfaces/blockchain-sync.interface';
import { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import {
ExpenseProposalRepository,
EXPENSE_PROPOSAL_REPOSITORY,
} from '../../domain/repositories/expense-proposal.repository';
import { ExpenseProposalDeltaMapper } from '../../infrastructure/blockchain/mappers/expense-proposal-delta.mapper';
import type { IExpenseProposalBlockchainData } from '../../domain/interfaces/expense-proposal-blockchain.interface';
/**
* Подписка на дельты `expense::proposals` и зеркалирование расходов в локальную БД.
*
* Канон capital-style flat (ADR-008 v2 декораторов в репозитории нет).
* После регистрации контракта `expense` в `cooptypes` маппер автоматически
* получит точный тип `IProposal` — изменений в syncer'е не потребуется.
*/
@Injectable()
export class ExpenseProposalSyncService
extends AbstractEntitySyncService<ExpenseProposalDomainEntity, IExpenseProposalBlockchainData>
implements OnModuleInit
{
protected readonly entityName = 'ExpenseProposal';
constructor(
@Inject(EXPENSE_PROPOSAL_REPOSITORY)
proposalRepository: ExpenseProposalRepository,
proposalDeltaMapper: ExpenseProposalDeltaMapper,
logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
super(proposalRepository, proposalDeltaMapper, logger);
}
async onModuleInit() {
const supportedVersions = this.getSupportedVersions();
this.logger.debug(
`Сервис синхронизации смет расходов инициализирован. Контракты: [${supportedVersions.contracts.join(
', '
)}], таблицы: [${supportedVersions.tables.join(', ')}]`
);
const allPatterns = this.getAllEventPatterns();
this.logger.debug(`Подписка на ${allPatterns.length} паттернов: ${allPatterns.join(', ')}`);
allPatterns.forEach((pattern) => {
this.eventEmitter.on(pattern, this.processDelta.bind(this));
});
this.logger.debug('ExpenseProposalSyncService готов принимать дельты parser2');
}
@OnEvent('fork::*')
async handleExpenseProposalFork(forkData: { block_num: number }): Promise<void> {
await this.handleFork(forkData.block_num);
}
/**
* Emit `entitysynced::expense::proposals` после успешного save в PG.
*
* Канал per-contract (см. controller/CLAUDE.md «Subscription anti-patterns»):
* pubsub-канал расширения отделён от глобальной шины `delta::*`. Слушают
* business-side-effect listeners (capital-trigger → capitalization Благороста,
* notification, audit).
*/
public override async handleSyncDelta(
syncKey: string,
syncValue: string,
blockchainData: IExpenseProposalBlockchainData,
blockNum: number,
present = true
): Promise<ISyncResult> {
const result = await super.handleSyncDelta(syncKey, syncValue, blockchainData, blockNum, present);
if (result.created || result.updated) {
const entity = await (this.repository as ExpenseProposalRepository).findBySyncKey(syncKey, syncValue);
if (entity) {
this.eventEmitter.emit('entitysynced::expense::proposals', {
entity,
blockNum,
syncResult: result,
});
}
}
return result;
}
}
@@ -0,0 +1,25 @@
/**
* Spec MinIO-бакета `expenses:files` — хранилище первичных файлов СЗ-расхода.
*
* Содержимое:
* - платёжки (PAYMENT_PROOF) — подтверждение оплаты item-а;
* - чеки / акты (REPORT_FILE) — подтверждение фактического расхода (ADVANCE);
* - возвраты (RETURN_PROOF) — подтверждение возврата неиспользованного аванса.
*
* Ключ объекта:
* `{coopname}/expenses/{proposal_hash}/{item_hash|_proposal}/{kind}/{checksum}.{ext}`
*/
export const EXPENSES_BUCKET = {
name: 'expenses:files',
maxBytes: 20 * 1024 * 1024,
allowedMime: [
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'application/pdf',
] as const,
defaultUrlTtlSeconds: 600,
} as const;
export type ExpensesBucketAllowedMime = (typeof EXPENSES_BUCKET.allowedMime)[number];
@@ -0,0 +1,139 @@
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import type { IExpenseProposalDatabaseData } from '../interfaces/expense-proposal-database.interface';
import type {
IExpenseProposalBlockchainData,
IExpenseProposalCallbackHandler,
IExpenseItemBlockchainData,
} from '../interfaces/expense-proposal-blockchain.interface';
import { ExpenseProposalStatus } from '../enums/expense-proposal-status.enum';
/**
* Доменная сущность СЗ-расхода.
*
* Зеркалит строку `proposals` контракта `expense` (scope = coopname).
* Композиция БД + блокчейна: БД хранит идентификацию + нормализованный
* статус; блокчейн — экономику items, ссылки на документы и колбэк
* закрытия. Конструктор бросает при рассинхроне `proposal_hash`.
*/
export class ExpenseProposalDomainEntity
extends BaseDomainEntity<IExpenseProposalDatabaseData>
implements IBlockchainSynchronizable
{
private static primary_key = 'id';
private static sync_key = 'proposal_hash';
public id?: number;
public proposal_hash: string;
public coopname: string;
public status: ExpenseProposalStatus;
public username?: string;
public source_wallet?: string;
public blockchain_status?: number;
public items?: IExpenseItemBlockchainData[];
public total_planned?: string;
public total_actual?: string;
public callback?: IExpenseProposalCallbackHandler;
public statement_doc?: ISignedDocumentDomainInterface;
public decision_doc?: ISignedDocumentDomainInterface;
public created_at?: string;
public updated_at?: string;
constructor(databaseData: IExpenseProposalDatabaseData, blockchainData?: IExpenseProposalBlockchainData) {
super(databaseData, ExpenseProposalStatus.UNDEFINED);
this.proposal_hash = databaseData.proposal_hash.toLowerCase();
this.coopname = databaseData.coopname;
this.status = databaseData.status ?? ExpenseProposalStatus.UNDEFINED;
if (blockchainData) {
if (this.proposal_hash !== blockchainData.proposal_hash.toLowerCase()) {
throw new Error(
`Expense proposal hash mismatch: db=${this.proposal_hash}, bc=${blockchainData.proposal_hash.toLowerCase()}`
);
}
this.id = Number(blockchainData.id);
this.coopname = blockchainData.coopname;
this.username = blockchainData.username;
this.source_wallet = blockchainData.source_wallet;
this.blockchain_status = blockchainData.status;
this.items = blockchainData.items;
this.total_planned = blockchainData.total_planned;
this.total_actual = blockchainData.total_actual;
this.callback = blockchainData.callback;
this.statement_doc = blockchainData.statement_doc;
this.decision_doc = blockchainData.decision_doc;
this.created_at = blockchainData.created_at;
this.updated_at = blockchainData.updated_at;
this.status = ExpenseProposalDomainEntity.mapStatusToDomain(blockchainData.status);
}
}
getBlockNum(): number | undefined {
return this.block_num;
}
public static getPrimaryKey(): string {
return ExpenseProposalDomainEntity.primary_key;
}
public static getSyncKey(): string {
return ExpenseProposalDomainEntity.sync_key;
}
getPrimaryKey(): string {
return ExpenseProposalDomainEntity.primary_key;
}
getSyncKey(): string {
return ExpenseProposalDomainEntity.sync_key;
}
updateFromBlockchain(blockchainData: IExpenseProposalBlockchainData, blockNum: number, present = true): void {
if (this.proposal_hash !== blockchainData.proposal_hash.toLowerCase()) {
throw new Error('Expense proposal hash mismatch on updateFromBlockchain');
}
this.id = Number(blockchainData.id);
this.username = blockchainData.username;
this.source_wallet = blockchainData.source_wallet;
this.blockchain_status = blockchainData.status;
this.items = blockchainData.items;
this.total_planned = blockchainData.total_planned;
this.total_actual = blockchainData.total_actual;
this.callback = blockchainData.callback;
this.statement_doc = blockchainData.statement_doc;
this.decision_doc = blockchainData.decision_doc;
this.created_at = blockchainData.created_at;
this.updated_at = blockchainData.updated_at;
this.status = ExpenseProposalDomainEntity.mapStatusToDomain(blockchainData.status);
this.block_num = blockNum;
this.present = present;
}
/**
* Маппит `uint8_t status` (`ExpenseDomain::ProposalStatus`) → enum.
* Неизвестное значение → UNDEFINED (alert на schema drift по канону).
*/
private static mapStatusToDomain(raw: number): ExpenseProposalStatus {
switch (raw) {
case 0:
return ExpenseProposalStatus.CREATED;
case 1:
return ExpenseProposalStatus.AUTHORIZED;
case 2:
return ExpenseProposalStatus.PARTIALLY_PAID;
case 3:
return ExpenseProposalStatus.REPORT_SUBMITTED;
case 4:
return ExpenseProposalStatus.CLOSED;
case 5:
return ExpenseProposalStatus.DECLINED;
default:
return ExpenseProposalStatus.UNDEFINED;
}
}
}
@@ -0,0 +1,22 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Назначение файла в MinIO-бакете `expenses:files`.
*/
export enum ExpenseFileKind {
PAYMENT_PROOF = 'PAYMENT_PROOF',
REPORT_FILE = 'REPORT_FILE',
RETURN_PROOF = 'RETURN_PROOF',
CLOSING_DOC = 'CLOSING_DOC',
}
registerEnumType(ExpenseFileKind, {
name: 'ExpenseFileKind',
description: 'Тип первичного файла расхода.',
valuesMap: {
PAYMENT_PROOF: { description: 'Платёжка/квитанция кассира об исполненной оплате.' },
REPORT_FILE: { description: 'Чек/первичка пайщика по отчёту об авансе под отчёт.' },
RETURN_PROOF: { description: 'Подтверждение возврата неиспользованного аванса.' },
CLOSING_DOC: { description: 'Закрывающий документ по оплате организации (акт/счёт-фактура/накладная).' },
},
});
@@ -0,0 +1,19 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Доменный статус item-а в СЗ-расходе. Зеркалит `ExpenseDomain::ItemStatus`
* из `components/contracts/cpp/expense/expense.hpp`.
*/
export enum ExpenseItemStatus {
APPROVED = 'APPROVED',
PAID = 'PAID',
REPORTED = 'REPORTED',
RETURNED = 'RETURNED',
OVERSPENT = 'OVERSPENT',
UNDEFINED = 'UNDEFINED',
}
registerEnumType(ExpenseItemStatus, {
name: 'ExpenseItemStatus',
description: 'Статус строки расхода.',
});
@@ -0,0 +1,18 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Способ оплаты item-а в СЗ-расходе.
* - ADVANCE — пайщик-получатель платит и приносит чек.
* - DIRECT — кассир/председатель платит организации напрямую.
*
* Зеркалит `ExpenseDomain::Mechanics` из `expense.hpp`.
*/
export enum ExpenseMechanics {
ADVANCE = 'ADVANCE',
DIRECT = 'DIRECT',
}
registerEnumType(ExpenseMechanics, {
name: 'ExpenseMechanics',
description: 'Способ оплаты строки расхода.',
});
@@ -0,0 +1,20 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Доменный статус СЗ-расхода. Зеркалит `ExpenseDomain::ProposalStatus`
* из `components/contracts/cpp/expense/expense.hpp`.
*/
export enum ExpenseProposalStatus {
CREATED = 'CREATED',
AUTHORIZED = 'AUTHORIZED',
PARTIALLY_PAID = 'PARTIALLY_PAID',
REPORT_SUBMITTED = 'REPORT_SUBMITTED',
CLOSED = 'CLOSED',
DECLINED = 'DECLINED',
UNDEFINED = 'UNDEFINED',
}
registerEnumType(ExpenseProposalStatus, {
name: 'ExpenseProposalStatus',
description: 'Статус сметы расхода.',
});
@@ -0,0 +1,20 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Тип получателя платежа в item-е СЗ-расхода.
* - SELF — сам создатель СЗ.
* - MEMBER — другой пайщик кооператива.
* - ORG — внешняя организация / поставщик.
*
* Зеркалит `ExpenseDomain::RecipientType` из `expense.hpp`.
*/
export enum ExpenseRecipientType {
SELF = 'SELF',
MEMBER = 'MEMBER',
ORG = 'ORG',
}
registerEnumType(ExpenseRecipientType, {
name: 'ExpenseRecipientType',
description: 'Тип получателя платежа.',
});
@@ -0,0 +1,24 @@
/**
* Состояние отчёта по строке-авансу под отчёт — зеркалится в платёж выдачи
* аванса (`payment.blockchain_data.report_state`), чтобы реестр платежей
* показывал статус отчёта рядом со статусом платежа без чтения цепи.
*
* Не путать со статусом ПЛАТЕЖА (`PaymentStatusEnum`, движение денег) и статусом
* ПОЗИЦИИ в контракте (`ItemStatus`). Это отдельная UI-ось «надо ли пайщику
* отчитаться»:
* - AWAITING — аванс выдан, отчёт ещё не подан (дефолт; отдельно не пишется);
* - SETTLEMENT_PENDING — отчёт подан с недо-/перерасходом, заведена платёжка
* расчёта разницы, ждём её подтверждения кассой;
* - CLOSED — отчёт принят on-chain (reportexp);
* - NOT_REQUIRED — отчёт пайщика не требуется (прямая оплата организации).
*
* Значения дублируются строковыми константами на фронте
* (`desktop/src/shared/lib/expenses`) — поле живёт в JSON-скаляре blockchain_data,
* а не в типизированной GraphQL-схеме.
*/
export enum ExpenseReportState {
NOT_REQUIRED = 'NOT_REQUIRED',
AWAITING = 'AWAITING',
SETTLEMENT_PENDING = 'SETTLEMENT_PENDING',
CLOSED = 'CLOSED',
}
@@ -0,0 +1,19 @@
/**
* Параметры шасси расходов.
*
* `fundNameDative` — фонд, по которому совет согласует списание затрат
* (строка в служебной записке 2010: «списание затрат по …»). Хранится
* в дательном падеже, т.к. подставляется после предлога «по». Дефолт —
* фонд хозяйственной деятельности; другой фонд (развития кооперации,
* материальной помощи и т.п.) задаётся через env EXPENSES_FUND_NAME.
*/
export const EXPENSES_CHASSIS_CONFIG = {
fundNameDative: process.env.EXPENSES_FUND_NAME ?? 'Фонду хозяйственной деятельности',
/**
* Назначение платежа для выплат пайщику (SELF/MEMBER) — всегда одно и то же,
* подставляется автоматически: в платёжке кассира неизменно «аванс под отчёт»,
* а на что он — видно из описания позиции. Для организации/ИП назначение
* вводится при создании расхода.
*/
advancePaymentPurpose: 'Аванс под отчёт',
};
@@ -0,0 +1,24 @@
import { ExpenseFileKind } from '../enums/expense-file-kind.enum';
/**
* Запись о первичном файле расхода — платёжке, чеке, доказательстве возврата.
* Сам бинарь лежит в MinIO-бакете `expenses:files`. В блокчейне файла нет —
* это чистая БД-сущность; для on-chain валидации используется `checksum_sha256`.
*
* Ключ MinIO: `{coopname}/expenses/{proposal_hash}/{item_hash|_proposal}/{kind}/{checksum}.{ext}`.
*/
export interface IExpenseFileDatabaseData {
id?: number;
coopname: string;
proposal_hash: string;
item_hash?: string | null;
kind: ExpenseFileKind;
checksum_sha256: string;
mime_type: string;
size_bytes: number;
storage_key: string;
/** Оригинальное имя загруженного файла — для отображения и поиска */
original_filename?: string | null;
uploaded_by_username: string;
uploaded_at: Date;
}
@@ -0,0 +1,13 @@
import type { IBaseDatabaseData } from '~/shared/sync/interfaces/base-database.interface';
import { ExpenseItemStatus } from '../enums/expense-item-status.enum';
/**
* Зеркало строки `items[]` СЗ-расхода. Хранится отдельной таблицей
* (`expense_items`) ради per-item фильтров и присоединения файлов.
*/
export interface IExpenseItemDatabaseData extends IBaseDatabaseData {
id?: number;
proposal_hash: string;
item_hash: string;
status: ExpenseItemStatus;
}
@@ -0,0 +1,47 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Колбэк, который контракт `expense` опубликует в `dispatcher` при закрытии СЗ.
* `contract.value === 0` означает «колбэка нет».
*/
export interface IExpenseProposalCallbackHandler {
contract: string;
action: string;
data: string;
}
/**
* Item — одна строка СЗ-расхода в `proposals.items` контракта `expense`.
* Зеркалит `ExpenseDomain::item`.
*/
export interface IExpenseItemBlockchainData {
item_hash: string;
mechanics: number;
recipient_type: number;
recipient: string;
description: string;
planned_amount: string;
actual_amount: string;
status: number;
}
/**
* СЗ-расход — строка `proposals` контракта `expense` (scope = coopname).
* Зеркалит `ExpenseDomain::proposal`.
*/
export interface IExpenseProposalBlockchainData {
id: string;
proposal_hash: string;
coopname: string;
username: string;
source_wallet: string;
status: number;
items: IExpenseItemBlockchainData[];
total_planned: string;
total_actual: string;
callback?: IExpenseProposalCallbackHandler;
statement_doc?: ISignedDocumentDomainInterface;
decision_doc?: ISignedDocumentDomainInterface;
created_at: string;
updated_at: string;
}
@@ -0,0 +1,13 @@
import type { IBaseDatabaseData } from '~/shared/sync/interfaces/base-database.interface';
import { ExpenseProposalStatus } from '../enums/expense-proposal-status.enum';
/**
* Поля, которые живут в собственной таблице зеркала СЗ-расхода
* (`expense_proposals`). Идентификация и нормализованный статус.
*/
export interface IExpenseProposalDatabaseData extends IBaseDatabaseData {
id?: number;
proposal_hash: string;
coopname: string;
status: ExpenseProposalStatus;
}
@@ -0,0 +1,29 @@
import { ExpenseContract } from 'cooptypes'
import type { TransactResult } from '@wharfkit/session'
/**
* Блокчейн-порт контракта `expense`. Hexagonal: domain видит интерфейс,
* implementation в `infrastructure/blockchain/adapters`.
*
* 6 actions, доступных backend'у. Авторизация (`authexp`) и отклонение
* (`declexp`) СЗ исполняются контрактом soviet как callbacks решения совета —
* backend их не вызывает.
*
* - `createexp` — создание + подача СЗ (signact1 statement_doc, type=2010);
* ставит вопрос в повестку совета
* - `payexp` — оплата item (ADVANCE/DIRECT)
* - `reportexp` — закрытие item чеком (ADVANCE)
* - `returnexp` — возврат неиспользованного аванса
* - `overspendexp` — доплата при перерасходе (ADVANCE)
* - `closeexp` — финализация СЗ-отчёта (REPORT_SUBMITTED → CLOSED)
*/
export interface ExpensesBlockchainPort {
createExp(data: ExpenseContract.Actions.CreateExp.ICreateExp): Promise<TransactResult>
payExp(data: ExpenseContract.Actions.PayExp.IPayExp): Promise<TransactResult>
reportExp(data: ExpenseContract.Actions.ReportExp.IReportExp): Promise<TransactResult>
returnExp(data: ExpenseContract.Actions.ReturnExp.IReturnExp): Promise<TransactResult>
overspendExp(data: ExpenseContract.Actions.OverspendExp.IOverspendExp): Promise<TransactResult>
closeExp(data: ExpenseContract.Actions.CloseExp.ICloseExp): Promise<TransactResult>
}
export const EXPENSES_BLOCKCHAIN_PORT = Symbol('ExpensesBlockchainPort')
@@ -0,0 +1,16 @@
import type { IExpenseFileDatabaseData } from '../interfaces/expense-file-database.interface';
/**
* Реестр первичных файлов расхода. Файл — чистая БД-сущность (MinIO + метаданные);
* блокчейна за ним нет, поэтому интерфейс не наследует `IBlockchainSyncRepository`.
*/
export interface ExpenseFileRepository {
create(data: IExpenseFileDatabaseData): Promise<IExpenseFileDatabaseData>;
findById(id: number): Promise<IExpenseFileDatabaseData | null>;
findByChecksum(coopname: string, checksum: string): Promise<IExpenseFileDatabaseData | null>;
findByProposal(coopname: string, proposalHash: string): Promise<IExpenseFileDatabaseData[]>;
findByItem(coopname: string, proposalHash: string, itemHash: string): Promise<IExpenseFileDatabaseData[]>;
delete(id: number): Promise<void>;
}
export const EXPENSE_FILE_REPOSITORY = Symbol('ExpenseFileRepository');
@@ -0,0 +1,29 @@
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import type { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import type { ExpenseProposalDomainEntity } from '../entities/expense-proposal.entity';
/**
* Репозиторий зеркала СЗ-расходов. Чтение строго из Postgres
* (ADR-011 — read-path); chain RPC только для reconciliation / forensic.
*
* `*Paginated` методы — каноничный путь для resolver'ов (см. controller/CLAUDE.md
* «Пагинация — единый паттерн»). Неснабжённые пагинацией методы оставлены для
* внутреннего DocumentAggregation / e2e и постепенно вытесняются.
*/
export interface ExpenseProposalRepository extends IBlockchainSyncRepository<ExpenseProposalDomainEntity> {
findByProposalHash(proposalHash: string): Promise<ExpenseProposalDomainEntity | null>;
findByCoopname(coopname: string): Promise<ExpenseProposalDomainEntity[]>;
findByUsername(coopname: string, username: string): Promise<ExpenseProposalDomainEntity[]>;
findByCoopnamePaginated(
coopname: string,
options?: PaginationInputDTO
): Promise<{ items: ExpenseProposalDomainEntity[]; totalCount: number }>;
findByUsernamePaginated(
coopname: string,
username: string,
options?: PaginationInputDTO
): Promise<{ items: ExpenseProposalDomainEntity[]; totalCount: number }>;
}
export const EXPENSE_PROPOSAL_REPOSITORY = Symbol('ExpenseProposalRepository');
@@ -0,0 +1,24 @@
import type { PaymentMethodDomainEntity } from '~/domain/payment-method/entities/method-domain.entity';
import type {
SBPDataDomainInterface,
BankTransferDataDomainInterface,
} from '~/domain/payment-method/interfaces/payment-methods-domain.interface';
/**
* Полная строка реквизитов платёжного метода — для документов (СЗ в совет,
* поручение бухгалтеру). Сокращённое представление для UI делает фронт.
*/
export function formatPaymentMethodRequisites(method: PaymentMethodDomainEntity): string {
if (method.method_type === 'sbp') {
const data = method.data as SBPDataDomainInterface;
return `СБП, телефон ${data.phone}`;
}
const data = method.data as BankTransferDataDomainInterface;
const parts = [`Банковский перевод: счёт ${data.account_number}`];
if (data.bank_name) parts.push(data.bank_name);
if (data.details?.bik) parts.push(`БИК ${data.details.bik}`);
if (data.details?.corr) parts.push(`к/с ${data.details.corr}`);
if (data.card_number) parts.push(`карта ${data.card_number}`);
return parts.join(', ');
}
@@ -0,0 +1,95 @@
import { Module } from '@nestjs/common';
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
import { DocumentDomainModule } from '~/domain/document/document.module';
import { BlockchainModule } from '~/infrastructure/blockchain/blockchain.module';
import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
import { GeneratorInfrastructureModule } from '~/infrastructure/generator/generator.module';
import { ExpensesDatabaseModule } from './infrastructure/database/expenses-database.module';
import { ExpenseContractInfoService } from './infrastructure/services/expense-contract-info.service';
import { ExpenseProposalDeltaMapper } from './infrastructure/blockchain/mappers/expense-proposal-delta.mapper';
import { ExpensesBlockchainAdapter } from './infrastructure/blockchain/adapters/expenses-blockchain.adapter';
import { ExpenseProposalTypeormRepository } from './infrastructure/repositories/expense-proposal.typeorm-repository';
import { ExpenseFileTypeormRepository } from './infrastructure/repositories/expense-file.typeorm-repository';
import { EXPENSE_PROPOSAL_REPOSITORY } from './domain/repositories/expense-proposal.repository';
import { EXPENSE_FILE_REPOSITORY } from './domain/repositories/expense-file.repository';
import { EXPENSES_BLOCKCHAIN_PORT } from './domain/interfaces/expenses-blockchain.port';
import { ExpenseProposalSyncService } from './application/syncers/expense-proposal-sync.service';
import { ExpensesManagementService } from './application/services/expenses-management.service';
import { ExpensesMutationsService } from './application/services/expenses-mutations.service';
import { ExpenseRequisiteSnapshotsService } from './application/services/expense-requisite-snapshots.service';
import { ExpensesCapitalTriggerService } from './application/services/expenses-capital-trigger.service';
import { ExpensePaymentsListener } from './application/services/expense-payments.listener';
import { ExpenseAdvanceReminderService } from './application/services/expense-advance-reminder.service';
import { ExpenseFilesService } from './application/services/expense-files.service';
import { ExpenseProposalResolver } from './application/resolvers/expense-proposal.resolver';
import { ExpenseMutationsResolver } from './application/resolvers/expense-mutations.resolver';
import { ExpenseFilesResolver } from './application/resolvers/expense-files.resolver';
import { ExpensesInterExpenseChassisAdapter } from './infrastructure/inter/expenses-inter-expense-chassis.adapter';
/**
* Шасси расходов цифрового кооператива (MVP — Благорост).
*
* Backend-сторона контракта `expense`:
* - TypeORM-зеркало on-chain proposals + items;
* - MinIO-bucket `expenses:files` (платёжки/чеки/возвраты);
* - GraphQL Query/Mutation для UI;
* - sync через parser2 (после ABI regen).
*
* Расширение НЕ предоставляет собственного «стола» — расход живёт в Столе
* Благороста и Председателя. Поэтому в `AppRegistry` запись не заводится,
* модуль подключается напрямую в `ExtensionsModule.register()`.
*
* Подробности и план реализации — см. `README.md` рядом.
*/
@Module({
imports: [
ExpensesDatabaseModule,
DocumentDomainModule,
BlockchainModule,
VaultDomainModule,
GeneratorInfrastructureModule,
FileStorageInfrastructureModule.forFeature([ExpenseFilesService]),
],
providers: [
ExpenseContractInfoService,
ExpenseProposalDeltaMapper,
ExpenseProposalTypeormRepository,
ExpenseFileTypeormRepository,
{
provide: EXPENSE_PROPOSAL_REPOSITORY,
useClass: ExpenseProposalTypeormRepository,
},
{
provide: EXPENSE_FILE_REPOSITORY,
useClass: ExpenseFileTypeormRepository,
},
{
provide: EXPENSES_BLOCKCHAIN_PORT,
useClass: ExpensesBlockchainAdapter,
},
ExpenseProposalSyncService,
ExpensesManagementService,
ExpenseRequisiteSnapshotsService,
ExpensesMutationsService,
ExpensesCapitalTriggerService,
ExpensePaymentsListener,
ExpenseAdvanceReminderService,
ExpenseFilesService,
ExpenseProposalResolver,
ExpenseMutationsResolver,
ExpenseFilesResolver,
ExpensesInterExpenseChassisAdapter,
],
exports: [
ExpenseContractInfoService,
ExpenseProposalDeltaMapper,
EXPENSE_PROPOSAL_REPOSITORY,
EXPENSE_FILE_REPOSITORY,
ExpenseProposalSyncService,
ExpensesManagementService,
ExpensesMutationsService,
ExpenseFilesService,
ExpensesInterExpenseChassisAdapter,
],
})
export class ExpensesExtensionModule {}
@@ -0,0 +1,121 @@
import { ExpenseContract } from 'cooptypes'
import { ExpensesBlockchainAdapter } from './expenses-blockchain.adapter'
import type { BlockchainService } from '~/infrastructure/blockchain/blockchain.service'
import type { VaultDomainService } from '~/domain/vault/services/vault-domain.service'
/**
* Контракт-тест адаптера: каждое действие подписывается ключом кооператива
* на `account = expense`, `permission = active`, `actor = coopname`.
*
* Гарантия — не вырастет дрейф между cooptypes/expense actionName'ами и тем,
* что реально уходит в `BlockchainService.transact`.
*/
describe('ExpensesBlockchainAdapter', () => {
let blockchain: jest.Mocked<Pick<BlockchainService, 'initialize' | 'transact'>>
let vault: jest.Mocked<Pick<VaultDomainService, 'getWif'>>
let adapter: ExpensesBlockchainAdapter
const fakeResult = { response: { transaction_id: 'tx_zzz' } } as never
beforeEach(() => {
blockchain = {
initialize: jest.fn(),
transact: jest.fn().mockResolvedValue(fakeResult),
}
vault = {
getWif: jest.fn().mockResolvedValue('5KQwrPbwdL6Ph...'),
}
adapter = new ExpensesBlockchainAdapter(
blockchain as unknown as BlockchainService,
vault as unknown as VaultDomainService
)
})
const coopname = 'voskhod'
function assertTransactShape(name: string): void {
expect(vault.getWif).toHaveBeenCalledWith(coopname)
expect(blockchain.initialize).toHaveBeenCalledWith(coopname, '5KQwrPbwdL6Ph...')
expect(blockchain.transact).toHaveBeenCalledWith(
expect.objectContaining({
account: ExpenseContract.contractName.production,
name,
authorization: [{ actor: coopname, permission: 'active' }],
})
)
}
const fakeDoc2 = {
version: '1',
hash: '0xh',
doc_hash: '0xd',
meta_hash: '0xm',
meta: '{}',
signatures: [],
} as any
it('createExp → transact(expense, createexp, [{coopname, active}])', async () => {
await adapter.createExp({
coopname,
username: 'ivanov',
proposal_hash: '0xabc',
source_wallet: 'w.cap.blago',
items: [],
callback: { contract: '', action: '', data: '' },
statement: fakeDoc2,
})
assertTransactShape(ExpenseContract.Actions.CreateExp.actionName)
})
// authexp/declexp исполняет контракт soviet как callbacks решения совета —
// в backend-адаптере их нет по дизайну (см. expenses-blockchain.port.ts).
it('payExp → transact(expense, payexp, [{coopname, active}])', async () => {
await adapter.payExp({
coopname,
proposal_hash: '0xabc',
item_hash: '0xdef',
actual_amount: '100.0000 RUB',
})
assertTransactShape(ExpenseContract.Actions.PayExp.actionName)
})
it('reportExp → transact(expense, reportexp, ...)', async () => {
await adapter.reportExp({ coopname, proposal_hash: '0xabc', item_hash: '0xdef' })
assertTransactShape(ExpenseContract.Actions.ReportExp.actionName)
})
it('returnExp → transact(expense, returnexp, ...)', async () => {
await adapter.returnExp({
coopname,
proposal_hash: '0xabc',
item_hash: '0xdef',
return_amount: '50.0000 RUB',
})
assertTransactShape(ExpenseContract.Actions.ReturnExp.actionName)
})
it('overspendExp → transact(expense, overspendexp, ...)', async () => {
await adapter.overspendExp({
coopname,
proposal_hash: '0xabc',
item_hash: '0xdef',
overspend_amount: '200.0000 RUB',
})
assertTransactShape(ExpenseContract.Actions.OverspendExp.actionName)
})
it('closeExp → transact(expense, closeexp, ...)', async () => {
await adapter.closeExp({ coopname, proposal_hash: '0xabc' })
assertTransactShape(ExpenseContract.Actions.CloseExp.actionName)
})
it('бросает 502, если vault не отдал ключ', async () => {
vault.getWif.mockResolvedValueOnce(null)
await expect(adapter.closeExp({ coopname, proposal_hash: '0xabc' })).rejects.toThrow(
'Не найден приватный ключ'
)
expect(blockchain.initialize).not.toHaveBeenCalled()
expect(blockchain.transact).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,93 @@
import { Inject, Injectable } from '@nestjs/common'
import { ExpenseContract } from 'cooptypes'
import type { TransactResult } from '@wharfkit/session'
import httpStatus from 'http-status'
import { BlockchainService } from '~/infrastructure/blockchain/blockchain.service'
import { VAULT_DOMAIN_SERVICE, VaultDomainService } from '~/domain/vault/services/vault-domain.service'
import { HttpApiError } from '~/utils/httpApiError'
import { ExpensesBlockchainPort } from '../../../domain/interfaces/expenses-blockchain.port'
/**
* Адаптер блокчейн-порта `expense`. Канон взят с `CapitalBlockchainAdapter`:
* подпись ключом кооператива (`active` permission), `account = contractName.production`.
*
* 6 backend-actions: createexp / payexp / reportexp / returnexp / overspendexp /
* closeexp (authexp/declexp вызывает контракт soviet по решению совета).
* Документы document2 (statement) идут как часть `data` action'а — wharfkit
* сериализует поля по C++ struct document2.
*/
@Injectable()
export class ExpensesBlockchainAdapter implements ExpensesBlockchainPort {
constructor(
private readonly blockchainService: BlockchainService,
@Inject(VAULT_DOMAIN_SERVICE) private readonly vaultDomainService: VaultDomainService
) {}
private async initWithCoopKey(coopname: string): Promise<void> {
const wif = await this.vaultDomainService.getWif(coopname)
if (!wif) {
throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции')
}
this.blockchainService.initialize(coopname, wif)
}
async createExp(data: ExpenseContract.Actions.CreateExp.ICreateExp): Promise<TransactResult> {
await this.initWithCoopKey(data.coopname)
return this.blockchainService.transact({
account: ExpenseContract.contractName.production,
name: ExpenseContract.Actions.CreateExp.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
})
}
async payExp(data: ExpenseContract.Actions.PayExp.IPayExp): Promise<TransactResult> {
await this.initWithCoopKey(data.coopname)
return this.blockchainService.transact({
account: ExpenseContract.contractName.production,
name: ExpenseContract.Actions.PayExp.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
})
}
async reportExp(data: ExpenseContract.Actions.ReportExp.IReportExp): Promise<TransactResult> {
await this.initWithCoopKey(data.coopname)
return this.blockchainService.transact({
account: ExpenseContract.contractName.production,
name: ExpenseContract.Actions.ReportExp.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
})
}
async returnExp(data: ExpenseContract.Actions.ReturnExp.IReturnExp): Promise<TransactResult> {
await this.initWithCoopKey(data.coopname)
return this.blockchainService.transact({
account: ExpenseContract.contractName.production,
name: ExpenseContract.Actions.ReturnExp.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
})
}
async overspendExp(data: ExpenseContract.Actions.OverspendExp.IOverspendExp): Promise<TransactResult> {
await this.initWithCoopKey(data.coopname)
return this.blockchainService.transact({
account: ExpenseContract.contractName.production,
name: ExpenseContract.Actions.OverspendExp.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
})
}
async closeExp(data: ExpenseContract.Actions.CloseExp.ICloseExp): Promise<TransactResult> {
await this.initWithCoopKey(data.coopname)
return this.blockchainService.transact({
account: ExpenseContract.contractName.production,
name: ExpenseContract.Actions.CloseExp.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
})
}
}
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import type { IDelta } from '~/types/common';
import { ExpenseProposalDomainEntity } from '../../../domain/entities/expense-proposal.entity';
import type { IExpenseProposalBlockchainData } from '../../../domain/interfaces/expense-proposal-blockchain.interface';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { ExpenseContractInfoService } from '../../services/expense-contract-info.service';
import { AbstractBlockchainDeltaMapper } from '~/shared/abstract-blockchain-delta.mapper';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
/**
* Маппер дельт таблицы `expense::proposals` в блокчейн-данные расхода.
*
* NB: типизация `delta.value` сейчас идёт через локальный интерфейс — после
* регистрации контракта `expense` в `cooptypes` сменить на
* `ExpenseContract.Tables.Proposals.IProposal` без правки логики.
*/
@Injectable()
export class ExpenseProposalDeltaMapper extends AbstractBlockchainDeltaMapper<
IExpenseProposalBlockchainData,
ExpenseProposalDomainEntity
> {
constructor(
private readonly logger: WinstonLoggerService,
private readonly contractInfo: ExpenseContractInfoService
) {
super();
this.logger.setContext(ExpenseProposalDeltaMapper.name);
}
mapDeltaToBlockchainData(delta: IDelta): IExpenseProposalBlockchainData | null {
try {
const value = delta.value as IExpenseProposalBlockchainData;
if (!value) {
this.logger.warn(`Delta has no value: table=${delta.table}, key=${delta.primary_key}`);
return null;
}
const statement_doc = value.statement_doc
? DomainToBlockchainUtils.convertChainDocumentToDomainFormat(value.statement_doc as never)
: undefined;
const decision_doc = value.decision_doc
? DomainToBlockchainUtils.convertChainDocumentToDomainFormat(value.decision_doc as never)
: undefined;
// callback.data on-chain — vector<char>; парсер десериализует его в
// Uint8Array, который в JSON становится {} / {"0":..}. GraphQL-поле —
// String, поэтому нормализуем в hex-строку ещё на записи в зеркало.
const callback = value.callback
? { ...value.callback, data: bytesToHex(value.callback.data) }
: undefined;
return {
...value,
proposal_hash: String(value.proposal_hash).toLowerCase(),
callback,
statement_doc,
decision_doc,
};
} catch (error: any) {
this.logger.error(`Error mapping delta to blockchain data: ${error.message}`, error.stack);
return null;
}
}
extractSyncValue(delta: IDelta): string {
if (!delta.value || !delta.value[this.extractSyncKey()]) {
throw new Error(`Delta has no value: table=${delta.table}, key=${this.extractSyncKey()}`);
}
return String(delta.value[this.extractSyncKey()]).toLowerCase();
}
extractSyncKey(): string {
return ExpenseProposalDomainEntity.getSyncKey();
}
getSupportedContractNames(): string[] {
return this.contractInfo.getSupportedContractNames();
}
getSupportedTableNames(): string[] {
return this.contractInfo.getTablePatterns('proposals');
}
}
function bytesToHex(data: unknown): string {
if (typeof data === 'string') return data;
if (data == null) return '';
const bytes = Array.isArray(data) ? data : Object.values(data as Record<string, number>);
return bytes.map((b) => Number(b).toString(16).padStart(2, '0')).join('');
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExpenseProposalTypeormEntity } from '../entities/expense-proposal.typeorm-entity';
import { ExpenseFileTypeormEntity } from '../entities/expense-file.typeorm-entity';
import { ExpenseRequisiteSnapshotTypeormEntity } from '../entities/expense-requisite-snapshot.typeorm-entity';
import { EntityVersionTypeormEntity } from '~/shared/sync/entities/entity-version.typeorm-entity';
/**
* TypeORM-модуль расширения `expense` — зеркало SP-расходов + реестр файлов
* + снимки реквизитов получателей.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
ExpenseProposalTypeormEntity,
ExpenseFileTypeormEntity,
ExpenseRequisiteSnapshotTypeormEntity,
EntityVersionTypeormEntity,
]),
],
exports: [TypeOrmModule],
})
export class ExpensesDatabaseModule {}
@@ -0,0 +1,50 @@
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
import { ExpenseFileKind } from '../../domain/enums/expense-file-kind.enum';
export const EntityName = 'expense_files';
@Entity(EntityName)
@Index(`uq_${EntityName}_checksum`, ['coopname', 'checksum_sha256'], { unique: true })
@Index(`idx_${EntityName}_proposal`, ['coopname', 'proposal_hash'])
@Index(`idx_${EntityName}_item`, ['coopname', 'proposal_hash', 'item_hash'])
export class ExpenseFileTypeormEntity {
static getTableName(): string {
return EntityName;
}
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar' })
coopname!: string;
@Column({ type: 'varchar' })
proposal_hash!: string;
@Column({ type: 'varchar', nullable: true })
item_hash!: string | null;
@Column({ type: 'enum', enum: ExpenseFileKind })
kind!: ExpenseFileKind;
@Column({ type: 'varchar', length: 64 })
checksum_sha256!: string;
@Column({ type: 'varchar', length: 120 })
mime_type!: string;
@Column({ type: 'integer' })
size_bytes!: number;
@Column({ type: 'varchar', length: 512 })
storage_key!: string;
@Column({ type: 'varchar', length: 255, nullable: true, comment: 'Оригинальное имя загруженного файла — для отображения и поиска' })
original_filename!: string | null;
@Column({ type: 'varchar' })
uploaded_by_username!: string;
@CreateDateColumn({ type: 'timestamp' })
uploaded_at!: Date;
}
@@ -0,0 +1,71 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseTypeormEntity } from '~/shared/sync/entities/base-typeorm.entity';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import type {
IExpenseItemBlockchainData,
IExpenseProposalCallbackHandler,
} from '../../domain/interfaces/expense-proposal-blockchain.interface';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
export const EntityName = 'expense_proposals';
@Entity(EntityName)
@Index(`idx_${EntityName}_blockchain_id`, ['id'])
@Index(`idx_${EntityName}_proposal_hash`, ['proposal_hash'])
@Index(`idx_${EntityName}_coopname_username`, ['coopname', 'username'])
@Index(`idx_${EntityName}_status`, ['status'])
@Index(`idx_${EntityName}_created_at`, ['_created_at'])
export class ExpenseProposalTypeormEntity extends BaseTypeormEntity {
static getTableName(): string {
return EntityName;
}
@Column({ type: 'integer', nullable: true, unique: true })
id!: number;
@Column({ type: 'varchar' })
proposal_hash!: string;
@Column({ type: 'varchar' })
coopname!: string;
@Column({ type: 'varchar' })
username!: string;
@Column({ type: 'varchar' })
source_wallet!: string;
@Column({ type: 'integer', nullable: true })
blockchain_status!: number;
@Column({ type: 'jsonb', nullable: true })
items!: IExpenseItemBlockchainData[];
@Column({ type: 'varchar' })
total_planned!: string;
@Column({ type: 'varchar' })
total_actual!: string;
@Column({ type: 'jsonb', nullable: true })
callback!: IExpenseProposalCallbackHandler | null;
@Column({ type: 'jsonb', nullable: true })
statement_doc!: ISignedDocumentDomainInterface | null;
@Column({ type: 'jsonb', nullable: true })
decision_doc!: ISignedDocumentDomainInterface | null;
@Column({ type: 'timestamp', nullable: true })
created_at!: Date;
@Column({ type: 'timestamp', nullable: true })
updated_at!: Date;
@Column({
type: 'enum',
enum: ExpenseProposalStatus,
default: ExpenseProposalStatus.UNDEFINED,
})
status!: ExpenseProposalStatus;
}
@@ -0,0 +1,51 @@
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
export const EntityName = 'expense_requisite_snapshots';
/**
* Снимок реквизитов получателя по строке расхода — фиксируется в момент
* создания СЗ (как payment_details в gateway-платежах): последующее изменение
* или удаление платёжного метода пайщиком не меняет то, куда платить по уже
* поданной смете. Источник реквизитов для поручения бухгалтеру (payexp).
*/
@Entity(EntityName)
@Index(`uq_${EntityName}_item`, ['coopname', 'proposal_hash', 'item_hash'], { unique: true })
@Index(`idx_${EntityName}_proposal`, ['coopname', 'proposal_hash'])
export class ExpenseRequisiteSnapshotTypeormEntity {
static getTableName(): string {
return EntityName;
}
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar' })
coopname!: string;
@Column({ type: 'varchar' })
proposal_hash!: string;
@Column({ type: 'varchar' })
item_hash!: string;
@Column({ type: 'varchar', comment: 'Получатель платежа (username пайщика или имя организации)' })
recipient!: string;
@Column({ type: 'varchar', nullable: true, comment: 'Идентификатор платёжного метода получателя (для пайщиков)' })
method_id!: string | null;
@Column({ type: 'varchar', nullable: true, comment: 'Тип метода (sbp / bank_transfer)' })
method_type!: string | null;
@Column({ type: 'jsonb', nullable: true, comment: 'Снимок данных платёжного метода на момент создания СЗ' })
data!: Record<string, unknown> | null;
@Column({ type: 'text', comment: 'Реквизиты строкой — как в документе СЗ' })
requisites!: string;
@Column({ type: 'text', nullable: true, comment: 'Назначение платежа (оплата по счёту) — для поручения кассиру' })
payment_purpose!: string | null;
@CreateDateColumn()
created_at!: Date;
}
@@ -0,0 +1,182 @@
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import type {
InterExpenseChassisPort,
InterExpenseItem,
InterExpensePagination,
InterExpensePaginatedResult,
InterExpenseProposalRead,
InterExpenseProposalStatus,
InterExpenseRequisiteItemInput,
} from '@coopenomics/inter';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { ExpenseProposalTypeormEntity } from '../entities/expense-proposal.typeorm-entity';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
import { ExpenseReportState } from '../../domain/enums/expense-report-state.enum';
import { ExpenseRequisiteSnapshotsService } from '../../application/services/expense-requisite-snapshots.service';
import {
EXPENSES_BLOCKCHAIN_PORT,
ExpensesBlockchainPort,
} from '../../domain/interfaces/expenses-blockchain.port';
/**
* Реализация `InterExpenseChassisPort` для consumer-расширений (capital, marketplace, EMP)
* и реестра платежей (gateway).
*
* Adapter — тонкий: читает из локального TypeORM-зеркала proposals + items, проектирует
* во внешний нейтральный DTO `@coopenomics/inter`. Write-методы (`payItem` /
* `returnItem` / `overspendItem` / `reportItem`) — on-chain действия шасси при
* подтверждении кассиром платежей расчёта (payexp / returnexp / overspendexp /
* reportexp), под подписью кооператива.
*/
@Injectable()
export class ExpensesInterExpenseChassisAdapter implements InterExpenseChassisPort {
constructor(
@InjectRepository(ExpenseProposalTypeormEntity)
private readonly repository: Repository<ExpenseProposalTypeormEntity>,
private readonly requisiteSnapshots: ExpenseRequisiteSnapshotsService,
@Inject(EXPENSES_BLOCKCHAIN_PORT)
private readonly chain: ExpensesBlockchainPort,
@Inject(PAYMENT_REPOSITORY)
private readonly payments: PaymentRepository,
) {}
async payItem(coopname: string, proposalHash: string, itemHash: string, actualAmount: string): Promise<void> {
await this.chain.payExp({
coopname,
proposal_hash: proposalHash.toLowerCase(),
item_hash: itemHash.toLowerCase(),
actual_amount: actualAmount,
});
}
async returnItem(coopname: string, proposalHash: string, itemHash: string, returnAmount: string): Promise<void> {
await this.chain.returnExp({
coopname,
proposal_hash: proposalHash.toLowerCase(),
item_hash: itemHash.toLowerCase(),
return_amount: returnAmount,
});
}
async overspendItem(coopname: string, proposalHash: string, itemHash: string, overspendAmount: string): Promise<void> {
await this.chain.overspendExp({
coopname,
proposal_hash: proposalHash.toLowerCase(),
item_hash: itemHash.toLowerCase(),
overspend_amount: overspendAmount,
});
}
async reportItem(coopname: string, proposalHash: string, itemHash: string): Promise<void> {
await this.chain.reportExp({
coopname,
proposal_hash: proposalHash.toLowerCase(),
item_hash: itemHash.toLowerCase(),
});
// Зеркалим закрытие отчёта в платёж выдачи аванса (hash платежа = item_hash):
// реестр платежей показывает «Отчёт принят» сразу после проводки расчёта кассой.
const payment = await this.payments.findByHash(itemHash.toLowerCase());
if (payment?.id) {
await this.payments.update(payment.id, {
blockchain_data: { ...(payment.blockchain_data ?? {}), report_state: ExpenseReportState.CLOSED },
});
}
}
async validateRequisites(coopname: string, items: InterExpenseRequisiteItemInput[]): Promise<void> {
await this.requisiteSnapshots.validate(coopname, items);
}
async snapshotRequisites(coopname: string, items: InterExpenseRequisiteItemInput[]): Promise<void> {
await this.requisiteSnapshots.snapshot(coopname, items);
}
async readProposalByHash(coopname: string, proposalHash: string): Promise<InterExpenseProposalRead | null> {
const entity = await this.repository.findOne({
where: { coopname, proposal_hash: proposalHash.toLowerCase() },
});
return entity ? this.toRead(entity) : null;
}
async readProposalsByHashes(coopname: string, proposalHashes: string[]): Promise<InterExpenseProposalRead[]> {
if (proposalHashes.length === 0) return [];
const normalized = proposalHashes.map((h) => h.toLowerCase());
const entities = await this.repository.find({
where: { coopname, proposal_hash: In(normalized) },
});
return entities.map((e) => this.toRead(e));
}
async listProposalsByOwner(
coopname: string,
ownerContract: string,
ownerAction?: string,
pagination?: InterExpensePagination,
): Promise<InterExpensePaginatedResult<InterExpenseProposalRead>> {
const sortBy = pagination?.sortBy === 'createdAt' ? 'expense_proposal.created_at' : 'expense_proposal.updated_at';
const sortOrder = pagination?.sortOrder === 'ASC' ? 'ASC' : 'DESC';
const limit = pagination?.limit ?? 50;
const offset = pagination?.offset ?? 0;
const qb = this.repository
.createQueryBuilder('expense_proposal')
.where('expense_proposal.coopname = :coopname', { coopname })
.andWhere(`expense_proposal.callback ->> 'contract' = :ownerContract`, { ownerContract });
if (ownerAction) {
qb.andWhere(`expense_proposal.callback ->> 'action' = :ownerAction`, { ownerAction });
}
const totalCount = await qb.getCount();
const entities = await qb.orderBy(sortBy, sortOrder).skip(offset).take(limit).getMany();
return { items: entities.map((e) => this.toRead(e)), totalCount };
}
private toRead(e: ExpenseProposalTypeormEntity): InterExpenseProposalRead {
return {
coopname: e.coopname,
proposalHash: e.proposal_hash,
sourceWalletCode: e.source_wallet ?? '',
creator: e.username ?? '',
status: this.mapStatus(e.status),
callback: e.callback
? { contract: e.callback.contract, action: e.callback.action, data: e.callback.data }
: undefined,
items: (e.items ?? []).map((it): InterExpenseItem => ({
itemHash: it.item_hash,
mechanics: it.mechanics,
recipientType: it.recipient_type,
recipient: it.recipient,
description: it.description,
plannedAmount: it.planned_amount,
actualAmount: it.actual_amount,
status: it.status,
})),
totalPlanned: e.total_planned ?? '',
totalActual: e.total_actual ?? '',
createdAt: e.created_at?.toISOString?.() ?? '',
updatedAt: e.updated_at?.toISOString?.() ?? '',
};
}
private mapStatus(status: ExpenseProposalStatus | undefined): InterExpenseProposalStatus {
switch (status) {
case ExpenseProposalStatus.CREATED:
return 'CREATED';
case ExpenseProposalStatus.AUTHORIZED:
return 'AUTHORIZED';
case ExpenseProposalStatus.PARTIALLY_PAID:
return 'PARTIALLY_PAID';
case ExpenseProposalStatus.REPORT_SUBMITTED:
return 'REPORT_SUBMITTED';
case ExpenseProposalStatus.CLOSED:
return 'CLOSED';
case ExpenseProposalStatus.DECLINED:
return 'DECLINED';
default:
return 'UNDEFINED';
}
}
}
@@ -0,0 +1,41 @@
import { ExpenseFileTypeormEntity } from '../entities/expense-file.typeorm-entity';
import type { IExpenseFileDatabaseData } from '../../domain/interfaces/expense-file-database.interface';
/**
* Маппер для записей файлов расхода. БД-only сущность — symmetric copy без
* дополнительной логики.
*/
export class ExpenseFileMapper {
static toDomain(entity: ExpenseFileTypeormEntity): IExpenseFileDatabaseData {
return {
id: entity.id,
coopname: entity.coopname,
proposal_hash: entity.proposal_hash,
item_hash: entity.item_hash,
kind: entity.kind,
checksum_sha256: entity.checksum_sha256,
mime_type: entity.mime_type,
size_bytes: entity.size_bytes,
storage_key: entity.storage_key,
original_filename: entity.original_filename,
uploaded_by_username: entity.uploaded_by_username,
uploaded_at: entity.uploaded_at,
};
}
static toEntity(data: IExpenseFileDatabaseData): Partial<ExpenseFileTypeormEntity> {
return {
coopname: data.coopname,
proposal_hash: data.proposal_hash,
item_hash: data.item_hash ?? null,
kind: data.kind,
checksum_sha256: data.checksum_sha256,
mime_type: data.mime_type,
size_bytes: data.size_bytes,
storage_key: data.storage_key,
original_filename: data.original_filename ?? null,
uploaded_by_username: data.uploaded_by_username,
uploaded_at: data.uploaded_at,
};
}
}
@@ -0,0 +1,91 @@
import { ExpenseProposalDomainEntity } from '../../domain/entities/expense-proposal.entity';
import { ExpenseProposalTypeormEntity } from '../entities/expense-proposal.typeorm-entity';
import type { IExpenseProposalDatabaseData } from '../../domain/interfaces/expense-proposal-database.interface';
import type { IExpenseProposalBlockchainData } from '../../domain/interfaces/expense-proposal-blockchain.interface';
import { ExpenseProposalStatus } from '../../domain/enums/expense-proposal-status.enum';
/**
* Маппер `ExpenseProposalTypeormEntity` ↔ `ExpenseProposalDomainEntity`.
*
* Логика идентична capital-стилю: статус/идентификация хранится локально,
* экономика (items, total_*, документы) — образ блокчейна и обновляется
* только через `AbstractEntitySyncService`. Поэтому `toUpdateEntity`
* пробрасывает наверх только `block_num`/`present`.
*/
export class ExpenseProposalMapper {
static toDomain(entity: ExpenseProposalTypeormEntity): ExpenseProposalDomainEntity {
const databaseData: IExpenseProposalDatabaseData = {
_id: entity._id,
block_num: entity.block_num,
present: entity.present,
proposal_hash: entity.proposal_hash,
coopname: entity.coopname,
status: entity.status ?? ExpenseProposalStatus.UNDEFINED,
_created_at: entity._created_at,
_updated_at: entity._updated_at,
};
let blockchainData: IExpenseProposalBlockchainData | undefined;
if (entity.id) {
blockchainData = {
id: String(entity.id),
proposal_hash: entity.proposal_hash,
coopname: entity.coopname,
username: entity.username,
source_wallet: entity.source_wallet,
status: entity.blockchain_status,
items: entity.items ?? [],
total_planned: entity.total_planned,
total_actual: entity.total_actual,
callback: entity.callback ?? undefined,
statement_doc: entity.statement_doc ?? undefined,
decision_doc: entity.decision_doc ?? undefined,
created_at: entity.created_at?.toISOString?.() ?? '',
updated_at: entity.updated_at?.toISOString?.() ?? '',
};
}
return new ExpenseProposalDomainEntity(databaseData, blockchainData);
}
static toEntity(domain: ExpenseProposalDomainEntity): Partial<ExpenseProposalTypeormEntity> {
const dbPart: Partial<ExpenseProposalTypeormEntity> = {
_id: domain._id,
block_num: domain.block_num ?? 0,
present: domain.present,
proposal_hash: domain.proposal_hash,
coopname: domain.coopname,
status: domain.status,
_created_at: domain._created_at as Date,
_updated_at: domain._updated_at as Date,
};
if (domain.id !== undefined) {
const bcPart: Partial<ExpenseProposalTypeormEntity> = {
id: domain.id,
username: domain.username,
source_wallet: domain.source_wallet,
blockchain_status: domain.blockchain_status,
items: domain.items ?? [],
total_planned: domain.total_planned,
total_actual: domain.total_actual,
callback: domain.callback ?? null,
statement_doc: domain.statement_doc ?? null,
decision_doc: domain.decision_doc ?? null,
created_at: domain.created_at ? new Date(domain.created_at) : new Date(),
updated_at: domain.updated_at ? new Date(domain.updated_at) : new Date(),
};
Object.assign(dbPart, bcPart);
}
return dbPart;
}
static toUpdateEntity(domain: Partial<ExpenseProposalDomainEntity>): Partial<ExpenseProposalTypeormEntity> {
const updateData: Partial<ExpenseProposalTypeormEntity> = {};
if (domain.block_num !== undefined) updateData.block_num = domain.block_num;
if (domain.present !== undefined) updateData.present = domain.present;
return updateData;
}
}

Some files were not shown because too many files have changed in this diff Show More