feat(capital/debt): markdebtoverd + due_at в debtpaycnfrm — статус просрочки

Story 1.3 эпика «Благорост» (blago issue 51F-15).

PRD-A3, FR-A11: «срок погашения 3 месяца, дальше — overdue, без блокировки операций».
Поле due_at и статус OVERDUE добавлены, helper sweep_overdue вычитан в action.

Изменения:
- статус OVERDUE — выставляется markdebtoverd для долгов с now > due_at
- поле debt.due_at (time_point_sec) — на 3 месяца (DEFAULT_DEBT_TERM_SECONDS = 90 дней)
  от даты подтверждения выплаты gateway
- helper debts.hpp::confirm_paid — атомарный переход pay_pending → paid с
  проставлением due_at и сбросом last_pay_error
- helper debts.hpp::sweep_overdue(coopname, batch_limit) — итерация по debts
  в scope, перевод paid→overdue для просроченных, не более batch_limit записей
- markdebtoverd(coopname) action — backend-cron вызовет раз в сутки (Эпик 4),
  batch=100 чтобы уложиться в 30s транзакции; повтор до 0 изменений
- mark_settled расширен: SETTLED доступен также из OVERDUE
- debtpaycnfrm теперь использует confirm_paid (раньше update_debt_status +
  только PAID, без due_at)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-18 16:51:13 +00:00
parent b42394bfb4
commit e932260847
5 changed files with 95 additions and 8 deletions
@@ -18,12 +18,9 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
// Проверяем что долг в статусе 'pay_pending' (outpay в полёте до gateway)
eosio::check(exist_debt.status == Capital::Debts::Status::PAY_PENDING,
"Долг должен быть в статусе 'pay_pending' для подтверждения оплаты");
// Обновляем статус долга на PAID
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::PAID, _gateway);
// Переводим долг в PAID + проставляем due_at = now + 3 месяца (срок погашения).
// confirm_paid внутри проверяет статус pay_pending и атомарно сбрасывает last_pay_error.
Capital::Debts::confirm_paid(coopname, exist_debt.id, _gateway);
// Выдача пайщику беспроцентного займа: Dr 58 / Cr 51, ISSUE LOAN_ISSUED (4051).
// Семантика момента — деньги ушли пайщику, у кооператива появилось финансовое
@@ -0,0 +1,24 @@
/**
* @brief Помечает просроченные займы статусом OVERDUE.
*
* Долг считается просроченным, когда current_time_point > debt.due_at
* (поле due_at проставляется в debtpaycnfrm на 3 месяца от даты выплаты).
* Из OVERDUE долг можно так же закрыть settledebt или signact2 как из PAID.
*
* Action рассчитан на запуск из backend через @Cron (см. Эпик 4) — раз в сутки.
* Один вызов помечает не более 100 записей чтобы уложиться в 30-секундный
* лимит транзакции EOSIO; backend повторяет до тех пор, пока action не
* помечает 0 — это сигнал, что в текущем scope больше нечего переводить.
*
* @param coopname Кооператив (scope таблицы debts)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель / системный).
*/
void capital::markdebtoverd(name coopname) {
require_auth(coopname);
constexpr uint32_t MAX_PER_BATCH = 100;
Capital::Debts::sweep_overdue(coopname, MAX_PER_BATCH);
}
@@ -93,6 +93,7 @@
#include "app/debt_managment/create_debt/debtpaycnfrm.cpp"
#include "app/debt_managment/create_debt/debtpaydcln.cpp"
#include "app/debt_managment/create_debt/declinedebt.cpp"
#include "app/debt_managment/create_debt/markdebtoverd.cpp"
#include "app/debt_managment/settle_debt/settledebt.cpp"
// Управление расходами
@@ -322,4 +322,7 @@ public:
[[eosio::action]]
void settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement);
[[eosio::action]]
void markdebtoverd(name coopname);
};
@@ -20,6 +20,7 @@ namespace Status {
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом, ожидает повторной отправки на оплату
constexpr name PAY_PENDING = "paypending"_n; ///< Долг отправлен в gateway на оплату (исходящий платёж создан, ждём debtpaycnfrm/debtpaydcln)
constexpr name PAID = "paid"_n; ///< Долг выплачен пайщику, активный (ждёт погашения)
constexpr name OVERDUE = "overdue"_n; ///< Долг просрочен (now > due_at) — выставляется action markdebtoverd
constexpr name SETTLED = "settled"_n; ///< Долг полностью погашен (деньгами через settledebt либо результатом через signact2)
}
@@ -45,6 +46,7 @@ struct [[eosio::table, eosio::contract(CAPITAL)]] debt {
document2 authorization; ///< Авторизация совета
std::string memo; ///< Примечание
std::string last_pay_error; ///< Последняя ошибка outpay от gateway (заполняется debtpaydcln; пусто = успех или ещё не отправлен)
time_point_sec due_at; ///< Срок погашения (current_time_point + 3 мес. при переходе в paid); now > due_at → overdue
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
@@ -198,6 +200,66 @@ inline void mark_pay_declined(
});
}
/**
* @brief Кол-во секунд в стандартном сроке погашения займа (3 месяца ≈ 90 дней).
*/
constexpr uint32_t DEFAULT_DEBT_TERM_SECONDS = 90 * 24 * 60 * 60;
/**
* @brief Переводит долг в PAID, фиксирует дату выплаты и проставляет due_at.
*
* Используется в debtpaycnfrm после успешной outpay из gateway. due_at рассчитывается
* от current_time_point на 3 месяца вперёд; долги с now > due_at и status == PAID
* далее перейдут в OVERDUE через markdebtoverd.
*/
inline void confirm_paid(
eosio::name coopname,
uint64_t debt_id,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAY_PENDING,
"Подтверждение оплаты допустимо только из pay_pending");
auto now = eosio::current_time_point();
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::PAID;
d.due_at = eosio::time_point_sec(now.sec_since_epoch() + DEFAULT_DEBT_TERM_SECONDS);
d.last_pay_error = "";
});
}
/**
* @brief Помечает долги PAID с now > due_at как OVERDUE в пределах одного batch.
*
* @param coopname Кооператив (scope)
* @param batch_limit Максимальное число записей за вызов (защита от 30-сек лимита транзакции)
* @return Сколько долгов помечено за этот вызов; backend-cron повторяет до 0.
*/
inline uint32_t sweep_overdue(
eosio::name coopname,
uint32_t batch_limit
) {
debts_index debts(_capital, coopname.value);
auto now = eosio::current_time_point();
auto now_sec = eosio::time_point_sec(now.sec_since_epoch());
uint32_t marked = 0;
for (auto it = debts.begin(); it != debts.end() && marked < batch_limit; ++it) {
if (it->status == Status::PAID && it->due_at != time_point_sec() && now_sec > it->due_at) {
debts.modify(it, coopname, [&](auto &d) {
d.status = Status::OVERDUE;
});
++marked;
}
}
return marked;
}
/**
* @brief Закрывает долг как погашенный — переводит в SETTLED, сохраняет документ-основание
* в поле memo (если передан) и фиксирует дату погашения.
@@ -216,8 +278,8 @@ inline void mark_settled(
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAID,
"Погашение допустимо только из статуса paid");
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
"Погашение допустимо только из статуса paid или overdue");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::SETTLED;