Compare commits

..

24 Commits

Author SHA1 Message Date
Alex Ant 6a450e2ac6 some steps 2025-07-03 17:58:45 +05:00
Alex Ant 9fc87a705f bug: meet service improve 2025-07-02 13:27:31 +05:00
Alex Ant 305864c1ed chore(release): publish 2025-07-02 12:16:36 +05:00
Alex Ant ad83d9cc77 fix: meet сервис на отправку уведомления о повторном собрании 2025-07-02 11:58:51 +05:00
Alex Ant 226c7f9473 chore(release): publish 2025-07-01 21:03:01 +05:00
Alex Ant 137d1ca8a4 migration 2025-07-01 20:31:21 +05:00
Alex Ant 59744e9d23 добавлены анимации для входа и выхода меню, улучшена логика рендеринга. Обновлены стили и добавлен ключ для перерендера при смене маршрутов. Исправлены мелкие недочеты в других компонентах. 2025-07-01 16:46:33 +05:00
Alex Ant ac8836628e chore(release): publish 2025-07-01 16:04:01 +05:00
Alex Ant 1f649c8951 Упрощен компонент ShadowHtml: удалена проверка на клиентскую среду, добавлены логи для отладки, обновлено состояние миграции с изменением тестового файла. 2025-07-01 15:45:10 +05:00
Alex Ant 310468fb69 Исправлено название типа платежа "Возврат средств" на "Возврат взноса" в PaymentDomainEntity. 2025-07-01 13:36:13 +05:00
Alex Ant 7f782e0a92 Рефакторинг пользователя в сессии на фронте, правки визуального отображения всех страниц, управление исходящими платежами в шлюзе, возврат паевого взноса в кошельке. 2025-07-01 13:34:46 +05:00
Alex Ant d0c9f8bffc Новая архитектура для входящих и исходящих платежей 2025-06-26 20:20:58 +05:00
Alex Ant 60a35f6f76 returnByMoneyDecision Document 2025-06-25 20:34:12 +05:00
Alex Ant a3a2923751 Добавляем шаблон заявления на возврат паевого взноса с ЦК 2025-06-24 16:18:34 +05:00
Alex Ant 32e6a45f05 chore(release): publish 2025-06-24 09:07:26 +05:00
Alex Ant 59665c6df4 fix navigation guard bug 2025-06-24 08:50:05 +05:00
Alex Ant d23b4e3aac поле ввода для поиска пайщика и извлечения его username 2025-06-23 20:56:07 +05:00
Alex Ant 20897cf199 search by private account 2025-06-23 13:30:34 +05:00
Alex Ant 0b57232606 Корректное удаление кооперативного участка. Общее собранее уполномоченных. Правки title шаблонов. 2025-06-19 12:09:10 +05:00
Alex Ant 1bd8f003f7 chore(release): publish 2025-06-19 10:00:51 +05:00
Alex Ant fcb614cd90 Правка сообщений с ошибками на фронте, расширение шаблонов общих собраний на повторяемость. 2025-06-19 09:41:41 +05:00
Alex Ant 1c376c003a chore(release): publish 2025-06-14 21:27:59 +05:00
Alex Ant a433ceaf1d FATALITY BUG FIX 2025-06-14 21:26:41 +05:00
Alex Ant 850478b7f7 Revert "откатываем функционал"
This reverts commit 8484a14905.
2025-06-14 20:28:02 +05:00
400 changed files with 16134 additions and 6141 deletions
Vendored
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"agentCanUpdateSnapshot": true
}
+8 -5
View File
@@ -1,16 +1,19 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.tabCompletion": "onlySnippets",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint":"explicit"
},
// "editor.formatOnSave": true,
// "editor.codeActionsOnSave": {
// "source.addMissingImports.ts": "explicit",
// "source.fixAll.ts": "explicit",
// "source.fixAll.eslint": "explicit"
// },
"eslint.run": "onType",
"eslint.validate": ["javascript", "typescript", "vue"],
"i18n-ally.localesPaths": ["src/i18n"],
"typescript.format.enable": true,
"typescript.format.indentSwitchCase": false,
"typescript.validate.enable": true
"typescript.validate.enable": true,
"notebook.defaultFormatter": "Vue.volar"
}
+60
View File
@@ -1,3 +1,63 @@
# v2025.7.1-1
В этом релизе реализованы механизмы возврата паевого взноса пайщика и инструменты управления этим процессом для членов совета. Также внесены визуальные доработки интерфейсов для улучшения восприятия информации.
✨ Новые функции
- [#276](https://github.com/coopenomics/mono/issues/276): Реализовать путь возврата паевого взноса из кошелька пайщика
- [#281](https://github.com/coopenomics/mono/issues/281): Генерация заявления на возврат паевого взноса
- [#282](https://github.com/coopenomics/mono/issues/282): Генерация решения совета на возврат паевого взноса
- [#278](https://github.com/coopenomics/mono/issues/278): Введение методов управления возвратами паевых взносов в контроллере
- [#279](https://github.com/coopenomics/mono/issues/279): Отобразить исходящие платежи с кнопками управления в реестре платежей для совета
🐛 Исправления ошибок
- [#286](https://github.com/coopenomics/mono/issues/286): Поправить ошибки склонений времени
- [#262](https://github.com/coopenomics/mono/issues/262): Баг: рабочий стол совета включается, однако, страница всегда открывается со стола пайщика
- [#261](https://github.com/coopenomics/mono/issues/261): Баг: первая загрузка переадресует на главную страницу всегда - прямой переход на собрание становится недоступен.
🔧 Улучшения
- [#285](https://github.com/coopenomics/mono/issues/285): Корректировка дизайна кошелька, профиля, повестки совета, реестра документов, реестра платежей
- [#284](https://github.com/coopenomics/mono/issues/284): Разместить кошелек на главную вместо профиля
- [#283](https://github.com/coopenomics/mono/issues/283): Ввести лоадер на переходе между рабочими столами
- [#280](https://github.com/coopenomics/mono/issues/280): Мигрировать имеющиеся данные о входящих платежах в новую модель
- [#277](https://github.com/coopenomics/mono/issues/277): Рефакторинг модуля платежей: переход на унифицированную модель входящего и исходящего платежа
#releases
---
# v2025.6.14
Разработан модуль для проведения очередных общих собраний пайщиков. Исправлены баги, внесены улучшения пользовательского интерфейса.
✨ Новые функции
- [#264](https://github.com/coopenomics/mono/issues/264): Разработан смарт-контракт общего собрания пайщиков (meet)
- [#263](https://github.com/coopenomics/mono/issues/263): Реализован модуль оповещений на электронные почты по жизненному циклу общего собрания пайщиков
- [#273](https://github.com/coopenomics/mono/issues/273): Встроены реальные шаблоны документов общего собрания
- [#268](https://github.com/coopenomics/mono/issues/268): Добавлена страница результатов общего собрания
- [#267](https://github.com/coopenomics/mono/issues/267): Добавлена страница просмотра и скачивания бюллетеней и уведомлений по собранию
- [#270](https://github.com/coopenomics/mono/issues/270): Ссылка в оповещении ведет на страницу собрания с документом-уведомлением для подписи
🐛 Исправления ошибок
- [#262](https://github.com/coopenomics/mono/issues/262): Исправлено некорректное открытие рабочего стола совета
- [#261](https://github.com/coopenomics/mono/issues/261): Исправлена ошибка с редиректом при первой загрузке и прямом переходе на собрание
- [#259](https://github.com/coopenomics/mono/issues/259): Исправлены отступы в мобильной карточке пайщика
- [#258](https://github.com/coopenomics/mono/issues/258): Убран hover-эффект на документе и пайщике в таблице
🔧 Улучшения
- [#274](https://github.com/coopenomics/mono/issues/274): Настроена рассылка оповещений на почту при получении решения о проведении собрания
- [#272](https://github.com/coopenomics/mono/issues/272): Подписанные уведомления сохраняются и извлекаются из реестра по Graph-QL
- [#271](https://github.com/coopenomics/mono/issues/271): Ведется подсчет количества пайщиков в каждом кооперативе при добавлении и удалении
- [#260](https://github.com/coopenomics/mono/issues/260): Введен счетчик количества пайщиков в кооперативе на контракте регистратора
- [#256](https://github.com/coopenomics/mono/issues/256): Отображается статус членства каждого пайщика
- [#255](https://github.com/coopenomics/mono/issues/255): В разделе Платежи отображается ФИО/Наименование плательщика
- [#269](https://github.com/coopenomics/mono/issues/269): Реализована рассылка уведомлений перед началом собрания
- [#265](https://github.com/coopenomics/mono/issues/265): Проведена отладка и тестирование процесса общего собрания пайщиков
- [#266](https://github.com/coopenomics/mono/issues/266): Протестированы все процессы общего собрания
#releases
---
# v2025.5.14
В этом релизе реализован новый стандарт передачи и хранения документов по блокчейну с поддержкой неограниченного количества подписей и их валидацией. Также внесены улучшения в интерфейс и исправлены ошибки.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2025.6.13",
"version": "2025.7.2",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2025.6.13",
"version": "2025.7.2",
"private": true,
"description": "",
"scripts": {
@@ -14,6 +14,12 @@ void gateway::createoutpay(eosio::name coopname, eosio::name username, checksum2
eosio::check(confirm_callback != ""_n, "Действие для коллбэка-успеха должено быть установлено");
eosio::check(decline_callback != ""_n, "Действие для коллбэка-отлонения должно быть установлено");
// Валидируем callback действия если они от wallet контракта
if (callback_contract == _wallet) {
Wallet::get_valid_wallet_action(confirm_callback);
Wallet::get_valid_wallet_action(decline_callback);
}
auto outcome = Gateway::get_outcome(coopname, outcome_hash);
eosio::check(!outcome.has_value(), "Объект возврата уже существует с указанным хэшем");
@@ -1,6 +1,5 @@
void gateway::outcomplete(eosio::name coopname, checksum256 outcome_hash){
require_auth(coopname);
auto exist_outcome = Gateway::get_outcome(coopname, outcome_hash);
eosio::check(exist_outcome.has_value(), "Объект возврата не существует с указанным хэшем");
@@ -9,13 +8,15 @@ void gateway::outcomplete(eosio::name coopname, checksum256 outcome_hash){
auto outcome = outcomes.find(exist_outcome -> id);
eosio::check(outcome -> status == "pending"_n, "Только принятые заявления на вывод могут быть обработаны");
action(
permission_level{ _gateway, "active"_n},
// Используем интерфейс для типизированного вызова callback действия
Action::send<completewthd_interface>(
outcome -> callback_contract,
outcome -> confirm_callback,
std::make_tuple(coopname, outcome -> outcome_hash)
).send();
_gateway,
coopname,
outcome -> outcome_hash
);
outcomes.erase(outcome);
};
@@ -2,19 +2,22 @@ void gateway::outdecline(eosio::name coopname, checksum256 outcome_hash, std::st
require_auth(coopname);
auto exist_outcome = Gateway::get_outcome(coopname, outcome_hash);
eosio::check(!exist_outcome.has_value(), "Объект возврата уже существует с указанным хэшем");
eosio::check(exist_outcome.has_value(), "Объект возврата не существует с указанным хэшем");
Gateway::outcomes_index outcomes(_gateway, coopname.value);
auto outcome = outcomes.find(exist_outcome -> id);
eosio::check(outcome -> status == "pending"_n, "Только принятые заявления на вывод могут быть обработаны");
action(
permission_level{ _gateway, "active"_n},
outcome -> callback_contract,
outcome -> decline_callback,
std::make_tuple(coopname, outcome -> outcome_hash, reason)
).send();
// Используем интерфейс для типизированного вызова callback действия
Action::send<declinewthd_interface>(
outcome -> callback_contract,
outcome -> decline_callback,
_gateway,
coopname,
outcome -> outcome_hash,
reason
);
outcomes.erase(outcome);
};
+1
View File
@@ -21,5 +21,6 @@
#include "shared_approver.hpp"
#include "shared_registrator.hpp"
#include "shared_loan.hpp"
#include "shared_branch.hpp"
#include "approves.hpp"
#include "debts.hpp"
+2 -8
View File
@@ -114,17 +114,11 @@ static const std::set<eosio::name> soviet_actions = {
"capwthdrprog"_n, //произвести возврат накопленных членских взносов по программе на капиталиста
"capwthdrproj"_n, //произвести возврат накопленных членских взносов по проекту на актора
"capwthdrres"_n, //произвести возврат из задания
};
static const std::set<eosio::name> gateway_income_actions = {
"deposit"_n, //паевой взнос по ЦПП Кошелёк
//WALLET
"createwthd"_n, //создать заявление на возврат паевого взноса
};
static const std::set<eosio::name> gateway_outcome_actions = {
"withdraw"_n, //возврат паевого взноса по ЦПП Кошелёк
};
//program_ids
static constexpr uint64_t _wallet_program_id = 1;
static constexpr uint64_t _sosedi_program_id = 2;
@@ -0,0 +1,53 @@
#pragma once
namespace Branch {
/**
* @brief Получает количество кооперативных участков для данного кооператива
* @param coopname Имя кооператива
* @return Количество участков кооператива
*/
uint64_t get_branch_count(eosio::name coopname) {
branchstat_index stat(_branch, _branch.value);
auto st = stat.find(coopname.value);
if (st == stat.end()) {
return 0;
}
return st->count;
}
/**
* @brief Проверяет, является ли пользователь уполномоченным лицом (trustee) кооперативного участка
* @param coopname Имя кооператива
* @param username Имя пользователя для проверки
* @return true если пользователь является уполномоченным, false в противном случае
*/
bool is_trustee(eosio::name coopname, eosio::name username) {
branch_index branches(_branch, coopname.value);
auto by_trustee_index = branches.get_index<"bytrustee"_n>();
auto trustee_itr = by_trustee_index.find(username.value);
return trustee_itr != by_trustee_index.end();
}
/**
* @brief Проверяет, является ли пользователь доверенным лицом (trusted) в указанном участке кооператива
* @param coopname Имя кооператива
* @param braname Имя участка
* @param username Имя пользователя для проверки
* @return true если пользователь является доверенным, false в противном случае
*/
bool is_trusted(eosio::name coopname, eosio::name braname, eosio::name username) {
branch_index branches(_branch, coopname.value);
auto branch_itr = branches.find(braname.value);
if (branch_itr == branches.end()) {
return false;
}
return branch_itr->is_account_in_trusted(username);
}
}
@@ -3,6 +3,16 @@
using createoutpay_interface = void(CREATEOUTPAY_SIGNATURE);
namespace Gateway {
static const std::set<eosio::name> gateway_income_actions = {
"deposit"_n, //паевой взнос по ЦПП Кошелёк
};
static const std::set<eosio::name> gateway_outcome_actions = {
"withdraw"_n, //возврат паевого взноса по ЦПП Кошелёк
};
inline eosio::name get_valid_income_action(const eosio::name& action) {
eosio::check(gateway_income_actions.contains(action), "Недопустимое имя действия");
return action;
@@ -1,5 +1,22 @@
#define COMPLETEWTHD_SIGNATURE name coopname, checksum256 withdraw_hash
#define DECLINEWTHD_SIGNATURE name coopname, checksum256 withdraw_hash, std::string reason
using completewthd_interface = void(COMPLETEWTHD_SIGNATURE);
using declinewthd_interface = void(DECLINEWTHD_SIGNATURE);
namespace Wallet {
static const std::set<eosio::name> wallet_callback_actions = {
"authwthd"_n, // авторизация возврата паевого взноса
"declinewthd"_n, // отклонение возврата паевого взноса
"completewthd"_n, // завершение возврата паевого взноса
};
inline eosio::name get_valid_wallet_action(const eosio::name& action) {
eosio::check(wallet_callback_actions.contains(action), "Недопустимое имя действия wallet");
return action;
}
inline void validate_asset(const eosio::asset& amount) {
check(amount.symbol == _root_govern_symbol, "Invalid token symbol");
check(amount.is_valid(), "Invalid asset");
+17 -1
View File
@@ -1,6 +1,14 @@
// Заглушка для получения числа пайщиков кооператива.
uint64_t get_total_participants(eosio::name coopname) {
// Используем метод из shared_registrator для получения актуального количества активных пайщиков
// Получаем кооператив и проверяем режим участков
auto cooperative = get_cooperative_or_fail(coopname);
// Если включен режим кооперативных участков, используем количество участков
if (cooperative.is_branched) {
return Branch::get_branch_count(coopname);
}
// Иначе используем обычный подсчет активных пайщиков
return Registrator::get_active_participants_count(coopname);
}
@@ -14,6 +22,14 @@ void meet::vote(name coopname, checksum256 hash, name username, document2 ballot
eosio::check(meet_opt.has_value(), "Собрание не найдено");
auto meet_record = meet_opt.value();
// Получаем кооператив для проверки режима участков
auto cooperative = get_cooperative_or_fail(coopname);
// Если включен режим кооперативных участков, проверяем что голосующий является уполномоченным
if (cooperative.is_branched) {
eosio::check(Branch::is_trustee(coopname, username), "Только уполномоченные кооперативных участков могут голосовать при режиме участков");
}
// Проверяем статус собрания и временное окно голосования
eosio::check(meet_record.status == "authorized"_n, "Собрание не авторизовано для голосования");
auto now = current_time_point();
@@ -1,6 +1,6 @@
//TODO: нужно вырезать этот метод, заменив его на что-то другое.
// сейчас мы в лоб проходим по всем пайщикам и отключаем их от кооперативного участка, в случае, если он удален.
// так вообще не то. Годится только на малых масштабах.
// так вообще не то. Годится только на малых масштабах (до 100 пайщиков на участке будет ок)
/**
* @brief Эффект удаления кооперативного участка
* @param coopname Имя кооператива
@@ -25,6 +25,6 @@ void wallet::approvewthd(eosio::name coopname, checksum256 withdraw_hash, docume
withdraw -> statement,
std::string("")
)
).send();
).send();
}
@@ -14,16 +14,16 @@ void wallet::authwthd(eosio::name coopname, checksum256 withdraw_hash) {
});
// создаём объект исходящего платежа в gateway с коллбэком после обработки
action(permission_level{ _capital, "active"_n}, _gateway, "createoutpay"_n,
std::make_tuple(
coopname,
withdraw -> username,
withdraw_hash,
withdraw -> quantity,
_wallet,
"completewthd"_n,
"declinewthd"_n
)
).send();
Action::send<createoutpay_interface>(
_gateway,
"createoutpay"_n,
_wallet,
coopname,
withdraw -> username,
withdraw_hash,
withdraw -> quantity,
_wallet,
Wallet::get_valid_wallet_action("completewthd"_n),
Wallet::get_valid_wallet_action("declinewthd"_n)
);
}
@@ -1,4 +1,4 @@
void wallet::completewthd(eosio::name coopname, checksum256 withdraw_hash, std::string memo) {
void wallet::completewthd(COMPLETEWTHD_SIGNATURE) {
require_auth(_gateway);
auto exist = Wallet::get_withdraw(coopname, withdraw_hash);
@@ -16,7 +16,7 @@ void wallet::completewthd(eosio::name coopname, checksum256 withdraw_hash, std::
std::make_tuple(coopname, withdraw -> quantity, false)
).send();
std::string memo_in = "Возврат части целевого паевого взноса по ЦПП 'Цифровой Кошелёк'";
std::string memo_in = "Возврат части паевого взноса по ЦПП 'Цифровой Кошелёк'";
Wallet::sub_blocked_funds(_wallet, coopname, withdraw -> username, withdraw -> quantity, _wallet_program, memo_in);
withdraws.erase(withdraw);
@@ -28,22 +28,20 @@ void wallet::createwthd(eosio::name coopname, eosio::name username, checksum256
d.created_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
});
//TODO: перевести approve на hash-идентификатор???
action(
permission_level{_capital, "active"_n}, // кто вызывает
Action::send<createagenda_interface>(
_soviet,
"createapprv"_n,
std::make_tuple(
coopname,
username,
statement,
withdraw_hash,
_wallet, // callback_contract (текущий контракт)
"approvewthd"_n, // callback_action_approve
"declinewthd"_n, // callback_action_decline
std::string("")
)
).send();
"createagenda"_n,
_wallet,
coopname,
username,
get_valid_soviet_action("createwthd"_n),
withdraw_hash,
_wallet, // callback_contract (текущий контракт)
Wallet::get_valid_wallet_action("authwthd"_n), // callback_action_approve
Wallet::get_valid_wallet_action("declinewthd"_n), // callback_action_decline
statement,
std::string("")
);
std::string memo_in = "Возврат части целевого паевого взноса по ЦПП 'Цифровой Кошелёк'";
Wallet::block_funds(_wallet, coopname, username, quantity, _wallet_program, memo_in);
+2 -2
View File
@@ -25,8 +25,8 @@ public:
//возврат паевого взноса
[[eosio::action]] void createwthd(eosio::name coopname, eosio::name username, checksum256 withdraw_hash, eosio::asset quantity, document2 statement);
[[eosio::action]] void completewthd(eosio::name coopname, checksum256 withdraw_hash, std::string memo);
[[eosio::action]] void declinewthd(eosio::name coopname, checksum256 withdraw_hash, std::string reason);
[[eosio::action]] void completewthd(COMPLETEWTHD_SIGNATURE);
[[eosio::action]] void declinewthd(DECLINEWTHD_SIGNATURE);
[[eosio::action]] void authwthd(eosio::name coopname, checksum256 withdraw_hash);
[[eosio::action]] void approvewthd(eosio::name coopname, checksum256 withdraw_hash, document2 approved_statement);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2025.6.13",
"version": "2025.7.2",
"private": true,
"scripts": {
"build:all": "./build-all.sh",
+42
View File
@@ -0,0 +1,42 @@
-- Исправление PRIMARY KEY в таблице payments
-- Сначала проверим текущую структуру
\d payments
-- Если таблица существует и у неё есть PK на hash, то:
-- 1. Удаляем старый PRIMARY KEY (если есть)
DO $$
DECLARE
pk_name TEXT;
BEGIN
SELECT conname INTO pk_name
FROM pg_constraint
WHERE conrelid = 'payments'::regclass AND contype = 'p';
IF pk_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE payments DROP CONSTRAINT ' || pk_name;
RAISE NOTICE 'Удален PRIMARY KEY: %', pk_name;
END IF;
END $$;
-- 2. Добавляем колонку id если её нет
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'payments' AND column_name = 'id') THEN
ALTER TABLE payments ADD COLUMN id uuid DEFAULT gen_random_uuid();
UPDATE payments SET id = gen_random_uuid() WHERE id IS NULL;
ALTER TABLE payments ALTER COLUMN id SET NOT NULL;
RAISE NOTICE 'Добавлена колонка id';
END IF;
END $$;
-- 3. Создаем новый PRIMARY KEY по id
ALTER TABLE payments ADD PRIMARY KEY (id);
-- 4. Делаем hash nullable
ALTER TABLE payments ALTER COLUMN hash DROP NOT NULL;
-- Проверяем результат
\d payments
@@ -0,0 +1,32 @@
# Миграции
## Правила именования
- Обычные миграции: `V{версия}__описание.ts` (например, `V1.0.0__initial.ts`)
- Тестовые миграции: `V{версия}__описание__test.ts` (например, `V1.0.2__blockchain_data_sync__test.ts`)
## Поведение
- Обычные миграции применяются только один раз, если их версия ещё не применялась.
- Тестовые миграции (файлы с `__test` в имени) запускаются всегда, даже если версия уже применялась.
- Флаг `isTest` в коде миграции больше не используется — только по имени файла.
- Если в миграции указан параметр `validUntil` (дата), то миграция не будет применена, если эта дата уже прошла (по UTC).
## Пример
- `V1.0.0__initial.ts` — обычная миграция
- `V1.0.2__blockchain_data_sync__test.ts` — тестовая миграция
## validUntil
В объекте миграции можно указать поле `validUntil` типа `Date`. Если текущая дата превышает это значение, миграция не будет применена.
```ts
export default {
name: 'example',
validUntil: new Date('2024-07-01T00:00:00Z'),
up: async ({ blockchain }) => {
/* ... */
},
};
```
@@ -0,0 +1,265 @@
import { Order } from '../src/models';
import { TypeOrmPaymentRepository } from '../src/infrastructure/database/typeorm/repositories/typeorm-payment.repository';
import { PaymentEntity } from '../src/infrastructure/database/typeorm/entities/payment.entity';
import { DataSource } from 'typeorm';
import config from '../src/config/config';
import { PaymentStatusEnum } from '../src/domain/gateway/enums/payment-status.enum';
import { PaymentTypeEnum, PaymentDirectionEnum, getPaymentDirection } from '../src/domain/gateway/enums/payment-type.enum';
import type { PaymentDomainInterface } from '../src/domain/gateway/interfaces/payment-domain.interface';
import { sha256 } from '../src/utils/sha256';
import { generator } from '../src/services/document.service';
export default {
name: 'Миграция платежей из MongoDB в PostgreSQL (унифицированная модель)',
validUntil: new Date('2025-07-03'), // Действует до
async up(): Promise<boolean> {
console.log('Выполнение миграции: Перенос платежей из MongoDB в PostgreSQL (унифицированная модель)');
try {
await generator.connect(config.mongoose.url);
// Инициализируем подключение к PostgreSQL
const dataSource = new DataSource({
type: 'postgres',
host: config.postgres.host,
port: Number(config.postgres.port),
username: config.postgres.username,
password: config.postgres.password,
database: config.postgres.database,
entities: [PaymentEntity],
synchronize: true, // Создаем таблицу если она не существует
});
await dataSource.initialize();
console.log('Подключение к PostgreSQL установлено');
// Создаем репозиторий для работы с платежами
const paymentRepository = new TypeOrmPaymentRepository(dataSource.getRepository(PaymentEntity));
// Получаем все заказы из MongoDB
const orders = await Order.find();
console.log(`Найдено ${orders.length} заказов в MongoDB`);
let migratedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const order of orders) {
try {
// Генерируем hash из order_num для поиска существующих платежей
const hash = sha256(String(order.order_num));
// Проверяем, существует ли уже платеж с таким hash
const existingPayment = await paymentRepository.findByHash(hash);
if (existingPayment) {
console.log(`Платеж с hash ${hash} (order_num: ${order.order_num}) уже существует, пропускаем`);
skippedCount++;
continue;
}
// Мапим старые статусы на новые
const statusMapping: Record<string, PaymentStatusEnum> = {
pending: PaymentStatusEnum.PENDING,
paid: PaymentStatusEnum.PAID,
completed: PaymentStatusEnum.COMPLETED,
failed: PaymentStatusEnum.FAILED,
expired: PaymentStatusEnum.EXPIRED,
refunded: PaymentStatusEnum.REFUNDED,
};
// Мапим старые типы на новые
const typeMapping: Record<string, PaymentTypeEnum> = {
registration: PaymentTypeEnum.REGISTRATION,
deposit: PaymentTypeEnum.DEPOSIT,
};
const paymentType = typeMapping[order.type] || PaymentTypeEnum.REGISTRATION;
const paymentDirection = getPaymentDirection(paymentType);
const paymentStatus = statusMapping[order.status] || PaymentStatusEnum.PENDING;
// Проверяем, что все мигрируемые платежи действительно входящие
if (paymentDirection !== PaymentDirectionEnum.INCOMING) {
console.warn(`⚠️ Платеж ${order.order_num} имеет неожиданное направление ${paymentDirection}, пропускаем`);
skippedCount++;
continue;
}
// Создаем объект платежа согласно доменному интерфейсу
const paymentData: PaymentDomainInterface = {
hash, // используем сгенерированный hash вместо blockchain_num
coopname: order.creator,
username: order.username,
quantity: parseFloat(order.quantity),
symbol: order.symbol,
type: paymentType,
direction: paymentDirection,
status: paymentStatus,
provider: order.provider,
secret: order.secret,
message: order.message,
expired_at: order.expired_at,
created_at: (order as any).createdAt || new Date(), // Mongoose timestamps
updated_at: (order as any).updatedAt || new Date(),
payment_details: order.details
? {
data: order.details.data,
amount_plus_fee: order.details.amount_plus_fee,
amount_without_fee: order.details.amount_without_fee,
fee_amount: order.details.fee_amount,
fee_percent: order.details.fee_percent,
fact_fee_percent: order.details.fact_fee_percent,
tolerance_percent: order.details.tolerance_percent,
}
: undefined,
};
// Создаем платеж через репозиторий
await paymentRepository.create(paymentData);
migratedCount++;
console.log(
`✅ Мигрирован платеж ${order.order_num} -> hash: ${hash} (${paymentType}/${paymentDirection}, ${paymentStatus})`
);
} catch (error) {
errorCount++;
console.error(`❌ Ошибка при миграции платежа ${order.order_num}:`, error);
}
}
console.log('\n=== Результаты миграции ===');
console.log(`Мигрировано: ${migratedCount}`);
console.log(`Пропущено: ${skippedCount}`);
console.log(`Ошибок: ${errorCount}`);
console.log(`Всего обработано: ${orders.length}`);
// Проверяем, что все мигрированные платежи действительно входящие
console.log('\n=== Проверка миграции ===');
const allPayments = await paymentRepository.getAllPayments({}, { page: 1, limit: 1000, sortOrder: 'ASC' });
const incomingCount = allPayments.items.filter(
(p: PaymentDomainInterface) => p.direction === PaymentDirectionEnum.INCOMING
).length;
const outgoingCount = allPayments.items.filter(
(p: PaymentDomainInterface) => p.direction === PaymentDirectionEnum.OUTGOING
).length;
console.log(`Входящих платежей: ${incomingCount}`);
console.log(`Исходящих платежей: ${outgoingCount}`);
if (outgoingCount > 0) {
console.warn(`⚠️ Обнаружены исходящие платежи после миграции! Это может указывать на проблему.`);
}
// Дополнительная валидация данных
console.log('\n=== Валидация данных ===');
const registrationPayments = allPayments.items.filter(
(p: PaymentDomainInterface) => p.type === PaymentTypeEnum.REGISTRATION
).length;
const depositPayments = allPayments.items.filter(
(p: PaymentDomainInterface) => p.type === PaymentTypeEnum.DEPOSIT
).length;
console.log(`Регистрационных платежей: ${registrationPayments}`);
console.log(`Депозитных платежей: ${depositPayments}`);
// Проверяем обязательные поля
const paymentsWithMissingData = allPayments.items.filter(
(p: PaymentDomainInterface) => !p.coopname || !p.username || !p.quantity || !p.symbol || !p.provider || !p.secret
);
if (paymentsWithMissingData.length > 0) {
console.warn(`⚠️ Найдено ${paymentsWithMissingData.length} платежей с отсутствующими обязательными данными`);
paymentsWithMissingData.forEach((p: PaymentDomainInterface) => {
console.warn(` Платеж ${p.hash}: отсутствуют поля`, {
coopname: !p.coopname,
username: !p.username,
quantity: !p.quantity,
symbol: !p.symbol,
provider: !p.provider,
secret: !p.secret,
});
});
} else {
console.log('✅ Все платежи имеют обязательные поля');
}
// Проверяем корректность типов и направлений
const invalidDirections = allPayments.items.filter((p: PaymentDomainInterface) => {
const expectedDirection = getPaymentDirection(p.type);
return p.direction !== expectedDirection;
});
if (invalidDirections.length > 0) {
console.warn(`⚠️ Найдено ${invalidDirections.length} платежей с некорректным направлением`);
} else {
console.log('✅ Все платежи имеют корректные направления');
}
// Создаем базовые индексы для оптимизации запросов
console.log('\n=== Создание индексов ===');
try {
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_type ON payments(type);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_direction ON payments(direction);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_username ON payments(username);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_coopname ON payments(coopname);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_secret ON payments(secret);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_hash ON payments(hash);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_quantity ON payments(quantity);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_symbol ON payments(symbol);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_expired_at ON payments(expired_at);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_composite ON payments(coopname, username, status, type);
`);
console.log('Базовые индексы созданы');
} catch (indexError) {
console.warn('Ошибка при создании индексов:', indexError);
}
// Закрываем подключение к PostgreSQL
await dataSource.destroy();
console.log('Подключение к PostgreSQL закрыто');
console.log('Миграция завершена: Перенос платежей в унифицированную модель выполнен успешно');
return true;
} catch (error) {
console.error('Ошибка при выполнении миграции платежей:', error);
return false;
}
},
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2025.6.13",
"version": "2025.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2025.6.13",
"version": "2025.7.2",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2025.6.13",
"version": "2025.7.2",
"description": "",
"private": false,
"bin": "bin/createNodejsApp.js",
+385 -86
View File
@@ -244,6 +244,9 @@ input AnnualGeneralMeetingAgendaGenerateDocumentInput {
"""Имя генератора, использованного для создания документа"""
generator: String
"""Флаг повторного собрания"""
is_repeated: Boolean!
"""Язык документа"""
lang: String
@@ -298,6 +301,9 @@ input AnnualGeneralMeetingAgendaSignedMetaDocumentInput {
"""Имя генератора, использованного для создания документа"""
generator: String!
"""Флаг повторного собрания"""
is_repeated: Boolean!
"""Язык документа"""
lang: String!
@@ -524,6 +530,9 @@ input AnnualGeneralMeetingSovietDecisionGenerateDocumentInput {
"""Имя генератора, использованного для создания документа"""
generator: String
"""Флаг повторного собрания"""
is_repeated: Boolean!
"""Язык документа"""
lang: String
@@ -1491,7 +1500,10 @@ input CreateChildOrderInput {
input CreateDepositPaymentInput {
"""Сумма взноса"""
quantity: String!
quantity: Float!
"""Символ валюты"""
symbol: String!
"""Имя аккаунта пользователя"""
username: String!
@@ -1631,6 +1643,34 @@ input CreateProjectFreeDecisionInput {
question: String!
}
input CreateWithdrawInput {
"""Имя аккаунта кооператива"""
coopname: String!
"""ID метода платежа"""
method_id: String!
"""Хеш платежа для связи с withdraw"""
payment_hash: String!
"""Количество средств"""
quantity: Float!
"""Подписанное заявление на возврат средств"""
statement: ReturnByMoneySignedDocumentInput!
"""Символ валюты"""
symbol: String!
"""Имя пользователя"""
username: String!
}
type CreateWithdrawResponse {
"""Хеш созданной заявки на вывод"""
withdraw_hash: String!
}
type CreatedProjectFreeDecision {
"""Проект решения, которое предлагается принять"""
decision: String!
@@ -2061,6 +2101,92 @@ input FreeDecisionGenerateDocumentInput {
version: String
}
type GatewayPayment {
"""Данные из блокчейна"""
blockchain_data: JSON
"""Можно ли изменить статус"""
can_change_status: Boolean!
"""Название кооператива"""
coopname: String!
"""Дата создания"""
created_at: DateTime!
"""Направление платежа"""
direction: PaymentDirection!
"""Человекочитаемое направление платежа"""
direction_label: String!
"""Дата истечения"""
expired_at: DateTime
"""Форматированная сумма"""
formatted_amount: String!
"""Хеш платежа"""
hash: String
"""Уникальный идентификатор платежа"""
id: ID
"""Хеш входящего платежа (устарело)"""
income_hash: String
"""Завершен ли платеж окончательно"""
is_final: Boolean!
"""Дополнительная информация"""
memo: String
"""Сообщение"""
message: String
"""Хеш исходящего платежа (устарело)"""
outcome_hash: String
"""Детали платежа"""
payment_details: PaymentDetails
"""ID платежного метода"""
payment_method_id: String
"""Провайдер платежа"""
provider: String
"""Количество/сумма"""
quantity: Float!
"""Подписанный документ заявления"""
statement: JSON
"""Статус платежа"""
status: PaymentStatus!
"""Человекочитаемый статус"""
status_label: String!
"""Символ валюты"""
symbol: String!
"""Тип платежа"""
type: PaymentType!
"""Человекочитаемый тип платежа"""
type_label: String!
"""Дата обновления"""
updated_at: DateTime
"""Имя пользователя"""
username: String!
"""Сертификат пользователя, создавшего платеж"""
username_certificate: UserCertificateUnion
}
input GenerateDocumentInput {
"""Номер блока, на котором был создан документ"""
block_num: Int
@@ -2190,20 +2316,6 @@ input GetPaymentMethodsInput {
username: String
}
input GetPaymentsInput {
"""Идентификатор платежа в блокчейне"""
blockchain_id: String
"""Идентификатор платежа во внутренней системе"""
id: String
"""Статус платежа"""
status: PaymentStatus
"""Имя пользователя для фильтрации платежей"""
username: String
}
type Individual {
"""Дата рождения"""
birthdate: String!
@@ -2342,6 +2454,9 @@ type Meet {
"""Сертификат инициатора собрания"""
initiator_certificate: UserCertificateUnion
"""Уровень собрания"""
level: String!
"""Список пользователей, которые подписали уведомление"""
notified_users: [String!]!
@@ -2652,14 +2767,14 @@ type Mutation {
createChildOrder(data: CreateChildOrderInput!): Transaction!
"""
Создание объекта паевого платежа производится мутацией CreateDeposit. Выполнение мутации возвращает идентификатор платежа и данные для его совершения в зависимости от выбранного платежного провайдера.
Создание объекта паевого платежа производится мутацией createDepositPayment. Выполнение мутации возвращает идентификатор платежа и данные для его совершения в зависимости от выбранного платежного провайдера.
"""
createDepositPayment(data: CreateDepositPaymentInput!): Payment!
createDepositPayment(data: CreateDepositPaymentInput!): GatewayPayment!
"""
Создание объекта регистрационного платежа производится мутацией CreateInitial. Выполнение мутации возвращает идентификатор платежа и данные для его совершения в зависимости от выбранного платежного провайдера.
Создание объекта регистрационного платежа производится мутацией createInitialPayment. Выполнение мутации возвращает идентификатор платежа и данные для его совершения в зависимости от выбранного платежного провайдера.
"""
createInitialPayment(data: CreateInitialPaymentInput!): Payment!
createInitialPayment(data: CreateInitialPaymentInput!): GatewayPayment!
"""Создать предложение на поставку имущества"""
createParentOffer(data: CreateParentOfferInput!): Transaction!
@@ -2669,6 +2784,9 @@ type Mutation {
"""
createProjectOfFreeDecision(data: CreateProjectFreeDecisionInput!): CreatedProjectFreeDecision!
"""Создать заявку на вывод средств"""
createWithdraw(input: CreateWithdrawInput!): CreateWithdrawResponse!
"""Отклонить заявку"""
declineRequest(data: DeclineRequestInput!): Transaction!
@@ -2737,6 +2855,12 @@ type Mutation {
"""Сгенерировать документ заявления о возврате имущества."""
generateReturnByAssetStatement(data: ReturnByAssetStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""Сгенерировать документ решения совета о возврате паевого взноса"""
generateReturnByMoneyDecisionDocument(data: ReturnByMoneyDecisionGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""Сгенерировать документ заявления на возврат паевого взноса"""
generateReturnByMoneyStatementDocument(data: ReturnByMoneyGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""Сгенерировать документ, подтверждающий выбор кооперативного участка"""
generateSelectBranchDocument(data: SelectBranchGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
@@ -2822,7 +2946,7 @@ type Mutation {
"""
Управление статусом платежа осущствляется мутацией setPaymentStatus. При переходе платежа в статус PAID вызывается эффект в блокчейне, который завершает операцию автоматическим переводом платежа в статус COMPLETED. При установке статуса REFUNDED запускается процесс отмены платежа в блокчейне. Остальные статусы не приводят к эффектам в блокчейне.
"""
setPaymentStatus(data: SetPaymentStatusInput!): Payment!
setPaymentStatus(data: SetPaymentStatusInput!): GatewayPayment!
"""Сохранить приватный ключ в зашифрованном серверном хранилище"""
setWif(data: SetWifInput!): Boolean!
@@ -2963,6 +3087,20 @@ enum OrganizationType {
ZAO
}
type PaginatedGatewayPaymentsPaginationResult {
"""Текущая страница"""
currentPage: Int!
"""Элементы текущей страницы"""
items: [GatewayPayment!]!
"""Общее количество элементов"""
totalCount: Int!
"""Общее количество страниц"""
totalPages: Int!
}
input PaginationInput {
"""Количество элементов на странице"""
limit: Int! = 10
@@ -3182,54 +3320,6 @@ input PassportInput {
series: Float!
}
type Payment {
"""Аккаунт пользователя, совершающего платеж"""
account: Account!
"""Сумма платежа"""
amount: Float!
"""Идентификационный номер платежа в блокчейне"""
blockchain_id: Float!
"""Дата создания платежа"""
created_at: DateTime!
"""Детали платежа"""
details: PaymentDetails!
"""Дата истечения срока давности платежа"""
expired_at: DateTime!
"""Идентификатор платежа во внутренней системе учёта"""
id: String!
"""Содержит сервисное сообщение провайдера об ошибке обработки платежа"""
message: String!
"""
Идентификатор наименования провайдера платежа, ответственного за обработку
"""
provider: String!
"""
Идентификатор номера платежа, который отображается пользователю в платежных документах
"""
status: PaymentStatus!
"""Символ тикера валюты платежа"""
symbol: String!
"""Тип платежа (deposit или registration)"""
type: String!
"""Дата обновления платежа"""
updated_at: DateTime!
"""Имя аккаунта пользователя, совершающего платеж"""
username: String!
}
type PaymentDetails {
"""Сумма платежа с учетом комиссии"""
amount_plus_fee: String!
@@ -3237,8 +3327,8 @@ type PaymentDetails {
"""Сумма платежа без учета комиссии"""
amount_without_fee: String!
"""Строка с данными платежа"""
data: String!
"""Данные платежа (QR-код, токен, реквизиты и т.д.)"""
data: JSON!
"""Фактический процент комиссии"""
fact_fee_percent: Float!
@@ -3253,6 +3343,35 @@ type PaymentDetails {
tolerance_percent: Float!
}
"""Направление платежа"""
enum PaymentDirection {
INCOMING
OUTGOING
}
input PaymentFiltersInput {
"""Название кооператива"""
coopname: String
"""Направление платежа"""
direction: PaymentDirection
"""Хэш платежа"""
hash: String
"""Провайдер платежа"""
provider: String
"""Статус платежа"""
status: PaymentStatus
"""Тип платежа"""
type: PaymentType
"""Имя пользователя"""
username: String
}
type PaymentMethod {
"""Дата создания"""
created_at: DateTime!
@@ -3292,30 +3411,25 @@ type PaymentMethodPaginationResult {
totalPages: Int!
}
type PaymentPaginationResult {
"""Текущая страница"""
currentPage: Int!
"""Элементы текущей страницы"""
items: [Payment!]!
"""Общее количество элементов"""
totalCount: Int!
"""Общее количество страниц"""
totalPages: Int!
}
"""Статус платежа"""
enum PaymentStatus {
CANCELLED
COMPLETED
EXPIRED
FAILED
PAID
PENDING
PROCESSING
REFUNDED
}
"""Тип платежа по назначению"""
enum PaymentType {
DEPOSIT
REGISTRATION
WITHDRAWAL
}
type Permission {
"""Родительское разрешение"""
parent: String!
@@ -3352,6 +3466,22 @@ type PrivateAccount {
type: AccountType!
}
union PrivateAccountSearchData = Entrepreneur | Individual | Organization
type PrivateAccountSearchResult {
"""Данные найденного аккаунта"""
data: PrivateAccountSearchData!
"""Поля, в которых найдены совпадения"""
highlightedFields: [String!]
"""Оценка релевантности результата"""
score: Float
"""Тип аккаунта"""
type: String!
}
input ProhibitRequestInput {
"""Имя аккаунта кооператива"""
coopname: String!
@@ -3519,11 +3649,18 @@ type Query {
"""Получить список методов оплаты"""
getPaymentMethods(data: GetPaymentMethodsInput): PaymentMethodPaginationResult!
"""Получить список платежей"""
getPayments(data: GetPaymentsInput, options: PaginationInput): PaymentPaginationResult!
"""
Получить список платежей с возможностью фильтрации по типу, статусу и направлению.
"""
getPayments(data: PaymentFiltersInput, options: PaginationInput): PaginatedGatewayPaymentsPaginationResult!
"""Получить сводную публичную информацию о системе"""
getSystemInfo: SystemInfo!
"""
Поиск приватных данных аккаунтов по запросу. Поиск осуществляется по полям ФИО, ИНН, ОГРН, наименованию организации и другим приватным данным.
"""
searchPrivateAccounts(data: SearchPrivateAccountsInput!): [PrivateAccountSearchResult!]!
}
"""Вопрос повестки собрания с результатами голосования"""
@@ -4018,11 +4155,173 @@ input ReturnByAssetStatementSignedMetaDocumentInput {
version: String!
}
input ReturnByMoneyDecisionGenerateDocumentInput {
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Валюта"""
currency: String!
"""ID решения совета"""
decision_id: Float!
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Хэш платежа"""
payment_hash: String!
"""Количество средств к возврату"""
quantity: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input ReturnByMoneyGenerateDocumentInput {
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Валюта"""
currency: String!
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""ID платежного метода"""
method_id: String!
"""Хеш платежа для связи с withdraw"""
payment_hash: String!
"""Количество средств к возврату"""
quantity: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input ReturnByMoneySignedDocumentInput {
"""Хэш содержимого документа"""
doc_hash: String!
"""Общий хэш (doc_hash + meta_hash)"""
hash: String!
"""
Метаинформация для документа заявления на возврат паевого взноса денежными средствами
"""
meta: ReturnByMoneySignedMetaDocumentInput!
"""Хэш мета-данных"""
meta_hash: String!
"""Вектор подписей"""
signatures: [SignatureInfoInput!]!
"""Версия стандарта документа"""
version: String!
}
input ReturnByMoneySignedMetaDocumentInput {
"""Номер блока, на котором был создан документ"""
block_num: Int!
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String!
"""Валюта"""
currency: String!
"""Имя генератора, использованного для создания документа"""
generator: String!
"""Язык документа"""
lang: String!
"""Ссылки, связанные с документом"""
links: [String!]!
"""ID платежного метода"""
method_id: String!
"""Хеш платежа для связи с withdraw"""
payment_hash: String!
"""Количество средств к возврату"""
quantity: String!
"""ID документа в реестре"""
registry_id: Int!
"""Часовой пояс, в котором был создан документ"""
timezone: String!
"""Название документа"""
title: String!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String!
}
type SbpAccount {
"""Мобильный телефон получателя"""
phone: String!
}
input SearchPrivateAccountsInput {
"""Поисковый запрос для поиска приватных аккаунтов"""
query: String!
}
input SelectBranchGenerateDocumentInput {
"""Номер блока, на котором был создан документ"""
block_num: Int
+1 -2
View File
@@ -1,6 +1,6 @@
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerDefinition from '../src/docs/swaggerDef';
import fs from 'fs'
import fs from 'fs';
import path from 'path';
const specs = swaggerJsdoc({
@@ -8,5 +8,4 @@ const specs = swaggerJsdoc({
apis: ['src/docs/*.yml', 'src/**/*.ts'],
});
fs.writeFileSync(path.join(__dirname, '../src/docs/swagger.json'), JSON.stringify(specs, null, 2));
+1 -1
View File
@@ -2,7 +2,7 @@ spectaql:
targetDir: ./docs
introspection:
# schemaFile: ./schema.gql
url: 'http://localhost:2998/v1/graphql'
url: 'https://testnet.coopenomics.world/api/v1/graphql'
info:
title: 'API-Документация'
@@ -68,6 +68,18 @@ const envVarsSchema = z.object({
REDIS_PASSWORD: z.string(),
BLOCKCHAIN_RPC: z.string().min(1, { message: 'Не должно быть пустым' }),
CHAIN_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
// Параметры блокчейна
ROOT_SYMBOL: z.string().default('AXON'),
ROOT_GOVERN_SYMBOL: z.string().default('RUB'),
ROOT_PRECISION: z
.string()
.default('4')
.transform((val) => parseInt(val, 10)),
ROOT_GOVERN_PRECISION: z
.string()
.default('4')
.transform((val) => parseInt(val, 10)),
});
// Валидация переменных окружения
@@ -95,6 +107,10 @@ export default {
blockchain: {
url: envVars.data.BLOCKCHAIN_RPC,
id: envVars.data.CHAIN_ID,
root_symbol: envVars.data.ROOT_SYMBOL,
root_govern_symbol: envVars.data.ROOT_GOVERN_SYMBOL,
root_precision: envVars.data.ROOT_PRECISION,
root_govern_precision: envVars.data.ROOT_GOVERN_PRECISION,
},
mongoose: {
url: envVars.data.MONGODB_URL + (envVars.data.NODE_ENV === 'test' ? '-test' : ''),
@@ -1,7 +1,6 @@
export * as authController from './auth.controller';
export * as userController from './user.controller';
export * as documentController from './document.controller';
export * as orderController from './order.controller';
export * as coopController from './coop.controller';
export * as notifyController from './notify.controller';
export * as systemController from './system.controller';
@@ -1,61 +0,0 @@
import http from 'http-status';
import catchAsync from '../utils/catchAsync';
import { orderService } from '../services';
import { ICreateDeposit } from '../types';
import { Request, Response } from 'express';
import httpStatus from 'http-status';
import pick from '../utils/pick';
import logger from '../config/logger';
import { ProviderInteractor } from '~/domain/provider/provider.interactor';
import { nestApp } from '..';
export const catchIPN = catchAsync(async (req: Request, res: Response) => {
const providerName = req.params.provider;
logger.info(`Recieve new IPN for provider ${providerName}`, { body: req.body, source: 'catchIPN' });
const providerInteractor = nestApp.get(ProviderInteractor);
const provider = providerInteractor.getProvider(providerName);
// Обрабатываем IPN данные
await provider.handleIPN(req.body);
res.status(200).send();
});
export const createDeposit = catchAsync(async (req: any, res: Response) => {
const username = req.user.username;
const result = await orderService.createDeposit(username, req.body as ICreateDeposit);
res.status(http.CREATED).send(result);
});
export const createInitialPayment = catchAsync(async (req: any, res: Response) => {
const username = req.user.username;
const result = await orderService.createInitialOrder(username);
res.status(http.CREATED).send(result);
});
export const getOrders = catchAsync(async (req: Request, res: Response) => {
const filter = pick(req.query, ['username', 'id', 'order_num', 'status']);
const options = pick(req.query, ['sortBy', 'limit', 'page']);
const orders = await orderService.getOrders(filter, options);
res.status(httpStatus.OK).send(orders);
});
export const getMyOrders = catchAsync(async (req: Request, res: Response) => {
const filter = pick(req.query, ['status', 'id']);
const options = pick(req.query, ['sortBy', 'limit', 'page']);
filter.username = req.params.username;
const orders = await orderService.getOrders(filter, options);
res.status(httpStatus.OK).send(orders);
});
export const setStatus = catchAsync(async (req: Request, res: Response) => {
const { status, id } = req.body;
await orderService.setStatus(id, status);
res.status(httpStatus.OK).send();
});
@@ -17,6 +17,10 @@ import { AccountType } from '~/modules/account/enum/account-type.enum';
import { ORGANIZATION_REPOSITORY, OrganizationRepository } from '~/domain/common/repositories/organization.repository';
import { INDIVIDUAL_REPOSITORY, IndividualRepository } from '~/domain/common/repositories/individual.repository';
import { ENTREPRENEUR_REPOSITORY, EntrepreneurRepository } from '~/domain/common/repositories/entrepreneur.repository';
import {
SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
SearchPrivateAccountsRepository,
} from '~/domain/common/repositories/search-private-accounts.repository';
import { IndividualDomainEntity } from '~/domain/branch/entities/individual-domain.entity';
import { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity';
import { EntrepreneurDomainEntity } from '~/domain/branch/entities/entrepreneur-domain.entity';
@@ -24,6 +28,10 @@ import { ACCOUNT_BLOCKCHAIN_PORT, AccountBlockchainPort } from '../interfaces/ac
import { CANDIDATE_REPOSITORY, CandidateRepository } from '../repository/candidate.repository';
import { userStatus } from '~/types/user.types';
import { sha256 } from '~/utils/sha256';
import type {
SearchPrivateAccountsInputDomainInterface,
PrivateAccountSearchResultDomainInterface,
} from '~/domain/common/interfaces/search-private-accounts-domain.interface';
@Injectable()
export class AccountDomainInteractor {
@@ -32,6 +40,8 @@ export class AccountDomainInteractor {
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
@Inject(INDIVIDUAL_REPOSITORY) private readonly individualRepository: IndividualRepository,
@Inject(ENTREPRENEUR_REPOSITORY) private readonly entrepreneurRepository: EntrepreneurRepository,
@Inject(SEARCH_PRIVATE_ACCOUNTS_REPOSITORY)
private readonly searchPrivateAccountsRepository: SearchPrivateAccountsRepository,
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository
) {}
@@ -176,4 +186,21 @@ export class AccountDomainInteractor {
throw new HttpException(`Ошибка при регистрации в блокчейне: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Поиск приватных аккаунтов по запросу
* @param input Входные данные для поиска
* @returns Массив результатов поиска
*/
async searchPrivateAccounts(
input: SearchPrivateAccountsInputDomainInterface
): Promise<PrivateAccountSearchResultDomainInterface[]> {
this.logger.log(`Поиск приватных аккаунтов по запросу: "${input.query}"`);
const results = await this.searchPrivateAccountsRepository.searchPrivateAccounts(input);
this.logger.log(`Найдено ${results.length} приватных аккаунтов`);
return results;
}
}
@@ -242,7 +242,7 @@ export class BranchDomainInteractor {
async selectBranch(data: SelectBranchInputDomainInterface): Promise<boolean> {
// TODO move it to separate document domain service for validate
const document = await this.documentRepository.findByHash(data.document.hash);
const document = await this.documentRepository.findByHash(data.document.doc_hash);
if (!document) throw new BadRequestException('Документ не найден');
if (data.document.meta.registry_id != Cooperative.Registry.SelectBranchStatement.registry_id)
@@ -0,0 +1,28 @@
import type { IndividualDomainInterface } from './individual-domain.interface';
import type { EntrepreneurDomainInterface } from './entrepreneur-domain.interface';
import type { OrganizationDomainInterface } from './organization-domain.interface';
/**
* Интерфейс для входных данных поиска приватных аккаунтов
*/
export interface SearchPrivateAccountsInputDomainInterface {
query: string;
}
/**
* Тип данных результата поиска - union type всех возможных типов приватных данных
*/
export type PrivateAccountSearchDataDomainInterface =
| IndividualDomainInterface
| EntrepreneurDomainInterface
| OrganizationDomainInterface;
/**
* Интерфейс результата поиска приватных аккаунтов
*/
export interface PrivateAccountSearchResultDomainInterface {
type: 'individual' | 'entrepreneur' | 'organization';
data: PrivateAccountSearchDataDomainInterface;
score?: number;
highlightedFields?: string[];
}
@@ -0,0 +1,20 @@
import type {
SearchPrivateAccountsInputDomainInterface,
PrivateAccountSearchResultDomainInterface,
} from '../interfaces/search-private-accounts-domain.interface';
/**
* Доменный репозиторий для поиска приватных аккаунтов
*/
export interface SearchPrivateAccountsRepository {
/**
* Поиск приватных аккаунтов по запросу
* @param input Входные данные для поиска
* @returns Массив результатов поиска
*/
searchPrivateAccounts(
input: SearchPrivateAccountsInputDomainInterface
): Promise<PrivateAccountSearchResultDomainInterface[]>;
}
export const SEARCH_PRIVATE_ACCOUNTS_REPOSITORY = Symbol('SearchPrivateAccountsRepository');
@@ -5,9 +5,6 @@ import type { DocumentDomainEntity } from '../entity/document-domain.entity';
import type { PaginationResultDomainInterface } from '~/domain/common/interfaces/pagination.interface';
import type { DocumentPackageAggregateDomainInterface } from '../interfaces/document-package-aggregate-domain.interface';
import type { GetDocumentsInputDomainInterface } from '../interfaces/get-documents-input-domain.interface';
import { toDotNotation } from '~/utils/toDotNotation';
import { getActions } from '~/utils/getFetch';
import { SovietContract } from 'cooptypes';
import { DocumentPackageAggregator } from '../aggregators/document-package.aggregator';
import { DocumentAggregator } from '../aggregators/document.aggregator';
import type { DocumentAggregateDomainInterface } from '../interfaces/document-domain-aggregate.interface';
@@ -2,7 +2,6 @@
import { Module } from '@nestjs/common';
import { AccountDomainModule } from './account/account-domain.module';
import { ExtensionDomainModule } from './extension/extension-domain.module';
import { PaymentDomainModule } from './payment/payment.module';
import { PaymentMethodDomainModule } from './payment-method/payment-method-domain.module';
import { ProviderDomainModule } from './provider/provider.module';
import { SystemDomainModule } from './system/system-domain.module';
@@ -18,6 +17,8 @@ import { DesktopDomainModule } from './desktop/desktop-domain.module';
import { MeetDomainModule } from './meet/meet-domain.module';
import { ControllerWsMeetModule } from './meet/controllers-ws-meet.module';
import { UserCertificateDomainModule } from './user-certificate/user-certificate.module';
import { GatewayDomainModule } from './gateway/gateway-domain.module';
import { WalletDomainModule } from './wallet/wallet-domain.module';
@Module({
imports: [
@@ -27,7 +28,6 @@ import { UserCertificateDomainModule } from './user-certificate/user-certificate
AgreementDomainModule,
DesktopDomainModule,
ExtensionDomainModule,
PaymentDomainModule,
PaymentMethodDomainModule,
ProviderDomainModule,
SystemDomainModule,
@@ -39,6 +39,8 @@ import { UserCertificateDomainModule } from './user-certificate/user-certificate
MeetDomainModule,
ControllerWsMeetModule,
UserCertificateDomainModule,
GatewayDomainModule,
WalletDomainModule,
],
exports: [
AuthDomainModule,
@@ -47,7 +49,6 @@ import { UserCertificateDomainModule } from './user-certificate/user-certificate
AgreementDomainModule,
DesktopDomainModule,
ExtensionDomainModule,
PaymentDomainModule,
PaymentMethodDomainModule,
ProviderDomainModule,
SystemDomainModule,
@@ -58,6 +59,8 @@ import { UserCertificateDomainModule } from './user-certificate/user-certificate
CooplaceDomainModule,
MeetDomainModule,
UserCertificateDomainModule,
GatewayDomainModule,
WalletDomainModule,
],
})
export class DomainModule {}
@@ -0,0 +1,181 @@
import type { PaymentDomainInterface, PaymentDetailsDomainInterface } from '../interfaces/payment-domain.interface';
import { PaymentStatusEnum } from '../enums/payment-status.enum';
import { PaymentTypeEnum, PaymentDirectionEnum } from '../enums/payment-type.enum';
import type { UserCertificateDomainInterface } from '~/domain/user-certificate/interfaces/user-certificate-domain.interface';
import { IndividualCertificateDTO } from '~/modules/common/dto/individual-certificate.dto';
import { EntrepreneurCertificateDTO } from '~/modules/common/dto/entrepreneur-certificate.dto';
import { OrganizationCertificateDTO } from '~/modules/common/dto/organization-certificate.dto';
import { AccountType } from '~/modules/account/enum/account-type.enum';
/**
* Универсальная доменная сущность платежа
* Работает как для входящих, так и для исходящих платежей
*/
export class PaymentDomainEntity implements PaymentDomainInterface {
id?: string;
coopname: string;
username: string;
hash: string;
quantity: number;
symbol: string;
type: PaymentTypeEnum;
direction: PaymentDirectionEnum;
status: PaymentStatusEnum;
provider?: string;
payment_method_id?: string;
secret?: string;
message?: string;
memo?: string;
expired_at?: Date;
created_at: Date;
updated_at?: Date;
payment_details?: PaymentDetailsDomainInterface;
blockchain_data?: any;
statement?: any;
constructor(data: PaymentDomainInterface) {
this.id = data.id;
this.coopname = data.coopname;
this.username = data.username;
this.hash = data.hash;
this.quantity = data.quantity;
this.symbol = data.symbol;
this.type = data.type;
this.direction = data.direction;
this.status = data.status;
this.provider = data.provider;
this.payment_method_id = data.payment_method_id;
this.secret = data.secret;
this.message = data.message;
this.memo = data.memo;
this.expired_at = data.expired_at;
this.created_at = data.created_at;
this.updated_at = data.updated_at;
this.payment_details = data.payment_details;
this.blockchain_data = data.blockchain_data;
this.statement = data.statement;
}
/**
* Получить количество и символ в формате строки
*/
getFormattedAmount(): string {
return `${this.quantity} ${this.symbol}`;
}
/**
* Проверить, может ли статус быть изменен
*/
canChangeStatus(): boolean {
return (
this.status !== PaymentStatusEnum.COMPLETED &&
this.status !== PaymentStatusEnum.CANCELLED &&
this.status !== PaymentStatusEnum.REFUNDED
);
}
/**
* Получить человекочитаемый статус
*/
getStatusLabel(): string {
const statusLabels: Record<PaymentStatusEnum, string> = {
[PaymentStatusEnum.PENDING]: 'Ожидает обработки',
[PaymentStatusEnum.PROCESSING]: 'В процессе',
[PaymentStatusEnum.PAID]: 'Оплачен',
[PaymentStatusEnum.COMPLETED]: 'Завершен',
[PaymentStatusEnum.FAILED]: 'Ошибка',
[PaymentStatusEnum.EXPIRED]: 'Истек',
[PaymentStatusEnum.CANCELLED]: 'Отменен',
[PaymentStatusEnum.REFUNDED]: 'Возвращен',
};
return statusLabels[this.status] || this.status;
}
/**
* Получить человекочитаемый тип платежа
*/
getTypeLabel(): string {
const typeLabels: Record<PaymentTypeEnum, string> = {
[PaymentTypeEnum.REGISTRATION]: 'Регистрационный взнос',
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат взноса',
};
return typeLabels[this.type] || this.type;
}
/**
* Получить человекочитаемое направление платежа
*/
getDirectionLabel(): string {
const directionLabels: Record<PaymentDirectionEnum, string> = {
[PaymentDirectionEnum.INCOMING]: 'Входящий',
[PaymentDirectionEnum.OUTGOING]: 'Исходящий',
};
return directionLabels[this.direction] || this.direction;
}
/**
* Получить соответствующий хеш для типа платежа
*/
getSpecificHash(): { [key: string]: string } {
const hashValue = this.hash || '';
if (this.direction === PaymentDirectionEnum.OUTGOING) {
return { outcome_hash: hashValue };
} else {
return { income_hash: hashValue };
}
}
/**
* Преобразовать в DTO для GraphQL (без sensitive данных)
*/
toDTO(usernameCertificate?: UserCertificateDomainInterface | null): any {
// Вспомогательная функция для создания сертификата DTO
const createCertificateDTO = (
certificate: UserCertificateDomainInterface | null
): IndividualCertificateDTO | EntrepreneurCertificateDTO | OrganizationCertificateDTO | null => {
if (!certificate) return null;
switch (certificate.type) {
case AccountType.individual:
return new IndividualCertificateDTO(certificate);
case AccountType.entrepreneur:
return new EntrepreneurCertificateDTO(certificate);
case AccountType.organization:
return new OrganizationCertificateDTO(certificate);
default:
return null;
}
};
return {
id: this.id,
coopname: this.coopname,
username: this.username,
username_certificate: createCertificateDTO(usernameCertificate || null),
hash: this.hash,
...this.getSpecificHash(), // Добавляем конкретный хеш
quantity: this.quantity,
symbol: this.symbol,
type: this.type,
direction: this.direction,
status: this.status,
provider: this.provider,
payment_method_id: this.payment_method_id,
message: this.message,
memo: this.memo,
expired_at: this.expired_at,
created_at: this.created_at,
updated_at: this.updated_at,
payment_details: this.payment_details,
blockchain_data: this.blockchain_data,
statement: this.statement,
formatted_amount: this.getFormattedAmount(),
status_label: this.getStatusLabel(),
type_label: this.getTypeLabel(),
direction_label: this.getDirectionLabel(),
can_change_status: this.canChangeStatus(),
is_final: ![PaymentStatusEnum.PENDING, PaymentStatusEnum.PROCESSING].includes(this.status),
};
}
}
@@ -0,0 +1,53 @@
/**
* Единый enum для статусов платежей
* Покрывает все состояния входящих и исходящих платежей
*/
export enum PaymentStatusEnum {
// Начальные состояния
PENDING = 'pending',
// Процесс обработки
PROCESSING = 'processing',
PAID = 'paid',
// Завершенные состояния
COMPLETED = 'completed',
// Ошибочные состояния
FAILED = 'failed',
EXPIRED = 'expired',
// Отмененные состояния
CANCELLED = 'cancelled',
REFUNDED = 'refunded',
}
/**
* Человекочитаемые названия статусов
*/
export const PAYMENT_STATUS_LABELS: Record<PaymentStatusEnum, string> = {
[PaymentStatusEnum.PENDING]: 'Ожидает оплаты',
[PaymentStatusEnum.PROCESSING]: 'Обрабатывается',
[PaymentStatusEnum.PAID]: 'Оплачен',
[PaymentStatusEnum.COMPLETED]: 'Завершен',
[PaymentStatusEnum.FAILED]: 'Не удался',
[PaymentStatusEnum.EXPIRED]: 'Истек',
[PaymentStatusEnum.CANCELLED]: 'Отменен',
[PaymentStatusEnum.REFUNDED]: 'Возвращен',
};
/**
* Статусы, при которых платеж можно редактировать
*/
export const EDITABLE_PAYMENT_STATUSES = [PaymentStatusEnum.PENDING, PaymentStatusEnum.PROCESSING];
/**
* Финальные статусы платежей
*/
export const FINAL_PAYMENT_STATUSES = [
PaymentStatusEnum.COMPLETED,
PaymentStatusEnum.FAILED,
PaymentStatusEnum.EXPIRED,
PaymentStatusEnum.CANCELLED,
PaymentStatusEnum.REFUNDED,
];
@@ -0,0 +1,53 @@
/**
* Тип платежа по назначению
*/
export enum PaymentTypeEnum {
// Входящие платежи (от пользователей к кооперативу)
REGISTRATION = 'registration', // Регистрационный взнос
DEPOSIT = 'deposit', // Паевой взнос
WITHDRAWAL = 'withdrawal', // Возврат паевого взноса
}
/**
* Направление платежа
*/
export enum PaymentDirectionEnum {
INCOMING = 'incoming', // Входящий платеж
OUTGOING = 'outgoing', // Исходящий платеж
}
/**
* Человекочитаемые названия типов платежей
*/
export const PAYMENT_TYPE_LABELS: Record<PaymentTypeEnum, string> = {
[PaymentTypeEnum.REGISTRATION]: 'Вступительный и мин. паевой взносы',
[PaymentTypeEnum.DEPOSIT]: 'Паевой взнос',
[PaymentTypeEnum.WITHDRAWAL]: 'Возврат паевого взноса',
};
/**
* Человекочитаемые названия направлений платежей
*/
export const PAYMENT_DIRECTION_LABELS: Record<PaymentDirectionEnum, string> = {
[PaymentDirectionEnum.INCOMING]: 'Входящий',
[PaymentDirectionEnum.OUTGOING]: 'Исходящий',
};
/**
* Определяет направление платежа по его типу
*/
export function getPaymentDirection(type: PaymentTypeEnum): PaymentDirectionEnum {
const incomingTypes = [PaymentTypeEnum.REGISTRATION, PaymentTypeEnum.DEPOSIT];
return incomingTypes.includes(type) ? PaymentDirectionEnum.INCOMING : PaymentDirectionEnum.OUTGOING;
}
/**
* Входящие типы платежей
*/
export const INCOMING_PAYMENT_TYPES = [PaymentTypeEnum.REGISTRATION, PaymentTypeEnum.DEPOSIT];
/**
* Исходящие типы платежей
*/
export const OUTGOING_PAYMENT_TYPES = [PaymentTypeEnum.WITHDRAWAL];
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { GatewayInteractor } from './interactors/gateway.interactor';
import { GatewayExpiryCronService } from './gateway-expiry-cron.service';
import { InfrastructureModule } from '~/infrastructure/infrastructure.module';
import { AccountDomainModule } from '~/domain/account/account-domain.module';
import { ProviderDomainModule } from '~/domain/provider/provider.module';
import { SystemDomainModule } from '~/domain/system/system-domain.module';
@Module({
imports: [InfrastructureModule, AccountDomainModule, ProviderDomainModule, SystemDomainModule],
providers: [GatewayInteractor, GatewayExpiryCronService],
exports: [GatewayInteractor],
})
export class GatewayDomainModule {}
@@ -0,0 +1,21 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import cron from 'node-cron';
import { GatewayInteractor } from './interactors/gateway.interactor';
@Injectable()
export class GatewayExpiryCronService implements OnModuleInit {
private readonly logger = new Logger(GatewayExpiryCronService.name);
constructor(private readonly gatewayInteractor: GatewayInteractor) {}
onModuleInit() {
// Запуск задачи раз в 10 минут
cron.schedule('*/10 * * * *', async () => {
const count = await this.gatewayInteractor.expireOutdatedPayments();
if (count > 0) {
this.logger.log(`Истекло ${count} платежей (переведены в статус EXPIRED) через node-cron`);
}
});
this.logger.log('node-cron для expireOutdatedPayments запущен (каждые 10 минут)');
}
}
@@ -0,0 +1,521 @@
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import type { SetPaymentStatusInputDomainInterface } from '../interfaces/set-payment-status-domain-input.interface';
import type { InternalPaymentFiltersDomainInterface } from '../interfaces/payment-filters-domain.interface';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentDomainEntity } from '../entities/payment-domain.entity';
import { GatewayBlockchainPort, GATEWAY_BLOCKCHAIN_PORT } from '../ports/gateway-blockchain.port';
import { PaymentRepository, PAYMENT_REPOSITORY } from '../repositories/payment.repository';
import { PaymentStatusEnum } from '../enums/payment-status.enum';
import { PaymentDirectionEnum, PaymentTypeEnum } from '../enums/payment-type.enum';
import type { PaymentDomainInterface } from '../interfaces/payment-domain.interface';
import type { CreateInitialPaymentInputDomainInterface } from '../interfaces/create-initial-payment-input-domain.interface';
import type { CreateDepositPaymentInputDomainInterface } from '../interfaces/create-deposit-payment-input-domain.interface';
import type { CreateWithdrawPaymentInputDomainInterface } from '../interfaces/create-withdraw-payment-input-domain.interface';
import config from '~/config/config';
import { AccountDomainInteractor } from '~/domain/account/interactors/account.interactor';
import type { CompleteIncomeDomainInterface } from '../interfaces/complete-income-domain.interface';
import type { CompleteOutcomeDomainInterface } from '../interfaces/complete-outcome-domain.interface';
import { generateUniqueHash } from '~/utils/generate-hash.util';
import { ProviderInteractor } from '~/domain/provider/provider.interactor';
import { QuantityUtils } from '~/shared/utils/quantity.utils';
import { SystemDomainInteractor } from '~/domain/system/interactors/system.interactor';
import { AccountDomainService, ACCOUNT_DOMAIN_SERVICE } from '~/domain/account/services/account-domain.service';
import { AccountType } from '~/modules/account/enum/account-type.enum';
import Settings from '~/models/settings.model';
import { PaymentMethodRepository, PAYMENT_METHOD_REPOSITORY } from '~/domain/common/repositories/payment-method.repository';
import type { PaymentDetailsDomainInterface } from '../interfaces/payment-domain.interface';
/**
* Интерактор домена gateway для управления платежами (просмотр, изменение статуса и создание)
*
* Особенности работы с истечением платежей:
* - Все новые платежи (регистрация и депозит) автоматически получают срок истечения +24 часа
* - При создании нового платежа проверяется наличие активного платежа того же типа
* - Истекшие платежи автоматически обновляются в статус EXPIRED при создании новых платежей
* - Доступен отдельный метод expireOutdatedPayments() для периодической очистки
*/
@Injectable()
export class GatewayInteractor {
private readonly logger = new Logger(GatewayInteractor.name);
constructor(
@Inject(GATEWAY_BLOCKCHAIN_PORT)
private readonly gatewayBlockchainPort: GatewayBlockchainPort,
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepository: PaymentRepository,
private readonly accountDomainInteractor: AccountDomainInteractor,
private readonly providerInteractor: ProviderInteractor,
private readonly systemDomainInteractor: SystemDomainInteractor,
@Inject(ACCOUNT_DOMAIN_SERVICE)
private readonly accountDomainService: AccountDomainService,
@Inject(PAYMENT_METHOD_REPOSITORY)
private readonly paymentMethodRepository: PaymentMethodRepository
) {}
/**
* Создать дату истечения платежа (24 часа от текущего момента)
* @returns дата истечения
*/
private createPaymentExpirationDate(): Date {
return new Date(Date.now() + 24 * 60 * 60 * 1000);
}
/**
* Получить платежи с фильтрацией (универсальный метод с поддержкой секрета)
*/
async getPayments(
filters: InternalPaymentFiltersDomainInterface,
options: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<PaymentDomainEntity>> {
const result = await this.paymentRepository.getAllPayments(filters, options);
// Преобразуем результаты в доменные сущности
const items = result.items.map((item) => new PaymentDomainEntity(item));
return {
items,
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage,
};
}
/**
* Установить статус платежа (работает для входящих и исходящих платежей)
*/
async setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity> {
try {
const payment = await this.paymentRepository.findById(data.id);
if (!payment) {
throw new NotFoundException(`Не удалось найти платеж с ID ${data.id}`);
}
const paymentEntity = new PaymentDomainEntity(payment);
const statusEnum = data.status as unknown as PaymentStatusEnum;
// Проверяем можно ли изменить статус
if (!paymentEntity.canChangeStatus()) {
throw new Error(`Невозможно изменить статус платежа с текущим статусом: ${payment.status}`);
}
const result = await this.paymentRepository.setPaymentStatus(data.id, statusEnum);
if (!result) {
throw new NotFoundException(`Не удалось найти платеж с ID ${data.id}`);
}
// Обрабатываем платеж при статусе PAID
if (statusEnum === PaymentStatusEnum.PAID) {
if (result.direction === PaymentDirectionEnum.INCOMING) {
// Обработка входящих платежей
await this.processIncomingPayment(result);
} else if (result.direction === PaymentDirectionEnum.OUTGOING) {
// Обработка исходящих платежей
await this.processOutgoingPayment(result);
}
}
return new PaymentDomainEntity(result);
} catch (error: any) {
this.logger.error(`Не удалось обновить статус платежа: ${error.message}`);
throw new NotFoundException(`Не удалось найти платеж с ID ${data.id}`);
}
}
/**
* Выполнить процессинг платежа
*/
async executeIncomePayment(id: string, status: PaymentStatusEnum) {
const payment = await this.paymentRepository.findById(id);
if (!payment) {
this.logger.error(`Платеж с ID ${id} не найден`);
return;
}
if (status === PaymentStatusEnum.PAID && payment.status !== PaymentStatusEnum.COMPLETED) {
await this.processIncomingPayment(payment);
} else {
this.logger.warn(`Платеж с ID ${id} имеет статус ${status}, пропускаем обработку`);
}
}
/**
* Обработка входящих платежей
*/
private async processIncomingPayment(payment: PaymentDomainInterface) {
this.logger.log(`Обрабатываем входящий платеж ${payment.id}`);
try {
if (payment.type === PaymentTypeEnum.REGISTRATION) {
await this.accountDomainInteractor.registerBlockchainAccount(payment.username);
this.logger.log(`Новый пользователь зарегистрирован: ${payment.username}`);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
} else if (payment.type === PaymentTypeEnum.DEPOSIT) {
// Парсим quantity и форматируем для блокчейна с правильным precision
const blockchainFormattedQuantity = QuantityUtils.formatQuantityForBlockchain(payment.quantity, payment.symbol);
const completeIncomeData: CompleteIncomeDomainInterface = {
coopname: payment.coopname,
username: payment.username,
quantity: blockchainFormattedQuantity,
income_hash: payment.hash,
};
await this.gatewayBlockchainPort.completeIncome(completeIncomeData);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Пользователь ${payment.username} внес паевой взнос на сумму ${blockchainFormattedQuantity}`);
}
} catch (e: any) {
if (payment.id) {
await this.paymentRepository.update(payment.id, {
status: PaymentStatusEnum.FAILED,
message: e.message,
});
}
this.logger.error(`Ошибка обработки входящего платежа: ${payment.id}, сообщение: ${e.message}`, e);
}
}
/**
* Обработка исходящих платежей
*/
private async processOutgoingPayment(payment: PaymentDomainInterface) {
this.logger.log(`Обрабатываем исходящий платеж ${payment.id}`);
try {
const completeOutcomeData: CompleteOutcomeDomainInterface = {
coopname: payment.coopname,
outcome_hash: payment.hash,
};
await this.gatewayBlockchainPort.completeOutcome(completeOutcomeData);
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.COMPLETED });
}
this.logger.log(`Завершен исходящий платеж: ${payment.hash} для пользователя ${payment.username}`);
} catch (e: any) {
console.error(e);
if (payment.id) {
await this.paymentRepository.update(payment.id, {
status: PaymentStatusEnum.FAILED,
message: e.message,
});
}
this.logger.error(`Ошибка обработки исходящего платежа: ${payment.id}, сообщение: ${e.message}`, e);
}
}
/**
* Создать регистрационный платеж
*/
async createInitialPayment(data: CreateInitialPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
// Обновляем истекшие платежи перед созданием нового
await this.paymentRepository.expireOutdatedPayments();
// Получаем информацию о системе для извлечения сумм платежей
const systemInfo = await this.systemDomainInteractor.getInfo();
const cooperatorAccount = systemInfo.cooperator_account;
// Получаем информацию об аккаунте пользователя для определения типа
const userAccount = await this.accountDomainService.getAccount(data.username);
if (!userAccount.private_account) {
throw new Error(`Не удалось определить тип аккаунта для пользователя ${data.username}`);
}
// Определяем сумму платежа на основе типа аккаунта
let registrationAmount: string;
if (userAccount.private_account.type === AccountType.organization) {
registrationAmount = cooperatorAccount.org_registration;
} else {
// Для individual и entrepreneur используем обычную сумму registration
registrationAmount = cooperatorAccount.registration;
}
// Парсим количество и символ из строки суммы
const { amount, symbol } = QuantityUtils.parseQuantityString(registrationAmount);
// Валидируем символ
QuantityUtils.validateSymbol(symbol);
// Проверяем, нет ли уже активного платежа этого типа для пользователя с такой же суммой
const existingPayment = await this.paymentRepository.findActivePendingPayment(
data.username,
PaymentTypeEnum.REGISTRATION,
amount,
symbol
);
if (existingPayment) {
this.logger.log(
`Найден существующий активный регистрационный платеж для пользователя ${data.username} на сумму ${amount} ${symbol}`
);
return new PaymentDomainEntity(existingPayment);
}
// Получаем настройки для определения провайдера
const settings = await Settings.getSettings();
const provider = settings.provider.name;
const secret = generateUniqueHash();
const hash = generateUniqueHash();
const now = new Date();
const expiredAt = this.createPaymentExpirationDate();
const paymentData: PaymentDomainInterface = {
id: '', // будет установлен в репозитории
coopname: config.coopname,
username: data.username,
quantity: amount,
symbol: symbol,
secret,
type: PaymentTypeEnum.REGISTRATION,
direction: PaymentDirectionEnum.INCOMING,
provider,
status: PaymentStatusEnum.PENDING,
memo: `Вступительный и минимальный паевой взносы №${hash.slice(0, 8)}`,
payment_method_id: undefined,
expired_at: expiredAt,
created_at: now,
updated_at: now,
hash,
};
// Создаем платеж в базе данных
const createdPayment = await this.paymentRepository.create(paymentData);
if (!createdPayment.id) {
throw new Error('Не удалось создать платеж - отсутствует ID');
}
// Получаем провайдер и создаем платежные детали
const paymentProvider = this.providerInteractor.getProvider(provider);
if (!paymentProvider) {
throw new Error(`Провайдер ${provider} не найден`);
}
try {
const paymentDetails = await paymentProvider.createPayment(hash);
// Обновляем платеж с деталями
await this.paymentRepository.update(createdPayment.id, {
payment_details: paymentDetails,
});
// Возвращаем обновленный платеж
const updatedPayment = await this.paymentRepository.findById(createdPayment.id);
if (!updatedPayment) {
throw new Error(`Не удалось найти обновленный платеж с ID ${createdPayment.id}`);
}
return new PaymentDomainEntity(updatedPayment);
} catch (error: any) {
this.logger.error(`Ошибка при создании платежных деталей: ${error.message}`);
// Если не удалось создать детали платежа, помечаем платеж как неудачный
await this.paymentRepository.update(createdPayment.id, {
status: PaymentStatusEnum.FAILED,
message: `Ошибка создания платежа: ${error.message}`,
});
throw error;
}
}
/**
* Создать депозитный платеж
*/
async createDeposit(data: CreateDepositPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
// Обновляем истекшие платежи перед созданием нового
await this.paymentRepository.expireOutdatedPayments();
// Валидируем символ перед созданием платежа
QuantityUtils.validateSymbol(data.symbol);
// Проверяем, нет ли уже активного платежа этого типа для пользователя с такой же суммой
const existingPayment = await this.paymentRepository.findActivePendingPayment(
data.username,
PaymentTypeEnum.DEPOSIT,
data.quantity,
data.symbol
);
if (existingPayment) {
this.logger.log(
`Найден существующий активный депозитный платеж для пользователя ${data.username} на сумму ${data.quantity} ${data.symbol}`
);
return new PaymentDomainEntity(existingPayment);
}
// Получаем настройки для определения провайдера
const settings = await Settings.getSettings();
const provider = settings.provider.name;
const secret = generateUniqueHash();
const hash = generateUniqueHash();
const now = new Date();
const expiredAt = this.createPaymentExpirationDate();
const paymentData: PaymentDomainInterface = {
id: '', // будет установлен в репозитории
coopname: config.coopname,
username: data.username,
quantity: data.quantity,
symbol: data.symbol,
type: PaymentTypeEnum.DEPOSIT,
direction: PaymentDirectionEnum.INCOMING,
provider: provider,
status: PaymentStatusEnum.PENDING,
memo: `Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк" №${hash.slice(0, 8)}`,
secret,
payment_method_id: undefined,
expired_at: expiredAt,
created_at: now,
updated_at: now,
hash,
};
// Создаем платеж в базе данных
const createdPayment = await this.paymentRepository.create(paymentData);
if (!createdPayment.id) {
throw new Error('Не удалось создать платеж - отсутствует ID');
}
// Получаем провайдер и создаем платежные детали
const paymentProvider = this.providerInteractor.getProvider(provider);
if (!paymentProvider) {
throw new Error(`Провайдер ${provider} не найден`);
}
try {
const paymentDetails = await paymentProvider.createPayment(hash);
// Обновляем платеж с деталями
await this.paymentRepository.update(createdPayment.id, {
payment_details: paymentDetails,
});
// Возвращаем обновленный платеж
const updatedPayment = await this.paymentRepository.findById(createdPayment.id);
if (!updatedPayment) {
throw new Error(`Не удалось найти обновленный платеж с ID ${createdPayment.id}`);
}
return new PaymentDomainEntity(updatedPayment);
} catch (error: any) {
this.logger.error(`Ошибка при создании платежных деталей: ${error.message}`);
// Если не удалось создать детали платежа, помечаем платеж как неудачный
await this.paymentRepository.update(createdPayment.id, {
status: PaymentStatusEnum.FAILED,
message: `Ошибка создания платежа: ${error.message}`,
});
throw error;
}
}
/**
* Создать исходящий платеж (withdraw)
*/
async createWithdraw(data: CreateWithdrawPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
// Обновляем истекшие платежи перед созданием нового
await this.paymentRepository.expireOutdatedPayments();
// Валидируем символ перед созданием платежа
QuantityUtils.validateSymbol(data.symbol);
// Проверяем, нет ли уже платежа с таким же хешем - это ошибка
const existingPayment = await this.paymentRepository.findByHash(data.payment_hash);
if (existingPayment) {
throw new Error(`Платеж с хешем ${data.payment_hash} уже существует. Возможно, заявка была создана ранее.`);
}
// Получаем настройки для определения провайдера
const settings = await Settings.getSettings();
const provider = settings.provider.name;
const now = new Date();
const expiredAt = this.createPaymentExpirationDate();
// Получаем данные платежного метода из generator repository
let paymentDetails: PaymentDetailsDomainInterface;
try {
const paymentMethod = await this.paymentMethodRepository.get({
username: data.username,
method_id: data.method_id,
});
// Формируем payment_details на основе данных платежного метода
paymentDetails = {
data: paymentMethod.data,
// Для исходящих платежей комиссия обычно не применяется
amount_plus_fee: data.quantity.toString(),
amount_without_fee: data.quantity.toString(),
fee_amount: '0',
fee_percent: 0,
fact_fee_percent: 0,
tolerance_percent: 0,
};
} catch (error: any) {
throw new BadRequestException(
`Платежный метод ${data.method_id} для пользователя ${data.username} не найден. Невозможно создать исходящий платеж.`
);
}
const paymentData: PaymentDomainInterface = {
id: '', // будет установлен в репозитории
coopname: data.coopname,
username: data.username,
quantity: data.quantity,
symbol: data.symbol,
type: PaymentTypeEnum.WITHDRAWAL,
direction: PaymentDirectionEnum.OUTGOING,
provider: provider,
status: PaymentStatusEnum.PENDING,
memo: `Возврат паевого взноса №${data.payment_hash.slice(0, 8)}`,
secret: generateUniqueHash(),
payment_method_id: data.method_id,
payment_details: paymentDetails,
expired_at: expiredAt,
created_at: now,
updated_at: now,
hash: data.payment_hash, // Используем переданный payment_hash
};
// Создаем платеж в базе данных
const createdPayment = await this.paymentRepository.create(paymentData);
if (!createdPayment.id) {
throw new Error('Не удалось создать платеж - отсутствует ID');
}
this.logger.log(
`Создан исходящий платеж ${data.payment_hash} для пользователя ${data.username} на сумму ${data.quantity} ${data.symbol} с платежным методом ${data.method_id}`
);
return new PaymentDomainEntity(createdPayment);
}
/**
* Обновить все истекшие платежи в статус EXPIRED
* Может вызываться периодически или по требованию
* @returns количество обновленных платежей
*/
async expireOutdatedPayments(): Promise<number> {
try {
const expiredCount = await this.paymentRepository.expireOutdatedPayments();
if (expiredCount > 0) {
this.logger.log(`Истекло ${expiredCount} платежей (переведены в статус EXPIRED)`);
}
return expiredCount;
} catch (error: any) {
this.logger.error(`Ошибка при истечении платежей: ${error.message}`, error);
return 0;
}
}
}
@@ -0,0 +1,6 @@
export interface CompleteIncomeDomainInterface {
coopname: string;
username: string;
quantity: string;
income_hash: string;
}
@@ -0,0 +1,4 @@
export interface CompleteOutcomeDomainInterface {
coopname: string;
outcome_hash: string;
}
@@ -1,4 +1,5 @@
export interface CreateDepositPaymentInputDomainInterface {
quantity: string;
quantity: number;
username: string;
symbol: string;
}
@@ -0,0 +1,14 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Доменный интерфейс для создания исходящего платежа
*/
export interface CreateOutgoingPaymentInputDomainInterface {
coopname: string;
username: string;
quantity: string;
symbol: string;
method_id: string;
memo?: string;
statement: ISignedDocumentDomainInterface;
}
@@ -0,0 +1,14 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Доменный интерфейс для создания исходящего платежа (withdraw)
*/
export interface CreateWithdrawPaymentInputDomainInterface {
coopname: string;
username: string;
quantity: number;
symbol: string;
method_id: string;
statement: ISignedDocumentDomainInterface;
payment_hash: string; // Хеш платежа для связи с withdraw
}
@@ -0,0 +1,5 @@
export interface DeclineOutcomeDomainInterface {
coopname: string;
outcome_hash: string;
reason: string;
}
@@ -1,4 +1,4 @@
export enum PaymentTypeDomainInterface {
export enum IncomingPaymentTypeDomainInterface {
REGISTRATION = 'registration',
DEPOSIT = 'deposit',
}
@@ -0,0 +1,89 @@
import type { PaymentStatusEnum } from '../enums/payment-status.enum';
import type { PaymentTypeEnum, PaymentDirectionEnum } from '../enums/payment-type.enum';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Детали платежа
*/
export interface PaymentDetailsDomainInterface {
/** Строка с данными платежа (QR-код, токен, реквизиты и т.д.) */
data: any;
/** Сумма платежа с учетом комиссии */
amount_plus_fee: string;
/** Сумма платежа без учета комиссии */
amount_without_fee: string;
/** Размер комиссии в абсолютных значениях */
fee_amount: string;
/** Процент комиссии */
fee_percent: number;
/** Фактический процент комиссии */
fact_fee_percent: number;
/** Допустимый процент отклонения */
tolerance_percent: number;
}
/**
* Унифицированный доменный интерфейс платежа
* Подходит для всех типов платежей (входящих и исходящих)
*/
export interface PaymentDomainInterface {
/** Уникальный идентификатор платежа */
id?: string;
/** Хеш платежа для идентификации в блокчейне */
hash: string;
/** Название кооператива */
coopname: string;
/** Имя пользователя */
username: string;
/** Количество/сумма платежа */
quantity: number;
/** Символ валюты */
symbol: string;
/** Тип платежа (назначение) */
type: PaymentTypeEnum;
/** Направление платежа (входящий/исходящий) */
direction: PaymentDirectionEnum;
/** Статус платежа */
status: PaymentStatusEnum;
/** Провайдер платежа */
provider?: string;
/** ID платежного метода */
payment_method_id?: string;
/** Секрет для авторизации платежа */
secret?: string;
/** Сообщение об ошибке или дополнительная информация */
message?: string;
/** Дополнительная информация */
memo?: string;
/** Дата истечения платежа */
expired_at?: Date;
/** Дата создания */
created_at: Date;
/** Дата последнего обновления */
updated_at?: Date;
/** Детали платежа */
payment_details?: PaymentDetailsDomainInterface;
/** Данные из блокчейна */
blockchain_data?: any;
/** Подписанный документ заявления (для исходящих платежей) */
statement?: ISignedDocumentDomainInterface;
}
@@ -0,0 +1,49 @@
import type { PaymentStatusEnum } from '../enums/payment-status.enum';
import type { PaymentTypeEnum, PaymentDirectionEnum } from '../enums/payment-type.enum';
/**
* Доменный интерфейс для фильтрации платежей (внешний API)
* Не содержит sensitive данные как secret
*/
export interface PaymentFiltersDomainInterface {
coopname?: string;
username?: string;
status?: PaymentStatusEnum;
type?: PaymentTypeEnum;
direction?: PaymentDirectionEnum;
provider?: string;
hash?: string;
}
/**
* Расширенный доменный интерфейс для внутренней фильтрации платежей
* Содержит все поля включая sensitive данные
*/
export interface InternalPaymentFiltersDomainInterface extends PaymentFiltersDomainInterface {
secret?: string;
}
/**
* Доменный интерфейс для создания платежа
*/
export interface CreatePaymentDomainInterface {
coopname: string;
username: string;
quantity: string;
symbol: string;
type: PaymentTypeEnum;
provider?: string;
payment_method_id?: string;
memo?: string;
expired_at?: Date;
statement?: any;
}
/**
* Доменный интерфейс для обновления статуса платежа
*/
export interface UpdatePaymentStatusDomainInterface {
id: string;
status: PaymentStatusEnum;
message?: string;
}
@@ -0,0 +1,6 @@
import type { PaymentStatusEnum } from '../enums/payment-status.enum';
export interface SetPaymentStatusInputDomainInterface {
id: string;
status: PaymentStatusEnum;
}
@@ -0,0 +1,28 @@
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
import type { GatewayContract } from 'cooptypes';
import type { CompleteIncomeDomainInterface } from '../interfaces/complete-income-domain.interface';
import type { CompleteOutcomeDomainInterface } from '../interfaces/complete-outcome-domain.interface';
import type { DeclineOutcomeDomainInterface } from '../interfaces/decline-outcome-domain.interface';
/**
* Порт для взаимодействия с gateway блокчейном
* Только управление уже созданными outcomes - создание происходит автоматически другими контрактами
*/
export interface GatewayBlockchainPort {
// Завершение исходящего платежа
completeOutcome(data: CompleteOutcomeDomainInterface): Promise<TransactionResult>;
// Отклонение исходящего платежа
declineOutcome(data: DeclineOutcomeDomainInterface): Promise<TransactionResult>;
// Получение исходящих платежей из блокчейна
getOutcomes(coopname: string): Promise<GatewayContract.Tables.Outcomes.IOutcome[]>;
// Получение конкретного исходящего платежа
getOutcome(coopname: string, outcome_hash: string): Promise<GatewayContract.Tables.Outcomes.IOutcome | null>;
// Завершение входящего платежа (депозита)
completeIncome(data: CompleteIncomeDomainInterface): Promise<TransactionResult>;
}
export const GATEWAY_BLOCKCHAIN_PORT = Symbol('GATEWAY_BLOCKCHAIN_PORT');
@@ -0,0 +1,50 @@
import type { PaymentStatusEnum } from '../enums/payment-status.enum';
import type { PaymentDomainInterface } from '../interfaces/payment-domain.interface';
import type { InternalPaymentFiltersDomainInterface } from '../interfaces/payment-filters-domain.interface';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentTypeEnum } from '../enums/payment-type.enum';
export interface PaymentRepository {
// Общие методы
findById(id: string): Promise<PaymentDomainInterface | null>;
findByHash(hash: string): Promise<PaymentDomainInterface | null>;
create(data: PaymentDomainInterface): Promise<PaymentDomainInterface>;
update(id: string, data: Partial<PaymentDomainInterface>): Promise<PaymentDomainInterface | null>;
// Методы для работы с платежами
updatePaymentWithBlockchainData(hash: string, blockchain_data: any): Promise<PaymentDomainInterface | null>;
setPaymentStatus(id: string, status: PaymentStatusEnum): Promise<PaymentDomainInterface | null>;
// Универсальные методы
getAllPayments(
filters: InternalPaymentFiltersDomainInterface,
options: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<PaymentDomainInterface>>;
// Новые методы для работы с истекшими платежами
/**
* Обновить все истекшие платежи в статус EXPIRED
* @returns количество обновленных платежей
*/
expireOutdatedPayments(): Promise<number>;
/**
* Найти активный платеж того же типа для пользователя
* @param username имя пользователя
* @param type тип платежа
* @param quantity количество
* @param symbol символ валюты
* @returns активный платеж или null
*/
findActivePendingPayment(
username: string,
type: PaymentTypeEnum,
quantity: number,
symbol: string
): Promise<PaymentDomainInterface | null>;
}
export const PAYMENT_REPOSITORY = Symbol('PaymentRepository');
@@ -146,7 +146,6 @@ export class MeetDomainInteractor {
// Получаем данные processed из репозитория
const processedMeetEntity = await this.meetProcessedRepository.findByHash(processingData.hash);
// Создаем агрегат с данными из репозитория и блокчейна
return new MeetAggregate(preMeet, processingData, processedMeetEntity);
})
@@ -16,6 +16,9 @@ import http from 'http-status';
import { PublicKey, Signature } from '@wharfkit/antelope';
import { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import { Classes } from '@coopenomics/sdk';
import { GatewayInteractor } from '~/domain/gateway/interactors/gateway.interactor';
import type { CreateInitialPaymentInputDomainInterface } from '~/domain/gateway/interfaces/create-initial-payment-input-domain.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
@Injectable()
export class ParticipantDomainInteractor {
@@ -24,7 +27,8 @@ export class ParticipantDomainInteractor {
constructor(
private readonly documentDomainService: DocumentDomainService,
private readonly accountDomainService: AccountDomainService,
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
private readonly gatewayInteractor: GatewayInteractor
) {}
async generateParticipantApplication(
@@ -167,4 +171,11 @@ export class ParticipantDomainInteractor {
return this.accountDomainService.getAccount(data.username);
}
/**
* Создать регистрационный платеж
*/
async createInitialPayment(data: CreateInitialPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.createInitialPayment(data);
}
}
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { DocumentDomainModule } from '../document/document.module';
import { ParticipantDomainInteractor } from './interactors/participant-domain.interactor';
import { AccountDomainModule } from '../account/account-domain.module';
import { GatewayDomainModule } from '../gateway/gateway-domain.module';
@Module({
imports: [AccountDomainModule, DocumentDomainModule],
imports: [AccountDomainModule, DocumentDomainModule, GatewayDomainModule],
providers: [ParticipantDomainInteractor],
exports: [ParticipantDomainInteractor],
})
@@ -1,6 +1,7 @@
export interface GetPaymentMethodDomainInterface {
username: string;
method_type: 'bank_transfer' | 'spb';
method_type?: 'bank_transfer' | 'spb';
method_id?: string;
is_default?: boolean;
block_num?: number;
}
@@ -1,113 +0,0 @@
import type { PaymentDetailsDTO } from '~/modules/payment/dto/payment-details.dto';
import { PaymentStatus } from '../interfaces/payment-status-domain.interface';
import { type IOrder } from '~/types';
import type { PaymentDTO } from '~/modules/payment/dto/payment.dto';
import type { PaymentTypeDomainInterface } from '../interfaces/payment-type-domain.interface';
import type { AccountDomainEntity } from '~/domain/account/entities/account-domain.entity';
import { AccountDTO } from '~/modules/account/dto/account.dto';
export class PaymentDomainEntity {
id?: string;
orderNum?: number;
secret!: string;
creator!: string;
status: PaymentStatus = PaymentStatus.PENDING;
type!: PaymentTypeDomainInterface;
provider!: string;
message?: string;
username!: string;
quantity!: string;
symbol!: string;
account!: AccountDomainEntity;
details?: {
data: any;
amount_plus_fee: string;
amount_without_fee: string;
fee_amount: string;
fee_percent: number;
fact_fee_percent: number;
tolerance_percent: number;
};
expiredAt!: Date;
userId!: string;
createdAt!: Date;
updatedAt!: Date;
constructor(orderData: IOrder, account?: AccountDomainEntity);
constructor(data: Partial<IOrder & { created_at?: Date; updated_at?: Date; account?: AccountDomainEntity }>);
constructor(
orderOrData: IOrder | Partial<IOrder & { created_at?: Date; updated_at?: Date; account?: AccountDomainEntity }>,
accountParam?: AccountDomainEntity
) {
// Распознаем форму вызова конструктора
const data = orderOrData as Partial<IOrder & { created_at?: Date; updated_at?: Date; account?: AccountDomainEntity }>;
const account = accountParam || data.account;
Object.assign(this, {
id: data.id,
orderNum: data.order_num,
secret: data.secret,
creator: data.creator,
status: data.status as unknown as PaymentStatus,
type: data.type,
provider: data.provider,
message: data.message,
username: data.username,
quantity: data.quantity,
symbol: data.symbol,
details: data.details,
account: account,
expiredAt:
data.expired_at ||
(() => {
const now = new Date();
now.setDate(now.getDate() + 1);
return now;
})(),
user: data.user,
createdAt: data.created_at ?? new Date(),
updatedAt: data.updated_at ?? new Date(),
});
}
toDTO(): PaymentDTO {
if (!this.id) {
throw new Error('ID is required for PaymentDTO');
}
if (this.orderNum === undefined || this.orderNum === null) {
throw new Error('Order number is required for PaymentDTO');
}
if (!this.account) {
throw new Error('Account is required for PaymentDTO');
}
const details: PaymentDetailsDTO | undefined = this.details
? {
data: this.details.data,
amount_plus_fee: this.details.amount_plus_fee,
amount_without_fee: this.details.amount_without_fee,
fee_amount: this.details.fee_amount,
fee_percent: this.details.fee_percent,
fact_fee_percent: this.details.fact_fee_percent,
tolerance_percent: this.details.tolerance_percent,
}
: undefined;
return {
id: this.id,
blockchain_id: this.orderNum,
provider: this.provider,
type: this.type,
details: details || ({} as PaymentDetailsDTO),
status: this.status,
message: this.message || '',
amount: parseFloat(this.quantity),
symbol: this.symbol,
username: this.username,
account: new AccountDTO(this.account),
expired_at: this.expiredAt,
created_at: this.createdAt,
updated_at: this.updatedAt,
};
}
}
@@ -1,160 +0,0 @@
// domain/payment/interactors/PaymentInteractor.ts
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Order } from '~/models';
import { userService, blockchainService } from '~/services';
import { userStatus } from '~/types/user.types';
import { type IOrder } from '~/types/order.types';
import { PaymentTypeDomainInterface } from '../interfaces/payment-type-domain.interface';
import { PaymentStatus } from '../interfaces/payment-status-domain.interface';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import type { GetPaymentsInputDomainInterface } from '../interfaces/get-payments-input-domain.interface';
import type { CreateInitialPaymentInputDomainInterface } from '../interfaces/create-initial-input-domain.interface';
import type { CreateDepositPaymentInputDomainInterface } from '../interfaces/create-deposit-input-domain.interface';
import type { SetPaymentStatusInputDomainInterface } from '../interfaces/set-payment-status-domain-input.interface';
import { PaymentDomainService } from '../services/payment-domain.service';
import type { PaginationResultLegacy } from '~/types/pagination.types';
import { PaymentDomainEntity } from '../entity/payment.entity';
import { AccountDomainInteractor } from '~/domain/account/interactors/account.interactor';
@Injectable()
export class PaymentInteractor {
constructor(
private readonly paymentDomainService: PaymentDomainService,
private readonly accountDomainInteractor: AccountDomainInteractor
) {}
private readonly logger = new Logger(PaymentInteractor.name);
async getPayments(
data: GetPaymentsInputDomainInterface,
options: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<PaymentDomainEntity>> {
const pre_result: PaginationResultLegacy<IOrder> = await this.paymentDomainService.getOrders(data, options);
const items = await Promise.all(
pre_result.results.map(async (el) => {
try {
// Получаем аккаунт для каждого платежа
const account = await this.accountDomainInteractor.getAccount(el.username);
// Создаем объект платежа, просто передавая IOrder и account
return new PaymentDomainEntity(el, account);
} catch (error: any) {
this.logger.warn(
`Не удалось получить данные аккаунта для платежа ${el.id}, username: ${el.username}: ${error.message}`
);
return null;
}
})
);
// Удаляем null из массива
const validItems = items.filter(Boolean) as PaymentDomainEntity[];
const result: PaginationResultDomainInterface<PaymentDomainEntity> = {
items: validItems,
totalCount: pre_result.totalResults,
totalPages: pre_result.totalPages,
currentPage: pre_result.page,
};
return result;
}
async createInitialPayment(data: CreateInitialPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
try {
// Сначала получаем аккаунт
const account = await this.accountDomainInteractor.getAccount(data.username);
// Затем создаем платеж
const result = await this.paymentDomainService.createInitialOrder(data);
// Создаем и возвращаем сущность платежа, просто передавая IOrder и account
return new PaymentDomainEntity(result, account);
} catch (error: any) {
this.logger.error(`Не удалось создать регистрационный платеж: ${error.message}`);
throw new NotFoundException(`Не удалось найти аккаунт пользователя ${data.username} или создать платеж`);
}
}
async createDeposit(data: CreateDepositPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
try {
// Сначала получаем аккаунт
const account = await this.accountDomainInteractor.getAccount(data.username);
// Затем создаем платеж
const result = await this.paymentDomainService.createDeposit(data);
// Создаем и возвращаем сущность платежа, просто передавая IOrder и account
return new PaymentDomainEntity(result, account);
} catch (error: any) {
this.logger.error(`Не удалось создать паевой платеж: ${error.message}`);
throw new NotFoundException(`Не удалось найти аккаунт пользователя ${data.username} или создать платеж`);
}
}
async setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity> {
try {
// Получаем обновленный платеж
const result = await this.paymentDomainService.setStatus(data);
// Получаем аккаунт
const account = await this.accountDomainInteractor.getAccount(result.username);
// Создаем и возвращаем сущность платежа, просто передавая IOrder и account
return new PaymentDomainEntity(result, account);
} catch (error: any) {
this.logger.error(`Не удалось обновить статус платежа: ${error.message}`);
throw new NotFoundException(`Не удалось найти платеж с ID ${data.id} или аккаунт пользователя`);
}
}
async execute(id: string, status: string) {
const order = await Order.findById(id);
if (!order) {
this.logger.error(`Order with id ${id} not found`);
return;
}
switch (status) {
case PaymentStatus.PAID:
if (order.status !== 'completed') await this.processOrder(order);
break;
case PaymentStatus.FAILED:
this.logger.warn(`Payment for order ${id} failed`);
break;
default:
this.logger.log(`Status ${status} for order ${id} does not require processing`);
}
}
private async processOrder(order: IOrder) {
this.logger.log(`Processing blockchain data for order ${order.id}`);
try {
if (order.type === PaymentTypeDomainInterface.REGISTRATION) {
// Используем новый интерактор для регистрации в блокчейне
await this.accountDomainInteractor.registerBlockchainAccount(order.username);
this.logger.log('New user registered:', { username: order.username });
// Обновляем статус заказа
await Order.updateOne({ _id: order.id }, { status: PaymentStatus.COMPLETED });
} else if (order.type === PaymentTypeDomainInterface.DEPOSIT) {
await blockchainService.completeDeposit(order);
// Обновляем статус заказа
await Order.updateOne({ _id: order.id }, { status: PaymentStatus.COMPLETED });
this.logger.log(`User ${order.username} made a share contribution of ${order.quantity}`);
}
} catch (e: any) {
await Order.updateOne({ _id: order.id }, { status: PaymentStatus.FAILED, message: e.message });
this.logger.error(`Error processing blockchain transaction for order: ${order.id} with message: ${e.message}`, e);
}
}
}
@@ -1,6 +0,0 @@
export interface GetPaymentsInputDomainInterface {
username?: string;
id?: string;
blockchain_id?: string;
status?: string;
}
@@ -1,8 +0,0 @@
export enum PaymentStatus {
PENDING = 'pending',
PAID = 'paid',
COMPLETED = 'completed',
FAILED = 'failed',
EXPIRED = 'expired',
REFUNDED = 'refunded',
}
@@ -1,6 +0,0 @@
import type { PaymentStatus } from './payment-status-domain.interface';
export interface SetPaymentStatusInputDomainInterface {
id: string;
status: PaymentStatus;
}
@@ -1,13 +0,0 @@
// domain/payment/payment.module.ts
import { Module } from '@nestjs/common';
import { PaymentInteractor } from './interactors/payment.interactor';
import { PaymentDomainService } from './services/payment-domain.service';
import { AccountDomainModule } from '../account/account-domain.module';
@Module({
imports: [AccountDomainModule],
providers: [PaymentInteractor, PaymentDomainService],
exports: [PaymentInteractor, PaymentDomainService],
})
export class PaymentDomainModule {}
@@ -1,205 +0,0 @@
import { Order } from '../../../models/index';
import mongoose, { type ObjectId } from 'mongoose';
import { getUserByUsername } from '../../../services/user.service';
import logger from '../../../config/logger';
import { generator } from '../../../services/document.service';
import { orderStatus, type IOrder } from '../../../types/order.types';
import ApiError from '../../../utils/ApiError';
import httpStatus from 'http-status';
import { redisPublisher } from '../../../services/redis.service';
import crypto from 'crypto';
import Settings from '../../../models/settings.model';
import { blockchainService, userService } from '../../../services';
import config from '../../../config/config';
import { userStatus } from '../../../types/user.types';
import { Injectable } from '@nestjs/common';
import { PaymentTypeDomainInterface } from '../interfaces/payment-type-domain.interface';
import { ProviderDomainService } from '~/domain/provider/provider-domain.service';
import type { PaginationResultLegacy } from '~/types/pagination.types';
import type { CreateDepositPaymentInputDomainInterface } from '../interfaces/create-deposit-input-domain.interface';
import type { CreateInitialPaymentInputDomainInterface } from '../interfaces/create-initial-input-domain.interface';
import type { SetPaymentStatusInputDomainInterface } from '../interfaces/set-payment-status-domain-input.interface';
import { sha256 } from '~/utils/sha256';
@Injectable()
export class PaymentDomainService {
constructor(private readonly providerDomainService: ProviderDomainService) {}
async createOrder(
username: string,
providerName: string,
type: PaymentTypeDomainInterface,
amount: string
): Promise<IOrder> {
const cooperative = await generator.constructCooperative(config.coopname);
if (!cooperative) throw new Error('Кооператив не найден');
const session = await mongoose.startSession();
const [, symbol] = amount.split(' ');
let result;
try {
await session.withTransaction(async () => {
const user = await getUserByUsername(username);
let db_order;
if (type === PaymentTypeDomainInterface.REGISTRATION && user.initial_order) {
db_order = await Order.findOne({ _id: user.initial_order }); //add expiration
}
if (!db_order) {
db_order = new Order({
creator: config.coopname,
secret: this.generateOrderSecret(),
status: orderStatus.pending,
type: type,
provider: providerName,
username,
quantity: amount,
symbol: symbol,
user: user._id as unknown as ObjectId,
});
user.initial_order = db_order._id;
await db_order.save({ session });
await user.save({ session });
}
const secret = db_order.secret;
const provider = this.providerDomainService.getProvider(providerName);
const paymentDetails = await provider.createPayment(
amount,
symbol,
type === 'deposit'
? `Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк" №${db_order._id.toString().slice(0, 8)}`
: `Вступительный и минимальный паевый взносы №${db_order._id.toString().slice(0, 8)}`,
db_order.order_num as number,
secret
);
db_order.details = paymentDetails;
await db_order.save({ session });
logger.info('Order created', { providerName, type, username, amount, source: 'createDeposit' });
result = db_order;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
logger.error(`Error happen on createOrder process: ${e.message}`);
throw e;
}
return result;
}
async createDeposit(data: CreateDepositPaymentInputDomainInterface): Promise<IOrder> {
const cooperative = await generator.constructCooperative(config.coopname);
if (!cooperative) throw new Error('Кооператив не найден');
const [, symbol_initial] = cooperative.initial.split(' ');
const [amount, symbol] = data.quantity.split(' ');
const quantity = `${parseFloat(amount).toFixed(4)} ${symbol}`;
if (symbol_initial != symbol) throw new ApiError(httpStatus.BAD_REQUEST, 'Неверный символ для платежа');
const provider = (await Settings.getSettings()).provider.name;
return this.createOrder(data.username, provider, PaymentTypeDomainInterface.DEPOSIT, quantity);
}
async createInitialOrder(data: CreateInitialPaymentInputDomainInterface): Promise<IOrder> {
const cooperative = await generator.constructCooperative(config.coopname);
if (!cooperative) throw new Error('Кооператив не найден');
const user = await getUserByUsername(data.username);
let amount = '';
if (user.type === 'individual' || user.type === 'entrepreneur') {
amount = cooperative.registration;
} else {
amount = cooperative.org_registration;
}
const provider = (await Settings.getSettings()).provider.name;
return this.createOrder(data.username, provider, PaymentTypeDomainInterface.REGISTRATION, amount);
}
checkPaymentSymbol(incomeSymbol: string, extectedSymbol: string) {
if (incomeSymbol != extectedSymbol) return { status: 'error', message: `${incomeSymbol} != expectedSymbol` };
else return { status: 'success', message: '' };
}
checkPaymentAmount(incomeAmount: string, expectedAmount: string, tolerancePercentage: number) {
//погрешность возникает при округлении суммы платежа на стороне провайдера при расчете им процентов комиссии
const tolerance = parseFloat(expectedAmount) * (tolerancePercentage / 100); // Абсолютное значение погрешности
if (parseFloat(incomeAmount) < parseFloat(expectedAmount) - tolerance) {
return {
status: 'error',
message: `Недостаточно средств, поступило: ${incomeAmount}, ожидается: ${expectedAmount}`,
};
}
return {
status: 'success',
message: '',
};
}
generateOrderSecret(length = 48): string {
return crypto.randomBytes(length).toString('hex'); // Генерирует случайный секрет в виде hex-строки
}
getAmountPlusFee(amount: number, fee: number): number {
if (fee < 0 || fee >= 100) {
throw new Error('Fee must be between 0 and 100.');
}
return amount / ((100 - fee) / 100);
}
async setStatus(data: SetPaymentStatusInputDomainInterface): Promise<IOrder> {
const order = await Order.findById(data.id);
if (!order) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Ордер не найден');
}
if (data.status == 'refunded') {
await blockchainService.cancelOrder({
coopname: config.coopname,
income_hash: sha256(order.order_num as number),
reason: '',
});
if (order.type === 'registration') {
const user = await userService.getUserByUsername(order.username);
user.status = userStatus['100_Refunded'];
user.save();
}
}
//обновляем статус
order.status = orderStatus[data.status];
await order.save();
// Отправляем обновление через Redis, чтобы обработчик мог выполнить действия
redisPublisher.publish(`${config.coopname}:orderStatusUpdate`, JSON.stringify({ id: data.id, status: data.status }));
logger.info(`Статус ордера ${data.id} обновлен до ${data.status}`, { source: 'setStatus' });
return order;
}
async getOrders(filter: any = {}, options: any = {}): Promise<PaginationResultLegacy<IOrder>> {
const orders = await Order.paginate(filter, options);
return orders;
}
}
@@ -0,0 +1,113 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { Cooperative } from 'cooptypes';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import { WalletBlockchainPort, WALLET_BLOCKCHAIN_PORT } from '../ports/wallet-blockchain.port';
import type { CreateWithdrawInputDomainInterface } from '../interfaces/create-withdraw-input-domain.interface';
import { GatewayInteractor } from '~/domain/gateway/interactors/gateway.interactor';
import type { CreateDepositPaymentInputDomainInterface } from '~/domain/gateway/interfaces/create-deposit-payment-input-domain.interface';
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
/**
* Интерактор домена wallet для управления паевыми взносами, их возвратами, и генерацией документов
*/
@Injectable()
export class WalletDomainInteractor {
private readonly logger = new Logger(WalletDomainInteractor.name);
constructor(
private readonly documentDomainService: DocumentDomainService,
@Inject(WALLET_BLOCKCHAIN_PORT)
private readonly walletBlockchainPort: WalletBlockchainPort,
private readonly gatewayInteractor: GatewayInteractor
) {}
/**
* Создать депозитный платеж
*/
async createDepositPayment(data: CreateDepositPaymentInputDomainInterface): Promise<PaymentDomainEntity> {
return await this.gatewayInteractor.createDeposit(data);
}
/**
* Генерация документа заявления на возврат паевого взноса (900)
*/
async generateReturnByMoneyStatementDocument(
data: Cooperative.Registry.ReturnByMoney.Action,
options: Cooperative.Document.IGenerationOptions
): Promise<DocumentDomainEntity> {
// Устанавливаем registry_id для документа заявления
data.registry_id = Cooperative.Registry.ReturnByMoney.registry_id;
return await this.documentDomainService.generateDocument({ data, options });
}
/**
* Генерация документа решения совета о возврате паевого взноса (901)
*/
async generateReturnByMoneyDecisionDocument(
data: Cooperative.Registry.ReturnByMoneyDecision.Action,
options: Cooperative.Document.IGenerationOptions
): Promise<DocumentDomainEntity> {
// Устанавливаем registry_id для документа решения
data.registry_id = Cooperative.Registry.ReturnByMoneyDecision.registry_id;
return await this.documentDomainService.generateDocument({ data, options });
}
/**
* Создание заявки на вывод средств (createWithdraw)
* Данный метод создает платеж в gateway и withdraw в blockchain
*/
async createWithdraw(data: CreateWithdrawInputDomainInterface): Promise<{ withdraw_hash: string }> {
// Используем payment_hash из параметров вместо генерации нового
const withdraw_hash = data.payment_hash;
let createdPayment: PaymentDomainEntity | null = null;
try {
// 1. Создаем исходящий платеж в gateway для отслеживания
createdPayment = await this.gatewayInteractor.createWithdraw({
coopname: data.coopname,
username: data.username,
quantity: data.quantity,
symbol: data.symbol,
method_id: data.method_id,
statement: data.statement,
payment_hash: data.payment_hash,
});
// 2. Создаем withdraw в wallet контракте
// wallet контракт автоматически создаст outcome в gateway контракте
await this.walletBlockchainPort.createWithdraw({
coopname: data.coopname,
username: data.username,
withdraw_hash,
quantity: `${data.quantity} ${data.symbol}`,
statement: data.statement,
});
this.logger.log(`Создан withdraw в wallet: ${withdraw_hash}, outcome будет создан автоматически в gateway`);
return { withdraw_hash };
} catch (error: any) {
this.logger.error(`Ошибка при создании withdraw: ${error.message}`, error);
// Если платеж был создан, но произошла ошибка при создании withdraw в блокчейне
if (createdPayment?.id) {
try {
// Обновляем статус платежа на FAILED с сообщением об ошибке
await this.gatewayInteractor.setPaymentStatus({
id: createdPayment.id,
status: PaymentStatusEnum.FAILED,
});
this.logger.log(`Платеж ${createdPayment.id} помечен как FAILED из-за ошибки создания withdraw`);
} catch (updateError: any) {
this.logger.error(`Ошибка при обновлении статуса платежа: ${updateError.message}`, updateError);
}
}
throw error;
}
}
}
@@ -0,0 +1,14 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Доменный интерфейс для создания вывода средств (withdraw)
*/
export interface CreateWithdrawInputDomainInterface {
coopname: string;
username: string;
quantity: number;
symbol: string;
method_id: string;
statement: ISignedDocumentDomainInterface;
payment_hash: string; // Хеш платежа, генерируется на фронтенде
}
@@ -0,0 +1,42 @@
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
import type { WalletContract } from 'cooptypes';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Доменные интерфейсы для входных данных wallet блокчейн адаптера
*/
export interface CreateWithdrawDomainInterface {
coopname: string;
username: string;
withdraw_hash: string;
quantity: string;
statement: ISignedDocumentDomainInterface;
}
export interface GenerateReturnStatementDomainInterface {
coopname: string;
username: string;
quantity: string;
symbol: string;
method_id: string;
memo?: string;
}
/**
* Порт для взаимодействия с wallet блокчейном
*/
export interface WalletBlockchainPort {
// Создание заявки на вывод средств в wallet контракте
createWithdraw(data: CreateWithdrawDomainInterface): Promise<TransactionResult>;
// Генерация заявления на возврат паевого взноса
generateReturnStatement(data: GenerateReturnStatementDomainInterface): Promise<ISignedDocumentDomainInterface>;
// Получение данных из wallet контракта
getWithdraw(coopname: string, withdraw_hash: string): Promise<WalletContract.Tables.Withdraws.IWithdraws | null>;
// Получение всех withdrawals для кооператива
getWithdraws(coopname: string): Promise<WalletContract.Tables.Withdraws.IWithdraws[]>;
}
export const WALLET_BLOCKCHAIN_PORT = Symbol('WALLET_BLOCKCHAIN_PORT');
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { WalletDomainInteractor } from './interactors/wallet.interactor';
import { DocumentDomainModule } from '~/domain/document/document.module';
import { InfrastructureModule } from '~/infrastructure/infrastructure.module';
import { GatewayDomainModule } from '../gateway/gateway-domain.module';
/**
* Доменный модуль Wallet
*/
@Module({
imports: [DocumentDomainModule, InfrastructureModule, GatewayDomainModule],
providers: [WalletDomainInteractor],
exports: [WalletDomainInteractor],
})
export class WalletDomainModule {}
@@ -50,7 +50,11 @@ export class MeetTrackerService {
// Основная функция проверки собраний
async checkMeets(): Promise<void> {
try {
this.logger.info('Проверка собраний');
// Подтягиваем актуальный конфиг из базы
const repo = await this.extensionRepository.findByName(this.extensionName);
if (repo) {
this.pluginConfig = repo;
}
// Получаем все собрания из блокчейна через порт
const meets = await this.meetPort.getMeets({ coopname: config.coopname }, undefined);
@@ -141,6 +145,8 @@ export class MeetTrackerService {
if (closedMeetIds.includes(meetID)) {
continue;
}
// Удаляем все старые собрания с тем же id перед добавлением нового
this.pluginConfig.config.trackedMeets = this.pluginConfig.config.trackedMeets.filter((tm) => tm.id !== meetID);
// Создаем новое отслеживаемое собрание
const newTrackedMeet: TrackedMeet = {
id: meetID,
@@ -209,6 +215,15 @@ export class MeetTrackerService {
if (statusChanged) {
this.logger.info(`Изменение статуса собрания ${meetHash} (№${meetID}): ${oldStatus} -> ${extendedStatus}`);
if (
oldStatus === ExtendedMeetStatus.ONRESTART &&
extendedStatus === ExtendedMeetStatus.WAITING_FOR_OPENING &&
!trackedMeet.notifications.restartNotification
) {
await this.notificationSender.sendRestartNotification(trackedMeet);
trackedMeet.notifications.restartNotification = true;
}
// При переходе с created на waitingForOpening отправляем начальное уведомление
if (
oldStatus === ExtendedMeetStatus.CREATED &&
@@ -1,7 +1,7 @@
import type { PaymentDetails } from '../../types';
import { generator } from '../../services/document.service';
import { getAmountPlusFee } from '../../services/order.service';
import Joi from 'joi';
import { getAmountPlusFee } from '~/shared/utils/payments';
import { PaymentProvider } from '../../services/payment/paymentProvider';
import { nestApp } from '~/index';
import { ProviderInteractor } from '~/domain/provider/provider.interactor';
@@ -16,6 +16,9 @@ import type { ExtensionDomainEntity } from '~/domain/extension/entities/extensio
import { z } from 'zod';
import config from '~/config/config';
import type { Cooperative } from 'cooptypes';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { TypeOrmPaymentRepository } from '~/infrastructure/database/typeorm/repositories/typeorm-payment.repository';
import { QuantityUtils } from '~/shared/utils/quantity.utils';
// Дефолтные параметры конфигурации
export const defaultConfig = {};
@@ -32,6 +35,7 @@ export interface ILog {}
export class QrPayPlugin extends PaymentProvider {
constructor(
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository,
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository,
private readonly logger: WinstonLoggerService
) {
super();
@@ -60,18 +64,22 @@ export class QrPayPlugin extends PaymentProvider {
this.logger.log(`Платежный провайдер ${this.name} успешно зарегистрирован.`);
}
public async createPayment(
amount: string,
symbol: string,
description: string,
order_num: number,
secret: string
): Promise<PaymentDetails> {
public async createPayment(hash: string): Promise<PaymentDetails> {
// Получаем данные платежа по hash
const payment = await this.paymentRepository.findByHash(hash);
if (!payment) {
throw new Error(`Платеж с hash ${hash} не найден`);
}
const amount = payment.quantity;
const symbol = payment.symbol;
// eslint-disable-next-line prettier/prettier
const cooperative = await generator.constructCooperative(config.coopname);
const amount_plus_fee = getAmountPlusFee(parseFloat(amount), this.fee_percent).toFixed(2);
const fee_amount = (parseFloat(amount_plus_fee) - parseFloat(amount)).toFixed(2);
const fact_fee_percent = Math.round((parseFloat(fee_amount) / parseFloat(amount)) * 100 * 100) / 100;
const amount_plus_fee = getAmountPlusFee(amount, this.fee_percent).toFixed(2);
const fee_amount = (parseFloat(amount_plus_fee) - amount).toFixed(2);
const fact_fee_percent = Math.round((parseFloat(fee_amount) / amount) * 100 * 100) / 100;
const paymentMethod = (await generator.get('paymentMethod', {
username: config.coopname,
@@ -81,16 +89,18 @@ export class QrPayPlugin extends PaymentProvider {
const bankAccount = paymentMethod.data as Cooperative.Payments.IBankAccount;
const description = payment.memo || `Платеж для ${payment.username}`;
const invoice = `ST00012|Name=${cooperative?.full_name}|PersonalAcc=${bankAccount.account_number}|BankName=${
bankAccount.bank_name
}|BIC=${bankAccount.details.bik}|CorrespAcc=${bankAccount.details.corr}|Sum=${parseInt(
amount
amount_plus_fee
)}00|Purpose=${description}. Без НДС.|PayeeINN=${cooperative?.details.inn}|KPP=${cooperative?.details.kpp}`;
const result: PaymentDetails = {
data: invoice,
amount_plus_fee: `${amount_plus_fee} ${symbol}`,
amount_without_fee: amount,
amount_without_fee: `${amount.toFixed(2)} ${symbol}`,
fee_amount: `${fee_amount} ${symbol}`,
fee_percent: this.fee_percent,
fact_fee_percent,
@@ -109,6 +119,10 @@ export class QrPayPlugin extends PaymentProvider {
provide: EXTENSION_REPOSITORY, // токен для инъекции
useClass: TypeOrmExtensionDomainRepository, // Реализация для интерфейса
},
{
provide: PAYMENT_REPOSITORY,
useClass: TypeOrmPaymentRepository,
},
], // Регистрируем PowerupPlugin как провайдер
exports: [QrPayPlugin], // Экспортируем его для доступа в других модулях
})
@@ -1,22 +1,25 @@
import { PollingProvider } from '../../services/payment/polling/pollingProvider';
import { Order } from '../../models';
import type { PaymentDetails } from '../../types';
import { generator } from '../../services/document.service';
import { redisPublisher } from '../../services/redis.service';
import { PaymentState } from '../../models/paymentState.model';
import axios from 'axios';
import { checkPaymentAmount, checkPaymentSymbol, getAmountPlusFee } from '../../services/order.service';
import { orderStatus } from '../../types/order.types';
import { checkPaymentAmount, checkPaymentSymbol, getAmountPlusFee } from '~/shared/utils/payments';
import config from '../../config/config';
import { Inject, Module } from '@nestjs/common';
import {
EXTENSION_REPOSITORY,
type ExtensionDomainRepository,
} from '~/domain/extension/repositories/extension-domain.repository';
import { TypeOrmExtensionDomainRepository } from '~/infrastructure/database/typeorm/repositories/typeorm-extension.repository';
import { WinstonLoggerService } from '~/modules/logger/logger-app.service';
import type { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { z } from 'zod';
import type { Cooperative } from 'cooptypes';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import { TypeOrmPaymentRepository } from '~/infrastructure/database/typeorm/repositories/typeorm-payment.repository';
import { PaymentDirectionEnum } from '~/domain/gateway/enums/payment-type.enum';
// Дефолтные параметры конфигурации
export const defaultConfig = {};
@@ -75,6 +78,7 @@ export interface ILog {}
export class SberpollPlugin extends PollingProvider {
constructor(
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository,
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository,
private readonly logger: WinstonLoggerService
) {
super();
@@ -100,18 +104,22 @@ export class SberpollPlugin extends PollingProvider {
this.logger.info(`Инициализация ${this.name} с конфигурацией`, this.plugin);
}
public async createPayment(
amount: string,
symbol: string,
description: string,
order_num: number,
secret: string
): Promise<PaymentDetails> {
public async createPayment(hash: string): Promise<PaymentDetails> {
// Получаем данные платежа по hash
const payment = await this.paymentRepository.findByHash(hash);
if (!payment) {
throw new Error(`Платеж с hash ${hash} не найден`);
}
const amount = payment.quantity;
const symbol = payment.symbol;
// eslint-disable-next-line prettier/prettier
const cooperative = await generator.constructCooperative(config.coopname);
const amount_plus_fee = getAmountPlusFee(parseFloat(amount), this.fee_percent).toFixed(2);
const fee_amount = (parseFloat(amount_plus_fee) - parseFloat(amount)).toFixed(2);
const fact_fee_percent = Math.round((parseFloat(fee_amount) / parseFloat(amount)) * 100 * 100) / 100;
const amount_plus_fee = getAmountPlusFee(amount, this.fee_percent).toFixed(2);
const fee_amount = (parseFloat(amount_plus_fee) - amount).toFixed(2);
const fact_fee_percent = Math.round((parseFloat(fee_amount) / amount) * 100 * 100) / 100;
const paymentMethod = (await generator.get('paymentMethod', {
username: config.coopname,
@@ -121,16 +129,18 @@ export class SberpollPlugin extends PollingProvider {
const bankAccount = paymentMethod.data as Cooperative.Payments.IBankAccount;
const description = payment.memo || `Платеж для ${payment.username}`;
const invoice = `ST00012|Name=${cooperative?.full_name}|PersonalAcc=${bankAccount.account_number}|BankName=${
bankAccount.bank_name
}|BIC=${bankAccount.details.bik}|CorrespAcc=${bankAccount.details.corr}|Sum=${parseInt(
amount
amount_plus_fee
)}00|Purpose=${description}. Без НДС.|PayeeINN=${cooperative?.details.inn}|KPP=${cooperative?.details.kpp}`;
const result: PaymentDetails = {
data: invoice,
amount_plus_fee: `${amount_plus_fee} ${symbol}`,
amount_without_fee: amount,
amount_without_fee: `${amount} ${symbol}`,
fee_amount: `${fee_amount} ${symbol}`,
fee_percent: this.fee_percent,
fact_fee_percent,
@@ -213,61 +223,88 @@ export class SberpollPlugin extends PollingProvider {
const transactions = data.transactions || [];
for (const transaction of transactions) {
// Извлечение номера заказа
// Извлечение номера заказа из назначения платежа
const orderNumber = this.extractOrderNumber(transaction.paymentPurpose);
if (!orderNumber) {
this.logger.warn(
`Не найден номер заказа в строке назначения платежа: ${transaction.paymentPurpose} транзакции ${transaction.id}`
);
break; //прерываем цикл - в платеже не указан номер заказа
continue; // Переходим к следующей транзакции
}
this.logger.info(`Обработка транзакции ${transaction.id} с номером заказа ${orderNumber}`);
//TODO здесь нужно искать по ЧАСТИ _id а не по всему. Т.к. полного соответствия не будет.
const order = await Order.findOne({ _id: orderNumber });
// Ищем платеж по memo, который содержит номер заказа
// или по другому подходящему полю
const payments = await this.paymentRepository.getAllPayments(
{
direction: PaymentDirectionEnum.INCOMING,
status: PaymentStatusEnum.PENDING,
},
{ limit: 100, page: 1, sortOrder: 'ASC' }
);
if (!order) {
this.logger.warn(`Не найден заказ с номером ${orderNumber} по транзакции ${transaction.id}`);
break;
// Ищем платеж, у которого в memo или id содержится номер заказа
const payment = payments.items.find(
(p) => p.memo?.includes(orderNumber) || p.id?.includes(orderNumber) || p.hash?.includes(orderNumber)
);
if (!payment) {
this.logger.warn(`Не найден платеж с номером заказа ${orderNumber} по транзакции ${transaction.id}`);
continue; // Переходим к следующей транзакции
}
const [, symbol] = order.quantity.split(' ');
const symbol = payment.symbol;
const symbol_check = checkPaymentSymbol(transaction.amountRub.currencyName, symbol);
if (symbol_check.status === 'error') {
this.logger.warn(symbol_check.message);
order.status = orderStatus.failed;
order.message = symbol_check.message;
await order.save();
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: order.id, status: 'failed' })
);
break;
if (payment.id) {
await this.paymentRepository.update(payment.id, {
status: PaymentStatusEnum.FAILED,
message: symbol_check.message,
});
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.FAILED })
);
}
continue; // Переходим к следующей транзакции
}
//ордер найден, проверяем сумму платежа
const amount_check = checkPaymentAmount(transaction.amountRub.amount, order.quantity, this.tolerance_percent);
// Платеж найден, проверяем сумму платежа
const amount_check = checkPaymentAmount(
parseFloat(transaction.amountRub.amount),
payment.quantity,
this.tolerance_percent
);
if (amount_check.status === 'error') {
this.logger.warn(amount_check.message);
order.status = orderStatus.failed;
order.message = amount_check.message;
await order.save();
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: order.id, status: 'failed' })
);
break;
if (payment.id) {
await this.paymentRepository.update(payment.id, {
status: PaymentStatusEnum.FAILED,
message: amount_check.message,
});
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.FAILED })
);
}
continue; // Переходим к следующей транзакции
}
//отмечаем ордер оплаченным если все проверки пройдены
order.status = orderStatus.paid;
await order.save();
// Отмечаем платеж оплаченным если все проверки пройдены
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.PAID });
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.PAID })
);
}
redisPublisher.publish(`${config.coopname}:orderStatusUpdate`, JSON.stringify({ id: order.id, status: 'paid' }));
this.logger.info(`Платеж ${payment.id} успешно обработан и отмечен как оплаченный`);
}
// Обновление состояния после обработки текущей страницы
@@ -294,7 +331,17 @@ export class SberpollPlugin extends PollingProvider {
}
@Module({
providers: [SberpollPlugin], // Регистрируем SberpollPlugin как провайдер
providers: [
SberpollPlugin,
{
provide: EXTENSION_REPOSITORY,
useClass: TypeOrmExtensionDomainRepository,
},
{
provide: PAYMENT_REPOSITORY,
useClass: TypeOrmPaymentRepository,
},
], // Регистрируем SberpollPlugin как провайдер
exports: [SberpollPlugin], // Экспортируем его для доступа в других модулях
})
export class SberpollPluginModule {
@@ -1,54 +1,53 @@
import { Module, Inject } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { YooCheckout } from '@a2seven/yoo-checkout';
import { Order } from '../../models';
import { IPNProvider } from '../../services/payment/ipn/ipnProvider';
import type { PaymentDetails } from '../../types';
import IPN from '../../models/ipn.model';
import { checkPaymentAmount, checkPaymentSymbol, getAmountPlusFee } from '../../services/order.service';
import Settings from '../../models/settings.model';
import config from '../../config/config';
import { redisPublisher } from '../../services/redis.service';
import Joi from 'joi';
import { z } from 'zod';
import { nestApp } from '~/index';
import config from '~/config/config';
import { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { ProviderInteractor } from '~/domain/provider/provider.interactor';
import { Inject, Module } from '@nestjs/common';
import { IPNProvider } from '~/services/payment/ipn/ipnProvider';
import { WinstonLoggerService } from '~/modules/logger/logger-app.service';
import { TypeOrmPaymentRepository } from '~/infrastructure/database/typeorm/repositories/typeorm-payment.repository';
import { PaymentEntity } from '~/infrastructure/database/typeorm/entities/payment.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentDirectionEnum } from '~/domain/gateway/enums/payment-type.enum';
import IPN from '~/models/ipn.model';
import Settings from '~/models/settings.model';
import type { PaymentDetails } from '~/types/order.types';
import { PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
import { redisPublisher } from '~/services/redis.service';
import {
EXTENSION_REPOSITORY,
type ExtensionDomainRepository,
} from '~/domain/extension/repositories/extension-domain.repository';
import { WinstonLoggerService } from '~/modules/logger/logger-app.service';
import type { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { z } from 'zod';
import { TypeOrmExtensionDomainRepository } from '~/infrastructure/database/typeorm/repositories/typeorm-extension.repository';
import { checkPaymentAmount, checkPaymentSymbol, getAmountPlusFee } from '~/shared/utils/payments';
// Дефолтные параметры конфигурации
export const defaultConfig = {};
export const Schema = z.object({
client: z.string(),
secret: z.string(),
});
export const defaultConfig = {
client: '',
secret: '',
};
interface IIpnRequest {
event: string;
object: {
/**
* Unknown Property
*/
[x: string]: unknown;
amount: {
/**
* Unknown Property
*/
[x: string]: unknown;
currency: string;
value: string;
};
authorization_details?: {
/**
* Unknown Property
*/
[x: string]: unknown;
auth_code?: string;
rrn?: string;
three_d_secure?: {
/**
* Unknown Property
*/
[x: string]: unknown;
applied?: boolean;
};
@@ -58,30 +57,18 @@ interface IIpnRequest {
expires_at?: string;
id: string;
income_amount: {
/**
* Unknown Property
*/
[x: string]: unknown;
currency: string;
value: string;
};
metadata: {
secret: string;
/**
* Unknown Property
*/
[x: string]: unknown;
};
paid: boolean;
payment_method: {
/**
* Unknown Property
*/
[x: string]: unknown;
card?: {
/**
* Unknown Property
*/
[x: string]: unknown;
card_type?: string;
expiry_month?: string;
@@ -98,9 +85,6 @@ interface IIpnRequest {
};
refundable?: boolean;
refunded_amount: {
/**
* Unknown Property
*/
[x: string]: unknown;
currency: string;
value: string;
@@ -111,9 +95,6 @@ interface IIpnRequest {
type: string;
}
export const Schema = z.object({});
// Интерфейс для параметров конфигурации плагина
export type IConfig = z.infer<typeof Schema>;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@@ -122,6 +103,7 @@ export interface ILog {}
export class YookassaPlugin extends IPNProvider {
constructor(
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository,
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: TypeOrmPaymentRepository,
private readonly logger: WinstonLoggerService
) {
super();
@@ -151,7 +133,7 @@ export class YookassaPlugin extends IPNProvider {
public fee_percent = 3.5; ///%
public async handleIPN(request: IIpnRequest): Promise<void> {
const { event, object } = request;
const { event } = request;
const exist = (await IPN.findOne({ 'data.object.id': request.object.id }))?.data as IIpnRequest;
@@ -159,62 +141,93 @@ export class YookassaPlugin extends IPNProvider {
await IPN.create({ provider: 'yookassa', data: request });
const { secret } = request.object.metadata;
const order = await Order.findOne({ secret });
const payments = await this.paymentRepository.getAllPayments(
{
secret,
direction: PaymentDirectionEnum.INCOMING, // Ищем только входящие платежи
},
{ limit: 1, page: 1, sortOrder: 'DESC' }
);
const payment = payments.items[0];
if (order) {
this.logger.info('Order found', { source: 'handleIPN', id: order.id });
if (payment) {
this.logger.info('Payment found', { source: 'handleIPN', id: payment.id });
if (event === 'payment.succeeded') {
const [, symbol] = order.quantity.split(' ');
const symbol = payment.symbol;
const symbol_result = checkPaymentSymbol(request.object.income_amount.currency, symbol);
if (symbol_result.status == 'error') {
this.logger.warn('Payment symbol verification failed', {
source: 'handleIPN',
id: order.id,
id: payment.id,
message: symbol_result.message,
});
await Order.updateOne({ _id: order.id }, { status: 'failed', message: symbol_result.message });
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: order.id, status: 'failed' })
);
if (payment.id) {
await this.paymentRepository.update(payment.id, {
status: PaymentStatusEnum.FAILED,
message: symbol_result.message,
});
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.FAILED })
);
}
return;
}
const result = checkPaymentAmount(request.object.income_amount.value, order.quantity, this.tolerance_percent);
const result = checkPaymentAmount(
parseFloat(request.object.income_amount.value),
payment.quantity,
this.tolerance_percent
);
if (result.status === 'success') {
// Обработка успешного платежа
this.logger.info('Payment amount verified, updating order status to paid', {
this.logger.info('Payment amount verified, updating payment status to paid', {
source: 'handleIPN',
id: order.id,
id: payment.id,
});
await Order.updateOne({ _id: order.id }, { status: 'paid' });
redisPublisher.publish(`${config.coopname}:orderStatusUpdate`, JSON.stringify({ id: order.id, status: 'paid' }));
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.PAID });
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.PAID })
);
}
} else {
// Обработка неудачного платежа
this.logger.warn('Payment amount verification failed', {
source: 'handleIPN',
id: order.id,
id: payment.id,
message: result.message,
});
await Order.updateOne({ _id: order.id }, { status: 'failed', message: result.message });
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: order.id, status: 'failed' })
);
if (payment.id) {
await this.paymentRepository.update(payment.id, {
status: PaymentStatusEnum.FAILED,
message: result.message,
});
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.FAILED })
);
}
}
} else if (event === 'payment.failed') {
// Обработка неудачного платежа
this.logger.warn('Payment failed event received', { source: 'handleIPN', id: order.id });
this.logger.warn('Payment failed event received', { source: 'handleIPN', id: payment.id });
await Order.updateOne({ _id: order.id }, { status: 'failed' });
redisPublisher.publish(`${config.coopname}:orderStatusUpdate`, JSON.stringify({ id: order.id, status: 'failed' }));
if (payment.id) {
await this.paymentRepository.update(payment.id, { status: PaymentStatusEnum.FAILED });
redisPublisher.publish(
`${config.coopname}:orderStatusUpdate`,
JSON.stringify({ id: payment.id, status: PaymentStatusEnum.FAILED })
);
}
}
} else {
//TODO платеж есть, а ордера на него нет. Что делаем?
@@ -228,13 +241,22 @@ export class YookassaPlugin extends IPNProvider {
}
}
public async createPayment(
amount: string,
symbol: string,
description: string,
order_num: number,
secret: string
): Promise<PaymentDetails> {
public async createPayment(hash: string): Promise<PaymentDetails> {
// Получаем данные платежа по hash
const payment = await this.paymentRepository.findByHash(hash);
if (!payment) {
throw new Error(`Платеж с hash ${hash} не найден`);
}
// Используем QuantityUtils для парсинга quantity
const symbol = payment.symbol;
const amount = payment.quantity;
if (!payment.secret) {
throw new Error(`У платежа ${hash} отсутствует secret`);
}
const settings = await Settings.getSettings();
const checkout = new YooCheckout({
@@ -242,33 +264,36 @@ export class YookassaPlugin extends IPNProvider {
secretKey: settings.provider.secret,
});
const amount_plus_fee = getAmountPlusFee(parseFloat(amount), this.fee_percent).toFixed(2);
const fee_amount = (parseFloat(amount_plus_fee) - parseFloat(amount)).toFixed(2);
const amount_plus_fee = getAmountPlusFee(amount, this.fee_percent).toFixed(2);
const fee_amount = (parseFloat(amount_plus_fee) - amount).toFixed(2);
// Фактический процент комиссии
const fact_fee_percent = Math.round((parseFloat(fee_amount) / parseFloat(amount)) * 100 * 100) / 100;
const fact_fee_percent = Math.round((parseFloat(fee_amount) / amount) * 100 * 100) / 100;
const payment = await checkout.createPayment(
const description = payment.memo || `Платеж для ${payment.username}`;
const payment_result = await checkout.createPayment(
{
description,
amount: {
value: amount_plus_fee,
currency: 'RUB',
currency: symbol,
},
confirmation: {
type: 'embedded',
},
metadata: {
secret,
secret: payment.secret,
},
capture: true,
},
secret
payment.secret
);
return {
data: payment?.confirmation?.confirmation_token || '',
data: payment_result?.confirmation?.confirmation_token || '',
amount_plus_fee: `${amount_plus_fee} ${symbol}`,
amount_without_fee: amount,
amount_without_fee: `${amount} ${symbol}`,
fee_amount: `${fee_amount} ${symbol}`,
fee_percent: this.fee_percent,
fact_fee_percent,
@@ -278,8 +303,19 @@ export class YookassaPlugin extends IPNProvider {
}
@Module({
providers: [YookassaPlugin], // Регистрируем SberpollPlugin как провайдер
exports: [YookassaPlugin], // Экспортируем его для доступа в других модулях
imports: [TypeOrmModule.forFeature([PaymentEntity])],
providers: [
YookassaPlugin,
{
provide: PAYMENT_REPOSITORY,
useClass: TypeOrmPaymentRepository,
},
{
provide: EXTENSION_REPOSITORY,
useClass: TypeOrmExtensionDomainRepository,
},
],
exports: [YookassaPlugin],
})
export class YookassaPluginModule {
constructor(private readonly yookassaPlugin: YookassaPlugin) {}
@@ -0,0 +1,168 @@
import { Injectable, Logger } from '@nestjs/common';
import { BlockchainService } from '../blockchain.service';
import { GatewayContract, WalletContract } from 'cooptypes';
import { TransactResult } from '@wharfkit/session';
import Vault from '~/models/vault.model';
import httpStatus from 'http-status';
import { HttpApiError } from '~/errors/http-api-error';
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
import type { GatewayBlockchainPort } from '~/domain/gateway/ports/gateway-blockchain.port';
import type { CompleteIncomeDomainInterface } from '~/domain/gateway/interfaces/complete-income-domain.interface';
import type { CompleteOutcomeDomainInterface } from '~/domain/gateway/interfaces/complete-outcome-domain.interface';
import type { DeclineOutcomeDomainInterface } from '~/domain/gateway/interfaces/decline-outcome-domain.interface';
/**
* Блокчейн адаптер для gateway
* Реализует только управление уже созданными outcomes
*/
@Injectable()
export class GatewayBlockchainAdapter implements GatewayBlockchainPort {
private readonly logger = new Logger(GatewayBlockchainAdapter.name);
constructor(private readonly blockchainService: BlockchainService) {}
/**
* Завершение исходящего платежа
*/
async completeOutcome(data: CompleteOutcomeDomainInterface): Promise<TransactionResult> {
const wif = await Vault.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
// Преобразуем доменные данные в формат cooptypes контракта
const blockchainData: GatewayContract.Actions.CompleteOutcome.ICompleteOutcome = {
coopname: data.coopname,
outcome_hash: data.outcome_hash,
};
console.log('blockchainData', blockchainData);
const result = (await this.blockchainService.transact({
account: GatewayContract.contractName.production,
name: GatewayContract.Actions.CompleteOutcome.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: blockchainData,
})) as TransactResult;
this.logger.log(`Завершен исходящий платеж: ${data.outcome_hash}`);
return result;
}
/**
* Отклонение исходящего платежа
*/
async declineOutcome(data: DeclineOutcomeDomainInterface): Promise<TransactionResult> {
const wif = await Vault.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
// Преобразуем доменные данные в формат cooptypes контракта
const blockchainData: GatewayContract.Actions.DeclineOutcome.IOutDecline = {
coopname: data.coopname,
outcome_hash: data.outcome_hash,
reason: data.reason,
};
const result = (await this.blockchainService.transact({
account: GatewayContract.contractName.production,
name: GatewayContract.Actions.DeclineOutcome.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: blockchainData,
})) as TransactResult;
this.logger.log(`Отклонен исходящий платеж: ${data.outcome_hash}, причина: ${data.reason}`);
return result;
}
/**
* Получение исходящих платежей из блокчейна
*/
async getOutcomes(coopname: string): Promise<GatewayContract.Tables.Outcomes.IOutcome[]> {
return this.blockchainService.getAllRows(
GatewayContract.contractName.production,
coopname,
GatewayContract.Tables.Outcomes.tableName
);
}
/**
* Получение конкретного исходящего платежа
*/
async getOutcome(coopname: string, outcome_hash: string): Promise<GatewayContract.Tables.Outcomes.IOutcome | null> {
try {
const outcomes = await this.blockchainService.query(
GatewayContract.contractName.production,
coopname,
GatewayContract.Tables.Outcomes.tableName,
{
indexPosition: 'secondary',
from: outcome_hash,
to: outcome_hash,
}
);
return outcomes.length > 0 ? outcomes[0] : null;
} catch (error: any) {
this.logger.warn(`Не удалось получить outcome ${outcome_hash} для ${coopname}: ${error.message}`);
return null;
}
}
/**
* Завершение входящего платежа
*/
async completeIncome(data: CompleteIncomeDomainInterface): Promise<TransactionResult> {
// Получаем приватный ключ кооператива
const wif = await Vault.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
// Формируем данные для создания депозита
const createDeposit: WalletContract.Actions.CreateDeposit.ICreateDeposit = {
coopname: data.coopname,
username: data.username,
quantity: data.quantity,
deposit_hash: data.income_hash,
};
// Формируем данные для завершения дохода
const completeDeposit: GatewayContract.Actions.CompleteIncome.ICompleteIncome = {
coopname: data.coopname,
income_hash: data.income_hash,
};
// Формируем массив действий
const actions = [
{
account: WalletContract.contractName.production,
name: WalletContract.Actions.CreateDeposit.actionName,
authorization: [
{
actor: data.coopname,
permission: 'active',
},
],
data: createDeposit,
},
{
account: GatewayContract.contractName.production,
name: GatewayContract.Actions.CompleteIncome.actionName,
authorization: [
{
actor: data.coopname,
permission: 'active',
},
],
data: completeDeposit,
},
];
// Выполняем транзакцию
const result = await this.blockchainService.transact(actions);
this.logger.log(
`Входящий платеж завершён: income_hash=${data.income_hash}, username=${data.username}, quantity=${data.quantity}`
);
return result;
}
}
@@ -0,0 +1,104 @@
import { Injectable, Logger } from '@nestjs/common';
import { BlockchainService } from '../blockchain.service';
import { WalletContract } from 'cooptypes';
import { TransactResult } from '@wharfkit/session';
import Vault from '~/models/vault.model';
import httpStatus from 'http-status';
import { HttpApiError } from '~/errors/http-api-error';
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
import type {
WalletBlockchainPort,
CreateWithdrawDomainInterface,
GenerateReturnStatementDomainInterface,
} from '~/domain/wallet/ports/wallet-blockchain.port';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import { DomainToBlockchainUtils } from '../utils/domain-to-blockchain.utils';
/**
* Блокчейн адаптер для wallet
* Реализует взаимодействие с контрактом wallet
*/
@Injectable()
export class WalletBlockchainAdapter implements WalletBlockchainPort {
private readonly logger = new Logger(WalletBlockchainAdapter.name);
constructor(
private readonly blockchainService: BlockchainService,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils
) {}
/**
* Создание заявки на вывод средств в контракте wallet
*/
async createWithdraw(data: CreateWithdrawDomainInterface): Promise<TransactionResult> {
const wif = await Vault.getWif(data.coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(data.coopname, wif);
// Форматируем quantity к необходимой точности для блокчейна
const formattedQuantity = this.domainToBlockchainUtils.formatQuantityWithPrecision(data.quantity);
// Преобразуем доменные данные в формат cooptypes контракта
const blockchainData: WalletContract.Actions.CreateWithdraw.ICreateWithdraw = {
coopname: data.coopname,
username: data.username,
withdraw_hash: data.withdraw_hash,
quantity: formattedQuantity,
statement: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.statement),
};
const result = (await this.blockchainService.transact({
account: WalletContract.contractName.production,
name: WalletContract.Actions.CreateWithdraw.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: blockchainData,
})) as TransactResult;
this.logger.log(`Создана заявка на вывод средств: ${data.withdraw_hash}`);
return result;
}
/**
* Генерация заявления на возврат паевого взноса
*/
async generateReturnStatement(_data: GenerateReturnStatementDomainInterface): Promise<ISignedDocumentDomainInterface> {
// TODO: Реализовать генерацию заявления через документный сервис
// Пока возвращаем заглушку
throw new Error('Метод generateReturnStatement еще не реализован');
}
/**
* Получение данных из wallet контракта
*/
async getWithdraw(coopname: string, withdraw_hash: string): Promise<WalletContract.Tables.Withdraws.IWithdraws | null> {
try {
const withdraws = await this.blockchainService.query(
WalletContract.contractName.production,
coopname,
WalletContract.Tables.Withdraws.tableName,
{
indexPosition: 'secondary',
from: withdraw_hash,
to: withdraw_hash,
}
);
return withdraws.length > 0 ? withdraws[0] : null;
} catch (error: any) {
this.logger.warn(`Не удалось получить withdraw ${withdraw_hash} для ${coopname}: ${error.message}`);
return null;
}
}
/**
* Получение всех withdrawals для кооператива
*/
async getWithdraws(coopname: string): Promise<WalletContract.Tables.Withdraws.IWithdraws[]> {
return this.blockchainService.getAllRows(
WalletContract.contractName.production,
coopname,
WalletContract.Tables.Withdraws.tableName
);
}
}
@@ -15,6 +15,10 @@ import { MEET_BLOCKCHAIN_PORT } from '~/domain/meet/ports/meet-blockchain.port';
import { MeetBlockchainAdapter } from './adapters/meet-blockchain.adapter';
import { DomainToBlockchainUtils } from './utils/domain-to-blockchain.utils';
import { DomainModule } from '~/domain/domain.module';
import { GatewayBlockchainAdapter } from './adapters/gateway-blockchain.adapter';
import { WALLET_BLOCKCHAIN_PORT } from '~/domain/wallet/ports/wallet-blockchain.port';
import { GATEWAY_BLOCKCHAIN_PORT } from '~/domain/gateway/ports/gateway-blockchain.port';
import { WalletBlockchainAdapter } from './adapters/wallet-blockchain.adapter';
@Global()
@Module({
@@ -49,9 +53,20 @@ import { DomainModule } from '~/domain/domain.module';
provide: MEET_BLOCKCHAIN_PORT,
useClass: MeetBlockchainAdapter,
},
// Провайдеры для gateway
{
provide: GATEWAY_BLOCKCHAIN_PORT,
useClass: GatewayBlockchainAdapter,
},
// Провайдеры для wallet
{
provide: WALLET_BLOCKCHAIN_PORT,
useClass: WalletBlockchainAdapter,
},
DomainToBlockchainUtils,
],
exports: [
BlockchainService,
BLOCKCHAIN_PORT,
BRANCH_BLOCKCHAIN_PORT,
SYSTEM_BLOCKCHAIN_PORT,
@@ -59,6 +74,8 @@ import { DomainModule } from '~/domain/domain.module';
SOVIET_BLOCKCHAIN_PORT,
COOPLACE_BLOCKCHAIN_PORT,
MEET_BLOCKCHAIN_PORT,
GATEWAY_BLOCKCHAIN_PORT,
WALLET_BLOCKCHAIN_PORT,
DomainToBlockchainUtils,
],
})
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Cooperative } from 'cooptypes';
import moment from 'moment';
import config from '~/config/config';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Утилитарный класс для конвертации доменных объектов в инфраструктурные типы блокчейна
@@ -12,9 +14,7 @@ export class DomainToBlockchainUtils {
* @param document Доменный объект подписанного документа
* @returns Объект документа в формате блокчейна
*/
convertSignedDocumentToBlockchainFormat(
document: Cooperative.Document.ISignedDocument2
): Cooperative.Document.IChainDocument2 {
convertSignedDocumentToBlockchainFormat(document: ISignedDocumentDomainInterface): Cooperative.Document.IChainDocument2 {
return {
version: document.version,
hash: document.hash,
@@ -25,6 +25,22 @@ export class DomainToBlockchainUtils {
};
}
/**
* Преобразует документ из блокчейна в доменный интерфейс
* @param chainDoc Документ из блокчейна
* @returns Документ в доменном формате
*/
convertBlockchainDocumentToDomainFormat(chainDoc: Cooperative.Document.IChainDocument2): ISignedDocumentDomainInterface {
return {
version: chainDoc.version,
hash: chainDoc.hash,
doc_hash: chainDoc.doc_hash,
meta_hash: chainDoc.meta_hash,
meta: typeof chainDoc.meta === 'string' ? (chainDoc.meta === '' ? {} : JSON.parse(chainDoc.meta)) : chainDoc.meta,
signatures: chainDoc.signatures,
};
}
/**
* Преобразует Date в формат EOSIO time_point_sec для блокчейна
* @param date Дата или строка с датой
@@ -35,7 +51,44 @@ export class DomainToBlockchainUtils {
}
/**
* Преобразует объект IChainDocument2 в ISignedDocument2
* Форматирование quantity с проверкой символа и использованием precision из конфига
* @param quantity Строка в формате "число символ" (например, "100.0 AXON")
* @returns Отформатированная строка quantity для блокчейна
*/
formatQuantityWithPrecision(quantity: string): string {
// Извлекаем число и символ
const parts = quantity.split(' ');
if (parts.length !== 2) {
throw new Error(`Неверный формат quantity: ${quantity}. Ожидается "число символ"`);
}
const [amount, symbol] = parts;
const numericAmount = parseFloat(amount);
if (isNaN(numericAmount)) {
throw new Error(`Некорректное числовое значение в quantity: ${amount}`);
}
// Проверяем символ на соответствие с конфигом
let precision: number;
if (symbol === config.blockchain.root_symbol) {
precision = config.blockchain.root_precision;
} else if (symbol === config.blockchain.root_govern_symbol) {
precision = config.blockchain.root_govern_precision;
} else {
throw new Error(
`Неподдерживаемый символ: ${symbol}. Поддерживаются только: ${config.blockchain.root_symbol}, ${config.blockchain.root_govern_symbol}`
);
}
// Форматируем к необходимому количеству знаков после запятой
const formattedAmount = numericAmount.toFixed(precision);
return `${formattedAmount} ${symbol}`;
}
/**
* Преобразует объект IChainDocument2 в ISignedDocument2 (для совместимости)
* @param chainDoc Документ из блокчейна
* @returns Документ в формате ISignedDocument2
*/
@@ -14,6 +14,8 @@ import { PROJECT_FREE_DECISION_REPOSITORY } from '~/domain/common/repositories/p
import { ProjectFreeDecisionRepositoryImplementation } from './repositories/project-free-decision-generator.repository';
import { VARS_REPOSITORY } from '~/domain/common/repositories/vars.repository';
import { VarsRepositoryImplementation } from './repositories/vars-generator.repository';
import { SEARCH_PRIVATE_ACCOUNTS_REPOSITORY } from '~/domain/common/repositories/search-private-accounts.repository';
import { SearchPrivateAccountsRepositoryImplementation } from './repositories/search-private-accounts-generator.repository';
@Global()
@Module({
@@ -46,6 +48,10 @@ import { VarsRepositoryImplementation } from './repositories/vars-generator.repo
provide: VARS_REPOSITORY,
useClass: VarsRepositoryImplementation,
},
{
provide: SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
useClass: SearchPrivateAccountsRepositoryImplementation,
},
],
exports: [
ORGANIZATION_REPOSITORY,
@@ -55,6 +61,7 @@ import { VarsRepositoryImplementation } from './repositories/vars-generator.repo
DOCUMENT_REPOSITORY,
PROJECT_FREE_DECISION_REPOSITORY,
VARS_REPOSITORY,
SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
],
})
export class GeneratorRepositoriesModule {}
@@ -0,0 +1,151 @@
import { Injectable } from '@nestjs/common';
import { SearchPrivateAccountsRepository } from '~/domain/common/repositories/search-private-accounts.repository';
import type {
SearchPrivateAccountsInputDomainInterface,
PrivateAccountSearchResultDomainInterface,
PrivateAccountSearchDataDomainInterface,
} from '~/domain/common/interfaces/search-private-accounts-domain.interface';
import type { IndividualDomainInterface } from '~/domain/common/interfaces/individual-domain.interface';
import type { EntrepreneurDomainInterface } from '~/domain/common/interfaces/entrepreneur-domain.interface';
import type { OrganizationDomainInterface } from '~/domain/common/interfaces/organization-domain.interface';
import { generator } from '~/services/document.service';
import type { ISearchResult } from '@coopenomics/factory';
/**
* Инфраструктурная реализация репозитория поиска приватных аккаунтов
* Использует фабрику документов для выполнения поиска
*/
@Injectable()
export class SearchPrivateAccountsRepositoryImplementation implements SearchPrivateAccountsRepository {
/**
* Выполняет поиск приватных аккаунтов через фабрику документов
* @param input Входные данные для поиска
* @returns Массив результатов поиска, преобразованных в доменные интерфейсы
*/
async searchPrivateAccounts(
input: SearchPrivateAccountsInputDomainInterface
): Promise<PrivateAccountSearchResultDomainInterface[]> {
// Вызываем метод поиска из фабрики документов
const factoryResults: ISearchResult[] = await generator.search(input.query);
// Преобразуем результаты фабрики в доменные интерфейсы
const domainResults: PrivateAccountSearchResultDomainInterface[] = factoryResults.map((result) =>
this.convertFactoryResultToDomain(result)
);
return domainResults;
}
/**
* Преобразует результат из фабрики в доменный интерфейс
* @param factoryResult Результат поиска из фабрики
* @returns Результат поиска в доменном формате
*/
private convertFactoryResultToDomain(factoryResult: ISearchResult): PrivateAccountSearchResultDomainInterface {
let domainData: PrivateAccountSearchDataDomainInterface;
switch (factoryResult.type) {
case 'individual':
domainData = this.convertIndividualData(factoryResult.data as any);
break;
case 'entrepreneur':
domainData = this.convertEntrepreneurData(factoryResult.data as any);
break;
case 'organization':
domainData = this.convertOrganizationData(factoryResult.data as any);
break;
default:
throw new Error(`Неизвестный тип данных: ${factoryResult.type}`);
}
return {
type: factoryResult.type,
data: domainData,
score: factoryResult.score,
highlightedFields: factoryResult.highlightedFields,
};
}
/**
* Преобразует данные физического лица из фабрики в доменный интерфейс
* @param data Данные из фабрики
* @returns Доменный интерфейс физического лица
*/
private convertIndividualData(data: any): IndividualDomainInterface {
return {
username: data.username,
first_name: data.first_name,
last_name: data.last_name,
middle_name: data.middle_name,
birthdate: data.birthdate,
full_address: data.full_address,
phone: data.phone,
email: data.email,
passport: data.passport
? {
series: data.passport.series,
number: data.passport.number,
issued_by: data.passport.issued_by,
issued_at: data.passport.issued_at,
code: data.passport.code,
}
: undefined,
};
}
/**
* Преобразует данные индивидуального предпринимателя из фабрики в доменный интерфейс
* @param data Данные из фабрики
* @returns Доменный интерфейс индивидуального предпринимателя
*/
private convertEntrepreneurData(data: any): EntrepreneurDomainInterface {
return {
username: data.username,
first_name: data.first_name,
last_name: data.last_name,
middle_name: data.middle_name,
birthdate: data.birthdate,
phone: data.phone,
email: data.email,
country: data.country,
city: data.city,
full_address: data.full_address,
details: {
inn: data.details.inn,
ogrn: data.details.ogrn,
},
};
}
/**
* Преобразует данные организации из фабрики в доменный интерфейс
* @param data Данные из фабрики
* @returns Доменный интерфейс организации
*/
private convertOrganizationData(data: any): OrganizationDomainInterface {
return {
username: data.username,
type: data.type,
short_name: data.short_name,
full_name: data.full_name,
represented_by: {
first_name: data.represented_by.first_name,
last_name: data.represented_by.last_name,
middle_name: data.represented_by.middle_name,
position: data.represented_by.position,
based_on: data.represented_by.based_on,
},
country: data.country,
city: data.city,
full_address: data.full_address,
fact_address: data.fact_address,
phone: data.phone,
email: data.email,
details: {
inn: data.details.inn,
ogrn: data.details.ogrn,
kpp: data.details.kpp,
},
};
}
}
@@ -0,0 +1,85 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentTypeEnum, PaymentDirectionEnum } from '~/domain/gateway/enums/payment-type.enum';
import type {
PaymentDomainInterface,
PaymentDetailsDomainInterface,
} from '~/domain/gateway/interfaces/payment-domain.interface';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Унифицированная сущность платежа для TypeORM
* Поддерживает все типы платежей (входящие и исходящие)
*/
@Entity('payments')
export class PaymentEntity implements PaymentDomainInterface {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
hash!: string;
@Column()
coopname!: string;
@Column()
username!: string;
@Column()
quantity!: number;
@Column()
symbol!: string;
@Column({
type: 'enum',
enum: PaymentTypeEnum,
})
type!: PaymentTypeEnum;
@Column({
type: 'enum',
enum: PaymentDirectionEnum,
})
direction!: PaymentDirectionEnum;
@Column({
type: 'enum',
enum: PaymentStatusEnum,
default: PaymentStatusEnum.PENDING,
})
status!: PaymentStatusEnum;
@Column({ nullable: true })
provider?: string;
@Column({ nullable: true })
payment_method_id?: string;
@Column({ nullable: true })
secret?: string;
@Column({ nullable: true, type: 'text' })
message?: string;
@Column({ nullable: true, type: 'text' })
memo?: string;
@Column({ nullable: true, type: 'timestamp' })
expired_at?: Date;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;
@UpdateDateColumn({ nullable: true, type: 'timestamp' })
updated_at?: Date;
@Column('json', { nullable: true })
payment_details?: PaymentDetailsDomainInterface;
@Column('json', { nullable: true })
blockchain_data?: any;
@Column('json', { nullable: true })
statement?: ISignedDocumentDomainInterface;
}
@@ -0,0 +1,229 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PaymentEntity } from '../entities/payment.entity';
import type { PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
import type { InternalPaymentFiltersDomainInterface } from '~/domain/gateway/interfaces/payment-filters-domain.interface';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import { PaymentDirectionEnum, PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
@Injectable()
export class TypeOrmPaymentRepository implements PaymentRepository {
constructor(
@InjectRepository(PaymentEntity)
private readonly paymentRepository: Repository<PaymentEntity>
) {}
async findById(id: string): Promise<PaymentDomainInterface | null> {
const payment = await this.paymentRepository.findOneBy({ id });
if (!payment) return null;
return this.mapToDomainEntity(payment);
}
async findByHash(hash: string): Promise<PaymentDomainInterface | null> {
const payment = await this.paymentRepository.findOneBy({ hash });
if (!payment) return null;
return this.mapToDomainEntity(payment);
}
async create(data: PaymentDomainInterface): Promise<PaymentDomainInterface> {
const payment = new PaymentEntity();
payment.hash = data.hash;
payment.coopname = data.coopname;
payment.username = data.username;
payment.quantity = data.quantity;
payment.symbol = data.symbol;
payment.payment_method_id = data.payment_method_id;
payment.status = data.status;
payment.type = data.type;
payment.direction = data.direction;
payment.provider = data.provider;
payment.secret = data.secret;
payment.message = data.message;
payment.expired_at = data.expired_at;
payment.created_at = data.created_at;
payment.memo = data.memo;
payment.payment_details = data.payment_details;
payment.blockchain_data = data.blockchain_data;
payment.statement = data.statement;
const createdEntity = await this.paymentRepository.save(payment);
return this.mapToDomainEntity(createdEntity);
}
async update(id: string, data: Partial<PaymentDomainInterface>): Promise<PaymentDomainInterface | null> {
const payment = await this.paymentRepository.findOneBy({ id });
if (!payment) return null;
Object.assign(payment, data);
payment.updated_at = new Date();
const updatedEntity = await this.paymentRepository.save(payment);
return this.mapToDomainEntity(updatedEntity);
}
async updatePaymentWithBlockchainData(hash: string, blockchain_data: any): Promise<PaymentDomainInterface | null> {
const payment = await this.paymentRepository.findOneBy({ hash });
if (!payment) return null;
payment.blockchain_data = blockchain_data;
payment.updated_at = new Date();
const updatedEntity = await this.paymentRepository.save(payment);
return this.mapToDomainEntity(updatedEntity);
}
async setPaymentStatus(id: string, status: PaymentStatusEnum): Promise<PaymentDomainInterface | null> {
const payment = await this.paymentRepository.findOneBy({ id });
if (!payment) return null;
payment.status = status;
payment.updated_at = new Date();
const updatedEntity = await this.paymentRepository.save(payment);
return this.mapToDomainEntity(updatedEntity);
}
async getAllPayments(
filters: InternalPaymentFiltersDomainInterface,
options: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<PaymentDomainInterface>> {
const queryBuilder = this.paymentRepository.createQueryBuilder('payment');
if (filters.direction) {
queryBuilder.where('payment.direction = :direction', { direction: filters.direction });
} else if (filters.type) {
if (filters.type === PaymentTypeEnum.WITHDRAWAL) {
queryBuilder.where('payment.direction = :direction', { direction: PaymentDirectionEnum.OUTGOING });
} else {
queryBuilder.where('payment.direction = :direction', { direction: PaymentDirectionEnum.INCOMING });
}
}
if (filters.username) {
queryBuilder.andWhere('payment.username ILIKE :username', { username: `%${filters.username}%` });
}
if (filters.status) {
queryBuilder.andWhere('payment.status = :status', { status: filters.status });
}
if (filters.coopname) {
queryBuilder.andWhere('payment.coopname = :coopname', { coopname: filters.coopname });
}
if (filters.provider) {
queryBuilder.andWhere('payment.provider = :provider', { provider: filters.provider });
}
if (filters.secret) {
queryBuilder.andWhere('payment.secret = :secret', { secret: filters.secret });
}
if (filters.hash) {
queryBuilder.andWhere('payment.hash = :hash', { hash: filters.hash });
}
queryBuilder.orderBy('payment.created_at', 'DESC');
const limit = options.limit || 10;
const page = options.page || 1;
const skip = (page - 1) * limit;
queryBuilder.take(limit).skip(skip);
const [payments, totalCount] = await queryBuilder.getManyAndCount();
const items = payments.map((payment) => this.mapToDomainEntity(payment));
return {
items,
totalCount,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
};
}
private mapToDomainEntity(entity: PaymentEntity): PaymentDomainInterface {
return {
id: entity.id,
hash: entity.hash,
coopname: entity.coopname,
username: entity.username,
quantity: entity.quantity,
symbol: entity.symbol,
payment_method_id: entity.payment_method_id,
status: entity.status,
type: entity.type,
direction: entity.direction,
provider: entity.provider,
secret: entity.secret,
message: entity.message,
expired_at: entity.expired_at,
created_at: entity.created_at,
updated_at: entity.updated_at,
memo: entity.memo,
payment_details: entity.payment_details,
blockchain_data: entity.blockchain_data,
statement: entity.statement,
};
}
/**
* Обновить все истекшие платежи в статус EXPIRED
* @returns количество обновленных платежей
*/
async expireOutdatedPayments(): Promise<number> {
const now = new Date();
const result = await this.paymentRepository
.createQueryBuilder()
.update(PaymentEntity)
.set({
status: PaymentStatusEnum.EXPIRED,
updated_at: now,
})
.where('expired_at IS NOT NULL')
.andWhere('expired_at < :now', { now })
.andWhere('status = :status', { status: PaymentStatusEnum.PENDING })
.execute();
return result.affected || 0;
}
/**
* Найти активный платеж того же типа для пользователя
* @param username имя пользователя
* @param type тип платежа
* @param quantity количество
* @param symbol символ валюты
* @returns активный платеж или null
*/
async findActivePendingPayment(
username: string,
type: PaymentTypeEnum,
quantity: number,
symbol: string
): Promise<PaymentDomainInterface | null> {
const now = new Date();
const payment = await this.paymentRepository
.createQueryBuilder('payment')
.where('payment.username = :username', { username })
.andWhere('payment.type = :type', { type })
.andWhere('payment.quantity = :quantity', { quantity })
.andWhere('payment.symbol = :symbol', { symbol })
.andWhere('payment.status = :status', { status: PaymentStatusEnum.PENDING })
.andWhere('(payment.expired_at IS NULL OR payment.expired_at > :now)', { now })
.orderBy('payment.created_at', 'DESC')
.getOne();
if (!payment) return null;
return this.mapToDomainEntity(payment);
}
}
@@ -21,6 +21,9 @@ import { TypeOrmCandidateRepository } from './repositories/typeorm-candidate.rep
import { MeetProcessedEntity } from './entities/meet-processed.entity';
import { MEET_PROCESSED_REPOSITORY } from '~/domain/meet/repositories/meet-processed.repository';
import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-processed.repository';
import { PaymentEntity } from './entities/payment.entity';
import { PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.repository';
@Global()
@Module({
@@ -43,6 +46,7 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
MeetProcessedEntity,
MigrationEntity,
CandidateEntity,
PaymentEntity,
]),
],
providers: [
@@ -70,6 +74,10 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
provide: CANDIDATE_REPOSITORY,
useClass: TypeOrmCandidateRepository,
},
{
provide: PAYMENT_REPOSITORY,
useClass: TypeOrmPaymentRepository,
},
],
exports: [
NestTypeOrmModule,
@@ -79,6 +87,7 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
MEET_PROCESSED_REPOSITORY,
MIGRATION_REPOSITORY,
CANDIDATE_REPOSITORY,
PAYMENT_REPOSITORY,
],
})
export class TypeOrmModule {}
@@ -1,4 +1,3 @@
// app.module.ts
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { GraphqlModule } from './graphql/graphql.module';
@@ -0,0 +1,167 @@
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS_PROVIDER } from './redis.provider';
import { WinstonLoggerService } from '~/modules/logger/logger-app.service';
export interface StreamMessage {
messageId: string;
fields: Record<string, string>;
}
export interface StreamConsumerOptions {
stream: string;
group: string;
consumer: string;
count?: number;
block?: number;
}
/**
* Сервис для работы с Redis Streams
* Обеспечивает надежное потребление событий от parser
*/
@Injectable()
export class RedisStreamService implements OnModuleDestroy {
private consumers: Map<string, boolean> = new Map();
constructor(
@Inject(REDIS_PROVIDER) private readonly redisClient: { subscriber: Redis; publisher: Redis },
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(RedisStreamService.name);
}
onModuleDestroy() {
// Останавливаем всех потребителей
this.consumers.forEach((_, key) => {
this.consumers.set(key, false);
});
}
/**
* Создание группы потребителей
*/
async createConsumerGroup(stream: string, group: string, startId = '0'): Promise<void> {
try {
await this.redisClient.subscriber.xgroup('CREATE', stream, group, startId, 'MKSTREAM');
this.logger.log(`Consumer group created: ${group} for stream: ${stream}`);
} catch (error: any) {
if (error.message.includes('BUSYGROUP')) {
this.logger.log(`Consumer group ${group} already exists for stream ${stream}`);
} else {
this.logger.error(`Error creating consumer group: ${error.message}`);
throw error;
}
}
}
/**
* Потребление сообщений из stream
*/
async startConsumer(
options: StreamConsumerOptions,
messageHandler: (message: StreamMessage) => Promise<void>
): Promise<void> {
const { stream, group, consumer, count = 1, block = 1000 } = options;
const consumerKey = `${stream}:${group}:${consumer}`;
// Отмечаем потребителя как активного
this.consumers.set(consumerKey, true);
this.logger.log(`Starting consumer: ${consumerKey}`);
while (this.consumers.get(consumerKey)) {
try {
const result: any = await this.redisClient.subscriber.xreadgroup(
'GROUP',
group,
consumer,
'COUNT',
count.toString(),
'BLOCK',
block.toString(),
'STREAMS',
stream,
'>'
);
if (result && result.length > 0) {
for (const [streamName, messages] of result) {
if (streamName === stream) {
for (const [messageId, messageData] of messages) {
const fields: Record<string, string> = {};
// Преобразуем массив [key, value, key, value] в объект
for (let i = 0; i < messageData.length; i += 2) {
fields[messageData[i]] = messageData[i + 1];
}
try {
await messageHandler({ messageId, fields });
await this.acknowledgeMessage(stream, group, messageId);
} catch (error: any) {
this.logger.error(`Error processing message ${messageId}: ${error.message}`);
// Не подтверждаем сообщение при ошибке - оно останется в pending
}
}
}
}
}
} catch (error: any) {
if (this.consumers.get(consumerKey)) {
this.logger.error(`Consumer ${consumerKey} error: ${error.message}`);
// Небольшая пауза перед повторной попыткой
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
this.logger.log(`Consumer stopped: ${consumerKey}`);
}
/**
* Подтверждение обработки сообщения
*/
async acknowledgeMessage(stream: string, group: string, messageId: string): Promise<void> {
try {
await this.redisClient.subscriber.xack(stream, group, messageId);
this.logger.debug(`Message acknowledged: ${messageId} in stream ${stream}`);
} catch (error: any) {
this.logger.error(`Error acknowledging message ${messageId} in stream ${stream}, group ${group}: ${error.message}`);
throw error;
}
}
/**
* Остановка потребителя
*/
stopConsumer(stream: string, group: string, consumer: string): void {
const consumerKey = `${stream}:${group}:${consumer}`;
this.consumers.set(consumerKey, false);
this.logger.log(`Stopping consumer: ${consumerKey}`);
}
/**
* Получение информации о pending сообщениях
*/
async getPendingMessages(stream: string, group: string): Promise<any> {
try {
return await this.redisClient.subscriber.xpending(stream, group);
} catch (error: any) {
this.logger.error(`Error getting pending messages: ${error.message}`);
throw error;
}
}
/**
* Получение информации о группах потребителей
*/
async getConsumerGroups(stream: string): Promise<any> {
try {
return await this.redisClient.subscriber.xinfo('GROUPS', stream);
} catch (error: any) {
this.logger.error(`Error getting consumer groups: ${error.message}`);
throw error;
}
}
}
@@ -2,10 +2,11 @@
import { Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { RedisStreamService } from './redis-stream.service';
import { RedisProvider } from './redis.provider';
@Module({
providers: [RedisProvider, RedisService],
exports: [RedisService], // Экспортируем RedisService для использования в других модулях
providers: [RedisProvider, RedisService, RedisStreamService],
exports: [RedisService, RedisStreamService], // Экспортируем оба сервиса
})
export class RedisModule {}
@@ -41,11 +41,6 @@ export const RedisProvider: Provider = {
logConnectionStatus(redisSubscriber, 'Subscriber');
logConnectionStatus(redisPublisher, 'Publisher');
// await Promise.all([
// redisSubscriber.connect().catch((error) => logger.error(`Subscriber connect error: ${error.message}`)),
// redisPublisher.connect().catch((error) => logger.error(`Publisher connect error: ${error.message}`)),
// ]);
return {
subscriber: redisSubscriber,
publisher: redisPublisher,
@@ -9,7 +9,6 @@ import { WinstonLoggerService } from '../modules/logger/logger-app.service';
export interface Migration {
name: string;
isTest?: boolean; // Если true, то миграция запускается в тестовом режиме (без сохранения в БД)
validUntil?: Date; // Дата в UTC, до которой миграция должна применяться. Если не указана или null, миграция применяется всегда
up: (services: { blockchain: BlockchainService }) => Promise<boolean>;
}
@@ -146,9 +145,7 @@ export class MigrationManager {
});
}
async runMigration(migration: Migration, version: string, description: string): Promise<boolean> {
const isTest = migration.isTest === true;
async runMigration(migration: Migration, version: string, description: string, isTest: boolean): Promise<boolean> {
try {
if (isTest) {
logger.info(`[ТЕСТОВАЯ МИГРАЦИЯ] Запуск миграции ${version} (${description}): ${migration.name}`);
@@ -158,7 +155,11 @@ export class MigrationManager {
// Предоставляем блокчейн-сервис в миграцию
const result = await migration.up({ blockchain: this.blockchainService });
await this.markMigrationAsApplied(version, migration.name, result, isTest);
// Отмечаем как примененную только если миграция успешна И не является тестовой
if (result && !isTest) {
await this.markMigrationAsApplied(version, migration.name, true, false);
}
if (isTest) {
logger.info(
@@ -171,7 +172,12 @@ export class MigrationManager {
return result;
} catch (error) {
logger.error(`Ошибка при выполнении миграции ${version} (${description}):`, error);
await this.markMigrationAsApplied(version, migration.name, false, isTest);
// Отмечаем как неуспешную только если это НЕ тестовая миграция
if (!isTest) {
await this.markMigrationAsApplied(version, migration.name, false, false);
}
return false;
}
}
@@ -187,8 +193,8 @@ export class MigrationManager {
// Вывод списка найденных миграций
if (migrationFiles.length > 0) {
logger.info('Список найденных миграций:');
migrationFiles.forEach(({ version, description }) => {
logger.info(`- ${version}: ${description}`);
migrationFiles.forEach(({ version, description, filename }) => {
logger.info(`- ${version}: ${description} (${filename})`);
});
}
@@ -200,6 +206,15 @@ export class MigrationManager {
const currentDate = new Date();
for (const { filename, version, description } of migrationFiles) {
const isTest = filename.includes('__test');
// Обычные миграции пропускаем, если уже применялись
if (!isTest && appliedVersions.has(version)) {
logger.info(`Миграция ${version} (${description}) уже применена, пропускаем`);
continue;
}
// Грузим файл миграции
const result = await this.loadMigration(filename);
if (!result) {
logger.warn(`Не удалось загрузить миграцию из файла ${filename}`);
@@ -207,13 +222,6 @@ export class MigrationManager {
}
const { migration } = result;
const isTest = migration.isTest === true;
// Проверяем, была ли миграция уже применена успешно (для не тестовых миграций)
if (!isTest && appliedVersions.has(version)) {
logger.info(`Миграция ${version} (${description}) уже применена, пропускаем`);
continue;
}
// Проверяем дату валидности миграции
if (migration.validUntil && currentDate > migration.validUntil) {
@@ -225,13 +233,14 @@ export class MigrationManager {
// Если это тестовая миграция, сообщаем об этом
if (isTest) {
logger.info(`Миграция ${version} (${description}) запускается в ТЕСТОВОМ режиме`);
logger.info(`Миграция ${version} (${description}) запускается в ТЕСТОВОМ режиме (по имени файла)`);
}
// Запускаем миграцию
const success = await this.runMigration(migration, version, description);
const success = await this.runMigration(migration, version, description, isTest);
if (!success) {
// Останавливаем процесс только при ошибке в НЕ тестовых миграциях
if (!success && !isTest) {
logger.error(`Миграция ${version} (${description}) завершена с ошибкой, останавливаем процесс`);
break;
}
@@ -1,11 +1,15 @@
import mongoose, { Schema, model, type Model } from 'mongoose';
import { toJSON, paginate } from './plugins/index';
import AutoIncrementFactory from 'mongoose-sequence';
import { generateOrderSecret } from '../services/order.service';
import { type IOrder, orderStatus } from '../types/order.types';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
const AutoIncrement = AutoIncrementFactory(mongoose);
export function generateOrderSecret(length = 16): string {
return crypto.randomBytes(length).toString('hex'); // Генерирует случайный секрет в виде hex-строки
}
interface IOrderModel extends Model<IOrder> {
isEmailTaken(email: string, excludeUsername?: mongoose.Types.ObjectId): Promise<boolean>;
paginate(filter, options): any;
@@ -0,0 +1,11 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString, IsNotEmpty } from 'class-validator';
import type { SearchPrivateAccountsInputDomainInterface } from '~/domain/common/interfaces/search-private-accounts-domain.interface';
@InputType('SearchPrivateAccountsInput')
export class SearchPrivateAccountsInputDTO implements SearchPrivateAccountsInputDomainInterface {
@Field(() => String, { description: 'Поисковый запрос для поиска приватных аккаунтов' })
@IsString({ message: 'Поисковый запрос должен быть строкой' })
@IsNotEmpty({ message: 'Поисковый запрос не может быть пустым' })
query!: string;
}
@@ -0,0 +1,66 @@
import { ObjectType, Field, createUnionType } from '@nestjs/graphql';
import { IsString, IsArray, IsOptional, IsNumber } from 'class-validator';
import { IndividualDTO } from '~/modules/common/dto/individual.dto';
import { EntrepreneurDTO } from '~/modules/common/dto/entrepreneur.dto';
import { OrganizationDTO } from '~/modules/common/dto/organization.dto';
import type { PrivateAccountSearchResultDomainInterface } from '~/domain/common/interfaces/search-private-accounts-domain.interface';
// Создаем Union Type для GraphQL
export const PrivateAccountSearchDataUnion = createUnionType({
name: 'PrivateAccountSearchData',
types: () => [IndividualDTO, EntrepreneurDTO, OrganizationDTO] as const,
resolveType: (value) => {
if (IndividualDTO.isTypeOf(value)) {
return IndividualDTO;
}
if (EntrepreneurDTO.isTypeOf(value)) {
return EntrepreneurDTO;
}
if (OrganizationDTO.isTypeOf(value)) {
return OrganizationDTO;
}
return undefined;
},
});
@ObjectType('PrivateAccountSearchResult')
export class PrivateAccountSearchResultDTO {
@Field(() => String, { description: 'Тип аккаунта' })
@IsString()
type!: 'individual' | 'entrepreneur' | 'organization';
@Field(() => PrivateAccountSearchDataUnion, { description: 'Данные найденного аккаунта' })
data!: IndividualDTO | EntrepreneurDTO | OrganizationDTO;
@Field(() => Number, { nullable: true, description: 'Оценка релевантности результата' })
@IsOptional()
@IsNumber()
score?: number;
@Field(() => [String], { nullable: true, description: 'Поля, в которых найдены совпадения' })
@IsOptional()
@IsArray()
@IsString({ each: true })
highlightedFields?: string[];
constructor(entity: PrivateAccountSearchResultDomainInterface) {
this.type = entity.type;
this.score = entity.score;
this.highlightedFields = entity.highlightedFields;
// Преобразуем доменные данные в соответствующий DTO
switch (entity.type) {
case 'individual':
this.data = new IndividualDTO(entity.data as any);
break;
case 'entrepreneur':
this.data = new EntrepreneurDTO(entity.data as any);
break;
case 'organization':
this.data = new OrganizationDTO(entity.data as any);
break;
default:
throw new Error(`Неизвестный тип аккаунта: ${entity.type}`);
}
}
}
@@ -12,6 +12,8 @@ import type { PaginationResultDomainInterface } from '~/domain/common/interfaces
import { RegisterAccountInputDTO } from '../dto/register-account-input.dto';
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
import { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
import { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
export const AccountsPaginationResult = createPaginationResult(AccountDTO, 'Accounts');
@@ -40,6 +42,19 @@ export class AccountResolver {
return await this.accountService.getAccounts(data, options);
}
@Query(() => [PrivateAccountSearchResultDTO], {
name: 'searchPrivateAccounts',
description:
'Поиск приватных данных аккаунтов по запросу. Поиск осуществляется по полям ФИО, ИНН, ОГРН, наименованию организации и другим приватным данным.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async searchPrivateAccounts(
@Args('data', { type: () => SearchPrivateAccountsInputDTO }) data: SearchPrivateAccountsInputDTO
): Promise<PrivateAccountSearchResultDTO[]> {
return this.accountService.searchPrivateAccounts(data);
}
@Mutation(() => RegisteredAccountDTO, {
name: 'registerAccount',
description: 'Зарегистрировать аккаунт пользователя в системе',
@@ -8,6 +8,8 @@ import type { RegisterAccountInputDTO } from '../dto/register-account-input.dto'
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
import type { DeleteAccountInputDTO } from '../dto/delete-account-input.dto';
import type { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
import type { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
@Injectable()
export class AccountService {
@@ -44,4 +46,15 @@ export class AccountService {
const result = await this.accountDomainInteractor.registerAccount(data);
return new RegisteredAccountDTO(result);
}
/**
* Поиск приватных аккаунтов
* @param data Входные данные для поиска
* @returns Массив результатов поиска
*/
public async searchPrivateAccounts(data: SearchPrivateAccountsInputDTO): Promise<PrivateAccountSearchResultDTO[]> {
const results = await this.accountDomainInteractor.searchPrivateAccounts(data);
return results.map((result) => new PrivateAccountSearchResultDTO(result));
}
}
@@ -14,10 +14,11 @@ import { DecisionModule } from './free-decision/decision.module';
import { AgreementModule } from './agreement/agreement.module';
import { ParticipantModule } from './participant/participant.module';
import { AgendaModule } from './agenda/agenda.module';
import { PaymentModule } from './payment/payment.module';
import { CooplaceModule } from './cooplace/cooplace.module';
import { DesktopModule } from './desktop/desktop.module';
import { MeetModule } from './meet/meet.module';
import { GatewayModule } from './gateway/gateway.module';
import { WalletModule } from './wallet/wallet.module';
@Module({
imports: [
@@ -32,13 +33,14 @@ import { MeetModule } from './meet/meet.module';
PaymentMethodModule,
QueueModule,
RedisAppModule,
PaymentModule,
SystemModule,
DocumentModule,
DecisionModule,
ParticipantModule,
CooplaceModule,
MeetModule,
GatewayModule,
WalletModule,
],
exports: [
AccountModule,
@@ -50,7 +52,6 @@ import { MeetModule } from './meet/meet.module';
DesktopModule,
LoggerModule,
PaymentMethodModule,
PaymentModule,
QueueModule,
RedisAppModule,
SystemModule,
@@ -59,6 +60,8 @@ import { MeetModule } from './meet/meet.module';
ParticipantModule,
CooplaceModule,
MeetModule,
GatewayModule,
WalletModule,
],
})
export class ApplicationModule {}
@@ -5,7 +5,7 @@ import { MetaDocumentInputDTO } from '~/modules/document/dto/meta-document-input
import { SignedDigitalDocumentInputDTO } from '~/modules/document/dto/signed-digital-document-input.dto';
import type { ExcludeCommonProps } from '~/modules/document/types';
import { Type } from 'class-transformer';
import { IsArray, IsObject, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsNotEmpty, IsObject, ValidateNested } from 'class-validator';
import { AgendaMeetDTO } from '~/modules/meet/dto/agenda-meet.dto';
import { AgendaGeneralMeetQuestionDTO } from '~/modules/meet/dto/agenda-general-meet-question.dto';
@@ -25,6 +25,11 @@ class BaseAnnualGeneralMeetingAgendaMetaDocumentInputDTO implements ExcludeCommo
@ValidateNested({ each: true })
@Type(() => AgendaGeneralMeetQuestionDTO)
questions!: AgendaGeneralMeetQuestionDTO[];
@Field(() => Boolean, { description: 'Флаг повторного собрания' })
@IsBoolean()
@IsNotEmpty()
is_repeated!: boolean;
}
@InputType(`AnnualGeneralMeetingAgendaGenerateDocumentInput`)
@@ -4,7 +4,7 @@ import { GenerateMetaDocumentInputDTO } from '~/modules/document/dto/generate-me
import { MetaDocumentInputDTO } from '~/modules/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/modules/document/dto/signed-digital-document-input.dto';
import type { ExcludeCommonProps } from '~/modules/document/types';
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsNumber, IsString } from 'class-validator';
// интерфейс параметров для генерации
type action = Cooperative.Registry.AnnualGeneralMeetingSovietDecision.Action;
@@ -20,6 +20,11 @@ class BaseAnnualGeneralMeetingSovietDecisionDocumentInputDTO implements ExcludeC
@IsString()
@IsNotEmpty()
meet_hash!: string;
@Field(() => Boolean, { description: 'Флаг повторного собрания' })
@IsBoolean()
@IsNotEmpty()
is_repeated!: boolean;
}
@InputType(`AnnualGeneralMeetingSovietDecisionGenerateDocumentInput`)
@@ -31,16 +36,6 @@ export class AnnualGeneralMeetingSovietDecisionGenerateDocumentInputDTO
implements action
{
registry_id!: number;
@Field(() => Number, { description: 'ID решения совета' })
@IsNumber()
@IsNotEmpty()
decision_id!: number;
@Field(() => String, { description: 'Хеш собрания' })
@IsString()
@IsNotEmpty()
meet_hash!: string;
}
@InputType(`AnnualGeneralMeetingSovietDecisionSignedMetaDocumentInput`)

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