Биллинг подписок mono (зонтик): контракт billing + ledger2 + coopback + UI — НЕ мерджить, стек на ревью #129

Open
claude wants to merge 21 commits from provider-billing into dev
119 changed files with 5710 additions and 3687 deletions
+2
View File
@@ -23,3 +23,5 @@ components/contracts/cpp/ledger2/scripts/out/
.env.testnet
.pnpm-store
schema.gql
.claude
+1 -1
View File
@@ -72,7 +72,7 @@ Workflow:
pnpm jest tests/unit/marketplace/marketplace-onboarding-service.test.ts --runInBand
```
`pnpm generate-schema` / `pnpm generate-client`**не запускать локально**; та же memory/CPU полка вешает контейнер controller'а. Либо CI, либо пользователь сам когда контейнер остановлен.
`pnpm generate-schema` / `pnpm generate-client`**запускать локально можно** (ограничение снято 2026-05-26). Раньше боялись, что memory/CPU-полка повесит контейнер controller'а, но на практике генерация отрабатывает; запускать при поднятом controller'е.
Перед коммитом достаточно `tsc --noEmit` (быстрый, не блокирует).
+5
View File
@@ -128,4 +128,9 @@ export default [
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'billing',
path: path.join(userBase, 'billing'),
target: 'billing',
},
]
+4
View File
@@ -154,6 +154,10 @@ export default {
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'billing',
code_permissions_to: ['billing'],
},
// {
// name: provider_chairman,
// },
+12 -2
View File
@@ -29,12 +29,22 @@ export async function bootExtra() {
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
// сервера (например, просто пересев совета/чейна для других задач), поэтому
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
//
// EXTRA_SEED=1 (взаимоисключающее с EXTRA_RENT) — сидит partner1 как
// пайщика-organization Восхода (newaccount + reguser + wallet-signagree +
// засев coopback) БЕЗ regcoop+preInit. Pre-state для ручного прохода
// ConnectionAgreement: signin под WIF из фикстуры → UI выбора тарифа /
// подписания / активации руками. EXTRA_RENT приоритетен.
if (process.env.EXTRA_RENT === '1') {
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
await installExtraData(blockchain, 'full')
}
else if (process.env.EXTRA_SEED === '1') {
console.log('EXTRA_SEED=1 → installExtraData (candidate): partner1 — пайщик-organization, готов к UI Connect')
await installExtraData(blockchain, 'candidate')
}
else {
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
console.log('EXTRA_RENT/EXTRA_SEED не заданы → пропускаем провижининг partner1')
}
console.log('Инициализируем статус системы в PostgreSQL')
+83 -50
View File
@@ -580,7 +580,10 @@ export async function installInitialData(blockchain: Blockchain, isExtended = fa
console.log('Начальные данные установлены')
}
export async function installExtraData(blockchain: Blockchain) {
export async function installExtraData(
blockchain: Blockchain,
mode: 'full' | 'candidate' = 'full',
) {
// Регистрируем partner1 как coop с авто-approve от провайдера (Восхода).
// Без этого реальный UI flow требует ручной активации председателем —
// провайдер не подтянет partner1 в свою DB пока в registrator.coops его
@@ -592,33 +595,58 @@ export async function installExtraData(blockchain: Blockchain) {
// program_id > 0 с проверкой «подписывается через wallet::signagree»).
// Минимум для попадания в registrator.coops со status=active:
// newaccount → reguser(type=organization) → regcoop → stcoopstatus(active)
console.log('\n=== installExtraData: регистрируем partner1 как coop (active) ===')
//
// mode='candidate' — пропускает regcoop+preInit. partner1 остаётся
// пайщиком-organization Восхода: можно signin под WIF из фикстуры и пройти
// UI «Connect / выбор тарифа / подписание / активация» руками. Используется
// для отладки UX подключения без авто-провижининга.
console.log(`\n=== installExtraData (${mode}): partner1 ===`)
const username = 'partner1'
// Берём WIF из docs-harness/state/cooperatives/partner1.json, если он там есть.
// Без этого boot:extra на каждом запуске генерирует случайный privateKey,
// а harness 08 (chairman install wizard /partner1/install) подаёт WIF из
// фикстуры на шаге 1 — они расходятся, startInstall mutation не принимает
// ключ, wizard зависает на шаге 1 без видимой ошибки. На репозиторий
// фикстура коммит'ится разработчиком вручную; здесь только читаем.
// Источники WIF (по приоритету): env PARTNER1_WIF → фикстура
// docs-harness/state/cooperatives/partner1.json → DEFAULT_PARTNER1_WIF.
//
// DEFAULT_PARTNER1_WIF — общеизвестный test-ключ EOSIO (тот же, что у root
// системного eosio в boot/configs/config.ini). На dev-стенде использовать
// безопасно: контур закрытый, на проде partner1 не существует. Стабильный
// дефолт удобнее env-override'ов и фикстур — каждый reboot:extra даёт тот
// же signin, без записи в .env.
const DEFAULT_PARTNER1_WIF = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
let seededKeys: { privateKey: string; publicKey: string } | undefined
try {
const fixturePath = resolve(
__dirname,
'../../../docs-harness/state/cooperatives/partner1.json',
)
if (existsSync(fixturePath)) {
const fx = JSON.parse(readFileSync(fixturePath, 'utf-8'))
if (fx?.wif) {
// publicKey не хранится в фикстуре — деривим из приватного.
const pub = fx.publicKey || await ecc.privateToPublic(fx.wif)
seededKeys = { privateKey: fx.wif, publicKey: pub }
console.log(`✓ partner1: используем seeded WIF из фикстуры (pub=${pub.slice(0, 12)}…)`)
}
const envWif = process.env.PARTNER1_WIF?.trim()
if (envWif) {
try {
const pub = await ecc.privateToPublic(envWif)
seededKeys = { privateKey: envWif, publicKey: pub }
console.log(`✓ partner1: используем PARTNER1_WIF из env (pub=${pub.slice(0, 12)}…)`)
} catch (e: any) {
console.warn(`⚠ PARTNER1_WIF недопустим (${e.message}) — пробуем фикстуру`)
}
} catch (e: any) {
console.warn(`⚠ partner1 fixture read failed (${e.message}) — генерируем новый ключ`)
}
if (!seededKeys) {
try {
const fixturePath = resolve(
__dirname,
'../../../docs-harness/state/cooperatives/partner1.json',
)
if (existsSync(fixturePath)) {
const fx = JSON.parse(readFileSync(fixturePath, 'utf-8'))
if (fx?.wif) {
// publicKey не хранится в фикстуре — деривим из приватного.
const pub = fx.publicKey || await ecc.privateToPublic(fx.wif)
seededKeys = { privateKey: fx.wif, publicKey: pub }
console.log(`✓ partner1: используем seeded WIF из фикстуры (pub=${pub.slice(0, 12)}…)`)
}
}
} catch (e: any) {
console.warn(`⚠ partner1 fixture read failed (${e.message}) — пробуем дефолт`)
}
}
if (!seededKeys) {
const pub = await ecc.privateToPublic(DEFAULT_PARTNER1_WIF)
seededKeys = { privateKey: DEFAULT_PARTNER1_WIF, publicKey: pub }
console.log(`✓ partner1: используем DEFAULT_PARTNER1_WIF (pub=${pub.slice(0, 12)}…)`)
}
const account = await blockchain.generateKeypair(username, seededKeys, 'Аккаунт partner1')
@@ -648,34 +676,39 @@ export async function installExtraData(blockchain: Blockchain) {
registration_hash,
})
await blockchain.registerCooperative({
username: account.username,
coopname: account.username,
params: {
is_cooperative: true,
coop_type: 'conscoop',
announce: 'partner-dev.coopenomics.world',
description: 'Партнёрский кооператив (тестовый dev-стенд)',
initial: `100.0000 ${config.token.govern_symbol}`,
minimum: `300.0000 ${config.token.govern_symbol}`,
org_initial: `1000.0000 ${config.token.govern_symbol}`,
org_minimum: `3000.0000 ${config.token.govern_symbol}`,
},
document: {
hash: registration_hash,
signatures: [],
meta: '{}',
version: '1.0.0',
doc_hash: registration_hash,
meta_hash: registration_hash,
} as any,
})
if (mode === 'full') {
await blockchain.registerCooperative({
username: account.username,
coopname: account.username,
params: {
is_cooperative: true,
coop_type: 'conscoop',
announce: 'partner-dev.coopenomics.world',
description: 'Партнёрский кооператив (тестовый dev-стенд)',
initial: `100.0000 ${config.token.govern_symbol}`,
minimum: `300.0000 ${config.token.govern_symbol}`,
org_initial: `1000.0000 ${config.token.govern_symbol}`,
org_minimum: `3000.0000 ${config.token.govern_symbol}`,
},
document: {
hash: registration_hash,
signatures: [],
meta: '{}',
version: '1.0.0',
doc_hash: registration_hash,
meta_hash: registration_hash,
} as any,
})
await blockchain.preInit({
coopname: account.username,
username: config.provider,
status: 'active',
})
await blockchain.preInit({
coopname: account.username,
username: config.provider,
status: 'active',
})
}
else {
console.log('candidate-mode: пропускаем regcoop+preInit (UI Connect делает руками)')
}
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
+1
View File
@@ -58,6 +58,7 @@ add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(billing)
add_contract_build(system)
# Опции для тестов
@@ -0,0 +1,14 @@
cmake_minimum_required(VERSION 3.5)
# Проект
project(billing)
find_package(cdt)
# Добавление контракта
add_contract(billing billing billing.cpp)
if(IS_TESTNET)
target_compile_definitions(billing PUBLIC IS_TESTNET=1)
else()
# Не определяем IS_TESTNET для продакшн режима
endif()
@@ -0,0 +1,18 @@
#include "billing.hpp"
#include <ctime>
#include <eosio/transaction.hpp>
#include "src/convert.cpp"
#include "src/pay.cpp"
using namespace eosio;
/**
* @brief Миграция данных контракта.
* @ingroup public_billing_actions
* @note Авторизация требуется от аккаунта: @p billing
*/
[[eosio::action]]
void billing::migrate() {
require_auth(_billing);
}
@@ -0,0 +1,73 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/eosio.hpp>
#include "../lib/index.hpp"
/**
\defgroup public_billing Контракт BILLING
* Минимальный смарт-контракт оплаты инфраструктурных подписок членскими взносами
* пайщика (Epic 12). Ровно два действия:
* - `convert` — трансляция паевого взноса пайщика в членский на персональный
* биллинг-кошелёк (`w.wal.bill`) по подписанному заявлению (document2);
* - `pay` — списание с биллинг-кошелька суммарной стоимости подписок в
* инфраструктурный кошелёк кооператива (`w.sov.infra`) по идентификатору
* платежа (`payment_hash`), идемпотентно.
*
* Состав, цены и даты подписок on-chain НЕ хранятся — это зона оператора
* (provider backend); контракт несёт только сумму + `payment_hash` + memo.
*/
/**
\defgroup public_billing_actions Действия
\ingroup public_billing
*/
/**
\defgroup public_billing_tables Таблицы
\ingroup public_billing
*/
namespace Billing {
/**
* @brief Проверка, что аккаунт-плательщик является кооперативом (presence-only).
*
* По требованию @ant (2026-05-25): проверяем только НАЛИЧИЕ записи в реестре
* кооперативов регистратора и флаг `is_cooperative`, БЕЗ проверки статуса —
* заблокированные/на рассмотрении кооперативы тоже проходят этот guard
* (статус контролируется выше по потоку, на стороне оператора/ledger2).
*/
inline void assert_payer_is_cooperative(eosio::name coopname) {
cooperatives2_index coops(_registrator, _registrator.value);
auto org = coops.find(coopname.value);
eosio::check(org != coops.end(), "Плательщик не найден в реестре кооперативов");
eosio::check(org->is_coop(), "Плательщик не является кооперативом");
}
} // namespace Billing
/**
* \ingroup public_contracts
* @brief Контракт Billing (оплата подписок членскими взносами).
*/
class [[eosio::contract]] billing : public contract {
public:
using contract::contract;
[[eosio::action]] void migrate();
// Конвертация паевого взноса в членский на биллинг-кошелёк пайщика.
// convert_hash — детерминированный sha256 от (coopname, username, amount,
// anchor) — служит process-якорем: используется как ключ операции ledger2
// и как package_hash для Soviet::make_complete_document (документ в реестре
// ищется именно по convert_hash). Повтор с тем же convert_hash —
// идемпотентный no-op на уровне реестра soviet (newresolved уже зафиксирован).
[[eosio::action]] void convert(eosio::name coopname, eosio::name username,
eosio::asset amount, eosio::checksum256 convert_hash,
document2 document);
// Списание с биллинг-кошелька стоимости подписок (оплата провайдеру).
[[eosio::action]] void pay(eosio::name coopname, eosio::name username,
eosio::asset amount, eosio::checksum256 payment_hash,
std::string memo);
};
@@ -0,0 +1,72 @@
using namespace eosio;
/**
* @brief Конвертация паевого взноса пайщика в членский на биллинг-кошелёк.
*
* Пайщик подаёт подписанное заявление (document2) «прошу транслировать мой
* паевой взнос в членский за использование инфраструктуры». Контракт одним
* движением ledger2 (`o.bil.fund`) переводит средства с паевого кошелька
* `w.wal.share` на персональный биллинг-кошелёк `w.wal.bill` (Dr 80 / Cr 86):
* паевой взнос (возвратный) превращается в членский (целевой, невозвратный),
* зарезервированный под оплату подписок. Сам факт конвертации трактуется как
* согласие пайщика на последующие рекуррентные списания (см. `pay`).
*
* @param coopname Кооператив-плательщик (его подписью релеит бэкенд после JWT).
* @param username Пайщик, чей паевой конвертируется.
* @param amount Сумма конвертации (RUB, > 0).
* @param convert_hash Детерминированный sha256-якорь процесса конвертации
* (coopname, username, amount, anchor). Используется как
* process_hash ledger2-операции и как package_hash в
* Soviet::make_complete_document — по нему документ
* находится в реестре кооператива. Обеспечивает связность
* процесса и идемпотентность на уровне soviet-реестра.
* @param document Подписанное пайщиком заявление (обязательно, непустой hash).
*
* @ingroup public_billing_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
void billing::convert(name coopname, name username, asset amount,
checksum256 convert_hash, document2 document) {
require_auth(coopname);
// Плательщик должен быть кооперативом (presence-only, без статуса).
Billing::assert_payer_is_cooperative(coopname);
check(amount.is_valid() && amount.amount > 0, "Сумма конвертации должна быть положительной");
check(amount.symbol == _root_govern_symbol, "Неверный символ валюты для конвертации");
check(convert_hash != checksum256{}, "convert_hash обязателен (детерминированный якорь процесса)");
// Заявление обязательно и должно быть подписано пайщиком. Ловушка: пустой
// checksum256 ломает ABI-кодирование/верификацию — документ должен быть реальным.
check(!is_empty_document(document), "Заявление на конвертацию обязательно (пустой документ недопустим)");
verify_document_or_fail(document, std::vector<name>{username});
const std::string memo =
"Трансляция паевого взноса в членский на биллинг-кошелёк, пайщик=" + username.to_string();
Ledger2::apply(
_billing,
coopname,
operations::billing::CONVERT,
amount,
username,
convert_hash,
memo
);
// Фиксируем заявление в общем реестре документов кооператива (newsubmitted +
// newresolved в soviet — как в registrator::regcoop / capital::createpinv /
// soviet::converttoaxn). action_name = operations::billing::CONVERT
// (= "o.bil.fund"_n) — уникальное имя ledger2-операции, не пересекается с
// generic "convert"_n из других контрактов. package_hash = convert_hash —
// документ привязан к конкретному процессу; повтор с тем же convert_hash на
// уровне soviet-реестра идемпотентен.
Soviet::make_complete_document(
_billing,
coopname,
username,
operations::billing::CONVERT,
convert_hash,
document
);
}
@@ -0,0 +1,54 @@
using namespace eosio;
/**
* @brief Списание стоимости подписок с биллинг-кошелька пайщика (оплата).
*
* Оператор (Восход) одним действием списывает с биллинг-кошелька `w.wal.bill`
* пайщика суммарную стоимость его активных подписок и зачисляет её в
* инфраструктурный кошелёк кооператива `w.sov.infra` (ledger2 `o.bil.pay`).
* Отдельная подпись пайщика не требуется — авторизация обеспечена нахождением
* контракта в `contracts_whitelist` + фактом конвертации (`convert` = согласие).
*
* Состав подписок on-chain не раскрывается: контракт несёт только сумму,
* `payment_hash` (ссылка на запись в БД провайдера) и memo. Дедуп по
* `payment_hash` — у provider'а (Single-Hub v5: парсер Восхода ловит pay-event,
* coopback Восхода дёргает provider `POST /billing/payment-confirmed`, provider
* сам отвечает на повторы). Контракт своих таблиц не ведёт.
*
* @param coopname Кооператив-плательщик.
* @param username Пайщик, с чьего биллинг-кошелька списываем.
* @param amount Сумма к оплате (RUB, > 0).
* @param payment_hash Идентификатор платежа из БД провайдера (обязателен).
* @param memo Произвольный комментарий (< 256 символов).
*
* @ingroup public_billing_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
void billing::pay(name coopname, name username, asset amount,
checksum256 payment_hash, std::string memo) {
require_auth(coopname);
// Плательщик должен быть кооперативом (presence-only, без статуса).
Billing::assert_payer_is_cooperative(coopname);
check(amount.is_valid() && amount.amount > 0, "Сумма оплаты должна быть положительной");
check(amount.symbol == _root_govern_symbol, "Неверный символ валюты для оплаты");
check(payment_hash != checksum256{}, "payment_hash обязателен");
check(memo.size() < 256, "memo не должен превышать 255 символов");
const std::string ledger_memo =
memo.empty() ? std::string("Оплата подписки за инфраструктуру") : memo;
// Списание членских взносов пайщика → инфраструктурный кошелёк кооператива.
// Если на w.wal.bill недостаточно средств — walletop упадёт доменной ошибкой;
// оператор фиксирует подписку как просроченную (Epic 4: past_due → grace).
Ledger2::apply(
_billing,
coopname,
operations::billing::PAY,
amount,
username,
payment_hash,
ledger_memo
);
}
+4 -1
View File
@@ -140,6 +140,7 @@ static constexpr uint64_t _capital_program_id = 4;
#define LEDGER "ledger"
#define LEDGER2 "ledger2"
#define APPS "apps"
#define BILLING "billing"
/**
@@ -165,6 +166,7 @@ static constexpr uint64_t _capital_program_id = 4;
static constexpr eosio::name _ledger = "ledger"_n;
static constexpr eosio::name _ledger2 = "ledger2"_n;
static constexpr eosio::name _apps = "apps"_n;
static constexpr eosio::name _billing = "billing"_n;
static constexpr eosio::name _power_account = "eosio.power"_n;
static constexpr eosio::name _saving_account = "eosio.saving"_n;
@@ -193,7 +195,8 @@ static constexpr uint64_t _capital_program_id = 4;
"ledger"_n,
"ledger2"_n,
"capital"_n,
"apps"_n
"apps"_n,
"billing"_n
// Добавьте остальные стандартные или пользовательские контракты по необходимости
};
@@ -93,6 +93,14 @@ namespace operations {
inline constexpr eosio::name CONVERT_AXN = "o.sov.axncnv"_n; ///< Трансляция паевого взноса в членский (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → DELEGATE_FEES).
}
// billing (Epic 12 — контракт billing)
// ВНИМАНИЕ: имя константы НЕ `FUND` — `FUND` занят макросом `#define FUND "fund"`
// в consts.hpp (имя контракта fund). Используем `CONVERT` (бэкенд-имя действия).
namespace billing {
inline constexpr eosio::name CONVERT = "o.bil.fund"_n; ///< Трансляция паевого взноса в членский на биллинг-кошелёк пайщика (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → BILLING_FUND_PAY).
inline constexpr eosio::name PAY = "o.bil.pay"_n; ///< Оплата подписки членскими взносами пайщика (TRANSFER BILLING_FUND_PAY → INFRA_FEES, без Dr/Cr — оба разреза счёта 86).
}
// migration (только из migrate.cpp)
//
// В OPERATION_REGISTRY включены **только** те транзиты, которые проводятся
@@ -320,6 +328,27 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
// 20. Биллинг — пополнение биллинг-кошелька: Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → BILLING_FUND_PAY.
// Зеркало o.sov.axncnv (трансляция паевого в членский), но назначение —
// персональный USER_SHARED биллинг-кошелёк пайщика, а не кооперативный пул
// делегатских взносов. После конвертации средства невозвратны (членские),
// лежат на личном разрезе пайщика до списания за подписки (o.bil.pay). (Epic 12)
{ operations::billing::CONVERT, processes::billing::CONVERT, WalletOp::TRANSFER,
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::BILLING_FUND_PAY,
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
"Трансляция паевого взноса в членский на биллинг-кошелёк пайщика" },
// 21. Биллинг — оплата подписки: TRANSFER BILLING_FUND_PAY → INFRA_FEES, без Dr/Cr.
// Оба кошелька — аналитические разрезы счёта 86 (целевое финансирование):
// перенос «членский взнос пайщика, зарезервированный под подписки» →
// «членские взносы за инфраструктуру платформы». Бухпроводка не требуется
// (реклассификация внутри 86), по аналогии с o.cap.invest/o.wal.wthreq. (Epic 12)
{ operations::billing::PAY, processes::billing::PAY, WalletOp::TRANSFER,
ledger2_wallets::BILLING_FUND_PAY, ledger2_wallets::INFRA_FEES,
0, 0,
"Оплата подписки за инфраструктуру членскими взносами пайщика" },
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
@@ -65,6 +65,14 @@ namespace processes {
inline constexpr eosio::name AXN_CONVERT = "p.sov.axncnv"_n; ///< Конвертация паевого RUB → делегатский ЧВ (одноактовый).
}
// billing (Epic 12 — контракт billing)
// ВНИМАНИЕ: имя константы НЕ `FUND` — `FUND` занят макросом `#define FUND "fund"`
// в consts.hpp (имя контракта fund). Используем `CONVERT` (бэкенд-имя действия).
namespace billing {
inline constexpr eosio::name CONVERT = "p.bil.fund"_n; ///< Пополнение биллинг-кошелька пайщика (трансляция паевого в членский, одноактовый; anchor = hash заявления document2).
inline constexpr eosio::name PAY = "p.bil.pay"_n; ///< Оплата подписки с биллинг-кошелька (одноактовый; anchor = payment_hash из БД провайдера).
}
// migration
namespace migration {
inline constexpr eosio::name TRANSIT = "p.mig.trans"_n; ///< Транзитный перенос остатков legacy (серия apply на кооп).
@@ -46,6 +46,7 @@ struct ledger2_wallets {
// wallet — паевой фонд + возвраты + ЦК
static constexpr eosio::name SHARE_FUND_PAY = "w.wal.share"_n; ///< Паевой взнос пайщика (USER_SHARED)
static constexpr eosio::name CK_MEMBER = "w.wal.member"_n; ///< ЦК — членская часть пайщика (USER_SHARED)
static constexpr eosio::name BILLING_FUND_PAY = "w.wal.bill"_n; ///< ЦК — биллинг (USER_SHARED). Источник для контракта `billing`: o.bil.fund зачисляет (паевой→членский), o.bil.pay списывает в w.sov.infra.
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: исторический sink возвратов. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
static constexpr eosio::name WITHDRAW_PENDING = "w.wal.wpend"_n; ///< Резерв паевого под заявку на возврат (COOPERATIVE-пул). o.wal.wthreq переводит сюда с w.wal.share, o.wal.wthdec возвращает обратно, o.wal.wthcpl сжигает отсюда. Заменил механику blocked/BLOCK/UNBLOCK 2026-05-24.
@@ -95,11 +96,12 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (5) — L3-разрез по пайщику
inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "ЦК — паевой", WalletKind::USER_SHARED },
{ ledger2_wallets::CK_MEMBER, "ЦК — членский", WalletKind::USER_SHARED },
{ ledger2_wallets::BILLING_FUND_PAY, "ЦК — биллинг", WalletKind::USER_SHARED },
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
@@ -230,10 +232,11 @@ struct Ledger2WalletProgramMapping {
uint64_t required_program_id; // 0 = исключение (без проверки)
};
inline constexpr std::array<Ledger2WalletProgramMapping, 6> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
inline constexpr std::array<Ledger2WalletProgramMapping, 7> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
{ ledger2_wallets::MIN_SHARE_FUND, 0 /* w.reg.minshr — без проверки */ },
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК */ },
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК */ },
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК — паевой */ },
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК — членский */ },
{ ledger2_wallets::BILLING_FUND_PAY, 1 /* ЦК — биллинг */ },
{ ledger2_wallets::BLAGOROST_FUND, 4 /* Благорост */ },
{ ledger2_wallets::GENERATOR_FUND, 3 /* Генератор */ },
{ ledger2_wallets::PREIMP_FUND, 0 /* w.cap.preimp — РИД-учёт до перехода на электронный учёт, без проверки */ },
File diff suppressed because it is too large Load Diff
+5
View File
@@ -69,6 +69,7 @@ import { WalletModule } from './application/wallet/wallet.module';
import { NotificationModule } from './application/notification/notification.module';
import { LedgerModule } from './application/ledger/ledger.module';
import { Ledger2Module } from './application/ledger2/ledger2.module';
import { BillingModule } from './application/billing/billing.module';
import { ProcessRegistryModule } from './application/process-registry/process-registry.module';
import { BlockchainExplorerModule } from './application/blockchain-explorer/blockchain-explorer.module';
import { ProviderModule } from './application/provider/provider.module';
@@ -161,6 +162,10 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
NotificationModule,
LedgerModule,
Ledger2Module,
// Single-Hub v5: BillingModule подключается ТОЛЬКО на Восходе-хабе
// (BILLING_HUB_MODE=true). На спицах модуль не регистрируется —
// ни GraphQL Billing.*, ни крон-сервис, ни provider-client.
...(process.env.BILLING_HUB_MODE === 'true' ? [BillingModule] : []),
ProcessRegistryModule,
BlockchainExplorerModule,
ProviderModule,
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { BillingService } from './services/billing.service';
import { BillingResolver } from './resolvers/billing.resolver';
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
import { BillingCronService } from '~/domain/billing/services/billing-cron.service';
import { ProviderModule } from '~/application/provider/provider.module';
import { DocumentDomainModule } from '~/domain/document/document.module';
/**
* Модуль billing (Epic 12 / Single-Hub v5):
* - GraphQL-мутации оплаты подписок (convert/pay) — BillingResolver/BillingService;
* - периодическое списание — BillingCronService + HTTP-клиент провайдера;
* - список коопов для тика берётся из on-chain `registrator.coops` через
* ProviderService (никаких env-CSV).
*
* Blockchain-порт (`BILLING_BLOCKCHAIN_PORT`) предоставляется глобальным
* BlockchainModule. Подключается в корневой AppModule только при
* BILLING_HUB_MODE=true.
*/
@Module({
imports: [ProviderModule, DocumentDomainModule],
providers: [BillingService, BillingResolver, BillingProviderClient, BillingCronService],
exports: [BillingService],
})
export class BillingModule {}
@@ -0,0 +1,36 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsString, Matches, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { BillingConversionStatementSignedDocumentInputDTO } from '~/application/document/documents-dto/billing-conversion-statement-document.dto';
/**
* Input мутации `billingConvert` (действие `billing::convert`, operation `o.bil.fund`).
*
* Конвертирует паевой взнос пайщика в членский на персональный биллинг-кошелёк
* (`w.wal.bill`). `amount` — строка с символом, как в apply: `"1500.0000 RUB"`.
* `document` — подписанное пайщиком заявление (document2): согласие на конвертацию.
*/
@InputType('BillingConvertInput')
export class BillingConvertInputDTO {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта пайщика — владельца биллинг-кошелька' })
@IsString()
username!: string;
@Field(() => String, { description: 'Сумма с символом, например "1500.0000 RUB"' })
@IsString()
@Matches(/^\d+(\.\d+)?\s+[A-Z]{1,7}$/, { message: 'Формат "<amount> <SYMBOL>", например "1500.0000 RUB"' })
amount!: string;
@Field(() => BillingConversionStatementSignedDocumentInputDTO, {
description:
'Подписанное пайщиком заявление 1095.BillingConversionStatement ' +
'(document2 с типизированной meta: convert_amount).',
})
@ValidateNested()
@Type(() => BillingConversionStatementSignedDocumentInputDTO)
document!: BillingConversionStatementSignedDocumentInputDTO;
}
@@ -0,0 +1,37 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsString, Matches, MaxLength, Length } from 'class-validator';
/**
* Input мутации `billingPay` (действие `billing::pay`, operation `o.bil.pay`).
*
* Списывает с биллинг-кошелька пайщика суммарную стоимость подписок в
* инфраструктурный кошелёк кооператива. Идемпотентно по `paymentHash`.
* Состав и цены подписок on-chain не передаются — только сумма + payment_hash.
*/
@InputType('BillingPayInput')
export class BillingPayInputDTO {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта пайщика — владельца биллинг-кошелька' })
@IsString()
username!: string;
@Field(() => String, { description: 'Сумма с символом, например "1500.0000 RUB"' })
@IsString()
@Matches(/^\d+(\.\d+)?\s+[A-Z]{1,7}$/, { message: 'Формат "<amount> <SYMBOL>", например "1500.0000 RUB"' })
amount!: string;
@Field(() => String, {
description: 'Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера)',
})
@IsString()
@Length(1, 64)
paymentHash!: string;
@Field(() => String, { description: 'Назначение платежа' })
@IsString()
@MaxLength(255)
memo!: string;
}
@@ -0,0 +1,17 @@
import { Field, ObjectType } from '@nestjs/graphql';
/**
* Результат мутаций `billingConvert` / `billingPay`.
*
* `transactionId` — id транзакции в чейне для трассировки.
* `paymentHash` — для `billingPay` дублирует идентификатор платежа (для UI/сверки);
* для `billingConvert` пуст.
*/
@ObjectType('BillingResult')
export class BillingResultDTO {
@Field(() => String)
transactionId!: string;
@Field(() => String, { nullable: true })
paymentHash?: string;
}
@@ -0,0 +1,53 @@
import { Field, ObjectType, Int, Float } from '@nestjs/graphql';
/**
* Позиция разбивки «суммы к оплате» по подписке (из provider getBillingSummary).
*/
@ObjectType('BillingSummaryItem')
export class BillingSummaryItemDTO {
@Field(() => Int)
subscriptionId!: number;
@Field(() => Int)
subscriptionTypeId!: number;
@Field(() => String)
subscriptionTypeName!: string;
@Field(() => String)
status!: string;
@Field(() => Float)
amount!: number;
@Field(() => Boolean)
isFree!: boolean;
}
/**
* Сумма к оплате кооператива за период (проекция provider getBillingSummary в
* GraphQL для реестра кооперативов Восхода, Epic 12 / Story 12.5).
*/
@ObjectType('BillingSummary')
export class BillingSummaryDTO {
@Field(() => String)
coopname!: string;
@Field(() => Int)
periodDays!: number;
@Field(() => Float)
totalAmount!: number;
@Field(() => String)
currency!: string;
@Field(() => [BillingSummaryItemDTO])
items!: BillingSummaryItemDTO[];
@Field(() => String)
paymentHash!: string;
@Field(() => String, { nullable: true })
nextPaymentDue?: string | null;
}
@@ -0,0 +1,91 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { BillingService } from '../services/billing.service';
import { BillingConvertInputDTO } from '../dto/billing-convert-input.dto';
import { BillingPayInputDTO } from '../dto/billing-pay-input.dto';
import { BillingResultDTO } from '../dto/billing-result.dto';
import { BillingSummaryDTO } from '../dto/billing-summary.dto';
import { BillingConversionStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/billing-conversion-statement-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
/**
* GraphQL фасад billing (Epic 12 — оплата инфраструктурных подписок).
* - `billingConvert` — пайщик конвертирует паевой взнос в членский на личный
* биллинг-кошелёк (`w.wal.bill`), приложив подписанное заявление (document2);
* - `billingPay` — списание стоимости подписок с биллинг-кошелька в
* инфраструктурный кошелёк кооператива (оператор/председатель), идемпотентно.
*
* Подпись `coopname@active` (backend ретранслирует после JWT). Состав и цены
* подписок on-chain не хранятся — они живут на стороне оператора (provider).
*/
@Resolver()
export class BillingResolver {
constructor(private readonly service: BillingService) {}
@Query(() => BillingSummaryDTO, {
name: 'getBillingSummary',
description:
'Сумма к оплате кооператива за период (стоимость платных подписок, разбивка, ' +
'дата следующего платежа, payment_hash). Источник — provider backend оператора. ' +
'Для реестра кооперативов Восхода.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
getBillingSummary(
@Args('coopname', { type: () => String }) coopname: string,
@Args('period', { type: () => Number, nullable: true }) period?: number,
): Promise<BillingSummaryDTO> {
return this.service.getBillingSummary(coopname, period ?? 30);
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'generateBillingConversionStatement',
description:
'Генерирует заявление 1095.BillingConversionStatement (перед подписью пайщиком) — ' +
'аналог generateConvertToAxonStatement, канон documents-dto.',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['member', 'chairman'])
generateBillingConversionStatement(
@Args('data', { type: () => BillingConversionStatementGenerateDocumentInputDTO })
data: BillingConversionStatementGenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO,
): Promise<GeneratedDocumentDTO> {
return this.service.generateConversionStatement(data, options);
}
@Mutation(() => BillingResultDTO, {
name: 'billingConvert',
description:
'Конвертация паевого взноса пайщика в членский на биллинг-кошелёк (operation o.bil.fund). ' +
'Принимает подписанное пайщиком заявление 1095.BillingConversionStatement.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
billingConvert(
@Args('input', { type: () => BillingConvertInputDTO }) input: BillingConvertInputDTO,
): Promise<BillingResultDTO> {
return this.service.convert(input);
}
@Mutation(() => BillingResultDTO, {
name: 'billingPay',
description:
'Списание стоимости подписок с биллинг-кошелька пайщика в инфраструктурный ' +
'кошелёк кооператива (operation o.bil.pay). Идемпотентно по payment_hash.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
billingPay(
@Args('input', { type: () => BillingPayInputDTO }) input: BillingPayInputDTO,
): Promise<BillingResultDTO> {
return this.service.pay(input);
}
}
@@ -0,0 +1,97 @@
import { Inject, Injectable } from '@nestjs/common';
import {
BILLING_BLOCKCHAIN_PORT,
type BillingBlockchainPort,
} from '~/domain/billing/ports/billing-blockchain.port';
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import { AmountFormatterUtils } from '~/shared/utils/amount-formatter.utils';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import { TransactionUtils } from '~/shared/utils/transaction.utils';
import { Cooperative } from 'cooptypes';
import { BillingConversionStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/billing-conversion-statement-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import type { BillingConvertInputDTO } from '../dto/billing-convert-input.dto';
import type { BillingPayInputDTO } from '../dto/billing-pay-input.dto';
import type { BillingResultDTO } from '../dto/billing-result.dto';
import type { BillingSummaryDTO } from '../dto/billing-summary.dto';
/**
* Application-сервис billing (Epic 12). Тонкий фасад: валидацию входа делают
* class-validator-декораторы DTO, согласие пайщика (для convert) и presence-check
* кооператива проверяет on-chain сам контракт `billing`. Сервис делегирует подпись
* и отправку в blockchain-порт (`coopname@active`, WIF из vault).
*/
@Injectable()
export class BillingService {
constructor(
@Inject(BILLING_BLOCKCHAIN_PORT) private readonly blockchainPort: BillingBlockchainPort,
private readonly providerClient: BillingProviderClient,
private readonly documentDomainService: DocumentDomainService,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
) {}
/**
* Генерирует заявление 1095.BillingConversionStatement — подписывает пайщик
* на стороне desktop перед `billingConvert`. Канон: provider.service →
* generateConvertToAxonStatement.
*/
async generateConversionStatement(
data: BillingConversionStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO,
): Promise<GeneratedDocumentDTO> {
data.registry_id = Cooperative.Registry.BillingConversionStatement.registry_id;
data.convert_amount = AmountFormatterUtils.formatAmount(data.convert_amount);
const document = await this.documentDomainService.generateDocument({ data, options });
return document as unknown as GeneratedDocumentDTO;
}
/**
* Сумма к оплате кооператива за период — проекция provider getBillingSummary
* для реестра кооперативов (сумма/дата след. платежа/free-метки/payment_hash).
*/
async getBillingSummary(coopname: string, periodDays = 30): Promise<BillingSummaryDTO> {
const s = await this.providerClient.getBillingSummary(coopname, periodDays);
return {
coopname: s.coopname,
periodDays: s.period_days,
totalAmount: s.total_amount,
currency: s.currency,
paymentHash: s.payment_hash,
nextPaymentDue: s.next_payment_due,
items: (s.items ?? []).map((i) => ({
subscriptionId: i.subscription_id,
subscriptionTypeId: i.subscription_type_id,
subscriptionTypeName: i.subscription_type_name,
status: i.status,
amount: i.amount,
isFree: i.is_free,
})),
};
}
async convert(input: BillingConvertInputDTO): Promise<BillingResultDTO> {
const result = await this.blockchainPort.convert({
coopname: input.coopname,
username: input.username,
quantity: input.amount,
document: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(input.document),
});
return { transactionId: TransactionUtils.extractTransactionId(result) };
}
async pay(input: BillingPayInputDTO): Promise<BillingResultDTO> {
const result = await this.blockchainPort.pay({
coopname: input.coopname,
username: input.username,
quantity: input.amount,
paymentHash: input.paymentHash,
memo: input.memo,
});
return {
transactionId: TransactionUtils.extractTransactionId(result),
paymentHash: input.paymentHash,
};
}
}
@@ -0,0 +1,45 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString } from 'class-validator';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import type { ExcludeCommonProps } from '~/application/document/types';
// интерфейс параметров для генерации (registry_id = 1095)
type action = Cooperative.Registry.BillingConversionStatement.Action;
@InputType(`BaseBillingConversionStatementMetaDocumentInput`)
class BaseBillingConversionStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Сумма к конвертации паевого в членский на биллинг-кошелёк' })
@IsString()
convert_amount!: string;
}
@InputType(`BillingConversionStatementGenerateDocumentInput`)
export class BillingConversionStatementGenerateDocumentInputDTO
extends IntersectionType(
BaseBillingConversionStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements action
{
registry_id!: number;
constructor() {
super();
}
}
@InputType(`BillingConversionStatementSignedMetaDocumentInput`)
export class BillingConversionStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseBillingConversionStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {}
@InputType(`BillingConversionStatementSignedDocumentInput`)
export class BillingConversionStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BillingConversionStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация заявления на конвертацию паевого в биллинг-кошелёк',
})
public readonly meta!: BillingConversionStatementSignedMetaDocumentInputDTO;
}
@@ -1,6 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto';
import { Ledger2 } from 'cooptypes';
import { TransactionUtils } from '~/shared/utils/transaction.utils';
import {
LEDGER2_STATE_PORT,
type Ledger2StatePort,
@@ -136,7 +137,7 @@ export class Ledger2Service {
return {
processHash,
transactionId: this.extractTransactionId(result),
transactionId: TransactionUtils.extractTransactionId(result),
};
}
@@ -185,16 +186,4 @@ export class Ledger2Service {
private generateProcessHash(): string {
return createHash('sha256').update(randomBytes(32)).digest('hex');
}
private extractTransactionId(result: unknown): string {
if (result && typeof result === 'object' && 'transaction_id' in result) {
const tx = (result as { transaction_id?: unknown }).transaction_id;
if (typeof tx === 'string') return tx;
}
if (result && typeof result === 'object' && 'response' in result) {
const resp = (result as { response?: { transaction_id?: unknown } }).response;
if (resp && typeof resp.transaction_id === 'string') return resp.transaction_id;
}
return '';
}
}
@@ -0,0 +1,36 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ProviderSubscriptionDTO } from './provider-subscription.dto';
/**
* Элемент реестра кооперативов для оператора (Восход).
*
* Сводит в одну запись:
* - on-chain данные кооператива из registrator.coops (статус pending|active|blocked, домен, дата);
* - данные провайдера (подписки/инстанс/биллинг) из provider-backend, привязанные по coopname.
*
* Источник on-chain: BlockchainPort.getAllRows(registrator, coops).
* Источник provider: ProviderService.getUserSubscriptions(coopname) (server-secret канал).
*/
@ObjectType('CooperativeRegistryItem')
export class CooperativeRegistryItemDTO {
@Field(() => String, { description: 'Имя аккаунта кооператива (coopname)' })
coopname!: string;
@Field(() => String, { nullable: true, description: 'Наименование организации кооператива' })
name?: string;
@Field(() => String, { nullable: true, description: 'Анонсированный домен/сайт кооператива' })
announce?: string;
@Field(() => String, { description: 'Статус кооператива в блокчейне: pending | active | blocked' })
status!: string;
@Field(() => String, { nullable: true, description: 'Дата регистрации заявки кооператива (on-chain)' })
created_at?: string;
@Field(() => Boolean, { description: 'Есть ли у кооператива данные провайдера (хотя бы одна подписка)' })
has_provider_data!: boolean;
@Field(() => [ProviderSubscriptionDTO], { description: 'Подписки кооператива у провайдера (с инстансом и биллингом)' })
subscriptions!: ProviderSubscriptionDTO[];
}
@@ -3,11 +3,12 @@ import { ProviderService } from './services/provider.service';
import { ProviderResolver } from './resolvers/provider.resolver';
import { ConfigModule } from '@nestjs/config';
import { DocumentDomainModule } from '~/domain/document/document.module';
import { BlockchainModule } from '~/infrastructure/blockchain/blockchain.module';
// Импортируем для регистрации GraphQL enum
import '~/domain/instance-status.enum';
@Module({
imports: [ConfigModule, DocumentDomainModule],
imports: [ConfigModule, DocumentDomainModule, BlockchainModule],
providers: [ProviderService, ProviderResolver],
exports: [ProviderService],
})
@@ -3,6 +3,7 @@ import { UseGuards } from '@nestjs/common';
import { ProviderService } from '../services/provider.service';
import { ProviderSubscriptionDTO } from '../dto/provider-subscription.dto';
import { CurrentInstanceDTO } from '../dto/current-instance.dto';
import { CooperativeRegistryItemDTO } from '../dto/cooperative-registry-item.dto';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
@@ -18,6 +19,16 @@ import { Throttle } from '@nestjs/throttler';
export class ProviderResolver {
constructor(private readonly providerService: ProviderService) {}
@Query(() => [CooperativeRegistryItemDTO], {
name: 'getCooperativesRegistry',
description: 'Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['member', 'chairman'])
async getCooperativesRegistry(): Promise<CooperativeRegistryItemDTO[]> {
return this.providerService.getCooperativesRegistry();
}
@Query(() => [ProviderSubscriptionDTO], {
name: 'getProviderSubscriptions',
description: 'Получить подписки пользователя у провайдера',
@@ -1,9 +1,13 @@
import { Injectable, Logger, BadRequestException, Inject } from '@nestjs/common';
import { RegistratorContract } from 'cooptypes';
import { ProviderSubscriptionDTO } from '../dto/provider-subscription.dto';
import { CurrentInstanceDTO } from '../dto/current-instance.dto';
import { CooperativeRegistryItemDTO } from '../dto/cooperative-registry-item.dto';
import { InstanceStatus } from '~/domain/instance-status.enum';
import { Client, configureClient } from '@coopenomics/provider-client';
import { config } from '~/config';
import { BLOCKCHAIN_PORT, type BlockchainPort } from '~/domain/common/ports/blockchain.port';
import { ORGANIZATION_REPOSITORY, type OrganizationRepository } from '~/domain/common/repositories/organization.repository';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import { ConvertToAxonStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/convert-to-axon-statement-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
@@ -19,7 +23,9 @@ export class ProviderService {
constructor(
private readonly documentDomainService: DocumentDomainService,
@Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort
@Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort,
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
) {
// Проверяем наличие PROVIDER_BASE_URL
const providerBaseUrl = config.provider_base_url;
@@ -43,6 +49,64 @@ export class ProviderService {
return config.provider_base_url !== '';
}
/**
* Реестр кооперативов для оператора: сводит on-chain список кооперативов
* (registrator.coops) с данными провайдера (подписки/инстанс/биллинг по coopname).
*
* On-chain — источник истины по статусу кооператива (pending|active|blocked).
* Provider — источник данных подписок; если провайдер не настроен или у кооператива
* нет подписки, поле subscriptions остаётся пустым (кооператив всё равно в списке).
*/
async getCooperativesRegistry(): Promise<CooperativeRegistryItemDTO[]> {
const coops = (await this.blockchainPort.getAllRows(
RegistratorContract.contractName.production,
RegistratorContract.contractName.production,
RegistratorContract.Tables.Cooperatives.tableName
)) as RegistratorContract.Tables.Cooperatives.ICooperative[];
const providerAvailable = this.isProviderAvailable();
return Promise.all(
coops.map(async (coop) => {
const item = new CooperativeRegistryItemDTO();
item.coopname = coop.username;
item.name = await this.resolveOrganizationName(coop.username, coop.description);
item.announce = coop.announce;
item.status = coop.status;
item.created_at = coop.created_at;
item.subscriptions = [];
if (providerAvailable) {
try {
item.subscriptions = await this.getUserSubscriptions(coop.username);
} catch (error: any) {
// Провайдер недоступен или у кооператива нет подписок — не роняем весь реестр.
this.logger.warn(`Не удалось получить подписки провайдера для ${coop.username}: ${error.message}`);
}
}
item.has_provider_data = item.subscriptions.length > 0;
return item;
})
);
}
/**
* Извлечь человеко-читаемое наименование кооператива. Сначала пробуем
* organization-запись в Mongo (short_name → full_name), потом on-chain
* description; если и его нет — null, фронт сам отрисует coopname.
*/
private async resolveOrganizationName(username: string, onChainDescription: string): Promise<string | undefined> {
try {
const organization = await this.organizationRepository.findByUsername(username);
const name = organization?.short_name || organization?.full_name;
if (name) return name;
} catch {
// organization ещё не зарегистрирована — fallback на on-chain
}
return onChainDescription || undefined;
}
/**
* Получить подписки пользователя по username
*/
@@ -87,6 +87,24 @@ const envVarsSchema = z.object({
.describe('адрес сервиса GRAPHQL'),
PROVIDER_BASE_URL: z.string().default('').describe('базовый URL сервиса провайдера'),
// Billing Single-Hub v5: BillingModule подключается только на Восходе-хабе.
// На спицах флаг false → модуль вообще не регистрируется, endpoints/cron
// не появляются. Antelope не поддерживает deferred_trx — тик крона
// инициирует backend.
BILLING_HUB_MODE: z
.string()
.default('false')
.transform((v) => v === 'true' || v === '1')
.describe('узел работает как биллинг-хаб (только Восход): включает BillingModule, крон и GraphQL Billing.*'),
BILLING_CRON_EXPRESSION: z
.string()
.default('0 * * * *')
.describe('cron-выражение тика биллинга (по умолчанию ежечасно)'),
BILLING_CRON_PAYER: z
.string()
.default('')
.describe('пайщик-плательщик, чей w.wal.bill дебетуется (пусто — списание пропускается)'),
// Параметры союза кооперативов
UNION_LINK: z
.string()
@@ -262,6 +280,13 @@ export default {
coopname: envVars.data.COOPNAME,
graphql_service: envVars.data.GRAPHQL_SERVICE,
provider_base_url: envVars.data.PROVIDER_BASE_URL,
billing: {
hub_mode: envVars.data.BILLING_HUB_MODE,
cron_expression: envVars.data.BILLING_CRON_EXPRESSION,
// Список коопов — из on-chain `registrator.coops` через ProviderService
// (см. BillingCronService.activeCoopnames). Env-override отсутствует.
payer: envVars.data.BILLING_CRON_PAYER,
},
union: {
link: envVars.data.UNION_LINK,
is_unioned: envVars.data.IS_UNIONED,
@@ -0,0 +1,43 @@
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
import type { Cooperative } from 'cooptypes';
/**
* Параметры конвертации паевого взноса пайщика в членский на биллинг-кошелёк
* (`w.wal.bill`) действие `billing::convert` (operation `o.bil.fund`).
* Несёт подписанное пайщиком заявление (`document`).
*/
export interface BillingConvertBlockchainDomainInterface {
coopname: string;
/** Пайщик-владелец биллинг-кошелька. */
username: string;
/** Сумма с символом, например `"1500.0000 RUB"`. */
quantity: string;
/** Подписанное пайщиком заявление (document2). */
document: Cooperative.Document.IChainDocument2;
}
/**
* Параметры списания стоимости подписок с биллинг-кошелька пайщика в
* инфраструктурный кошелёк кооператива действие `billing::pay`
* (operation `o.bil.pay`). Идемпотентно по `paymentHash`.
*/
export interface BillingPayBlockchainDomainInterface {
coopname: string;
username: string;
/** Сумма с символом, например `"1500.0000 RUB"`. */
quantity: string;
/** Детерминированный идентификатор платежа (из provider getBillingSummary). */
paymentHash: string;
memo: string;
}
/**
* Порт billing для записи в блокчейн. Подпись `coopname@active` (WIF из vault);
* согласие пайщика для convert несёт `document`.
*/
export interface BillingBlockchainPort {
convert(data: BillingConvertBlockchainDomainInterface): Promise<TransactionResult>;
pay(data: BillingPayBlockchainDomainInterface): Promise<TransactionResult>;
}
export const BILLING_BLOCKCHAIN_PORT = Symbol('BILLING_BLOCKCHAIN_PORT');
@@ -0,0 +1,153 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger, Inject } from '@nestjs/common';
import cron from 'node-cron';
import config from '~/config/config';
import {
BILLING_BLOCKCHAIN_PORT,
type BillingBlockchainPort,
} from '~/domain/billing/ports/billing-blockchain.port';
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
import { ProviderService } from '~/application/provider/services/provider.service';
/**
* Периодическое списание подписок (Epic 12, Story 12.6) oracle-паттерн.
*
* Antelope не поддерживает deferred_trx, поэтому рекуррентность инициирует
* backend: на каждый тик узел запрашивает у провайдера «сумму к оплате»
* (источник истины по составу/ценам provider, on-chain их нет), и если есть
* что списывать и срок подошёл проводит on-chain `billing::pay`, затем
* подтверждает платёж провайдеру (тот продлевает подписки по `payment_hash`).
*
* Инварианты:
* - сумма = 0 (все подписки free) on-chain списание НЕ выполняется;
* - идемпотентность по `payment_hash` (контракт no-op + провайдер идемпотентен);
* - падение `pay` не зацикливает узел: ошибка логируется, тик продолжает
* следующий кооператив (grace/уведомления на стороне провайдера/Epic 9).
*
* Плательщик (`config.billing.payer`) пайщик, чей USER_SHARED-кошелёк
* `w.wal.bill` дебетуется. Списание per-пайщик, поэтому без указанного
* плательщика тик пропускается (см. конфиг `BILLING_CRON_PAYER`).
*/
@Injectable()
export class BillingCronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(BillingCronService.name);
private cronJob: cron.ScheduledTask | null = null;
private running = false;
constructor(
@Inject(BILLING_BLOCKCHAIN_PORT) private readonly blockchainPort: BillingBlockchainPort,
private readonly providerClient: BillingProviderClient,
private readonly providerService: ProviderService,
) {}
onModuleInit() {
if (!this.providerClient.isConfigured()) {
this.logger.warn('BillingCronService: PROVIDER_BASE_URL не задан — тик не будет запущен');
return;
}
if (!cron.validate(config.billing.cron_expression)) {
this.logger.error(`BillingCronService: некорректное cron-выражение "${config.billing.cron_expression}"`);
return;
}
this.cronJob = cron.schedule(config.billing.cron_expression, () => {
void this.tick();
});
this.logger.log(`BillingCronService запущен (cron="${config.billing.cron_expression}")`);
}
onModuleDestroy() {
if (this.cronJob) {
this.cronJob.stop();
this.cronJob = null;
}
}
/**
* Список коопов для тика берётся ИЗ on-chain `registrator.coops`
* (через ProviderService), отфильтрованный по `status === 'active'`.
* Никаких env-CSV кооперативы всегда есть в блокчейне.
*/
private async activeCoopnames(): Promise<string[]> {
const registry = await this.providerService.getCooperativesRegistry();
return registry.filter((c) => c.status === 'active').map((c) => c.coopname);
}
/**
* Один тик: обходит активные коопы из on-chain реестра, списывает подошедшие
* к оплате. Защита от наложения тиков (`running`) если предыдущий ещё идёт,
* пропускаем.
*/
async tick(): Promise<void> {
if (this.running) {
this.logger.warn('BillingCronService: предыдущий тик ещё выполняется — пропуск');
return;
}
const payer = config.billing.payer;
if (!payer) {
this.logger.warn('BillingCronService: BILLING_CRON_PAYER не задан — нечего дебетовать, пропуск тика');
return;
}
this.running = true;
try {
const coopnames = await this.activeCoopnames();
for (const coopname of coopnames) {
await this.processCoop(coopname, payer);
}
} finally {
this.running = false;
}
}
private async processCoop(coopname: string, payer: string): Promise<void> {
try {
const summary = await this.providerClient.getBillingSummary(coopname);
if (!summary || summary.total_amount <= 0) {
return; // всё free или нет подписок — пустые транзакции не гоняем
}
if (!this.isDue(summary.next_payment_due)) {
return; // срок ещё не подошёл
}
const quantity = `${summary.total_amount.toFixed(config.blockchain.root_govern_precision)} ${summary.currency || config.blockchain.root_govern_symbol}`;
const result = await this.blockchainPort.pay({
coopname,
username: payer,
quantity,
paymentHash: summary.payment_hash,
memo: `Оплата подписок за ${summary.period_days} дн.`,
});
const transactionId =
result && typeof result === 'object' && 'transaction_id' in result
? String((result as { transaction_id?: unknown }).transaction_id ?? '')
: '';
await this.providerClient.confirmPayment({
coopname,
paymentHash: summary.payment_hash,
amount: summary.total_amount,
blockchainTransactionId: transactionId,
periodDays: summary.period_days,
});
this.logger.log(`Списано ${quantity} за подписки ${coopname} (payment_hash=${summary.payment_hash})`);
} catch (error: any) {
// Падение списания (например, недостаток средств на w.wal.bill) не должно
// зацикливать узел: фиксируем и продолжаем. Перевод подписки в past_due/grace
// и уведомления — на стороне провайдера (Epic 4/9).
this.logger.error(`BillingCronService: списание для ${coopname} не выполнено: ${error?.message ?? error}`);
}
}
/**
* Срок оплаты подошёл, если дата следующего платежа не задана (первое списание)
* либо она в прошлом/сегодня.
*/
private isDue(nextPaymentDue: string | null): boolean {
if (!nextPaymentDue) return true;
const due = new Date(nextPaymentDue).getTime();
if (Number.isNaN(due)) return true;
return due <= Date.now();
}
}
@@ -0,0 +1,92 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import config from '~/config/config';
/**
* Позиция разбивки суммы к оплате (из provider getBillingSummary, Story 12.5).
*/
export interface ProviderBillingSummaryItem {
subscription_id: number;
subscription_type_id: number;
subscription_type_name: string;
status: string;
amount: number;
is_free: boolean;
}
/**
* Ответ агрегатора «сумма к оплате» провайдера (Story 12.5).
*/
export interface ProviderBillingSummary {
coopname: string;
period_days: number;
total_amount: number;
currency: string;
items: ProviderBillingSummaryItem[];
payment_hash: string;
next_payment_due: string | null;
}
/**
* HTTP-клиент к provider backend (Восход) для биллинга подписок (Epic 12).
*
* Провайдер источник истины по составу/ценам подписок (on-chain их нет).
* Доступ защищён `server-secret` (ServerSecretGuard на стороне провайдера).
* `provider_base_url` и `server_secret` берутся из конфига узла.
*/
@Injectable()
export class BillingProviderClient {
private readonly logger = new Logger(BillingProviderClient.name);
private get baseUrl(): string {
return config.provider_base_url.replace(/\/+$/, '');
}
private headers() {
return { 'server-secret': config.server_secret };
}
isConfigured(): boolean {
return this.baseUrl.length > 0;
}
/**
* Сумма к оплате кооператива за период (по умолчанию 30 дней).
*/
async getBillingSummary(coopname: string, periodDays = 30): Promise<ProviderBillingSummary> {
const url = `${this.baseUrl}/subscriptions/billing-summary/${coopname}`;
const { data } = await axios.get<ProviderBillingSummary>(url, {
params: { period: periodDays },
headers: this.headers(),
});
return data;
}
/**
* Подтверждение проведённого on-chain платежа: провайдер фиксирует факт и
* продлевает (`extend`) все подписки, входившие в `payment_hash`. Идемпотентно
* по `transaction_id = payment_hash` (Epic 3 / Story 12.5).
*/
async confirmPayment(input: {
coopname: string;
paymentHash: string;
amount: number;
blockchainTransactionId: string;
periodDays?: number;
}): Promise<void> {
const url = `${this.baseUrl}/subscriptions/confirm-batch-payment`;
await axios.post(
url,
{
coopname: input.coopname,
transaction_id: input.paymentHash,
payment_hash: input.paymentHash,
amount: input.amount,
blockchain_transaction_id: input.blockchainTransactionId,
period_days: input.periodDays ?? 30,
},
{ headers: this.headers() },
);
this.logger.log(`confirmPayment ${input.coopname} payment_hash=${input.paymentHash}`);
}
}
@@ -0,0 +1,86 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import httpStatus from 'http-status';
import { BillingContract } from 'cooptypes';
import { TransactResult } from '@wharfkit/session';
import { BlockchainService } from '../blockchain.service';
import { VAULT_DOMAIN_SERVICE, VaultDomainService } from '~/domain/vault/services/vault-domain.service';
import { HttpApiError } from '~/utils/httpApiError';
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
import type {
BillingBlockchainPort,
BillingConvertBlockchainDomainInterface,
BillingPayBlockchainDomainInterface,
} from '~/domain/billing/ports/billing-blockchain.port';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
/**
* Блокчейн-адаптер billing (Epic 12) оплата подписок членскими взносами.
*
* Подпись `coopname@active` через `BlockchainService.transact` с WIF из vault.
* Имена действий и payload из cooptypes (`BillingContract.Actions.{Convert,Pay}`),
* без сырых строк. Состав/цены подписок on-chain не передаются только сумма,
* payment_hash и memo.
*/
@Injectable()
export class BillingBlockchainAdapter implements BillingBlockchainPort {
private readonly logger = new Logger(BillingBlockchainAdapter.name);
constructor(
private readonly blockchainService: BlockchainService,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
@Inject(VAULT_DOMAIN_SERVICE) private readonly vaultDomainService: VaultDomainService,
) {}
private async initForCoop(coopname: string): Promise<void> {
const wif = await this.vaultDomainService.getWif(coopname);
if (!wif) {
throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ кооператива для подписания биллинг-операции');
}
this.blockchainService.initialize(coopname, wif);
}
async convert(data: BillingConvertBlockchainDomainInterface): Promise<TransactionResult> {
await this.initForCoop(data.coopname);
const formattedQuantity = this.domainToBlockchainUtils.formatQuantityWithPrecision(data.quantity);
const payload: BillingContract.Actions.Convert.IConvert = {
coopname: data.coopname,
username: data.username,
amount: formattedQuantity,
document: data.document as BillingContract.Actions.Convert.IConvert['document'],
};
const result = (await this.blockchainService.transact({
account: BillingContract.contractName.production,
name: BillingContract.Actions.Convert.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: payload,
})) as TransactResult;
this.logger.log(`billing::convert ${data.coopname}/${data.username} ${formattedQuantity}`);
return result;
}
async pay(data: BillingPayBlockchainDomainInterface): Promise<TransactionResult> {
await this.initForCoop(data.coopname);
const formattedQuantity = this.domainToBlockchainUtils.formatQuantityWithPrecision(data.quantity);
const payload: BillingContract.Actions.Pay.IPay = {
coopname: data.coopname,
username: data.username,
amount: formattedQuantity,
payment_hash: data.paymentHash,
memo: data.memo,
};
const result = (await this.blockchainService.transact({
account: BillingContract.contractName.production,
name: BillingContract.Actions.Pay.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data: payload,
})) as TransactResult;
this.logger.log(`billing::pay ${data.coopname}/${data.username} ${formattedQuantity}, payment_hash=${data.paymentHash}`);
return result;
}
}
@@ -28,6 +28,8 @@ import { LedgerBlockchainAdapter } from './adapters/ledger-blockchain.adapter';
import { LEDGER_BLOCKCHAIN_PORT } from '~/domain/ledger/ports/ledger.port';
import { Ledger2BlockchainAdapter } from './adapters/ledger2-blockchain.adapter';
import { LEDGER2_BLOCKCHAIN_PORT } from '~/domain/ledger2/ports/ledger2-blockchain.port';
import { BillingBlockchainAdapter } from './adapters/billing-blockchain.adapter';
import { BILLING_BLOCKCHAIN_PORT } from '~/domain/billing/ports/billing-blockchain.port';
import { SovietContractInfoService } from './services/soviet-contract-info.service';
import { WalletContractInfoService } from './services/wallet-contract-info.service';
import { Ledger2ContractInfoService } from './services/ledger2-contract-info.service';
@@ -88,6 +90,11 @@ import { Ledger2ContractInfoService } from './services/ledger2-contract-info.ser
provide: LEDGER2_BLOCKCHAIN_PORT,
useClass: Ledger2BlockchainAdapter,
},
// Провайдеры для billing (оплата подписок — convert/pay, Epic 12)
{
provide: BILLING_BLOCKCHAIN_PORT,
useClass: BillingBlockchainAdapter,
},
DomainToBlockchainUtils,
SovietContractInfoService,
WalletContractInfoService,
@@ -108,6 +115,7 @@ import { Ledger2ContractInfoService } from './services/ledger2-contract-info.ser
WALLET_BLOCKCHAIN_PORT,
LEDGER_BLOCKCHAIN_PORT,
LEDGER2_BLOCKCHAIN_PORT,
BILLING_BLOCKCHAIN_PORT,
DomainToBlockchainUtils,
SovietContractInfoService,
WalletContractInfoService,
@@ -0,0 +1,24 @@
/**
* Утилиты для работы с результатом on-chain транзакции, который возвращают
* blockchain-порты (`BillingBlockchainPort`, `Ledger2BlockchainPort` и т.п.).
* Форма ответа Wharfkit/Antelope разная: иногда `{ transaction_id }` прямо
* в корне, иногда `{ response: { transaction_id } }`. Выносим разбор в одно
* место, чтобы сервисы не дублировали.
*/
export class TransactionUtils {
/**
* Извлекает on-chain `transaction_id` из произвольного объекта-результата
* подписи/отправки. Возвращает пустую строку, если поле не найдено.
*/
static extractTransactionId(result: unknown): string {
if (result && typeof result === 'object' && 'transaction_id' in result) {
const tx = (result as { transaction_id?: unknown }).transaction_id;
if (typeof tx === 'string') return tx;
}
if (result && typeof result === 'object' && 'response' in result) {
const resp = (result as { response?: { transaction_id?: unknown } }).response;
if (resp && typeof resp.transaction_id === 'string') return resp.transaction_id;
}
return '';
}
}
+64
View File
@@ -127,6 +127,22 @@ export const AllTypesProps: Record<string,any> = {
},
BankAccountInput:{
details:"BankAccountDetailsInput"
},
BillingConversionStatementGenerateDocumentInput:{
},
BillingConversionStatementSignedDocumentInput:{
meta:"BillingConversionStatementSignedMetaDocumentInput",
signatures:"SignatureInfoInput"
},
BillingConversionStatementSignedMetaDocumentInput:{
},
BillingConvertInput:{
document:"BillingConversionStatementSignedDocumentInput"
},
BillingPayInput:{
},
BuhotchSignerType: "enum" as const,
CalculateVotesInput:{
@@ -644,6 +660,12 @@ export const AllTypesProps: Record<string,any> = {
addTrustedAccount:{
data:"AddTrustedAccountInput"
},
billingConvert:{
input:"BillingConvertInput"
},
billingPay:{
input:"BillingPayInput"
},
cancelRequest:{
data:"CancelRequestInput"
},
@@ -1027,6 +1049,10 @@ export const AllTypesProps: Record<string,any> = {
data:"AnnualGeneralMeetingVotingBallotGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
generateBillingConversionStatement:{
data:"BillingConversionStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
generateConvertToAxonStatement:{
data:"ConvertToAxonStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -1501,6 +1527,9 @@ export const AllTypesProps: Record<string,any> = {
getActions:{
filters:"ActionFiltersInput",
pagination:"PaginationInput"
},
getBillingSummary:{
},
getBranches:{
data:"GetBranchesInput"
@@ -2112,6 +2141,27 @@ export const ReturnTypes: Record<string,any> = {
title:"String",
voting:"CapitalProjectVotingData"
},
BillingResult:{
paymentHash:"String",
transactionId:"String"
},
BillingSummary:{
coopname:"String",
currency:"String",
items:"BillingSummaryItem",
nextPaymentDue:"String",
paymentHash:"String",
periodDays:"Int",
totalAmount:"Float"
},
BillingSummaryItem:{
amount:"Float",
isFree:"Boolean",
status:"String",
subscriptionId:"Int",
subscriptionTypeId:"Int",
subscriptionTypeName:"String"
},
BlockchainAccount:{
account_name:"String",
core_liquid_balance:"String",
@@ -3015,6 +3065,15 @@ export const ReturnTypes: Record<string,any> = {
is_active:"Boolean",
program_type:"String"
},
CooperativeRegistryItem:{
announce:"String",
coopname:"String",
created_at:"String",
has_provider_data:"Boolean",
name:"String",
status:"String",
subscriptions:"ProviderSubscription"
},
CreateSubscriptionResponse:{
message:"String",
subscription:"WebPushSubscriptionDto",
@@ -3505,6 +3564,8 @@ export const ReturnTypes: Record<string,any> = {
addParticipant:"Account",
addPaymentMethod:"PaymentMethod",
addTrustedAccount:"Branch",
billingConvert:"BillingResult",
billingPay:"BillingResult",
cancelRequest:"Transaction",
capitalAddAuthor:"CapitalProject",
capitalApproveCommit:"CapitalCommit",
@@ -3624,6 +3685,7 @@ export const ReturnTypes: Record<string,any> = {
generateAssetContributionDecision:"GeneratedDocument",
generateAssetContributionStatement:"GeneratedDocument",
generateBallotForAnnualGeneralMeetDocument:"GeneratedDocument",
generateBillingConversionStatement:"GeneratedDocument",
generateConvertToAxonStatement:"GeneratedDocument",
generateDocument:"GeneratedDocument",
generateFreeDecision:"GeneratedDocument",
@@ -4168,11 +4230,13 @@ export const ReturnTypes: Record<string,any> = {
getActions:"PaginatedActionsPaginationResult",
getAgenda:"AgendaWithDocuments",
getAvailableReports:"AvailableReport",
getBillingSummary:"BillingSummary",
getBranches:"Branch",
getCapitalIssueLogs:"PaginatedCapitalLogsPaginationResult",
getCapitalOnboardingState:"CapitalOnboardingState",
getCapitalProjectLogs:"PaginatedCapitalLogsPaginationResult",
getChairmanOnboardingState:"ChairmanOnboardingState",
getCooperativesRegistry:"CooperativeRegistryItem",
getCurrentInstance:"CurrentInstanceDTO",
getCurrentTableStates:"PaginatedCurrentTableStatesPaginationResult",
getDeltas:"PaginatedDeltasPaginationResult",
+573
View File
@@ -2078,6 +2078,119 @@ export type ValueTypes = {
voting?:ValueTypes["CapitalProjectVotingData"],
__typename?: boolean | `@${string}`,
['...on BaseCapitalProject']?: Omit<ValueTypes["BaseCapitalProject"], "...on BaseCapitalProject">
}>;
["BillingConversionStatementGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["BillingConversionStatementSignedDocumentInput"]: {
/** Хэш содержимого документа */
doc_hash: string | Variable<any, string>,
/** Общий хэш (doc_hash + meta_hash) */
hash: string | Variable<any, string>,
/** Метаинформация заявления на конвертацию паевого в биллинг-кошелёк */
meta: ValueTypes["BillingConversionStatementSignedMetaDocumentInput"] | Variable<any, string>,
/** Хэш мета-данных */
meta_hash: string | Variable<any, string>,
/** Вектор подписей */
signatures: Array<ValueTypes["SignatureInfoInput"]> | Variable<any, string>,
/** Версия стандарта документа */
version: string | Variable<any, string>
};
["BillingConversionStatementSignedMetaDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num: number | Variable<any, string>,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at: string | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator: string | Variable<any, string>,
/** Язык документа */
lang: string | Variable<any, string>,
/** Ссылки, связанные с документом */
links: Array<string> | Variable<any, string>,
/** ID документа в реестре */
registry_id: number | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone: string | Variable<any, string>,
/** Название документа */
title: string | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["BillingConvertInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string | Variable<any, string>,
/** Имя аккаунта кооператива */
coopname: string | Variable<any, string>,
/** Подписанное пайщиком заявление 1095.BillingConversionStatement (document2 с типизированной meta: convert_amount). */
document: ValueTypes["BillingConversionStatementSignedDocumentInput"] | Variable<any, string>,
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string | Variable<any, string>
};
["BillingPayInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string | Variable<any, string>,
/** Имя аккаунта кооператива */
coopname: string | Variable<any, string>,
/** Назначение платежа */
memo: string | Variable<any, string>,
/** Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера) */
paymentHash: string | Variable<any, string>,
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string | Variable<any, string>
};
["BillingResult"]: AliasType<{
paymentHash?:boolean | `@${string}`,
transactionId?:boolean | `@${string}`,
__typename?: boolean | `@${string}`,
['...on BillingResult']?: Omit<ValueTypes["BillingResult"], "...on BillingResult">
}>;
["BillingSummary"]: AliasType<{
coopname?:boolean | `@${string}`,
currency?:boolean | `@${string}`,
items?:ValueTypes["BillingSummaryItem"],
nextPaymentDue?:boolean | `@${string}`,
paymentHash?:boolean | `@${string}`,
periodDays?:boolean | `@${string}`,
totalAmount?:boolean | `@${string}`,
__typename?: boolean | `@${string}`,
['...on BillingSummary']?: Omit<ValueTypes["BillingSummary"], "...on BillingSummary">
}>;
["BillingSummaryItem"]: AliasType<{
amount?:boolean | `@${string}`,
isFree?:boolean | `@${string}`,
status?:boolean | `@${string}`,
subscriptionId?:boolean | `@${string}`,
subscriptionTypeId?:boolean | `@${string}`,
subscriptionTypeName?:boolean | `@${string}`,
__typename?: boolean | `@${string}`,
['...on BillingSummaryItem']?: Omit<ValueTypes["BillingSummaryItem"], "...on BillingSummaryItem">
}>;
["BlockchainAccount"]: AliasType<{
/** Имя аккаунта */
@@ -4268,6 +4381,24 @@ export type ValueTypes = {
program_type?:boolean | `@${string}`,
__typename?: boolean | `@${string}`,
['...on CooperativeProgram']?: Omit<ValueTypes["CooperativeProgram"], "...on CooperativeProgram">
}>;
["CooperativeRegistryItem"]: AliasType<{
/** Анонсированный домен/сайт кооператива */
announce?:boolean | `@${string}`,
/** Имя аккаунта кооператива (coopname) */
coopname?:boolean | `@${string}`,
/** Дата регистрации заявки кооператива (on-chain) */
created_at?:boolean | `@${string}`,
/** Есть ли у кооператива данные провайдера (хотя бы одна подписка) */
has_provider_data?:boolean | `@${string}`,
/** Наименование организации кооператива */
name?:boolean | `@${string}`,
/** Статус кооператива в блокчейне: pending | active | blocked */
status?:boolean | `@${string}`,
/** Подписки кооператива у провайдера (с инстансом и биллингом) */
subscriptions?:ValueTypes["ProviderSubscription"],
__typename?: boolean | `@${string}`,
['...on CooperativeRegistryItem']?: Omit<ValueTypes["CooperativeRegistryItem"], "...on CooperativeRegistryItem">
}>;
/** Страна регистрации пользователя */
["Country"]:Country;
@@ -6417,6 +6548,8 @@ acceptChildOrder?: [{ data: ValueTypes["AcceptChildOrderInput"] | Variable<any,
addParticipant?: [{ data: ValueTypes["AddParticipantInput"] | Variable<any, string>},ValueTypes["Account"]],
addPaymentMethod?: [{ data: ValueTypes["AddPaymentMethodInput"] | Variable<any, string>},ValueTypes["PaymentMethod"]],
addTrustedAccount?: [{ data: ValueTypes["AddTrustedAccountInput"] | Variable<any, string>},ValueTypes["Branch"]],
billingConvert?: [{ input: ValueTypes["BillingConvertInput"] | Variable<any, string>},ValueTypes["BillingResult"]],
billingPay?: [{ input: ValueTypes["BillingPayInput"] | Variable<any, string>},ValueTypes["BillingResult"]],
cancelRequest?: [{ data: ValueTypes["CancelRequestInput"] | Variable<any, string>},ValueTypes["Transaction"]],
capitalAddAuthor?: [{ data: ValueTypes["AddAuthorInput"] | Variable<any, string>},ValueTypes["CapitalProject"]],
capitalApproveCommit?: [{ data: ValueTypes["CommitApproveInput"] | Variable<any, string>},ValueTypes["CapitalCommit"]],
@@ -6539,6 +6672,7 @@ generateAssetContributionAct?: [{ data: ValueTypes["AssetContributionActGenerate
generateAssetContributionDecision?: [{ data: ValueTypes["AssetContributionDecisionGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateAssetContributionStatement?: [{ data: ValueTypes["AssetContributionStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateBallotForAnnualGeneralMeetDocument?: [{ data: ValueTypes["AnnualGeneralMeetingVotingBallotGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateBillingConversionStatement?: [{ data: ValueTypes["BillingConversionStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateConvertToAxonStatement?: [{ data: ValueTypes["ConvertToAxonStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateDocument?: [{ input: ValueTypes["GenerateAnyDocumentInput"] | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateFreeDecision?: [{ data: ValueTypes["FreeDecisionGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
@@ -7836,6 +7970,7 @@ getActions?: [{ filters?: ValueTypes["ActionFiltersInput"] | undefined | null |
Требуемые роли: chairman. */
getAvailableReports?:ValueTypes["AvailableReport"],
getBillingSummary?: [{ coopname: string | Variable<any, string>, period?: number | undefined | null | Variable<any, string>},ValueTypes["BillingSummary"]],
getBranches?: [{ data: ValueTypes["GetBranchesInput"] | Variable<any, string>},ValueTypes["Branch"]],
getCapitalIssueLogs?: [{ data: ValueTypes["GetCapitalIssueLogsInput"] | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaginatedCapitalLogsPaginationResult"]],
/** Получить состояние онбординга capital
@@ -7847,6 +7982,10 @@ getCapitalProjectLogs?: [{ data: ValueTypes["GetCapitalLogsInput"] | Variable<an
Требуемые роли: chairman. */
getChairmanOnboardingState?:ValueTypes["ChairmanOnboardingState"],
/** Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)
Требуемые роли: member, chairman. */
getCooperativesRegistry?:ValueTypes["CooperativeRegistryItem"],
/** Получить текущий инстанс пользователя
Требуемые роли: member, chairman, user. */
@@ -10524,6 +10663,116 @@ export type ResolverInputTypes = {
/** Данные голосования по методу Водянова */
voting?:ResolverInputTypes["CapitalProjectVotingData"],
__typename?: boolean | `@${string}`
}>;
["BillingConversionStatementGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["BillingConversionStatementSignedDocumentInput"]: {
/** Хэш содержимого документа */
doc_hash: string,
/** Общий хэш (doc_hash + meta_hash) */
hash: string,
/** Метаинформация заявления на конвертацию паевого в биллинг-кошелёк */
meta: ResolverInputTypes["BillingConversionStatementSignedMetaDocumentInput"],
/** Хэш мета-данных */
meta_hash: string,
/** Вектор подписей */
signatures: Array<ResolverInputTypes["SignatureInfoInput"]>,
/** Версия стандарта документа */
version: string
};
["BillingConversionStatementSignedMetaDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num: number,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at: string,
/** Имя генератора, использованного для создания документа */
generator: string,
/** Язык документа */
lang: string,
/** Ссылки, связанные с документом */
links: Array<string>,
/** ID документа в реестре */
registry_id: number,
/** Часовой пояс, в котором был создан документ */
timezone: string,
/** Название документа */
title: string,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["BillingConvertInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string,
/** Имя аккаунта кооператива */
coopname: string,
/** Подписанное пайщиком заявление 1095.BillingConversionStatement (document2 с типизированной meta: convert_amount). */
document: ResolverInputTypes["BillingConversionStatementSignedDocumentInput"],
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string
};
["BillingPayInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string,
/** Имя аккаунта кооператива */
coopname: string,
/** Назначение платежа */
memo: string,
/** Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера) */
paymentHash: string,
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string
};
["BillingResult"]: AliasType<{
paymentHash?:boolean | `@${string}`,
transactionId?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["BillingSummary"]: AliasType<{
coopname?:boolean | `@${string}`,
currency?:boolean | `@${string}`,
items?:ResolverInputTypes["BillingSummaryItem"],
nextPaymentDue?:boolean | `@${string}`,
paymentHash?:boolean | `@${string}`,
periodDays?:boolean | `@${string}`,
totalAmount?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["BillingSummaryItem"]: AliasType<{
amount?:boolean | `@${string}`,
isFree?:boolean | `@${string}`,
status?:boolean | `@${string}`,
subscriptionId?:boolean | `@${string}`,
subscriptionTypeId?:boolean | `@${string}`,
subscriptionTypeName?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["BlockchainAccount"]: AliasType<{
/** Имя аккаунта */
@@ -12652,6 +12901,23 @@ export type ResolverInputTypes = {
/** Тип программы: wallet/generator/blagorost/marketplace и т.п. */
program_type?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["CooperativeRegistryItem"]: AliasType<{
/** Анонсированный домен/сайт кооператива */
announce?:boolean | `@${string}`,
/** Имя аккаунта кооператива (coopname) */
coopname?:boolean | `@${string}`,
/** Дата регистрации заявки кооператива (on-chain) */
created_at?:boolean | `@${string}`,
/** Есть ли у кооператива данные провайдера (хотя бы одна подписка) */
has_provider_data?:boolean | `@${string}`,
/** Наименование организации кооператива */
name?:boolean | `@${string}`,
/** Статус кооператива в блокчейне: pending | active | blocked */
status?:boolean | `@${string}`,
/** Подписки кооператива у провайдера (с инстансом и биллингом) */
subscriptions?:ResolverInputTypes["ProviderSubscription"],
__typename?: boolean | `@${string}`
}>;
/** Страна регистрации пользователя */
["Country"]:Country;
@@ -14748,6 +15014,8 @@ acceptChildOrder?: [{ data: ResolverInputTypes["AcceptChildOrderInput"]},Resolve
addParticipant?: [{ data: ResolverInputTypes["AddParticipantInput"]},ResolverInputTypes["Account"]],
addPaymentMethod?: [{ data: ResolverInputTypes["AddPaymentMethodInput"]},ResolverInputTypes["PaymentMethod"]],
addTrustedAccount?: [{ data: ResolverInputTypes["AddTrustedAccountInput"]},ResolverInputTypes["Branch"]],
billingConvert?: [{ input: ResolverInputTypes["BillingConvertInput"]},ResolverInputTypes["BillingResult"]],
billingPay?: [{ input: ResolverInputTypes["BillingPayInput"]},ResolverInputTypes["BillingResult"]],
cancelRequest?: [{ data: ResolverInputTypes["CancelRequestInput"]},ResolverInputTypes["Transaction"]],
capitalAddAuthor?: [{ data: ResolverInputTypes["AddAuthorInput"]},ResolverInputTypes["CapitalProject"]],
capitalApproveCommit?: [{ data: ResolverInputTypes["CommitApproveInput"]},ResolverInputTypes["CapitalCommit"]],
@@ -14870,6 +15138,7 @@ generateAssetContributionAct?: [{ data: ResolverInputTypes["AssetContributionAct
generateAssetContributionDecision?: [{ data: ResolverInputTypes["AssetContributionDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateAssetContributionStatement?: [{ data: ResolverInputTypes["AssetContributionStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateBallotForAnnualGeneralMeetDocument?: [{ data: ResolverInputTypes["AnnualGeneralMeetingVotingBallotGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateBillingConversionStatement?: [{ data: ResolverInputTypes["BillingConversionStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateConvertToAxonStatement?: [{ data: ResolverInputTypes["ConvertToAxonStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateDocument?: [{ input: ResolverInputTypes["GenerateAnyDocumentInput"]},ResolverInputTypes["GeneratedDocument"]],
generateFreeDecision?: [{ data: ResolverInputTypes["FreeDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
@@ -16112,6 +16381,7 @@ getActions?: [{ filters?: ResolverInputTypes["ActionFiltersInput"] | undefined |
Требуемые роли: chairman. */
getAvailableReports?:ResolverInputTypes["AvailableReport"],
getBillingSummary?: [{ coopname: string, period?: number | undefined | null},ResolverInputTypes["BillingSummary"]],
getBranches?: [{ data: ResolverInputTypes["GetBranchesInput"]},ResolverInputTypes["Branch"]],
getCapitalIssueLogs?: [{ data: ResolverInputTypes["GetCapitalIssueLogsInput"], options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedCapitalLogsPaginationResult"]],
/** Получить состояние онбординга capital
@@ -16123,6 +16393,10 @@ getCapitalProjectLogs?: [{ data: ResolverInputTypes["GetCapitalLogsInput"]},Reso
Требуемые роли: chairman. */
getChairmanOnboardingState?:ResolverInputTypes["ChairmanOnboardingState"],
/** Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)
Требуемые роли: member, chairman. */
getCooperativesRegistry?:ResolverInputTypes["CooperativeRegistryItem"],
/** Получить текущий инстанс пользователя
Требуемые роли: member, chairman, user. */
@@ -18737,6 +19011,113 @@ export type ModelTypes = {
title: string,
/** Данные голосования по методу Водянова */
voting: ModelTypes["CapitalProjectVotingData"]
};
["BillingConversionStatementGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["BillingConversionStatementSignedDocumentInput"]: {
/** Хэш содержимого документа */
doc_hash: string,
/** Общий хэш (doc_hash + meta_hash) */
hash: string,
/** Метаинформация заявления на конвертацию паевого в биллинг-кошелёк */
meta: ModelTypes["BillingConversionStatementSignedMetaDocumentInput"],
/** Хэш мета-данных */
meta_hash: string,
/** Вектор подписей */
signatures: Array<ModelTypes["SignatureInfoInput"]>,
/** Версия стандарта документа */
version: string
};
["BillingConversionStatementSignedMetaDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num: number,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at: string,
/** Имя генератора, использованного для создания документа */
generator: string,
/** Язык документа */
lang: string,
/** Ссылки, связанные с документом */
links: Array<string>,
/** ID документа в реестре */
registry_id: number,
/** Часовой пояс, в котором был создан документ */
timezone: string,
/** Название документа */
title: string,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["BillingConvertInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string,
/** Имя аккаунта кооператива */
coopname: string,
/** Подписанное пайщиком заявление 1095.BillingConversionStatement (document2 с типизированной meta: convert_amount). */
document: ModelTypes["BillingConversionStatementSignedDocumentInput"],
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string
};
["BillingPayInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string,
/** Имя аккаунта кооператива */
coopname: string,
/** Назначение платежа */
memo: string,
/** Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера) */
paymentHash: string,
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string
};
["BillingResult"]: {
paymentHash?: string | undefined | null,
transactionId: string
};
["BillingSummary"]: {
coopname: string,
currency: string,
items: Array<ModelTypes["BillingSummaryItem"]>,
nextPaymentDue?: string | undefined | null,
paymentHash: string,
periodDays: number,
totalAmount: number
};
["BillingSummaryItem"]: {
amount: number,
isFree: boolean,
status: string,
subscriptionId: number,
subscriptionTypeId: number,
subscriptionTypeName: string
};
["BlockchainAccount"]: {
/** Имя аккаунта */
@@ -20798,6 +21179,22 @@ export type ModelTypes = {
is_active: boolean,
/** Тип программы: wallet/generator/blagorost/marketplace и т.п. */
program_type: string
};
["CooperativeRegistryItem"]: {
/** Анонсированный домен/сайт кооператива */
announce?: string | undefined | null,
/** Имя аккаунта кооператива (coopname) */
coopname: string,
/** Дата регистрации заявки кооператива (on-chain) */
created_at?: string | undefined | null,
/** Есть ли у кооператива данные провайдера (хотя бы одна подписка) */
has_provider_data: boolean,
/** Наименование организации кооператива */
name?: string | undefined | null,
/** Статус кооператива в блокчейне: pending | active | blocked */
status: string,
/** Подписки кооператива у провайдера (с инстансом и биллингом) */
subscriptions: Array<ModelTypes["ProviderSubscription"]>
};
["Country"]:Country;
["CreateAnnualGeneralMeetInput"]: {
@@ -22836,6 +23233,14 @@ export type ModelTypes = {
Требуемые роли: chairman. */
addTrustedAccount: ModelTypes["Branch"],
/** Конвертация паевого взноса пайщика в членский на биллинг-кошелёк (operation o.bil.fund). Принимает подписанное пайщиком заявление 1095.BillingConversionStatement.
Требуемые роли: user, member, chairman. */
billingConvert: ModelTypes["BillingResult"],
/** Списание стоимости подписок с биллинг-кошелька пайщика в инфраструктурный кошелёк кооператива (operation o.bil.pay). Идемпотентно по payment_hash.
Требуемые роли: chairman. */
billingPay: ModelTypes["BillingResult"],
/** Отменить заявку */
cancelRequest: ModelTypes["Transaction"],
/** Добавление автора проекта в CAPITAL контракте
@@ -23288,6 +23693,10 @@ export type ModelTypes = {
Требуемые роли: member. */
generateBallotForAnnualGeneralMeetDocument: ModelTypes["GeneratedDocument"],
/** Генерирует заявление 1095.BillingConversionStatement (перед подписью пайщиком) аналог generateConvertToAxonStatement, канон documents-dto.
Требуемые роли: member, chairman. */
generateBillingConversionStatement: ModelTypes["GeneratedDocument"],
/** Генерирует заявление на конвертацию паевого взноса в членский взнос
Требуемые роли: member, chairman. */
@@ -24694,6 +25103,10 @@ export type ModelTypes = {
Требуемые роли: chairman. */
getAvailableReports: Array<ModelTypes["AvailableReport"]>,
/** Сумма к оплате кооператива за период (стоимость платных подписок, разбивка, дата следующего платежа, payment_hash). Источник provider backend оператора. Для реестра кооперативов Восхода.
Требуемые роли: chairman, member. */
getBillingSummary: ModelTypes["BillingSummary"],
/** Получить список кооперативных участков */
getBranches: Array<ModelTypes["Branch"]>,
/** Получить логи событий по задаче */
@@ -24708,6 +25121,10 @@ export type ModelTypes = {
Требуемые роли: chairman. */
getChairmanOnboardingState: ModelTypes["ChairmanOnboardingState"],
/** Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)
Требуемые роли: member, chairman. */
getCooperativesRegistry: Array<ModelTypes["CooperativeRegistryItem"]>,
/** Получить текущий инстанс пользователя
Требуемые роли: member, chairman, user. */
@@ -27405,6 +27822,119 @@ export type GraphQLTypes = {
/** Данные голосования по методу Водянова */
voting: GraphQLTypes["CapitalProjectVotingData"],
['...on BaseCapitalProject']: Omit<GraphQLTypes["BaseCapitalProject"], "...on BaseCapitalProject">
};
["BillingConversionStatementGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["BillingConversionStatementSignedDocumentInput"]: {
/** Хэш содержимого документа */
doc_hash: string,
/** Общий хэш (doc_hash + meta_hash) */
hash: string,
/** Метаинформация заявления на конвертацию паевого в биллинг-кошелёк */
meta: GraphQLTypes["BillingConversionStatementSignedMetaDocumentInput"],
/** Хэш мета-данных */
meta_hash: string,
/** Вектор подписей */
signatures: Array<GraphQLTypes["SignatureInfoInput"]>,
/** Версия стандарта документа */
version: string
};
["BillingConversionStatementSignedMetaDocumentInput"]: {
/** Номер блока, на котором был создан документ */
block_num: number,
/** Сумма к конвертации паевого в членский на биллинг-кошелёк */
convert_amount: string,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at: string,
/** Имя генератора, использованного для создания документа */
generator: string,
/** Язык документа */
lang: string,
/** Ссылки, связанные с документом */
links: Array<string>,
/** ID документа в реестре */
registry_id: number,
/** Часовой пояс, в котором был создан документ */
timezone: string,
/** Название документа */
title: string,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["BillingConvertInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string,
/** Имя аккаунта кооператива */
coopname: string,
/** Подписанное пайщиком заявление 1095.BillingConversionStatement (document2 с типизированной meta: convert_amount). */
document: GraphQLTypes["BillingConversionStatementSignedDocumentInput"],
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string
};
["BillingPayInput"]: {
/** Сумма с символом, например "1500.0000 RUB" */
amount: string,
/** Имя аккаунта кооператива */
coopname: string,
/** Назначение платежа */
memo: string,
/** Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера) */
paymentHash: string,
/** Имя аккаунта пайщика — владельца биллинг-кошелька */
username: string
};
["BillingResult"]: {
__typename: "BillingResult",
paymentHash?: string | undefined | null,
transactionId: string,
['...on BillingResult']: Omit<GraphQLTypes["BillingResult"], "...on BillingResult">
};
["BillingSummary"]: {
__typename: "BillingSummary",
coopname: string,
currency: string,
items: Array<GraphQLTypes["BillingSummaryItem"]>,
nextPaymentDue?: string | undefined | null,
paymentHash: string,
periodDays: number,
totalAmount: number,
['...on BillingSummary']: Omit<GraphQLTypes["BillingSummary"], "...on BillingSummary">
};
["BillingSummaryItem"]: {
__typename: "BillingSummaryItem",
amount: number,
isFree: boolean,
status: string,
subscriptionId: number,
subscriptionTypeId: number,
subscriptionTypeName: string,
['...on BillingSummaryItem']: Omit<GraphQLTypes["BillingSummaryItem"], "...on BillingSummaryItem">
};
["BlockchainAccount"]: {
__typename: "BlockchainAccount",
@@ -29595,6 +30125,24 @@ export type GraphQLTypes = {
/** Тип программы: wallet/generator/blagorost/marketplace и т.п. */
program_type: string,
['...on CooperativeProgram']: Omit<GraphQLTypes["CooperativeProgram"], "...on CooperativeProgram">
};
["CooperativeRegistryItem"]: {
__typename: "CooperativeRegistryItem",
/** Анонсированный домен/сайт кооператива */
announce?: string | undefined | null,
/** Имя аккаунта кооператива (coopname) */
coopname: string,
/** Дата регистрации заявки кооператива (on-chain) */
created_at?: string | undefined | null,
/** Есть ли у кооператива данные провайдера (хотя бы одна подписка) */
has_provider_data: boolean,
/** Наименование организации кооператива */
name?: string | undefined | null,
/** Статус кооператива в блокчейне: pending | active | blocked */
status: string,
/** Подписки кооператива у провайдера (с инстансом и биллингом) */
subscriptions: Array<GraphQLTypes["ProviderSubscription"]>,
['...on CooperativeRegistryItem']: Omit<GraphQLTypes["CooperativeRegistryItem"], "...on CooperativeRegistryItem">
};
/** Страна регистрации пользователя */
["Country"]: Country;
@@ -31753,6 +32301,14 @@ export type GraphQLTypes = {
Требуемые роли: chairman. */
addTrustedAccount: GraphQLTypes["Branch"],
/** Конвертация паевого взноса пайщика в членский на биллинг-кошелёк (operation o.bil.fund). Принимает подписанное пайщиком заявление 1095.BillingConversionStatement.
Требуемые роли: user, member, chairman. */
billingConvert: GraphQLTypes["BillingResult"],
/** Списание стоимости подписок с биллинг-кошелька пайщика в инфраструктурный кошелёк кооператива (operation o.bil.pay). Идемпотентно по payment_hash.
Требуемые роли: chairman. */
billingPay: GraphQLTypes["BillingResult"],
/** Отменить заявку */
cancelRequest: GraphQLTypes["Transaction"],
/** Добавление автора проекта в CAPITAL контракте
@@ -32205,6 +32761,10 @@ export type GraphQLTypes = {
Требуемые роли: member. */
generateBallotForAnnualGeneralMeetDocument: GraphQLTypes["GeneratedDocument"],
/** Генерирует заявление 1095.BillingConversionStatement (перед подписью пайщиком) аналог generateConvertToAxonStatement, канон documents-dto.
Требуемые роли: member, chairman. */
generateBillingConversionStatement: GraphQLTypes["GeneratedDocument"],
/** Генерирует заявление на конвертацию паевого взноса в членский взнос
Требуемые роли: member, chairman. */
@@ -33742,6 +34302,10 @@ export type GraphQLTypes = {
Требуемые роли: chairman. */
getAvailableReports: Array<GraphQLTypes["AvailableReport"]>,
/** Сумма к оплате кооператива за период (стоимость платных подписок, разбивка, дата следующего платежа, payment_hash). Источник provider backend оператора. Для реестра кооперативов Восхода.
Требуемые роли: chairman, member. */
getBillingSummary: GraphQLTypes["BillingSummary"],
/** Получить список кооперативных участков */
getBranches: Array<GraphQLTypes["Branch"]>,
/** Получить логи событий по задаче */
@@ -33756,6 +34320,10 @@ export type GraphQLTypes = {
Требуемые роли: chairman. */
getChairmanOnboardingState: GraphQLTypes["ChairmanOnboardingState"],
/** Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)
Требуемые роли: member, chairman. */
getCooperativesRegistry: Array<GraphQLTypes["CooperativeRegistryItem"]>,
/** Получить текущий инстанс пользователя
Требуемые роли: member, chairman, user. */
@@ -36003,6 +36571,11 @@ type ZEUS_VARIABLES = {
["AssetContributionStatementSignedMetaDocumentInput"]: ValueTypes["AssetContributionStatementSignedMetaDocumentInput"];
["BankAccountDetailsInput"]: ValueTypes["BankAccountDetailsInput"];
["BankAccountInput"]: ValueTypes["BankAccountInput"];
["BillingConversionStatementGenerateDocumentInput"]: ValueTypes["BillingConversionStatementGenerateDocumentInput"];
["BillingConversionStatementSignedDocumentInput"]: ValueTypes["BillingConversionStatementSignedDocumentInput"];
["BillingConversionStatementSignedMetaDocumentInput"]: ValueTypes["BillingConversionStatementSignedMetaDocumentInput"];
["BillingConvertInput"]: ValueTypes["BillingConvertInput"];
["BillingPayInput"]: ValueTypes["BillingPayInput"];
["BuhotchSignerType"]: ValueTypes["BuhotchSignerType"];
["CalculateVotesInput"]: ValueTypes["CalculateVotesInput"];
["CalendarEntryStatus"]: ValueTypes["CalendarEntryStatus"];
@@ -82,3 +82,8 @@ export const _apps = {
production: 'apps',
testnet: 'apps',
} as const
export const _billing = {
production: 'billing',
testnet: 'billing',
} as const
@@ -0,0 +1,19 @@
import * as Permissions from '../../../common/permissions'
import type * as Billing from '../../../interfaces/billing'
import { Actors } from '../../../common'
/**
* Требуется авторизация {@link Actors._coopname | аккаунта кооператива}
* (бэкенд ретранслирует после JWT; согласие пайщика несёт `document`).
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
/**
* Имя действия
*/
export const actionName = 'convert'
/**
* @interface
*/
export type IConvert = Billing.IConvert
@@ -0,0 +1,5 @@
export * as Convert from './convert'
export * as Pay from './pay'
export * as Migrate from './migrate'
@@ -0,0 +1,18 @@
import * as Permissions from '../../../common/permissions'
import type * as Billing from '../../../interfaces/billing'
import { Actors } from '../../../common'
/**
* Требуется авторизация {@link Actors._contract | аккаунта контракта billing}.
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._contract }] as const
/**
* Имя действия
*/
export const actionName = 'migrate'
/**
* @interface
*/
export type IMigrate = Billing.IMigrate
@@ -0,0 +1,19 @@
import * as Permissions from '../../../common/permissions'
import type * as Billing from '../../../interfaces/billing'
import { Actors } from '../../../common'
/**
* Требуется авторизация {@link Actors._coopname | аккаунта кооператива}
* (списание инициирует оператор от имени кооператива).
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
/**
* Имя действия
*/
export const actionName = 'pay'
/**
* @interface
*/
export type IPay = Billing.IPay
@@ -0,0 +1,11 @@
import * as contractNames from '../../common/names'
export * as Actions from './actions'
export * as Tables from './tables'
/**
* @private
*/
export * as Interfaces from '../../interfaces/billing'
export const contractName = contractNames._billing
@@ -0,0 +1 @@
export * as Payments from './payments'
@@ -0,0 +1,18 @@
import type * as Billing from '../../../interfaces/billing'
import { Actors } from '../../../common'
/**
* Имя таблицы
*/
export const tableName = 'payments'
/**
* Таблица хранится в {@link Actors._coopname | области памяти кооператива}.
*/
export const scope = Actors._coopname
/**
* @interface
* Факты оплаты подписок: сумма, идентификатор платежа (`payment_hash`) и время.
*/
export type IPayment = Billing.IPayment
@@ -94,3 +94,11 @@ export * as MeetContract from './meet'
* Деплоится на корневой KE-цепи и обслуживает несколько подсетей одновременно.
*/
export * as AppsContract from './apps'
/**
* Смарт-контракт оплаты инфраструктурных подписок членскими взносами пайщика
* (Epic 12). Ровно два действия: `convert` (паевой членский на `w.wal.bill`
* по подписанному заявлению) и `pay` (списание стоимости подписок, идемпотентно
* по `payment_hash`). Состав и цены подписок on-chain не хранятся.
*/
export * as BillingContract from './billing'
@@ -0,0 +1,58 @@
import type { IGenerate, IMetaDocument } from '../../document'
import type { ICommonUser, ICooperativeData, IVars } from '../../model'
export const registry_id = 1095
/**
* Интерфейс генерации заявления на конвертацию паевого взноса в членский на
* биллинг-кошелёк пайщика (Epic 12, действие billing::convert). Пайщик просит
* транслировать паевой взнос в членский, зарезервированный под оплату
* инфраструктурных подписок; сам факт конвертации трактуется как согласие на
* последующие рекуррентные списания.
*/
export interface Action extends IGenerate {
registry_id: number
convert_amount: string
}
export type Meta = IMetaDocument & Action
// Модель данных
export interface Model {
meta: IMetaDocument
vars: IVars
coop: ICooperativeData
commonUser: ICommonUser
}
export const title = 'Заявление о конвертации паевого взноса в членский на биллинг-кошелёк'
export const description = 'Форма заявления о конвертации паевого взноса в членский взнос для оплаты инфраструктурных подписок (биллинг-кошелёк пайщика)'
export const context = `<style>.digital-document h1 {margin: 0px;text-align:center;}.digital-document {padding: 20px;}.subheader {padding-bottom: 20px;}</style><div class="digital-document"><div style="text-align: right; margin:"><p>{% trans 'to_council_of' %} {{vars.full_abbr_genitive}} «{{vars.name}}»</p><p>{% trans 'from_participant' %}</p><p>{{ commonUser.full_name_or_short_name }}</p></div><h1 class="header">{% trans 'convert_statement' %}</h1><p style="text-align: center">{{vars.full_abbr_genitive}} «{{vars.name}}»</p><p style="text-align: right">{{ meta.created_at }}, {{coop.city}}</p><p>{% trans 'convert_request_text', meta.convert_amount %}</p><div class="signature"><p>{% trans 'signature' %} </p><p>{{ commonUser.full_name_or_short_name }}</p></div></div>`
export const translations = {
ru: {
to_council_of: 'В Совет',
convert_request_text: 'Прошу транслировать мой паевой взнос в сумме {0} в членский взнос на персональный биллинг-кошелёк для оплаты инфраструктурных подписок. Подтверждаю своё согласие на последующие списания членских взносов с биллинг-кошелька в счёт оплаты подписок.',
signature: 'Документ подписан электронной подписью',
convert_statement: 'Заявление о конвертации паевого взноса на биллинг-кошелёк',
from_participant: 'от пайщика',
},
}
export const exampleData = {
meta: {
created_at: '04.03.2024 10:54',
convert_amount: '1500.00 RUB',
},
coop: {
city: 'Москва',
},
commonUser: {
full_name_or_short_name: 'ПК "РОМАШКА"',
},
vars: {
full_abbr_genitive: 'потребительского кооператива',
name: 'ВОСХОД',
},
}
@@ -65,6 +65,8 @@ export * as GenerationConvertStatement from './1080.GenerationConvertStatement'
export * as CapitalizationToMainWalletConvertStatement from './1090.CapitalizationToMainWalletConvertStatement'
export * as BillingConversionStatement from './1095.BillingConversionStatement'
export * as SosediAgreement from './699.SosediAgreement'
// общие собрания
@@ -0,0 +1,75 @@
// Epic 12: интерфейсы контракта billing (оплата подписок членскими взносами).
// Формат повторяет авто-генерируемые ABI-интерфейсы (eosio-abi2ts), но billing
// мал и стабилен (ровно convert/pay/migrate + таблица payments), поэтому
// поддерживается вручную до подключения контракта в общий abi2ts-пайплайн.
export type IAsset = string
export type IName = string
export type IChecksum256 = string
export type IPublicKey = string
export type ISignature = string
export type ITimePointSec = string
export type IUint32 = number
export type IUint64 = number | string
export interface ISignatureInfo {
id: IUint32
signed_hash: IChecksum256
signer: IName
public_key: IPublicKey
signature: ISignature
signed_at: ITimePointSec
meta: string
}
export interface IDocument2 {
version: string
hash: IChecksum256
doc_hash: IChecksum256
meta_hash: IChecksum256
meta: string
signatures: ISignatureInfo[]
}
/**
* Конвертация паевого взноса пайщика в членский на персональный биллинг-кошелёк
* (`w.wal.bill`). Несёт подписанное заявление пайщика (`document`).
*/
export interface IConvert {
coopname: IName
username: IName
amount: IAsset
document: IDocument2
}
/**
* Списание с биллинг-кошелька пайщика суммарной стоимости подписок в
* инфраструктурный кошелёк кооператива. Идемпотентно по `payment_hash`.
* Состав и цены подписок on-chain не хранятся (зона оператора).
*/
export interface IPay {
coopname: IName
username: IName
amount: IAsset
payment_hash: IChecksum256
memo: string
}
/**
* Сервисное действие миграции таблиц контракта.
*/
export interface IMigrate {
}
/**
* Строка таблицы платежей (`payments`, scope = коопнейм). Хранит факт оплаты
* подписок: сумму, идентификатор платежа и время.
*/
export interface IPayment {
id: IUint64
coopname: IName
username: IName
amount: IAsset
payment_hash: IChecksum256
paid_at: ITimePointSec
}
@@ -20,8 +20,9 @@ export interface WalletMeta {
*/
export const LEDGER2_WALLET_REGISTRY: readonly WalletMeta[] = [
{ name: "w.reg.minshr", human_name: "Минимальный паевой взнос", kind: "USER_SHARED" },
{ name: "w.wal.share", human_name: "Паевой взнос пайщика", kind: "USER_SHARED" },
{ name: "w.wal.member", human_name: "ЦК — членская часть пайщика", kind: "USER_SHARED" },
{ name: "w.wal.share", human_name: "ЦК — паевой", kind: "USER_SHARED" },
{ name: "w.wal.member", human_name: "ЦК — членский", kind: "USER_SHARED" },
{ name: "w.wal.bill", human_name: "ЦК — биллинг", kind: "USER_SHARED" },
{ name: "w.cap.blago", human_name: "ЦПП «Благорост» — единый кошелёк программы у пайщика", kind: "USER_SHARED" },
{ name: "w.cap.preimp", human_name: "Первичный учёт РИД-взносов до перехода на электронный учёт", kind: "USER_SHARED" },
{ name: "w.cap.gen", human_name: "ЦПП «Генератор» — единый кошелёк программы", kind: "COOPERATIVE" },
@@ -51,8 +52,9 @@ export interface ProgramWalletMapping {
*/
export const LEDGER2_USER_SHARED_PROGRAM_MAPPING: readonly ProgramWalletMapping[] = [
{ wallet_name: "w.reg.minshr", required_program_id: 0, program_label: null },
{ wallet_name: "w.wal.share", required_program_id: 1, program_label: "ЦК" },
{ wallet_name: "w.wal.member", required_program_id: 1, program_label: "ЦК" },
{ wallet_name: "w.wal.share", required_program_id: 1, program_label: "ЦК — паевой" },
{ wallet_name: "w.wal.member", required_program_id: 1, program_label: "ЦК — членский" },
{ wallet_name: "w.wal.bill", required_program_id: 1, program_label: "ЦК — биллинг" },
{ wallet_name: "w.cap.blago", required_program_id: 4, program_label: "Благорост" },
{ wallet_name: "w.cap.gen", required_program_id: 3, program_label: "Генератор" },
{ wallet_name: "w.cap.preimp", required_program_id: 0, program_label: null },
@@ -6,6 +6,7 @@ import { PaymentsPage } from 'src/pages/Cooperative/Payments';
import { ListOfMeetsPage } from 'src/pages/Cooperative/ListOfMeets';
import { MeetDetailsPage } from 'src/pages/Cooperative/MeetDetails';
import { UnionPageListOfCooperatives } from 'src/pages/Union/ListOfCooperatives';
import { UnionPageCooperativeDetail } from 'src/pages/Union/CooperativeDetail';
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace';
export default async function (): Promise<IWorkspaceConfig[]> {
@@ -94,6 +95,18 @@ export default async function (): Promise<IWorkspaceConfig[]> {
requiresAuth: true,
},
},
{
path: 'union/cooperatives/:detailCoopname',
name: 'union-cooperative-detail',
component: markRaw(UnionPageCooperativeDetail),
meta: {
title: 'Кооператив',
icon: 'fa-solid fa-handshake',
roles: ['chairman', 'member'],
conditions: 'coopname === "voskhod"',
requiresAuth: true,
},
},
],
},
],
@@ -0,0 +1,15 @@
import { client } from 'src/shared/api/client'
import { Queries } from '@coopenomics/sdk'
import type { IBillingSummary } from '../model'
async function loadBillingSummary(coopname: string, period?: number): Promise<IBillingSummary> {
const { [Queries.Billing.GetBillingSummary.name]: result } = await client.Query(
Queries.Billing.GetBillingSummary.query,
{ variables: { coopname, period } },
)
return result as IBillingSummary
}
export const api = {
loadBillingSummary,
}
@@ -0,0 +1,2 @@
export { api as billingApi } from './api'
export type { IBillingSummary } from './model'
@@ -0,0 +1,5 @@
import type { Queries } from '@coopenomics/sdk'
// Тип строго из SDK
export type IBillingSummary =
Queries.Billing.GetBillingSummary.IOutput[typeof Queries.Billing.GetBillingSummary.name]
@@ -1,13 +1,20 @@
import { RegistratorContract } from 'cooptypes'
import { Queries } from '@coopenomics/sdk'
import { defineStore } from 'pinia'
const namespace = 'union'
/**
* Элемент реестра кооперативов: on-chain данные кооператива + данные провайдера
* (подписки/инстанс/биллинг), сведённые бэкендом (coopback) по coopname.
*/
export type ICooperativeRegistryItem =
Queries.System.GetCooperativesRegistry.IOutput['getCooperativesRegistry'][number]
export const useUnionStore = defineStore(
namespace,
() => {
const coops = [] as RegistratorContract.Tables.Cooperatives.ICooperative[]
const coops = [] as ICooperativeRegistryItem[]
return {
coops
@@ -1,2 +1,3 @@
export * from './types'
export * from './stores'
export * from './useCooperativeMainWallet'
@@ -0,0 +1,96 @@
import { ref, watchEffect, type Ref } from 'vue'
import { Queries, Zeus } from '@coopenomics/sdk'
import { client } from 'src/shared/api/client'
/**
* Балансы MAIN-программы (split-кошелёк): `available` паевой взнос
* (w.wal.share), `membership_contribution` кошелёк членских/инфраструктурных
* взносов (w.wal.member/w.wal.bill). Backend отдаёт оба поля одной записью
* MAIN-программы, отдельных запросов для каждого waltype не требуется.
*
* coopname/username реактивны: смена перезапрос.
*/
export function useCooperativeMainWallet(
coopname: Ref<string> | (() => string),
username: Ref<string> | (() => string),
) {
const loading = ref(false)
// initialLoading — true ТОЛЬКО до первого успешного ответа. UI должен
// ориентироваться на него (а не на loading), иначе при тихих рефрешах
// (закрытие диалога конвертации, реактивная смена coopname в две фазы)
// плашка моргает «загрузка → значение → загрузка → значение».
const initialLoading = ref(true)
const error = ref('')
const available = ref('0')
const membership = ref('0')
const symbol = ref('')
const getCoopname = () =>
typeof coopname === 'function' ? coopname() : coopname.value
const getUsername = () =>
typeof username === 'function' ? username() : username.value
const splitAsset = (asset?: string | null) => {
if (!asset) return { amount: '0', symbol: '' }
const [amount, sym = ''] = String(asset).split(' ')
return { amount: amount || '0', symbol: sym }
}
let inFlight = 0
let lastKey = ''
const refresh = async () => {
const c = getCoopname()
const u = getUsername()
if (!c || !u) return
// Дедуп параллельных вызовов: если уже летит запрос на тот же ключ — пропустить.
const key = `${c}/${u}`
if (inFlight > 0 && lastKey === key) return
lastKey = key
inFlight++
loading.value = true
error.value = ''
try {
const { [Queries.Wallet.GetProgramWallet.name]: wallet } = await client.Query(
Queries.Wallet.GetProgramWallet.query,
{
variables: {
filter: {
coopname: c,
username: u,
program_type: Zeus.ProgramType.MAIN,
},
},
},
)
const av = splitAsset(wallet?.available)
const mb = splitAsset(wallet?.membership_contribution)
available.value = av.amount
membership.value = mb.amount
symbol.value = av.symbol || mb.symbol
} catch (e: any) {
// У пайщика может ещё не быть MAIN-кошелька (новый coop) —
// показываем нули, а не ошибку.
const msg = e?.message ?? String(e)
if (/not found|null/i.test(msg)) {
available.value = '0'
membership.value = '0'
symbol.value = ''
} else {
error.value = msg
}
} finally {
inFlight = Math.max(0, inFlight - 1)
loading.value = false
initialLoading.value = false
}
}
watchEffect(() => {
void getCoopname()
void getUsername()
void refresh()
})
return { available, membership, symbol, loading, initialLoading, error, refresh }
}
@@ -0,0 +1,15 @@
import { client } from 'src/shared/api/client'
import { Mutations } from '@coopenomics/sdk'
import type { IConvertToBillingInput } from '../model'
async function convertToBilling(input: IConvertToBillingInput) {
const { [Mutations.Billing.Convert.name]: result } = await client.Mutation(
Mutations.Billing.Convert.mutation,
{ variables: { input } },
)
return result
}
export const api = {
convertToBilling,
}
@@ -0,0 +1,4 @@
export { useConvertToBilling, useConvertToBillingVisibility } from './model'
export type { IConvertToBillingInput, IConvertToBillingOutput } from './model'
export { default as ConvertToBillingButton } from './ui/ConvertToBillingButton.vue'
export { default as ConvertToBillingDialog } from './ui/ConvertToBillingDialog.vue'
@@ -0,0 +1,80 @@
import { ref } from 'vue'
import type { Mutations } from '@coopenomics/sdk'
import { Cooperative } from 'cooptypes'
import { DigitalDocument } from 'src/shared/lib/document'
import { useSystemStore } from 'src/entities/System/model'
import { useSessionStore } from 'src/entities/Session'
import { api } from '../api'
// Типы строго из SDK
export type IConvertToBillingInput = Mutations.Billing.Convert.IInput['input']
export type IConvertToBillingOutput =
Mutations.Billing.Convert.IOutput[typeof Mutations.Billing.Convert.name]
// Видимость диалога — синглтон на уровне модуля (кнопка-триггер и сам диалог
// должны делить один ref, как useSelectBranch().isVisible).
const isVisible = ref(false)
export function useConvertToBillingVisibility() {
return { isVisible }
}
/**
* Конвертация паевого взноса пайщика в членский на биллинг-кошелёк (Epic 12).
*
* Двухшаговый flow (как select-branch): generate (заявление document2 по
* registry 1095) sign (WIF пайщика) мутация billingConvert.
* `amountRub` сумма в рублях (строка/число), к мутации уходит как "<N> RUB".
*/
export function useConvertToBilling() {
const isLoading = ref(false)
const isSubmitting = ref(false)
const step = ref(1)
const amountRub = ref<string>('')
const generated = ref<Awaited<ReturnType<DigitalDocument['generate']>>>()
const system = useSystemStore()
const session = useSessionStore()
const digitalDocument = new DigitalDocument()
const assetAmount = () => `${Number(amountRub.value)} ${system.info.symbols?.root_govern_symbol ?? 'RUB'}`
const generate = async () => {
isLoading.value = true
try {
generated.value = await digitalDocument.generate<Cooperative.Registry.BillingConversionStatement.Action>({
registry_id: Cooperative.Registry.BillingConversionStatement.registry_id,
coopname: system.info.coopname,
username: session.username,
convert_amount: assetAmount(),
})
step.value = 2
} finally {
isLoading.value = false
}
}
const sign = async (): Promise<IConvertToBillingOutput> => {
isSubmitting.value = true
try {
const document = await digitalDocument.sign<Cooperative.Registry.BillingConversionStatement.Meta>(
session.username,
)
const result = await api.convertToBilling({
coopname: system.info.coopname,
username: session.username,
amount: assetAmount(),
document,
})
isVisible.value = false
step.value = 1
amountRub.value = ''
return result
} finally {
isSubmitting.value = false
}
}
return { isVisible, isLoading, isSubmitting, step, amountRub, generated, generate, sign }
}
@@ -0,0 +1,31 @@
<template lang="pug">
q-btn(
@click='open',
:color='micro ? "accent" : "primary"',
:flat='micro',
:dense='micro',
:size='micro ? "sm" : undefined',
:unelevated='!micro'
)
q-icon(:name='micro ? "fa-solid fa-right-left" : "sync_alt"')
span(v-if='!micro').q-ml-sm Конвертировать в членский
q-tooltip(v-if='micro') Конвертировать в членский
</template>
<script setup lang="ts">
import { useConvertToBillingVisibility } from '../model'
interface Props {
micro?: boolean;
}
withDefaults(defineProps<Props>(), {
micro: false,
});
const { isVisible } = useConvertToBillingVisibility()
const open = () => {
isVisible.value = true
}
</script>
@@ -0,0 +1,85 @@
<template lang="pug">
BaseDialog(
v-model="isVisible"
title="Конвертация паевого взноса в членский"
:close-on-backdrop="false"
:close-on-escape="false"
)
div.q-pa-md
div(v-if="step === 1")
p
| Паевой взнос (возвратный) транслируется в членский (целевой) на ваш
| персональный биллинг-кошелёк для оплаты инфраструктурных подписок.
| Конвертация подтверждается подписанным заявлением.
Form(
:handler-submit="onGenerate"
:is-submitting="isLoading"
:showSubmit="!isLoading"
:showCancel="true"
:button-submit-txt="'Сформировать заявление'"
@cancel="onCancel"
)
q-input(
v-model="amountRub"
type="number"
min="1"
step="0.01"
label="Сумма конвертации, ₽"
:rules="[(v) => Number(v) > 0 || 'Введите сумму больше нуля']"
outlined
).q-mb-md
div(v-else-if="step === 2")
Loader(v-if="isLoading" :text="'Формируем документ...'")
div(v-else-if="generated")
DocumentHtmlReader(:html="generated.html")
div.q-mt-md
q-btn(@click="step = 1" flat) назад
q-btn(@click="onSign" color="primary" :loading="isSubmitting") подписать
</template>
<script setup lang="ts">
import { BaseDialog } from 'src/shared/ui/base/BaseDialog'
import { Form } from 'src/shared/ui/Form'
import { Loader } from 'src/shared/ui/Loader'
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader'
import { SuccessAlert, FailAlert } from 'src/shared/api'
import { useConvertToBilling } from '../model'
const {
isVisible,
isLoading,
isSubmitting,
step,
amountRub,
generated,
generate,
sign,
} = useConvertToBilling()
const onGenerate = async () => {
try {
await generate()
} catch (e: any) {
FailAlert(e)
}
}
const onSign = async () => {
try {
await sign()
SuccessAlert('Паевой взнос сконвертирован в членский')
} catch (e: any) {
FailAlert(e)
}
}
const onCancel = () => {
isVisible.value = false
}
</script>
<style>
.digital-document .header {
text-align: center;
}
</style>
@@ -0,0 +1,15 @@
import { client } from 'src/shared/api/client'
import { Mutations } from '@coopenomics/sdk'
import type { IPaySubscriptionsInput } from '../model'
async function paySubscriptions(input: IPaySubscriptionsInput) {
const { [Mutations.Billing.Pay.name]: result } = await client.Mutation(
Mutations.Billing.Pay.mutation,
{ variables: { input } },
)
return result
}
export const api = {
paySubscriptions,
}
@@ -0,0 +1,2 @@
export { usePaySubscriptions } from './model'
export type { IPaySubscriptionsInput, IPaySubscriptionsOutput } from './model'
@@ -0,0 +1,22 @@
import type { Mutations } from '@coopenomics/sdk'
import { api } from '../api'
// Типы строго из SDK
export type IPaySubscriptionsInput = Mutations.Billing.Pay.IInput['input']
export type IPaySubscriptionsOutput =
Mutations.Billing.Pay.IOutput[typeof Mutations.Billing.Pay.name]
/**
* Списание стоимости подписок с биллинг-кошелька пайщика (Epic 12, billing::pay).
*
* ОТКРЫТО: `username` пайщик-плательщик, чей USER_SHARED-кошелёк `w.wal.bill`
* дебетуется. Реестр оператора не знает плательщика целевого коопа автоматически,
* поэтому он указывается явно (та же конфиг-точка, что BILLING_CRON_PAYER в coopback).
*/
export function usePaySubscriptions() {
const pay = async (input: IPaySubscriptionsInput): Promise<IPaySubscriptionsOutput> => {
return await api.paySubscriptions(input)
}
return { pay }
}
@@ -1 +0,0 @@
export * from './ui'
@@ -1,240 +0,0 @@
<template lang="pug">
div
Form(:handler-submit="saveData" :showCancel="true" :button-cancel-txt="'Назад'" :button-submit-txt="'Продолжить'" @cancel="handleBack").q-gutter-md
//- Домен кооператива
.form-section.q-mb-lg
.section-header.q-mb-md
.section-title
q-icon(name="domain" size="20px" color="primary").q-mr-sm
span.text-subtitle1.text-weight-medium Домен кооператива
.section-description.text-body2.q-mt-sm
| Укажите домен или поддомен, на котором будет работать ваш кооператив
q-input.form-input(
standout="bg-teal text-white"
hint="domovoy.com или coop.domovoy.com"
label="Домен или поддомен для запуска"
v-model="formData.announce"
:rules="[val => notEmpty(val), val => isDomain(val)]"
)
//- Финансовые параметры
.form-section.q-mb-lg
.section-header.q-mb-md
.section-title
q-icon(name="account_balance_wallet" size="20px" color="secondary").q-mr-sm
span.text-subtitle1.text-weight-medium Финансовые параметры
.section-description.text-body2.q-mt-sm
| Введите вступительные и минимальные паевые взносы пайщиков. Эти параметры будут должны быть указаны в уставных документах кооператива.
.financial-grid
.grid-section.q-mb-md
.subsection-title.text-body1.text-weight-medium.q-mb-sm Для физических лиц и ИП
.input-row.q-mb-sm
q-input.form-input(
standout="bg-teal text-white"
placeholder="100"
label="Вступительный взнос"
v-model="formData.initial"
type="number"
:min="0"
:rules="[val => notEmpty(val)]"
)
template(#append)
span.text-overline RUB
.input-row
q-input.form-input(
standout="bg-teal text-white"
label="Минимальный паевый взнос"
placeholder="300"
v-model="formData.minimum"
type="number"
:min="0"
:rules="[val => notEmpty(val)]"
)
template(#append)
span.text-overline RUB
.grid-section
.subsection-title.text-body1.text-weight-medium.q-mb-sm Для организаций
.input-row.q-mb-sm
q-input.form-input(
standout="bg-teal text-white"
placeholder="1000"
label="Вступительный взнос"
v-model="formData.org_initial"
type="number"
:min="0"
:rules="[val => notEmpty(val)]"
)
template(#append)
span.text-overline RUB
.input-row
q-input.form-input(
standout="bg-teal text-white"
placeholder="3000"
label="Минимальный паевый взнос"
v-model="formData.org_minimum"
type="number"
:min="0"
:rules="[val => notEmpty(val)]"
)
template(#append)
span.text-overline RUB
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Form } from 'src/shared/ui/Form';
import { notEmpty, isDomain } from 'src/shared/lib/utils';
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement';
import type { ICooperativeFormData } from 'src/entities/ConnectionAgreement/model/types';
const emit = defineEmits(['continue', 'back'])
const connectionAgreement = useConnectionAgreementStore()
// Локальное состояние формы для избежания проблем с реактивностью
const formData = ref<ICooperativeFormData>({
announce: connectionAgreement.formData.announce || '',
initial: connectionAgreement.formData.initial || '',
minimum: connectionAgreement.formData.minimum || '',
org_initial: connectionAgreement.formData.org_initial || '',
org_minimum: connectionAgreement.formData.org_minimum || ''
})
// Синхронизируем локальное состояние с изменениями в store
watch(() => connectionAgreement.formData, (newFormData) => {
formData.value = {
announce: newFormData.announce || '',
initial: newFormData.initial || '',
minimum: newFormData.minimum || '',
org_initial: newFormData.org_initial || '',
org_minimum: newFormData.org_minimum || ''
}
}, { deep: true })
// Синхронизируем локальное состояние с store при изменении
const syncFormData = () => {
connectionAgreement.setFormData(formData.value)
}
const handleBack = () => {
emit('back')
}
const saveData = async () => {
console.log('📤 CooperativeDataForm: Данные формы:', formData.value)
// Синхронизируем данные с store перед отправкой
syncFormData()
emit('continue', formData.value)
}
</script>
<style scoped>
.form-section {
padding: 1.5rem;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.section-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding-bottom: 1rem;
display: block;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.section-description {
line-height: 1.5;
}
.financial-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.grid-section {
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.subsection-title {
color: var(--q-primary);
border-bottom: 2px solid var(--q-primary);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.input-row {
margin-bottom: 1rem;
}
.input-row:last-child {
margin-bottom: 0;
}
.form-input {
margin-bottom: 0;
}
/* Разделитель между секциями */
.form-section + .form-section {
margin-top: 2rem;
}
/* Адаптивность */
@media (max-width: 768px) {
.financial-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.form-section {
padding: 1.25rem;
}
}
@media (max-width: 480px) {
.form-section {
padding: 1rem;
}
.section-title {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.section-header {
text-align: center;
display: block !important;
}
.section-description {
display: block !important;
width: 100% !important;
}
.financial-grid {
gap: 1rem;
}
.grid-section {
padding: 0.75rem;
}
.subsection-title {
font-size: 1rem;
}
}
</style>
@@ -1 +0,0 @@
export { default as CooperativeDataForm } from './CooperativeDataForm.vue'
@@ -1,5 +1,7 @@
import { RegistratorContract } from 'cooptypes';
import { fetchTable } from 'src/shared/api';
import { client } from 'src/shared/api/client';
import { Queries } from '@coopenomics/sdk';
async function loadCoopByUsername(coopname: string): Promise<RegistratorContract.Tables.Cooperatives.ICooperative> {
const coop = (await fetchTable(
@@ -16,15 +18,17 @@ async function loadCoopByUsername(coopname: string): Promise<RegistratorContract
}
async function loadCoops(): Promise<RegistratorContract.Tables.Cooperatives.ICooperative[]> {
const requests = (await fetchTable(
RegistratorContract.contractName.production,
RegistratorContract.contractName.production,
RegistratorContract.Tables.Cooperatives.tableName
)) as RegistratorContract.Tables.Cooperatives.ICooperative[];
/**
* Реестр кооперативов оператора грузится через бэкенд (coopback GraphQL),
* а не напрямую из блокчейна: бэкенд сводит on-chain список кооперативов
* с данными провайдера (подписки / инстанс / биллинг по coopname).
*/
async function loadCoops(): Promise<Queries.System.GetCooperativesRegistry.IOutput['getCooperativesRegistry']> {
const { [Queries.System.GetCooperativesRegistry.name]: result } = await client.Query(
Queries.System.GetCooperativesRegistry.query
);
return requests;
return result;
}
export const api = {
@@ -140,44 +140,50 @@ const init = async () => {
connectionAgreement.setHasMatrixAccount(accountStatus.hasAccount);
}
// Подтягиваем on-chain запись кооператива её наличие означает, что
// соглашение о подключении уже подписано (regcoop проходит только после
// signagree). Используем как маркёр прохождения шагов 04.
try {
await connectionAgreement.reloadCooperative();
} catch {
// coop ещё не зарегистрирован это валидное состояние, не падаем
}
const instance = connectionAgreement.currentInstance;
const hasInstanceError = connectionAgreement.currentInstanceError;
const coop = connectionAgreement.coop;
// Определяем шаг на основе текущего прогресса установки (при каждом заходе)
// Сначала проверяем, была ли установка уже завершена (даже при ошибке загрузки)
// Сначала проверяем, была ли установка уже завершена
const isAlreadyCompleted = instance?.progress === 100 && instance?.status === Zeus.InstanceStatus.ACTIVE;
if (isAlreadyCompleted) {
console.log('✅ Установка уже завершена ранее, показываем дашборд');
// Не меняем шаг, оставляем текущий (или устанавливаем специальный шаг для завершенной установки)
isLoading.value = false;
return;
}
// Определяем шаг на основе текущего состояния
// Восстанавливаем фактический шаг по состоянию on-chain + provider.
// Идея: каждый шаг имеет хорошо различимый внешний маркёр; при перезагрузке
// не должно требоваться localStorage.
let targetStep: number;
if (hasInstanceError) {
// Если есть ошибка загрузки инстанса, определяем шаг на основе членства в союзе
targetStep = system.info.is_unioned ? 0 : 1;
} else if (instance && typeof instance.progress === 'number' && instance.progress > 0) {
// Если установка уже идет (прогресс > 0), переходим к шагу установки
targetStep = 6;
} else {
// Если инстанса нет ИЛИ его прогресс = 0, определяем шаг на основе членства в союзе
if (system.info.is_unioned) {
// Если кооператив не является членом союза, начинаем с нулевого шага
targetStep = 0;
if (coop) {
// Соглашение точно подписано (coop появился в registrator.coops).
if (instance && typeof instance.progress === 'number' && instance.progress > 0) {
targetStep = 7; // установка идёт
} else if (instance?.blockchain_status === 'active') {
targetStep = 7; // союз подтвердил, провайдер вот-вот стартует
} else if (instance?.is_delegated) {
targetStep = 6; // домен делегирован, ждём подтверждения союза
} else {
// Если кооператив уже член союза, начинаем с первого шага
targetStep = 1;
targetStep = 5; // DNS-шаг: ждём делегирования или провайдер ещё не подхватил coop
}
} else if (system.info.is_unioned) {
targetStep = 0; // нужна регистрация в мессенджере союза
} else {
targetStep = 1; // онбординг от выбора тарифа
}
// Устанавливаем определенный шаг
connectionAgreement.setCurrentStep(targetStep);
// Скрываем лоадер после загрузки данных
isLoading.value = false;
};
@@ -203,27 +209,24 @@ watch(
status: instance.status,
});
// Логика автоматических переходов (только для шагов 4, 5, 6)
// Шаги 0, 1, 2, 3 не имеют автоматических переходов
if (currentStep === 4) {
// Шаг 4: Проверка домена
// Автопереходы только для шагов 5 (dns), 6 (approval), 7 (installation).
// Шаги 0..4 (union/intro/domain/financial/agreement) пользовательский ввод.
if (currentStep === 5) {
// Шаг 5: Проверка делегирования домена.
if (instance.is_valid && instance.is_delegated) {
// Домен валиден и делегирован
if (instance.blockchain_status === 'active') {
// Можно переходить сразу к установке
console.log('✅ Домен готов и blockchain_status активен → переход к шагу 6');
connectionAgreement.setCurrentStep(6);
console.log('✅ Домен готов и blockchain_status активен → переход к шагу 7 (installation)');
connectionAgreement.setCurrentStep(7);
} else {
// Ожидаем подтверждения от союза
console.log('⏳ Домен готов, но ожидаем подтверждения → переход к шагу 5');
connectionAgreement.setCurrentStep(5);
console.log('⏳ Домен готов, ждём подтверждения союза → переход к шагу 6 (approval)');
connectionAgreement.setCurrentStep(6);
}
}
} else if (currentStep === 5) {
// Шаг 5: Ожидание подтверждения от союза
} else if (currentStep === 6) {
// Шаг 6: Ожидание подтверждения от союза.
if (instance.blockchain_status === 'active') {
console.log('✅ Подтверждение получено → переход к шагу 6');
connectionAgreement.setCurrentStep(6);
console.log('✅ Подтверждение получено → переход к шагу 7 (installation)');
connectionAgreement.setCurrentStep(7);
}
}
// Редирект на страницу завершения теперь делает только InstallationStep.vue
@@ -0,0 +1 @@
export * from './ui'
@@ -0,0 +1,346 @@
<template lang="pug">
.coop-detail.q-pa-md
.coop-detail__back
BaseButton(
variant="ghost"
size="sm"
type="button"
@click="goBack"
)
q-icon(name="arrow_back" size="16px").q-mr-xs
| К реестру
Loader(v-if="loading" :text="'Загрузка кооператива...'")
BaseBanner(v-else-if="!row" variant="warn")
| Кооператив не найден в реестре.
template(v-else)
BaseCard(
:title="row.name || row.coopname"
:subtitle="headerSubtitle"
)
template(#actions)
.coop-detail__head-actions
BaseChip(:variant="registryStatusVariant(row.status)" size="sm")
span {{ registryStatusLabel(row.status) }}
BaseButton(
v-if="row.status !== 'active'"
variant="ghost"
size="sm"
type="button"
@click="activate"
) Активировать
BaseButton(
v-if="row.status !== 'blocked'"
variant="ghost"
size="sm"
type="button"
@click="block"
) Заблокировать
DataRow(v-if="row.announce" label="Сайт" :value="row.announce" mono)
template(#value-override)
a.coop-detail__link.t-mono(
:href="resolveSiteUrl(row.announce)"
target="_blank"
rel="noopener"
) {{ row.announce }}
.coop-detail__section
.coop-detail__section-title Подписки
EmptyState(
v-if="!row.subscriptions || !row.subscriptions.length"
title="Подписок нет"
body="У кооператива пока нет активных подписок у провайдера"
)
BaseCard(v-else variant="flat")
BaseTable(
:columns="subscriptionColumns"
:rows="row.subscriptions"
row-key="id"
)
template(#cell-name="{ row: sub }")
.coop-detail__sub-name
span {{ sub.subscription_type_name }}
template(#cell-status="{ row: sub }")
BaseChip(:variant="subscriptionStatusVariant(sub.status)" size="sm")
span {{ subscriptionStatusLabel(sub.status) }}
template(#cell-period="{ row: sub }")
span.t-mono {{ sub.period_days }} дн.
template(#cell-price="{ row: sub }")
span.t-mono {{ formatMoney(sub.price) }} RUB
template(#cell-next="{ row: sub }")
span.t-mono(v-if="sub.next_payment_due") {{ formatDate(sub.next_payment_due) }}
span.t-muted(v-else)
.coop-detail__section
.coop-detail__section-title Кошельки кооператива в Восходе
BaseBanner(v-if="walletError" variant="neg") {{ walletError }}
.coop-detail__wallets
BaseCard(variant="flat" title="Паевой взнос")
.coop-detail__wallet-body
.coop-detail__wallet-amount.t-mono(v-if="walletLoading")
.coop-detail__wallet-amount.t-mono(v-else)
| {{ formatAmount(walletAvailable) }} {{ walletDisplaySymbol }}
.coop-detail__wallet-hint Возвратный взнос на счёте пайщика в Восходе.
BaseCard(variant="flat" title="Кошелёк членских взносов")
.coop-detail__wallet-body
.coop-detail__wallet-amount.t-mono(v-if="walletLoading")
.coop-detail__wallet-amount.t-mono(v-else)
| {{ formatAmount(walletMembership) }} {{ walletDisplaySymbol }}
.coop-detail__wallet-hint Списывается за инфраструктурные подписки.
.coop-detail__section
.coop-detail__section-title История оплат
EmptyState(
title="История пока недоступна"
body="Раздел появится после реализации выгрузки операций биллинга по кооперативу."
)
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import moment from 'src/shared/lib/utils/dates/moment'
import {
BaseBanner,
BaseButton,
BaseCard,
BaseChip,
BaseTable,
EmptyState,
} from 'src/shared/ui/base'
import { DataRow } from 'src/shared/ui/domain'
import Loader from 'src/shared/ui/Loader/Loader.vue'
import { useLoadCooperatives } from 'src/features/Union/LoadCooperatives'
import { useActivateCooperative } from 'src/features/Union/ActivateCooperative'
import { useBlockCooperative } from 'src/features/Union/BlockCooperative'
import { useUnionStore } from 'src/entities/Union/model'
import type { ICooperativeRegistryItem } from 'src/entities/Union/model'
import { useCooperativeMainWallet } from 'src/entities/Wallet/model'
import { useSystemStore } from 'src/entities/System/model'
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts'
type BaseChipVariant = 'neutral' | 'accent' | 'pos' | 'neg' | 'warn' | 'info'
const route = useRoute()
const router = useRouter()
const union = useUnionStore()
const system = useSystemStore()
const { loadCooperatives } = useLoadCooperatives()
const loading = ref(false)
const coopname = computed(() => String(route.params.detailCoopname || ''))
const row = computed<ICooperativeRegistryItem | undefined>(() =>
union.coops.find((c) => c.coopname === coopname.value),
)
const {
available: walletAvailable,
membership: walletMembership,
symbol: walletSymbolRaw,
loading: walletLoading,
error: walletError,
} = useCooperativeMainWallet(
() => system.info.coopname || '',
() => coopname.value,
)
const walletDisplaySymbol = computed(
() => walletSymbolRaw.value || system.info.symbols?.root_govern_symbol || 'RUB',
)
const formatAmount = (amount: string): string =>
new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(Number(amount))
const load = async () => {
loading.value = true
try {
await loadCooperatives()
} catch (e: any) {
FailAlert(e)
} finally {
loading.value = false
}
}
load()
const subscriptionColumns = [
{ key: 'name', label: 'Подписка', align: 'left' as const },
{ key: 'status', label: 'Статус', align: 'left' as const },
{ key: 'period', label: 'Период', align: 'right' as const, numeric: true },
{ key: 'price', label: 'Стоимость', align: 'right' as const, numeric: true },
{ key: 'next', label: 'След. оплата', align: 'right' as const },
]
const headerSubtitle = computed(() => {
const r = row.value
if (!r) return undefined
const parts: string[] = [r.coopname]
if (r.created_at) parts.push(`заявка от ${formatDateTime(r.created_at)}`)
return parts.join(' · ')
})
const formatDate = (d: string) => moment(d).format('DD.MM.YYYY')
const formatDateTime = (d: string) => moment(d).format('DD.MM.YY HH:mm')
const formatMoney = (value: number | string): string =>
new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(Number(value))
const resolveSiteUrl = (announce: string): string =>
/^https?:\/\//.test(announce) ? announce : `https://${announce}`
const registryStatusVariant = (status: string): BaseChipVariant => {
switch (status) {
case 'active':
return 'pos'
case 'pending':
return 'warn'
case 'blocked':
return 'neg'
default:
return 'neutral'
}
}
const registryStatusLabel = (status: string): string => {
switch (status) {
case 'active':
return 'активен'
case 'pending':
return 'на рассмотрении'
case 'blocked':
return 'заблокирован'
default:
return status
}
}
const subscriptionStatusVariant = (status: string): BaseChipVariant => {
switch (status) {
case 'ACTIVE':
return 'pos'
case 'TRIAL':
return 'info'
case 'EXPIRED':
return 'warn'
case 'CANCELLED':
return 'neg'
default:
return 'neutral'
}
}
const subscriptionStatusLabel = (status: string): string => {
switch (status) {
case 'ACTIVE':
return 'активна'
case 'TRIAL':
return 'триал'
case 'EXPIRED':
return 'истекла'
case 'CANCELLED':
return 'отменена'
default:
return status
}
}
const goBack = () => {
const params = { ...route.params }
delete (params as any).detailCoopname
router.push({ name: 'union-cooperatives', params })
}
const activate = async () => {
if (!row.value) return
const { activateCooperative } = useActivateCooperative()
try {
await activateCooperative(row.value.coopname)
await load()
SuccessAlert('Кооператив активирован')
} catch (e: any) {
FailAlert(e)
}
}
const block = async () => {
if (!row.value) return
const { blockCooperative } = useBlockCooperative()
try {
await blockCooperative(row.value.coopname)
await load()
SuccessAlert('Кооператив заблокирован')
} catch (e: any) {
FailAlert(e)
}
}
</script>
<style scoped>
.coop-detail__back {
margin-bottom: var(--p-3);
}
.coop-detail__head-actions {
display: inline-flex;
align-items: center;
gap: var(--p-2);
}
.coop-detail__link {
color: var(--p-primary);
text-decoration: none;
}
.coop-detail__link:hover {
text-decoration: underline;
}
.coop-detail__section {
margin-top: var(--p-5);
display: flex;
flex-direction: column;
gap: var(--p-3);
}
.coop-detail__section-title {
font-size: var(--p-fs-h6);
font-weight: 600;
color: var(--p-ink);
}
.coop-detail__sub-name {
display: inline-flex;
align-items: center;
}
.coop-detail__wallets {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--p-3);
}
.coop-detail__wallet-body {
display: flex;
flex-direction: column;
gap: var(--p-1);
padding: var(--p-2) 0;
}
.coop-detail__wallet-amount {
font-size: var(--p-fs-h6);
font-weight: 700;
color: var(--p-ink);
}
.coop-detail__wallet-hint {
font-size: var(--p-fs-caption);
color: var(--p-ink-2);
}
</style>
@@ -0,0 +1 @@
export { default as UnionPageCooperativeDetail } from './UnionPageCooperativeDetail.vue'
@@ -1,141 +1,274 @@
<template lang="pug">
div
q-table(
v-if="coops"
ref="tableRef" v-model:expanded="expanded"
flat
:rows="coops"
:columns="columns"
:table-colspan="9"
row-key="username"
:pagination="pagination"
virtual-scroll
:virtual-scroll-item-size="48"
:rows-per-page-options="[10]"
:loading="onLoading"
:no-data-label="'Нет кооперативов'"
).full-height
template(#top)
.coop-registry.q-pa-md
.banner
q-icon.banner__icon(name="info" size="18px")
.banner__body
| Заявки на подключение к платформе и состояние подписок у провайдера.
Loader(v-if="onLoading" :text="'Загрузка реестра...'")
template(#header="props")
EmptyState(
v-else-if="!coops || !coops.length"
title="Нет кооперативов"
body="В реестре пока нет заявок на подключение"
)
q-tr(:props="props")
q-th(auto-width)
.coop-registry__list(v-else)
BaseCard.coop-registry__card(
v-for="row in coops"
:key="row.coopname"
:title="row.name || row.coopname"
:subtitle="cardSubtitle(row)"
@click="openDetail(row.coopname)"
)
template(#actions)
.coop-registry__head-actions
BaseChip(:variant="registryStatusVariant(row.status)" size="sm")
span {{ registryStatusLabel(row.status) }}
BaseButton(
v-if="row.status !== 'active'"
variant="ghost"
size="sm"
type="button"
@click.stop="activate(row.coopname)"
) Активировать
BaseButton(
v-if="row.status !== 'blocked'"
variant="ghost"
size="sm"
type="button"
@click.stop="block(row.coopname)"
) Заблокировать
q-th(
v-for="col in props.cols"
:key="col.name"
:props="props"
) {{ col.label }}
.coop-registry__summary
.coop-registry__metric
.coop-registry__metric-label Подписок
.coop-registry__metric-value.t-mono {{ row.subscriptions?.length || 0 }}
.coop-registry__metric
.coop-registry__metric-label Сумма в месяц
.coop-registry__metric-value.t-mono
| {{ formatMoney(monthlyTotal(row)) }} RUB
.coop-registry__metric
.coop-registry__metric-label Следующая оплата
.coop-registry__metric-value.t-mono {{ nextPaymentLabel(row) }}
.coop-registry__metric
.coop-registry__metric-label Хостинг
.coop-registry__metric-value
BaseChip(v-if="hostingStatus(row)" :variant="instanceStatusVariant(hostingStatus(row))" size="sm")
span {{ instanceStatusLabel(hostingStatus(row)) }}
span.t-muted(v-else) нет
</template>
template(#body="props")
q-tr(:key="`m_${props.row.username}`" :props="props")
q-td(auto-width)
q-btn(size="sm" color="primary" round dense :icon="props.expand ? 'remove' : 'add'" @click="props.expand = !props.expand")
q-td {{ props.row.username }}
q-td {{ props.row.announce }}
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import moment from 'src/shared/lib/utils/dates/moment'
import {
BaseButton,
BaseCard,
BaseChip,
EmptyState,
} from 'src/shared/ui/base'
import Loader from 'src/shared/ui/Loader/Loader.vue'
import { useLoadCooperatives } from 'src/features/Union/LoadCooperatives'
import { useActivateCooperative } from 'src/features/Union/ActivateCooperative'
import { useBlockCooperative } from 'src/features/Union/BlockCooperative'
import { useUnionStore } from 'src/entities/Union/model'
import type { ICooperativeRegistryItem } from 'src/entities/Union/model'
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts'
q-td
q-badge(v-if="props.row.status === 'active'" color="teal") активен
q-badge(v-if="props.row.status === 'pending'" color="orange") на рассмотрении
q-badge(v-if="props.row.status === 'blocked'" color="red") заблокирован
type ICooperativeSubscription = ICooperativeRegistryItem['subscriptions'][number]
type BaseChipVariant = 'neutral' | 'accent' | 'pos' | 'neg' | 'warn' | 'info'
q-td {{ moment(props.row.created_at).format('DD.MM.YY HH:mm:ss') }}
const router = useRouter()
const route = useRoute()
const union = useUnionStore()
const { loadCooperatives } = useLoadCooperatives()
q-td
q-btn-dropdown( label="действия" flat size="sm")
q-list
q-item(v-if="props.row.status !== 'active'" clickable v-close-popup @click="activate(props.row.username)")
q-item-section
q-item-label Активировать
q-item(v-if="props.row.status !== 'blocked'" clickable v-close-popup @click="block(props.row.username)")
q-item-section
q-item-label Заблокировать
//- q-item(clickable v-close-popup @click="deleteCoop(props.row.username)")
//- q-item-section
//- q-item-label Удалить
const coops = computed(() => union.coops)
q-tr(v-show="props.expand" :key="`e_${props.row.username}`" :props="props" class="q-virtual-scroll--with-prev")
q-td(colspan="100%")
slot(:expand="props.expand" :receiver="props.row.username")
ListOfDocumentsWidget(
:username="props.row.username"
:filter="{data: getDataFilter(props.row.document.hash)}"
:expand="true"
:documentType="'newsubmitted'"
)
const onLoading = ref(false)
</template>
<script setup lang="ts">
import { useLoadCooperatives } from 'src/features/Union/LoadCooperatives';
const {loadCooperatives} = useLoadCooperatives()
import { useUnionStore } from 'src/entities/Union/model';
import { computed, ref } from 'vue';
import moment from 'src/shared/lib/utils/dates/moment'
import { useActivateCooperative } from 'src/features/Union/ActivateCooperative';
import { FailAlert, SuccessAlert } from 'src/shared/api/alerts';
import { useBlockCooperative } from 'src/features/Union/BlockCooperative';
const union = useUnionStore()
import { ListOfDocumentsWidget } from 'src/widgets/Cooperative/Documents/ListOfDocuments';
const getDataFilter = (hash: string) => {
return {
document: {
hash: hash.toUpperCase()
}
}
const load = async () => {
onLoading.value = true
try {
await loadCooperatives()
} catch (e: any) {
FailAlert(e)
} finally {
onLoading.value = false
}
}
const coops = computed(() => union.coops)
load()
loadCooperatives()
const hostingSubscription = (row: ICooperativeRegistryItem): ICooperativeSubscription | undefined =>
row.subscriptions?.find((s) => !!s.instance_status)
const activate = async (coopname: string) => {
const {activateCooperative} = useActivateCooperative()
const hostingStatus = (row: ICooperativeRegistryItem): string | null =>
hostingSubscription(row)?.instance_status ?? null
try {
await activateCooperative(coopname)
loadCooperatives()
SuccessAlert('Кооператив активирован')
} catch(e: any) {
FailAlert(e)
}
const monthlyTotal = (row: ICooperativeRegistryItem): number =>
(row.subscriptions ?? []).reduce((sum, s) => sum + (Number(s.price) || 0), 0)
const nextPaymentDate = (row: ICooperativeRegistryItem): string | null => {
const dates = (row.subscriptions ?? [])
.map((s) => s.next_payment_due)
.filter(Boolean) as string[]
if (!dates.length) return null
return dates.sort()[0]
}
const nextPaymentLabel = (row: ICooperativeRegistryItem): string => {
const d = nextPaymentDate(row)
return d ? moment(d).format('DD.MM.YYYY') : '—'
}
const formatDateTime = (value: string): string =>
moment(value).format('DD.MM.YY HH:mm')
const formatMoney = (value: number | string): string =>
new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(Number(value))
const cardSubtitle = (row: ICooperativeRegistryItem): string | undefined => {
const parts: string[] = []
if (row.name) parts.push(row.coopname)
if (row.created_at) parts.push(`заявка от ${formatDateTime(row.created_at)}`)
return parts.length ? parts.join(' · ') : undefined
}
const registryStatusVariant = (status: string): BaseChipVariant => {
switch (status) {
case 'active':
return 'pos'
case 'pending':
return 'warn'
case 'blocked':
return 'neg'
default:
return 'neutral'
}
}
const block = async (coopname: string) => {
const {blockCooperative} = useBlockCooperative()
try {
await blockCooperative(coopname)
loadCooperatives()
SuccessAlert('Кооператив заблокирован')
} catch(e: any) {
FailAlert(e)
}
const registryStatusLabel = (status: string): string => {
switch (status) {
case 'active':
return 'активен'
case 'pending':
return 'на рассмотрении'
case 'blocked':
return 'заблокирован'
default:
return status
}
}
const instanceStatusVariant = (status: string | null): BaseChipVariant => {
switch (status) {
case 'active':
return 'pos'
case 'install':
case 'rent':
case 'pending':
return 'warn'
case 'error':
case 'blocked':
case 'requires_manual_review':
return 'neg'
default:
return 'neutral'
}
}
const onLoading = ref(false)
const instanceStatusLabel = (status: string | null): string => {
switch (status) {
case 'active':
return 'активен'
case 'install':
return 'установка'
case 'rent':
return 'аренда'
case 'pending':
return 'ожидание'
case 'error':
return 'ошибка'
case 'blocked':
return 'заблокирован'
case 'requires_manual_review':
return 'нужна проверка'
default:
return status ?? '—'
}
}
const columns = [
{ name: 'username', align: 'left', label: 'Аккаунт', field: 'username', sortable: true },
{ name: 'announce', align: 'left', label: 'Сайт', field: 'announce', sortable: false },
const openDetail = (coopname: string) => {
const params = { ...route.params, detailCoopname: coopname }
router.push({ name: 'union-cooperative-detail', params })
}
{ name: 'status', align: 'left', label: 'Статус', field: 'status', sortable: true },
{
name: 'created_at',
align: 'left',
label: 'Дата заявки',
field: 'created_at',
sortable: true,
},
{ name: 'actions', align: 'center', label: '', field: 'actions', sortable: false },
] as any
const activate = async (coopname: string) => {
const { activateCooperative } = useActivateCooperative()
try {
await activateCooperative(coopname)
await load()
SuccessAlert('Кооператив активирован')
} catch (e: any) {
FailAlert(e)
}
}
const expanded = ref([])
const tableRef = ref(null)
const pagination = ref({ rowsPerPage: 0 })
const block = async (coopname: string) => {
const { blockCooperative } = useBlockCooperative()
try {
await blockCooperative(coopname)
await load()
SuccessAlert('Кооператив заблокирован')
} catch (e: any) {
FailAlert(e)
}
}
</script>
</script>
<style scoped>
.coop-registry__list {
display: flex;
flex-direction: column;
gap: var(--p-4);
margin-top: var(--p-4);
}
.coop-registry__card {
cursor: pointer;
transition: border-color 0.15s ease;
}
.coop-registry__card:hover {
border-color: var(--p-primary);
}
.coop-registry__head-actions {
display: inline-flex;
align-items: center;
gap: var(--p-2);
}
.coop-registry__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--p-3);
padding: var(--p-3) 0;
}
.coop-registry__metric {
display: flex;
flex-direction: column;
gap: var(--p-1);
}
.coop-registry__metric-label {
font-size: var(--p-fs-caption);
color: var(--p-ink-2);
}
.coop-registry__metric-value {
font-size: var(--p-fs-body);
font-weight: 600;
color: var(--p-ink);
}
</style>
@@ -2,15 +2,15 @@
.wallet-page
WalletProgramWidget
//- Действия страницы в шапке через canon Teleport в слот-host шапки.
//- defer: target (#header-actions-host) живёт в layout-шапке, смонтированной
//- раньше страницы. На мобильном micro-вариант кнопок (иконка + tooltip),
//- чтобы не раздувать узкую шапку; на десктопе полные кнопки с подписью.
//- Сами кнопки решают свою видимость (DepositButton скрыт, пока пайщик
//- не принят status !== 'active').
Teleport(to='#header-actions-host', defer)
DepositButton(:micro='isMobile')
WithdrawButton(:micro='isMobile')
//- Действия страницы в шапке через canon Teleport в слот-host шапки.
//- defer: target (#header-actions-host) живёт в layout-шапке, смонтированной
//- раньше страницы. На мобильном micro-вариант кнопок (иконка + tooltip),
//- чтобы не раздувать узкую шапку; на десктопе полные кнопки с подписью.
//- Сами кнопки решают свою видимость (DepositButton скрыт, пока пайщик
//- не принят status !== 'active').
Teleport(to='#header-actions-host', defer)
DepositButton(:micro='isMobile')
WithdrawButton(:micro='isMobile')
</template>
<script lang="ts" setup>
@@ -192,7 +192,7 @@ function labelClass(step: StepperStep): Record<string, boolean> {
color: var(--p-ink-1);
}
.vertical-stepper__label--clickable:hover {
color: var(--p-primary);
color: var(--p-accent);
}
.vertical-stepper__label--active {
color: var(--p-ink);
@@ -1,46 +1,31 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { IStepProps } from '../model/types'
import { BaseButton } from 'src/shared/ui/base/BaseButton'
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader'
import { Loader } from 'src/shared/ui/Loader'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
import { useAddCooperative } from 'src/features/Union/AddCooperative/model'
import { useSessionStore } from 'src/entities/Session'
const props = withDefaults(defineProps<IStepProps & {
defineProps<{
html?: string
}>(), {})
}>()
const connectionAgreement = useConnectionAgreementStore()
const isActive = computed(() => props.isActive)
const isDone = computed(() => props.isDone)
const isSigning = ref(false)
// Проверяем, готов ли документ для подписания
const isDocumentReady = computed(() => {
return connectionAgreement.document && connectionAgreement.document.sign
})
const isDocumentReady = computed(
() => !!(connectionAgreement.document && connectionAgreement.document.sign),
)
const handleSign = async () => {
if (isSigning.value) return // Предотвращаем повторные клики
// Дополнительная проверка готовности документа
if (!isDocumentReady.value) {
console.warn('⚠️ Документ еще не готов для подписания')
return
}
if (isSigning.value || !isDocumentReady.value) return
isSigning.value = true
try {
console.log('📝 AgreementStep: Подписываем документ')
await connectionAgreement.signDocument()
console.log('🔗 AgreementStep: Отправляем транзакцию registerCooperative в блокчейн')
const { addCooperative } = useAddCooperative()
const session = useSessionStore()
// Формируем данные для registerCooperative действия
const registerData = {
coopname: session.username,
params: {
@@ -51,36 +36,24 @@ const handleSign = async () => {
initial: connectionAgreement.formData.initial,
minimum: connectionAgreement.formData.minimum,
org_initial: connectionAgreement.formData.org_initial,
org_minimum: connectionAgreement.formData.org_minimum
org_minimum: connectionAgreement.formData.org_minimum,
},
username: session.username,
document: {
...connectionAgreement.signedDocument,
meta: JSON.stringify(connectionAgreement.signedDocument.meta)
}
meta: JSON.stringify(connectionAgreement.signedDocument.meta),
},
}
// Отправляем данные формы в блокчейн
await addCooperative(registerData)
console.log('✅ AgreementStep: Транзакция успешно отправлена')
// Обновляем информацию о кооперативе из блокчейна
console.log('🔄 AgreementStep: Обновляем информацию о кооперативе')
await connectionAgreement.reloadCooperative()
// Обновляем информацию об инстансе
console.log('🔄 AgreementStep: Обновляем информацию об инстансе')
await connectionAgreement.loadCurrentInstance()
console.log('✅ AgreementStep: Информация обновлена')
// Переходим к следующему шагу после подписания и отправки в блокчейн
if (connectionAgreement.currentStep < 5) {
if (connectionAgreement.currentStep < 7) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep + 1)
}
} catch (error) {
console.error('Ошибка при подписании или отправке в блокчейн:', error)
console.error('Ошибка при подписании или отправке в блокчейн:', error)
throw error
} finally {
isSigning.value = false
@@ -88,47 +61,57 @@ const handleSign = async () => {
}
const handleBack = () => {
// Специальная логика для возврата - очищаем документы для показа Loader при повторном переходе
console.log(`⬅️ AgreementStep: Возврат с шага ${connectionAgreement.currentStep}`)
// Очищаем документы, чтобы при повторном переходе вперед показался Loader
connectionAgreement.setDocument(null)
connectionAgreement.setSignedDocument(null)
if (connectionAgreement.currentStep > 1) {
if (connectionAgreement.currentStep > 0) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep - 1)
}
}
</script>
<template lang="pug">
q-step(
:name="3"
title="Соглашение о подключении к платформе"
icon="description"
:done="isDone"
)
.q-pa-md
template(v-if="html")
.agreement-step
template(v-if="html")
.agreement-step__doc
DocumentHtmlReader(:html="html")
template(v-else)
Loader(:text='`Готовим соглашение...`')
q-stepper-navigation.q-gutter-sm(v-if="html")
q-btn(
v-if="isActive"
color="grey-6"
flat
label="Назад"
@click="handleBack"
)
q-btn(
v-if="isActive"
color="primary"
:loading="isSigning"
:disable="!isDocumentReady"
label="Подписать"
@click="handleSign"
)
.agreement-step__actions
BaseButton(
variant="ghost"
size="sm"
@click="handleBack"
) Назад
.agreement-step__spacer
BaseButton(
variant="primary"
size="sm"
:loading="isSigning"
:disabled="!isDocumentReady"
@click="handleSign"
) Подписать
template(v-else)
Loader(:text="'Готовим соглашение...'")
</template>
<style scoped>
.agreement-step {
display: flex;
flex-direction: column;
gap: var(--p-5);
}
.agreement-step__doc {
background: var(--p-surface);
border: 1px solid var(--p-line-1);
border-radius: var(--p-r-md);
padding: var(--p-5);
max-height: 540px;
overflow: auto;
}
.agreement-step__actions {
display: flex;
align-items: center;
gap: var(--p-2);
}
.agreement-step__spacer {
flex: 1 1 auto;
}
</style>
@@ -1,70 +1,17 @@
<template lang="pug">
q-step(
:name="5"
title="Ожидание подтверждения"
icon="schedule"
:done="isDone"
)
.approval-waiting-container.q-pa-md
//- Основная карточка ожидания
.waiting-card.bg-surface.q-mb-xl
.waiting-header
.waiting-icon-container
q-icon.waiting-icon(
:name="getWaitingIcon"
:color="getWaitingIconColor"
size="80px"
@click="refreshStatus"
clickable
)
.pulse-ring(v-if="instance?.blockchain_status !== 'active'")
.waiting-title.text-h5.text-weight-medium.text-primary Ожидание подтверждения союза
//- Статусная информация
.status-info.q-mt-lg
.status-description.text-body1.text-on-surface
| Все технические подготовки завершены. Ваш Цифровой Кооператив готов к установке и подключению к платформе Кооперативной Экономики.
| Теперь мы ожидаем подтверждения от представителя союза о готовности произвести стандартизацию вашего документооборота.
| Когда оно будет получено - установка продолжится автоматически.
//- Навигация
q-stepper-navigation.q-gutter-sm
q-btn(
color="grey-6"
flat
label="Назад"
@click="handleBack"
)
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { IStepProps } from '../model/types'
import {
BaseButton,
BaseCard,
BaseChip,
} from 'src/shared/ui/base'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
const props = withDefaults(defineProps<IStepProps>(), {})
const connectionAgreement = useConnectionAgreementStore()
// Получаем данные напрямую из store
const instance = computed(() => connectionAgreement.currentInstance)
const isDone = computed(() => props.isDone)
const isApproved = computed(() => instance.value?.blockchain_status === 'active')
// Иконка ожидания
const getWaitingIcon = computed(() => {
if (instance.value?.blockchain_status === 'active') return 'check_circle'
return 'schedule'
})
// Цвет иконки ожидания
const getWaitingIconColor = computed(() => {
if (instance.value?.blockchain_status === 'active') return 'positive'
return 'warning'
})
// Функция обновления статуса
const refreshStatus = async () => {
try {
await connectionAgreement.loadCurrentInstance()
@@ -72,161 +19,43 @@ const refreshStatus = async () => {
console.error('Ошибка обновления статуса:', error)
}
}
const handleBack = () => {
connectionAgreement.setCurrentStep(4)
}
</script>
<template lang="pug">
.approval-step
BaseCard(
title="Подтверждение союза"
subtitle="Технические подготовки завершены. Цифровой Кооператив готов к установке и подключению к платформе Кооперативной Экономики."
)
p.approval-step__body
| Ждём подтверждения от представителя союза о готовности произвести стандартизацию документооборота. Как только оно появится установка продолжится автоматически.
.approval-step__status
BaseChip(:variant="isApproved ? 'pos' : 'warn'")
span {{ isApproved ? 'Подтверждено союзом' : 'Ожидаем подтверждения союза' }}
BaseButton(
v-if="!isApproved"
variant="ghost"
size="sm"
type="button"
@click="refreshStatus"
) Проверить
</template>
<style scoped>
.approval-waiting-container {
max-width: 900px;
margin: 0 auto;
position: relative;
.approval-step {
max-width: 640px;
}
/* Основная карточка ожидания */
.waiting-card {
border-radius: 20px;
padding: 2.5rem;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
position: relative;
overflow: hidden;
.approval-step__body {
margin: 0;
color: var(--p-ink-1);
font-size: var(--p-fs-body);
line-height: var(--p-lh-body);
}
.waiting-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--q-primary) 0%, var(--q-secondary) 50%, var(--q-primary) 100%);
background-size: 200% 100%;
animation: shimmer 4s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
@keyframes shimmer {
0% { background-position: -100% 0; }
100% { background-position: 100% 0; }
}
/* Заголовок карточки */
.waiting-header {
text-align: center;
margin-bottom: 2rem;
}
.waiting-icon-container {
position: relative;
display: inline-block;
margin-bottom: 1.5rem;
}
.waiting-icon {
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15));
animation: gentle-pulse 3s ease-in-out infinite;
cursor: pointer;
transition: all 0.3s ease;
}
.waiting-icon:hover {
transform: scale(1.1);
filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.25));
}
@keyframes gentle-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.9;
}
}
.pulse-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90px;
height: 90px;
border: 1px solid rgba(255, 152, 0, 0.2);
border-radius: 50%;
animation: pulse-ring 2s cubic-bezier(0.455, 0.03, 0.515, 0.955) infinite;
}
@keyframes pulse-ring {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.4);
opacity: 0;
}
}
.waiting-title {
font-weight: 700;
letter-spacing: -0.5px;
}
/* Статусная информация */
.status-info {
padding: 1.5rem;
border-radius: 16px;
border: 1px solid rgba(255, 152, 0, 0.1);
}
.status-description {
line-height: 1.6;
text-align: center;
}
/* Адаптивность */
@media (max-width: 768px) {
.waiting-card {
padding: 2rem;
}
.pulse-ring {
width: 70px;
height: 70px;
}
}
@media (max-width: 480px) {
.approval-waiting-container {
padding: 1rem;
}
.waiting-card {
padding: 1.5rem;
}
.waiting-icon {
font-size: 60px;
}
.pulse-ring {
width: 60px;
height: 60px;
}
.status-info {
padding: 1rem;
}
.approval-step__status {
display: inline-flex;
align-items: center;
gap: var(--p-3);
margin-top: var(--p-4);
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
BaseInput,
BaseButton,
BaseCard,
BaseForm,
} from 'src/shared/ui/base'
import { isDomain, notEmpty } from 'src/shared/lib/utils'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
const connectionAgreement = useConnectionAgreementStore()
const announce = ref<string>(connectionAgreement.formData.announce || '')
const errorMsg = ref<string>('')
watch(
() => connectionAgreement.formData.announce,
(v) => {
announce.value = v || ''
},
)
const validate = (): boolean => {
const v = (announce.value || '').trim()
if (notEmpty(v) !== true) {
errorMsg.value = 'Укажите домен или поддомен'
return false
}
if (isDomain(v) !== true) {
errorMsg.value = 'Введите корректное имя домена'
return false
}
errorMsg.value = ''
return true
}
const handleContinue = () => {
if (!validate()) return
connectionAgreement.setFormData({
...connectionAgreement.formData,
announce: announce.value,
})
if (connectionAgreement.currentStep < 7) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep + 1)
}
}
const handleBack = () => {
if (connectionAgreement.currentStep > 1) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep - 1)
}
}
</script>
<template lang="pug">
BaseForm.domain-form-step(@submit="handleContinue")
BaseCard(title="Адрес кооператива в сети интернет")
BaseInput(
v-model="announce"
label="Домен или поддомен"
placeholder="domovoy.com или coop.domovoy.com"
:error="errorMsg || undefined"
)
template(#footer)
.row.items-center.q-gutter-sm
BaseButton(variant="ghost" size="sm" type="button" @click="handleBack") Назад
q-space
BaseButton(variant="primary" size="sm" type="submit") Дальше
</template>
<style scoped>
.domain-form-step {
max-width: 640px;
}
</style>
@@ -1,92 +1,24 @@
<template lang="pug">
q-step(
:name="4"
title="Настройка домена"
icon="domain"
:done="isDone"
)
.domain-validation-container.q-pa-md
//- Основная инструкция
.dns-instruction-card.q-mb-xl
.instruction-header
.text-subtitle2.instruction-title Добавьте DNS-запись
//- Краткая инструкция
.instruction-intro.q-mb-lg
.text-body1.text-grey-8
| Перейдите в панель управления вашим доменом и добавьте A-запись со следующими параметрами:
.row.q-mb-md.justify-center
.col-md-6.col-xs-12.q-pa-sm
.dns-record-item
.record-label Домен
.record-value {{ coop?.announce }}
.col-md-6.col-xs-12.q-pa-sm
.dns-record-item
.record-label Адрес
.record-value.record-highlight
| {{ SERVER_IP }}
q-btn.copy-ip-btn(
size="sm"
flat
round
dense
icon="content_copy"
@click="copyIpAddress"
)
q-tooltip Копировать IP
.instruction-separator
.status-chips.row.justify-center.q-mb-md
.chip-wrapper(v-if="instance?.is_valid")
q-chip.status-chip(
:class="{ 'delegated': instance?.is_delegated, 'waiting': !instance?.is_delegated }"
text-color="white"
size="md"
clickable
@click="handleReload"
)
q-spinner(size="16px" color="white")
span.q-ml-xs {{ instance?.is_delegated ? 'Делегирован' : 'Ожидаем делегирования' }}
.instruction-footer
| Мы автоматически проверим домен и активируем установку вашего Цифрового Кооператива
//- Навигация
q-stepper-navigation.q-gutter-sm
q-btn(
color="grey-6"
flat
label="Назад"
@click="handleBack"
)
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { copyToClipboard } from 'quasar'
import { FailAlert, SuccessAlert } from 'src/shared/api'
import type { IStepProps } from '../model/types'
import {
BaseButton,
BaseCard,
BaseChip,
BaseForm,
} from 'src/shared/ui/base'
import { DataRow } from 'src/shared/ui/domain'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
import { useProviderSubscriptions } from 'src/features/Provider/model'
const props = withDefaults(defineProps<IStepProps>(), {})
const connectionAgreement = useConnectionAgreementStore()
const { loadCurrentInstance } = connectionAgreement
const { SERVER_IP } = useProviderSubscriptions()
// Получаем данные напрямую из store
const coop = computed(() => connectionAgreement.coop)
const instance = computed(() => connectionAgreement.currentInstance)
const isDelegated = computed(() => !!instance.value?.is_delegated)
// Загружаем кооператива при монтировании, если его нет в store
const loadCoopIfNeeded = async () => {
onMounted(async () => {
if (!coop.value) {
try {
await connectionAgreement.reloadCooperative()
@@ -94,17 +26,15 @@ const loadCoopIfNeeded = async () => {
console.error('Ошибка загрузки кооператива:', error)
}
}
}
onMounted(async () => {
await loadCoopIfNeeded()
})
const isDone = computed(() => props.isDone)
const handleBack = () => connectionAgreement.setCurrentStep(4)
const handleBack = () => {
connectionAgreement.setCurrentStep(2)
const handleContinue = () => {
if (!isDelegated.value) return
if (connectionAgreement.currentStep < 7) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep + 1)
}
}
const handleReload = async () => {
@@ -114,254 +44,48 @@ const handleReload = async () => {
console.error('Ошибка обновления инстанса:', error)
}
}
const copyIpAddress = async () => {
try {
await copyToClipboard(SERVER_IP)
SuccessAlert('IP адрес скопирован в буфер обмена')
} catch (error) {
console.error('Ошибка копирования:', error)
FailAlert('Не удалось скопировать IP адрес')
}
}
</script>
<template lang="pug">
BaseForm.dns-step(@submit="handleContinue")
BaseCard(
title="Делегирование домена"
subtitle="Откройте панель управления вашим доменом и добавьте A-запись со значениями ниже. Делегирование проверим автоматически."
)
DataRow(label="Домен" :value="coop?.announce" mono)
DataRow(label="IP-адрес" :value="SERVER_IP" mono copyable)
.dns-step__status
BaseChip(:variant="isDelegated ? 'pos' : 'warn'")
span {{ isDelegated ? 'Домен делегирован' : 'Ожидаем делегирования' }}
BaseButton(
v-if="!isDelegated"
variant="ghost"
size="sm"
type="button"
@click="handleReload"
) Проверить сейчас
template(#footer)
.row.items-center.q-gutter-sm
BaseButton(variant="ghost" size="sm" type="button" @click="handleBack") Назад
q-space
BaseButton(
v-if="isDelegated"
variant="primary"
size="sm"
type="submit"
) Дальше
</template>
<style scoped>
.domain-validation-container {
max-width: 800px;
margin: 0 auto;
position: relative;
.dns-step {
max-width: 640px;
}
/* Статус чипы */
.status-header {
display: flex;
justify-content: center;
.dns-step__status {
display: inline-flex;
align-items: center;
margin-bottom: 2rem;
gap: var(--p-3);
margin-top: var(--p-4);
}
.status-chips {
display: flex;
gap: 1rem;
align-items: center;
}
.chip-wrapper {
position: relative;
}
.status-chip {
border-radius: 20px;
font-weight: 500;
letter-spacing: 0.5px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.status-chip:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.status-chip.valid {
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
border: 1px solid rgba(76, 175, 80, 0.2);
}
.status-chip.warning {
background: linear-gradient(135deg, #FF9800 0%, #f57c00 100%);
border: 1px solid rgba(255, 152, 0, 0.2);
}
.status-chip.delegated {
background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
border: 1px solid rgba(33, 150, 243, 0.2);
}
.status-chip.waiting {
background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);
border: 1px solid rgba(156, 39, 176, 0.2);
}
/* DNS инструкция */
.dns-instruction-card {
border-radius: 16px;
padding: 2rem;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
position: relative;
overflow: hidden;
}
.dns-instruction-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #2196F3 0%, #21CBF3 50%, #2196F3 100%);
background-size: 200% 100%;
animation: shimmer 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
@keyframes shimmer {
0% { background-position: -100% 0; }
100% { background-position: 100% 0; }
}
.instruction-header {
text-align: center;
margin-bottom: 1.5rem;
}
.instruction-intro {
text-align: center;
padding: 1rem 1.5rem;
border-radius: 12px;
}
.instruction-title {
font-weight: 600;
letter-spacing: 0.5px;
position: relative;
display: inline-block;
}
.instruction-title::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 2px;
border-radius: 1px;
}
/* DNS записи */
.dns-record-item {
text-align: center;
padding: 0.75rem;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.dns-record-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
/* Кнопка копирования IP */
.copy-ip-btn {
position: absolute;
top: 6px;
right: 6px;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 50% !important;
}
.record-highlight:hover .copy-ip-btn {
opacity: 1;
}
.record-label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 0.5rem;
}
.record-value {
font-size: 0.95rem;
font-weight: 600;
word-break: break-all;
}
.record-highlight {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
padding: 0.25rem 0.5rem;
border-radius: 6px;
}
/* Разделитель */
.instruction-separator {
height: 1px;
margin: 2rem 0;
position: relative;
}
.instruction-separator::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Футер инструкции */
.instruction-footer {
text-align: center;
font-size: 0.875rem;
line-height: 1.5;
font-weight: 400;
}
/* Адаптивность */
@media (max-width: 768px) {
.dns-record-item {
padding: 0.5rem;
}
.status-chips {
flex-direction: column;
gap: 0.75rem;
}
.dns-instruction-card {
padding: 1.5rem;
}
.instruction-intro {
padding: 0.75rem 1rem;
}
}
@media (max-width: 480px) {
.domain-validation-container {
padding: 1rem;
}
.dns-instruction-card {
padding: 1rem;
}
.instruction-title {
font-size: 1rem;
}
.instruction-intro {
padding: 0.5rem 0.75rem;
}
.record-value {
font-size: 0.9rem;
}
.copy-ip-btn {
top: 4px;
right: 4px;
}
}
</style>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
BaseInput,
BaseButton,
BaseCard,
BaseForm,
} from 'src/shared/ui/base'
import { notEmpty } from 'src/shared/lib/utils'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
const connectionAgreement = useConnectionAgreementStore()
const initial = ref<string>(connectionAgreement.formData.initial || '')
const minimum = ref<string>(connectionAgreement.formData.minimum || '')
const orgInitial = ref<string>(connectionAgreement.formData.org_initial || '')
const orgMinimum = ref<string>(connectionAgreement.formData.org_minimum || '')
const errors = ref<Record<string, string>>({})
watch(
() => connectionAgreement.formData,
(v) => {
initial.value = v.initial || ''
minimum.value = v.minimum || ''
orgInitial.value = v.org_initial || ''
orgMinimum.value = v.org_minimum || ''
},
{ deep: true },
)
const validate = (): boolean => {
const e: Record<string, string> = {}
if (notEmpty(initial.value) !== true) e.initial = 'Укажите сумму'
if (notEmpty(minimum.value) !== true) e.minimum = 'Укажите сумму'
if (notEmpty(orgInitial.value) !== true) e.orgInitial = 'Укажите сумму'
if (notEmpty(orgMinimum.value) !== true) e.orgMinimum = 'Укажите сумму'
errors.value = e
return Object.keys(e).length === 0
}
const handleContinue = () => {
if (!validate()) return
connectionAgreement.setFormData({
...connectionAgreement.formData,
initial: initial.value,
minimum: minimum.value,
org_initial: orgInitial.value,
org_minimum: orgMinimum.value,
})
if (connectionAgreement.currentStep < 7) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep + 1)
}
}
const handleBack = () => {
if (connectionAgreement.currentStep > 1) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep - 1)
}
}
</script>
<template lang="pug">
BaseForm.financial-step(@submit="handleContinue")
BaseCard.q-mb-md(
title="Финансовые параметры"
subtitle="Установите вступительные и минимальные паевые взносы для физических лиц, индивидуальных предпринимателей и организаций"
)
BaseCard.q-mb-md(title="Физические лица и ИП" subtitle="Применяется к пайщикам-гражданам при вступлении в кооператив")
.row.q-col-gutter-md
.col-12.col-md-6
BaseInput(
v-model="initial"
label="Вступительный взнос"
type="number"
placeholder="100"
suffix="RUB"
:error="errors.initial || undefined"
)
.col-12.col-md-6
BaseInput(
v-model="minimum"
label="Минимальный паевый взнос"
type="number"
placeholder="300"
suffix="RUB"
:error="errors.minimum || undefined"
)
BaseCard(title="Организации" subtitle="Применяется к пайщикам-юрлицам при вступлении в кооператив")
.row.q-col-gutter-md
.col-12.col-md-6
BaseInput(
v-model="orgInitial"
label="Вступительный взнос"
type="number"
placeholder="1000"
suffix="RUB"
:error="errors.orgInitial || undefined"
)
.col-12.col-md-6
BaseInput(
v-model="orgMinimum"
label="Минимальный паевый взнос"
type="number"
placeholder="3000"
suffix="RUB"
:error="errors.orgMinimum || undefined"
)
template(#footer)
.row.items-center.q-gutter-sm
BaseButton(variant="ghost" size="sm" type="button" @click="handleBack") Назад
q-space
BaseButton(variant="primary" size="sm" type="submit") Дальше
</template>
<style scoped>
.financial-step {
max-width: 640px;
}
</style>
@@ -1,83 +0,0 @@
<script setup lang="ts">
import type { IStepProps } from '../model/types'
import { CooperativeDataForm } from 'src/features/Union/CooperativeDataForm'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
withDefaults(defineProps<IStepProps & {
document?: any
signedDocument?: any
}>(), {})
const connectionAgreement = useConnectionAgreementStore()
const handleContinue = (formData?: any) => {
console.log('📝 FormStep: Продолжаем с данными формы:', formData)
// Сохраняем данные формы в стор
if (formData) {
connectionAgreement.setFormData(formData)
}
// Переходим к следующему шагу (документ сгенерируется в watch)
if (connectionAgreement.currentStep < 5) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep + 1)
}
}
const handleBack = () => {
if (connectionAgreement.currentStep > 1) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep - 1)
}
}
</script>
<template lang="pug">
q-step(
:name="2"
title="Параметры кооператива"
icon="settings"
:done="isDone"
)
.form-step-container.q-pa-md
//- Заголовок шага
.step-header.q-mb-xl
.text-h6.form-title Настройка параметров кооператива
.subtitle.text-body2.text-grey-7.q-mt-sm
| Заполните основные параметры для запуска вашего Цифрового Кооператива
//- Форма ввода данных
CooperativeDataForm(
@continue="handleContinue"
@back="handleBack"
)
</template>
<style scoped>
.form-step-container {
max-width: 900px;
margin: 0 auto;
position: relative;
}
.step-header {
text-align: center;
margin-bottom: 2rem;
}
.form-title {
font-weight: 700;
letter-spacing: -0.5px;
}
/* Адаптивность */
@media (max-width: 480px) {
.form-step-container {
padding: 1rem;
}
.step-header {
margin-bottom: 1.5rem;
}
}
</style>
@@ -1,368 +1,97 @@
<template lang="pug">
q-step(
:name="6"
title="Поставка Цифрового Кооператива"
icon="cloud_download"
:done="isDone"
)
.installation-container.q-pa-md
//- Установка в процессе
div
//- Заголовок с градиентом
.installation-header
.text-h6.installation-title Поставка Цифрового Кооператива
.subtitle.text-body2.text-grey-7.q-mt-sm
| с подключением к платформе Кооперативной Экономики
//- Основная карточка прогресса
.progress-card.q-mb-xl
.progress-header
.progress-title
q-icon(name="rocket_launch" size="24px" color="primary").q-mr-sm
span.text-subtitle1.text-weight-medium Прогресс поставки
//- Живой прогресс-бар с анимацией
.progress-bar-container.q-mt-lg
.progress-bar-background
.progress-bar-fill(
:style="{ width: (instance?.progress || 0) + '%' }"
)
.progress-bar-shimmer
.progress-bar-glow(:style="{ width: (instance?.progress || 0) + '%' }")
//- Оставшееся время
.remaining-time.q-mt-md
.time-display
q-icon(name="schedule" size="18px" color="primary").q-mr-sm
span.text-body2 Осталось: {{ remainingTime }} мин
//- Статус текущего этапа
.current-stage.q-mt-lg
.stage-info
.stage-icon
q-icon(:name="currentStageIcon" size="32px" :color="currentStageColor")
.stage-details
.stage-title.text-body1.text-weight-medium {{ currentStageTitle }}
.stage-description.text-body2.text-grey-7.q-mt-xs {{ currentStageDescription }}
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { IStepProps } from '../model/types'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
const props = withDefaults(defineProps<IStepProps>(), {})
const connectionAgreement = useConnectionAgreementStore()
// Получаем данные напрямую из store
const instance = computed(() => connectionAgreement.currentInstance)
const isDone = computed(() => props.isDone)
const progress = computed(() => Math.max(0, Math.min(100, instance.value?.progress || 0)))
// Текущий этап (соответствует логике из startup-workflow.service.ts)
const currentStageInfo = computed(() => {
const progress = instance.value?.progress || 0
if (progress < 20) {
return {
icon: 'settings',
color: 'primary',
title: 'Подготовка серверного окружения',
description: 'Настраиваем инфраструктуру, разворачиваем серверные компоненты'
}
} else if (progress < 40) {
return {
icon: 'download',
color: 'info',
title: 'Загрузка компонентов Цифрового Кооператива',
description: 'Устанавливаем программное обеспечение и зависимости для работы платформы'
}
} else if (progress < 60) {
return {
icon: 'storage',
color: 'warning',
title: 'Настройка баз данных и хранилищ',
description: 'Разворачиваем базы данных, инициализируем структуры и настраиваем резервное копирование'
}
} else if (progress < 80) {
return {
icon: 'security',
color: 'secondary',
title: 'Запуск блокчейн-узла',
description: 'Разворачиваем и синхронизируем блокчейн-узел для подключения к Кооперативной Экономике'
}
} else {
return {
icon: 'check_circle',
color: 'positive',
title: 'Финализация поставки',
description: 'Выполняем заключительные настройки, проверяем работоспособность всех компонентов'
}
}
const stage = computed(() => {
const p = progress.value
if (p < 20) return { title: 'Подготовка серверного окружения', body: 'Настраиваем инфраструктуру и развёртываем серверные компоненты.' }
if (p < 40) return { title: 'Загрузка компонентов Цифрового Кооператива', body: 'Устанавливаем программное обеспечение и зависимости платформы.' }
if (p < 60) return { title: 'Настройка баз данных и хранилищ', body: 'Разворачиваем базы данных, инициализируем структуры и резервное копирование.' }
if (p < 80) return { title: 'Запуск блокчейн-узла', body: 'Разворачиваем и синхронизируем узел с сетью Кооперативной Экономики.' }
return { title: 'Финализация поставки', body: 'Выполняем заключительные настройки и проверяем работоспособность.' }
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const currentStageIcon = computed(() => currentStageInfo.value.icon)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const currentStageColor = computed(() => currentStageInfo.value.color)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const currentStageTitle = computed(() => currentStageInfo.value.title)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const currentStageDescription = computed(() => currentStageInfo.value.description)
// Расчет оставшегося времени (общая установка 100 минут)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const remainingTime = computed(() => {
const progress = instance.value?.progress || 0
const totalMinutes = 100
const remaining = totalMinutes - progress
return Math.max(0, remaining) // Не показываем отрицательные значения
})
// Редирект теперь обрабатывается в ConnectionAgreementPage через реактивность
// Здесь только отображение прогресса
const remainingMinutes = computed(() => Math.max(0, 100 - progress.value))
</script>
<template lang="pug">
.installation-step
p.t-sm.t-muted.installation-step__lead Поставка Цифрового Кооператива с подключением к платформе Кооперативной Экономики.
.installation-step__progress
.installation-step__progress-head
span.t-eyebrow.t-muted Прогресс поставки
span.t-mono.t-num.installation-step__pct {{ progress }}%
.installation-step__bar
.installation-step__bar-fill(:style="{ width: progress + '%' }")
p.t-meta.t-muted.installation-step__remaining
| Осталось примерно {{ remainingMinutes }} мин
.installation-step__stage
.t-h3 {{ stage.title }}
p.t-sm.t-muted.installation-step__stage-body {{ stage.body }}
</template>
<style scoped>
.installation-container {
max-width: 900px;
margin: 0 auto;
position: relative;
}
/* Заголовок с градиентом */
.installation-header {
text-align: center;
margin-bottom: 2rem;
}
.installation-title {
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--q-primary) 0%, rgba(25, 118, 210, 0.8) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 2px 4px rgba(25, 118, 210, 0.3);
}
/* Основная карточка прогресса */
.progress-card {
border-radius: 20px;
padding: 2.5rem;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
position: relative;
overflow: hidden;
}
.progress-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--q-primary) 0%, var(--q-secondary) 50%, var(--q-accent) 100%);
background-size: 200% 100%;
animation: shimmer 4s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.progress-header {
.installation-step {
display: flex;
flex-direction: column;
gap: var(--p-5);
max-width: 640px;
}
.installation-step__lead {
margin: 0;
}
.installation-step__progress {
display: flex;
flex-direction: column;
gap: var(--p-2);
padding: var(--p-4);
border: 1px solid var(--p-line-1);
border-radius: var(--p-r-md);
background: var(--p-surface);
}
.installation-step__progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.progress-title {
display: flex;
align-items: center;
.installation-step__pct {
font-size: var(--p-fs-body);
font-weight: 600;
color: var(--q-primary);
color: var(--p-ink);
}
/* Живой прогресс-бар */
.progress-bar-container {
position: relative;
height: 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 20px;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.progress-bar-background {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(90deg, #e0e0e0 0%, #f0f0f0 100%);
}
.progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(135deg, var(--q-primary) 0%, var(--q-secondary) 100%);
border-radius: 20px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 0 20px rgba(25, 118, 210, 0.3),
inset 0 1px 2px rgba(255, 255, 255, 0.2);
.installation-step__bar {
position: relative;
height: 4px;
border-radius: var(--p-r-pill);
background: var(--p-surface-3);
overflow: hidden;
}
.progress-bar-shimmer {
position: absolute;
top: 0;
left: -100%;
width: 100%;
.installation-step__bar-fill {
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.6) 30%,
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0.6) 70%,
transparent 100%
);
animation: progress-shimmer 1.2s infinite cubic-bezier(0.25, 0.46, 0.45, 0.94);
background: var(--p-primary);
border-radius: var(--p-r-pill);
transition: width var(--p-dur-slow) var(--p-ease-standard);
}
@keyframes progress-shimmer {
0% {
left: -100%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
left: 100%;
opacity: 0;
}
.installation-step__remaining {
margin: 0;
}
.progress-bar-glow {
position: absolute;
top: -2px;
left: 0;
height: 16px;
background: linear-gradient(135deg, var(--q-primary) 0%, rgba(25, 118, 210, 0.3) 100%);
border-radius: 20px;
filter: blur(4px);
opacity: 0.6;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Текущий этап */
.current-stage {
margin-top: 2rem;
padding: 1.5rem;
background: rgba(25, 118, 210, 0.02);
border-radius: 16px;
border: 1px solid rgba(25, 118, 210, 0.1);
}
.stage-info {
.installation-step__stage {
display: flex;
align-items: center;
gap: 1rem;
flex-direction: column;
gap: var(--p-2);
padding: var(--p-4);
border: 1px solid var(--p-line-1);
border-radius: var(--p-r-md);
background: var(--p-surface);
}
.stage-icon {
background: linear-gradient(135deg, var(--q-primary) 0%, rgba(25, 118, 210, 0.8) 100%);
border-radius: 50%;
padding: 0.75rem;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
}
.stage-details {
flex: 1;
}
.stage-title {
color: var(--q-primary);
margin-bottom: 0.25rem;
}
.stage-description {
color: #666;
}
/* Оставшееся время */
.remaining-time {
display: flex;
justify-content: center;
align-items: center;
}
.time-display {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
background: rgba(25, 118, 210, 0.05);
border-radius: 12px;
border: 1px solid rgba(25, 118, 210, 0.1);
}
/* Адаптивность */
@media (max-width: 768px) {
.progress-card {
padding: 2rem;
}
.progress-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.stage-info {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
.time-display {
padding: 0.5rem 1rem;
}
}
@media (max-width: 480px) {
.installation-container {
padding: 1rem;
}
.progress-card {
padding: 1.5rem;
}
.time-display {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.installation-step__stage-body {
margin: 0;
}
</style>
@@ -1,23 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { IStepProps } from '../model/types'
import { BaseButton } from 'src/shared/ui/base/BaseButton'
import { TariffSelector, type ITariff } from '../Tariffs'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
import { useSystemStore } from 'src/entities/System/model'
const props = withDefaults(defineProps<IStepProps & {
const props = defineProps<{
selectedTariff?: ITariff | null
}>(), {})
}>()
const connectionAgreement = useConnectionAgreementStore()
const system = useSystemStore()
const isActive = computed(() => props.isActive)
const isDone = computed(() => props.isDone)
const selectedTariff = computed({
get: () => props.selectedTariff || connectionAgreement.selectedTariff,
set: (value) => connectionAgreement.setSelectedTariff(value)
set: (value) => connectionAgreement.setSelectedTariff(value),
})
const canContinue = computed(() => selectedTariff.value !== null)
@@ -37,41 +34,47 @@ const handleBack = () => {
}
const handleContinue = () => {
if (canContinue.value && connectionAgreement.currentStep < 5) {
if (canContinue.value && connectionAgreement.currentStep < 7) {
connectionAgreement.setCurrentStep(connectionAgreement.currentStep + 1)
}
}
</script>
<template lang="pug">
q-step(
:name="1"
title="Выбор тарифа подключения"
icon="info"
:done="isDone"
)
.q-pa-md
TariffSelector(
:disabled="!isActive"
:selected-tariff="selectedTariff"
@tariff-selected="handleTariffSelected"
@tariff-deselected="handleTariffDeselected"
)
q-stepper-navigation.q-gutter-sm
q-btn(
v-if="isActive && system.info.is_unioned"
color="grey-6"
flat
label="Назад"
.intro-step
TariffSelector(
:selected-tariff="selectedTariff"
@tariff-selected="handleTariffSelected"
@tariff-deselected="handleTariffDeselected"
)
.intro-step__actions
BaseButton(
v-if="system.info.is_unioned"
variant="ghost"
size="sm"
@click="handleBack"
)
q-btn(
v-if="isActive"
color="primary"
:disable="!canContinue"
label="Продолжить"
) Назад
.intro-step__spacer
BaseButton(
variant="primary"
size="sm"
:disabled="!canContinue"
@click="handleContinue"
)
) Дальше
</template>
<style scoped>
.intro-step {
display: flex;
flex-direction: column;
gap: var(--p-5);
}
.intro-step__actions {
display: flex;
align-items: center;
gap: var(--p-2);
}
.intro-step__spacer {
flex: 1 1 auto;
}
</style>
@@ -1,436 +1,127 @@
<template lang="pug">
q-step(
:name="0"
title="Членство в союзе"
icon="group"
:done="isDone"
)
.union-membership-container.q-pa-md
//- Основная информационная карточка
.membership-info-card.q-mb-xl
.card-header
.text-h6.membership-title Членство в Союзе Потребительских Обществ
.subtitle.text-body2.text-grey-7.q-mt-sm
| Необходимый шаг для полноценной работы на платформе Кооперативной Экономики
//- Что будет сделано
.membership-benefits.q-mb-lg
.benefit-section
.section-title
q-icon(name="check_circle" size="20px" color="positive").q-mr-sm
span.text-subtitle1.text-weight-medium Подключение к платформе
.benefit-description.text-body2.q-mt-sm.q-ml-lg
| Ваш кооператив будет подключен к платформе. Вы получите полный доступ ко всем сервисам цифрового кооператива.
.benefit-section.q-mt-md
.section-title
q-icon(name="group_add" size="20px" color="primary").q-mr-sm
span.text-subtitle1.text-weight-medium Импорт участников и цифровые подписи
.benefit-description.text-body2.q-mt-sm.q-ml-lg
| Вы сможете импортировать пайщиков и пригласите их для выпуска цифровых подписей.
.benefit-section.q-mt-md
.section-title
q-icon(name="event_available" size="20px" color="secondary").q-mr-sm
span.text-subtitle1.text-weight-medium 30 дней на организацию
.benefit-description.text-body2.q-mt-sm.q-ml-lg
| После подключения у вас будет 30 дней для проведения необходимых решений совета и общего собрания пайщиков в соответствии с требованиями союза.
//- Требование членства
.membership-requirement.q-mt-lg
.requirement-card
.requirement-header
q-icon(name="verified_user" size="24px" color="warning").q-mr-sm
.text-subtitle1.text-weight-medium Членство в союзе
.requirement-description.text-body2.q-mt-sm
| Членство в Союзе Потребительских Обществ является обязательным условием для работы на платформе. Союз обеспечивает стандартизацию вашего документооборота и приводит его в соответствие с требованиями законодательства, исключая проблемы с проверками и регуляторами.
| Для связи с представителем союза и координации процесса подключения вам потребуется аккаунт в кооперативном мессенджере.
//- Информационная карточка если аккаунт уже есть
.matrix-account-info-card.q-mb-xl(v-if="hasMatrixAccount")
.card-header
.text-h6.account-title У вас есть аккаунт
.subtitle.text-body2.text-grey-7.q-mt-sm
| Ваш аккаунт в кооперативном мессенджере активен
.account-info.q-mb-lg
.info-section
.section-title
q-icon(name="message" size="20px" color="primary").q-mr-sm
span.text-subtitle1.text-weight-medium Связь с представителем союза
.info-description.text-body2.q-mt-sm.q-ml-lg
| Для связи с представителем союза перейдите на страницу
q-btn.q-pa-none.q-ma-none.text-primary.text-weight-medium(
flat
dense
no-caps
label="Быстрый клиент"
@click="goToQuickClient"
).q-ml-sm
| .
.info-description.text-body2.q-mt-sm.q-ml-lg
| Там вы сможете начать общение и получить всю необходимую помощь в процессе подключения.
//- Регистрация в мессенджере (показываем только если аккаунта нет)
.q-mt-xl(v-if="!hasMatrixAccount")
div(@accountCreated="handleAccountCreated")
slot(name="registration")
q-stepper-navigation.q-gutter-sm.q-pa-md
.text-body2.text-grey-7(v-if="isActive")
span(v-if="hasMatrixAccount") Отлично, связь установлена можем продолжить подключение.
span(v-else) Создайте аккаунт в кооперативном мессенджере, чтобы продолжить.
q-btn(
v-if="isActive"
color="primary"
label="Продолжить"
:disable="!hasMatrixAccount"
@click="handleContinue"
)
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { BaseButton } from 'src/shared/ui/base/BaseButton'
import { BaseChip } from 'src/shared/ui/base/BaseChip'
import { useDesktopStore } from 'src/entities/Desktop'
import type { IStepProps } from '../model/types'
import { useConnectionAgreementStore } from 'src/entities/ConnectionAgreement'
const props = withDefaults(defineProps<IStepProps>(), {})
const connectionAgreement = useConnectionAgreementStore()
const router = useRouter()
const desktopStore = useDesktopStore()
const isActive = computed(() => props.isActive)
const isDone = computed(() => props.isDone)
const hasMatrixAccount = computed(() => connectionAgreement.hasMatrixAccount)
const handleContinue = () => {
if (!hasMatrixAccount.value) {
return
}
if (!hasMatrixAccount.value) return
if (connectionAgreement.currentStep === 0) {
connectionAgreement.setCurrentStep(1)
}
}
const goToQuickClient = () => {
// Сначала переключаем рабочий стол на chatcoop
desktopStore.selectWorkspace('chatcoop')
// Затем переходим на страницу "Быстрый клиент"
router.push({ name: 'chatcoop-chat' })
}
const handleAccountCreated = () => {
// После создания аккаунта обновляем флаг
connectionAgreement.setHasMatrixAccount(true)
}
</script>
<template lang="pug">
.union-step
p.t-body.union-step__lead
| Членство в Союзе Потребительских Обществ обязательное условие работы на платформе.
| Союз стандартизирует документооборот вашего кооператива и приводит его в соответствие
| с требованиями законодательства.
ul.union-step__list
li
strong Подключение к платформе.
| Полный доступ ко всем сервисам Цифрового Кооператива.
li
strong Импорт пайщиков.
| Пригласите участников и выпустите электронные подписи.
li
strong 30 дней на организацию.
| Время для решений совета и общего собрания после подключения.
.union-step__matrix(v-if="hasMatrixAccount")
BaseChip(variant="pos")
span Аккаунт в кооперативном мессенджере создан
p.t-sm.t-muted.union-step__matrix-body
| Для связи с представителем союза откройте
|
a.union-step__link(href="#" @click.prevent="goToQuickClient") Быстрый клиент
| . Там можно начать общение и получить помощь по подключению.
.union-step__matrix(v-else)
p.t-sm.t-muted.union-step__matrix-body
| Создайте аккаунт в кооперативном мессенджере нужен для связи с представителем союза.
div(@accountCreated="handleAccountCreated")
slot(name="registration")
.union-step__actions
BaseButton(
variant="primary"
size="sm"
:disabled="!hasMatrixAccount"
@click="handleContinue"
) Продолжить
</template>
<style scoped>
.union-membership-container {
max-width: 900px;
margin: 0 auto;
position: relative;
}
/* Основная информационная карточка */
.membership-info-card {
border-radius: 20px;
padding: 2.5rem;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
position: relative;
overflow: hidden;
}
.membership-info-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--q-primary) 0%, var(--q-secondary) 50%, var(--q-accent) 100%);
background-size: 200% 100%;
animation: shimmer 4s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.card-header {
text-align: center;
margin-bottom: 2rem;
}
.membership-title {
font-weight: 700;
letter-spacing: -0.5px;
}
/* Секции преимуществ */
.membership-benefits {
.union-step {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--p-4);
max-width: 640px;
}
.benefit-section {
padding: 1.5rem;
border-radius: 16px;
background: rgba(25, 118, 210, 0.02);
border: 1px solid rgba(25, 118, 210, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.union-step__lead {
margin: 0;
color: var(--p-ink-1);
}
.benefit-section:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(25, 118, 210, 0.1);
background: rgba(25, 118, 210, 0.05);
.union-step__list {
margin: 0;
padding: var(--p-4);
border: 1px solid var(--p-line-1);
border-radius: var(--p-r-md);
background: var(--p-surface);
font-size: var(--p-fs-body-sm);
line-height: var(--p-lh-body-sm);
color: var(--p-ink-1);
list-style: none;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
.union-step__list li + li {
margin-top: var(--p-3);
padding-top: var(--p-3);
border-top: 1px solid var(--p-line);
}
.benefit-description {
line-height: 1.6;
padding-left: 24px;
position: relative;
.union-step__list strong {
color: var(--p-ink);
font-weight: 600;
}
.benefit-description::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--q-primary) 0%, rgba(25, 118, 210, 0.3) 100%);
border-radius: 1px;
}
/* Требование членства */
.membership-requirement {
border-top: 1px solid rgba(0, 0, 0, 0.08);
padding-top: 2rem;
}
.requirement-card {
padding: 2rem;
border-radius: 16px;
background: linear-gradient(135deg, rgba(255, 152, 0, 0.02) 0%, rgba(255, 193, 7, 0.02) 100%);
border: 1px solid rgba(255, 152, 0, 0.1);
position: relative;
}
.requirement-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #FF9800 0%, #FFC107 50%, #FF9800 100%);
border-radius: 16px 16px 0 0;
}
.requirement-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.requirement-description {
line-height: 1.6;
}
/* Секция ссылки на союз */
.union-link-section {
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.union-link-btn {
width: 100%;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 12px;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.union-link-btn:hover {
transform: translateY(-1px);
}
/* Адаптивность */
@media (max-width: 768px) {
.membership-info-card {
padding: 2rem;
}
.benefit-section {
padding: 1.25rem;
}
.requirement-card {
padding: 1.5rem;
}
.benefit-description {
padding-left: 20px;
}
.benefit-description::before {
width: 1.5px;
}
}
@media (max-width: 480px) {
.union-membership-container {
padding: 1rem;
}
.membership-info-card {
padding: 1.5rem;
}
.benefit-section {
padding: 1rem;
}
.requirement-card {
padding: 1.25rem;
}
.section-title {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.benefit-description {
padding-left: 0;
text-align: center;
}
.benefit-description::before {
display: none;
}
}
/* Карточка информации об аккаунте в мессенджере */
.matrix-account-info-card {
border-radius: 20px;
padding: 2.5rem;
border: 1px solid rgba(25, 118, 210, 0.1);
background: linear-gradient(135deg, rgba(25, 118, 210, 0.02) 0%, rgba(66, 165, 245, 0.02) 100%);
box-shadow:
0 8px 32px rgba(25, 118, 210, 0.08),
0 2px 8px rgba(25, 118, 210, 0.04);
position: relative;
overflow: hidden;
}
.matrix-account-info-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #2196F3 0%, #42A5F5 50%, #2196F3 100%);
background-size: 200% 100%;
animation: shimmer 4s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
}
.account-title {
font-weight: 700;
letter-spacing: -0.5px;
color: var(--q-primary);
}
.account-info {
.union-step__matrix {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--p-3);
padding: var(--p-4);
border: 1px solid var(--p-line-1);
border-radius: var(--p-r-md);
background: var(--p-surface);
}
.info-section {
padding: 1.5rem;
border-radius: 16px;
background: rgba(25, 118, 210, 0.02);
border: 1px solid rgba(25, 118, 210, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.union-step__matrix-body {
margin: 0;
}
.info-section:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(25, 118, 210, 0.1);
background: rgba(25, 118, 210, 0.05);
.union-step__link {
color: var(--p-primary);
text-decoration: none;
}
.info-description {
line-height: 1.6;
padding-left: 24px;
position: relative;
.union-step__link:hover {
text-decoration: underline;
}
.info-description::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--q-primary) 0%, rgba(25, 118, 210, 0.3) 100%);
border-radius: 1px;
}
/* Адаптивность для карточки аккаунта */
@media (max-width: 768px) {
.matrix-account-info-card {
padding: 2rem;
}
.info-section {
padding: 1.25rem;
}
}
@media (max-width: 480px) {
.matrix-account-info-card {
padding: 1.5rem;
}
.info-section {
padding: 1rem;
}
.info-description {
padding-left: 0;
text-align: center;
}
.info-description::before {
display: none;
}
.union-step__actions {
display: flex;
}
</style>
@@ -1,11 +1,12 @@
// Этот файл больше не используется
// Компоненты импортируются напрямую в ConnectionAgreementStepper.vue
// Оставлен для обратной совместимости, если нужен где-то ещё
// Реестр шагов ConnectionAgreement (не используется напрямую — шаги
// импортируются по имени в ConnectionAgreementStepper.vue). Оставлен как
// барель-экспорт на случай переиспользования отдельных шагов.
export { default as UnionMembershipStep } from './UnionMembershipStep.vue'
export { default as IntroStep } from './IntroStep.vue'
export { default as DomainStep } from './DomainStep.vue'
export { default as FinancialParamsStep } from './FinancialParamsStep.vue'
export { default as AgreementStep } from './AgreementStep.vue'
export { default as FormStep } from './FormStep.vue'
export { default as DomainValidationStep } from './DomainValidationStep.vue'
export { default as ApprovalWaitingStep } from './ApprovalWaitingStep.vue'
export { default as InstallationStep } from './InstallationStep.vue'
@@ -6,6 +6,7 @@ const props = defineProps<{
tariff: ITariff
selected?: boolean
disabled?: boolean
groupName?: string
}>()
const emits = defineEmits<{
@@ -13,256 +14,166 @@ const emits = defineEmits<{
deselect: [tariffId: string]
}>()
const isSelected = computed(() => props.selected)
const isDisabled = computed(() => props.disabled)
const isSelected = computed(() => !!props.selected)
const isDisabled = computed(() => !!props.disabled)
const isFree = computed(() => props.tariff.price === 'Бесплатно')
const cardClasses = computed(() => {
const classes = ['tariff-card', 'cursor-pointer', 'transition-all', 'duration-200']
const onChange = () => {
if (isDisabled.value) return
emits('select', props.tariff.id)
}
if (isSelected.value) {
classes.push('tariff-selected')
}
if (isDisabled.value) {
classes.push('tariff-disabled')
}
return classes.join(' ')
})
const handleSelect = () => {
if (!isDisabled.value) {
// Если тариф уже выбран, снимаем выбор, иначе выбираем
if (isSelected.value) {
emits('deselect', props.tariff.id)
} else {
emits('select', props.tariff.id)
}
}
const onClick = () => {
if (isDisabled.value) return
if (isSelected.value) emits('deselect', props.tariff.id)
}
</script>
<template lang="pug">
div.tariff-card-container
.tariff-card(
:class="cardClasses"
@click="handleSelect"
label.tariff-card(
:class="{ 'is-selected': isSelected, 'is-disabled': isDisabled }"
@click="onClick"
)
input.tariff-card__input(
type="radio"
:name="groupName || 'tariff'"
:value="tariff.id"
:checked="isSelected"
:disabled="isDisabled"
@change="onChange"
)
.tariff-header
.tariff-checkmark(v-if="isSelected")
q-icon(name="check_circle" size="24px" color="white")
.tariff-title
h6.text-h6 {{ tariff.name }}
.tariff-card__head
span.tariff-card__box(aria-hidden="true")
.tariff-card__title {{ tariff.name }}
.tariff-content
p.text-body2.text-grey-7.q-mb-md.text-center {{ tariff.description }}
p.tariff-card__sub.t-sm.t-muted(v-if="tariff.description") {{ tariff.description }}
.tariff-features
.feature-item(v-for="feature in tariff.features" :key="feature")
q-icon(name="check" size="16px" color="positive").q-mr-xs
span {{ feature }}
ul.tariff-card__list
li.tariff-card__item(v-for="feature in tariff.features" :key="feature") {{ feature }}
template(v-if="tariff.additionalCosts && tariff.additionalCosts.length")
.tariff-additional.q-mt-md
p.text-subtitle2.text-grey-8 Дополнительные расходы:
.additional-item(v-for="cost in tariff.additionalCosts" :key="cost")
q-icon(name="add_circle_outline" size="14px" color="info").q-mr-xs
span.text-caption {{ cost }}
.tariff-footer
.tariff-price
.price-display {{ tariff.price }}
.price-period(v-if="tariff.price !== 'Бесплатно'") в месяц
.select-hint.text-caption.text-grey-6
| Нажмите для выбора
.tariff-card__foot
.tariff-card__price
span.tariff-card__price-value.t-num {{ tariff.price }}
span.tariff-card__price-period(v-if="!isFree") &nbsp;/ мес
</template>
<style scoped>
.tariff-card-container {
position: relative;
height: 100%;
}
.tariff-card {
position: relative;
height: 100%;
min-height: 360px; /* Уменьшенная минимальная высота */
padding: 24px;
border-radius: 16px;
border: 2px solid #f0f0f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: var(--p-5);
padding: var(--p-6);
border: 1px solid var(--p-line-1);
border-radius: var(--p-r-lg);
background: var(--p-surface);
box-shadow: var(--p-shadow-card);
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
transition:
border-color var(--p-dur-fast) var(--p-ease-standard),
background var(--p-dur-fast) var(--p-ease-standard),
box-shadow var(--p-dur-fast) var(--p-ease-standard);
}
.tariff-card:hover:not(.is-disabled):not(.is-selected) {
border-color: var(--p-line-2);
box-shadow: var(--p-shadow-pop);
}
.tariff-card.is-selected {
border-color: var(--p-primary);
background: var(--p-primary-soft);
box-shadow: var(--p-shadow-card);
}
.tariff-card.is-disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.tariff-card:hover:not(.tariff-disabled):not(.tariff-selected) {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #e0e0e0;
}
.tariff-selected {
border-color: var(--q-accent);
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(25, 118, 210, 0.02) 100%);
box-shadow:
0 8px 32px rgba(25, 118, 210, 0.2),
0 0 0 1px var(--q-accent);
transform: translateY(-2px);
}
.tariff-disabled {
opacity: 0.6;
cursor: not-allowed !important;
.tariff-card__input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.tariff-card__input:focus-visible ~ .tariff-card__head .tariff-card__box {
box-shadow: var(--p-focus-ring);
}
.tariff-header {
position: relative;
height: 40px; /* Фиксированная высота для предотвращения прыжков */
.tariff-card__head {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
gap: var(--p-3);
}
.tariff-checkmark {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%) scale(0.8);
background: var(--q-accent);
border-radius: 50%;
padding: 4px;
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.3);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tariff-selected .tariff-checkmark {
opacity: 1;
transform: translateY(-50%) scale(1);
}
.tariff-title {
text-align: center;
margin-right: 36px; /* Отступ для иконки */
}
.tariff-content {
flex: 1;
display: flex;
flex-direction: column;
}
.tariff-features {
flex: 1;
margin-bottom: 16px;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 8px;
padding: 4px 0;
transition: color 0.2s ease;
}
.feature-item:hover {
color: var(--q-primary);
}
.tariff-additional {
border-top: 1px solid #f5f5f5;
padding-top: 16px;
}
.additional-item {
display: flex;
align-items: center;
margin-bottom: 6px;
color: #666;
font-size: 12px;
}
.tariff-footer {
position: relative;
height: 80px; /* Увеличенная высота для богатого оформления */
margin-top: auto;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 20px 32px 20px 12px;
}
.tariff-price {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
text-align: right;
}
.price-display {
font-size: 36px;
.tariff-card__title {
font-size: var(--p-fs-h2);
line-height: var(--p-lh-h2);
letter-spacing: var(--p-ls-h2);
font-weight: 600;
letter-spacing: -1px;
background: linear-gradient(135deg, var(--q-accent) 0%, rgba(25, 118, 210, 0.8) 100%);
-webkit-background-clip: text;
background-clip: text;
line-height: 1;
text-shadow: 0 2px 4px rgba(25, 118, 210, 0.3);
color: var(--p-ink);
}
.price-period {
font-size: 12px;
font-weight: 400;
color: #888;
letter-spacing: 0.5px;
text-transform: uppercase;
opacity: 0.8;
.tariff-card__box {
position: relative;
flex: 0 0 auto;
width: 20px;
height: 20px;
border: 1.5px solid var(--p-line-2);
border-radius: var(--p-r-pill);
background: var(--p-surface);
transition: border-color var(--p-dur-fast) var(--p-ease-standard);
}
.select-hint {
.tariff-card.is-selected .tariff-card__box {
border-color: var(--p-primary);
}
.tariff-card.is-selected .tariff-card__box::after {
content: '';
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
color: #999;
font-style: italic;
opacity: 0;
animation: pulse 2s infinite;
pointer-events: none;
transition: opacity 0.3s ease;
inset: 4px;
border-radius: var(--p-r-pill);
background: var(--p-primary);
}
.tariff-card:not(.tariff-selected):not(.tariff-disabled) .select-hint {
opacity: 1;
.tariff-card__sub {
margin: 0;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
.tariff-card__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.tariff-card__item {
position: relative;
padding: var(--p-3) 0;
font-size: var(--p-fs-body-sm);
line-height: var(--p-lh-body-sm);
color: var(--p-ink-1);
border-bottom: 1px solid var(--p-line);
}
.tariff-card__item:last-child {
border-bottom: 0;
}
/* Responsive adjustments */
@media (max-width: 599px) {
.tariff-card {
padding: 16px;
}
.tariff-header {
margin-bottom: 12px;
}
.tariff-title h6 {
font-size: 1rem;
}
.tariff-card__foot {
margin-top: auto;
padding-top: var(--p-4);
border-top: 1px solid var(--p-line);
}
.tariff-card__price {
display: inline-flex;
align-items: baseline;
gap: var(--p-1);
color: var(--p-ink);
}
.tariff-card__price-value {
font-family: var(--p-mono);
font-size: var(--p-fs-h1);
line-height: var(--p-lh-h1);
letter-spacing: var(--p-ls-h1);
font-weight: 600;
}
.tariff-card__price-period {
font-size: var(--p-fs-body-sm);
color: var(--p-ink-2);
}
</style>
@@ -1,27 +1,20 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { TariffCard, type ITariff } from './index'
// Доступные тарифы
const availableTariffs: ITariff[] = [
{
id: 'test',
name: 'Тестовый',
description: 'Единственный тариф на период бета-тестирования платформы',
price: '1500 RUB',
id: 'base',
name: 'Базовый',
description: 'Подключение кооператива к платформе Кооперативной Экономики',
price: '3 000 ₽',
features: [
'150 AXON на счёт кооператива',
'достаточно для 100 пакетов документов',
'и 50 регистраций пайщиков',
'50 пакетов документов в месяц',
'50 регистраций пайщика в месяц',
'Хостинг на изолированном сервере',
'Техническая поддержка'
'Техническая поддержка',
],
additionalCosts: [
'5 AXON в день списывается со счёта кооператива',
'1 AXON списывается за каждый пакет документов',
'1 AXON списывается за регистрацию нового пайщика',
]
}
},
]
const props = defineProps<{
@@ -36,16 +29,14 @@ const emits = defineEmits<{
const selectedTariffId = ref<string>(props.selectedTariff?.id || '')
const currentSelectedTariff = computed(() => {
return availableTariffs.find(tariff => tariff.id === selectedTariffId.value)
})
const currentSelectedTariff = computed(() =>
availableTariffs.find((tariff) => tariff.id === selectedTariffId.value),
)
const handleTariffSelect = (tariffId: string) => {
selectedTariffId.value = tariffId
const tariff = availableTariffs.find(t => t.id === tariffId)
if (tariff) {
emits('tariffSelected', tariff)
}
const tariff = availableTariffs.find((t) => t.id === tariffId)
if (tariff) emits('tariffSelected', tariff)
}
const handleTariffDeselect = (tariffId: string) => {
@@ -55,42 +46,41 @@ const handleTariffDeselect = (tariffId: string) => {
}
}
// Экспортируем для использования в родительском компоненте
// Если тариф один и ничего ещё не выбрано отмечаем сразу. Канон-степпер
// предполагает что пользователь делает явный выбор; экран с единственной
// опцией не должен требовать лишнего клика.
onMounted(() => {
if (!selectedTariffId.value && availableTariffs.length === 1) {
handleTariffSelect(availableTariffs[0].id)
}
})
defineExpose({
currentSelectedTariff,
hasSelection: computed(() => !!selectedTariffId.value)
hasSelection: computed(() => !!selectedTariffId.value),
})
</script>
<template lang="pug">
div
.text-center.q-mb-lg
//- h5.text-h5.q-mb-sm Выберите тариф
p.text-body2.text-grey-7 Выберите подходящий тариф для вашего кооператива
.tariff-grid
div(v-for="tariff in availableTariffs" :key="tariff.id")
TariffCard(
:tariff="tariff"
:selected="selectedTariffId === tariff.id"
:disabled="disabled"
@select="handleTariffSelect"
@deselect="handleTariffDeselect"
)</template>
.tariff-selector
.tariff-selector__list
TariffCard(
v-for="tariff in availableTariffs"
:key="tariff.id"
:tariff="tariff"
:selected="selectedTariffId === tariff.id"
:disabled="disabled"
group-name="connection-tariff"
@select="handleTariffSelect"
@deselect="handleTariffDeselect"
)
</template>
<style scoped>
.tariff-grid {
.tariff-selector__list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
max-width: 800px;
margin: 0 auto;
}
@media (max-width: 599px) {
.tariff-grid {
grid-template-columns: 1fr;
gap: 16px;
}
grid-template-columns: repeat(auto-fit, minmax(260px, 300px));
gap: var(--p-4);
align-items: stretch;
}
</style>
@@ -1,70 +1,12 @@
<template lang="pug">
div
q-stepper(
:model-value="currentStep"
flat
vertical
color="accent"
animated
done-color="teal"
)
UnionMembershipStep(
v-if="system.info.is_unioned"
:current-step="currentStep"
:is-active="currentStep === 0"
:is-done="currentStep > 0"
)
template(#registration)
slot(name="union-registration")
IntroStep(
:current-step="currentStep"
:is-active="currentStep === 1"
:is-done="currentStep > 1"
:selected-tariff="selectedTariff"
)
FormStep(
:current-step="currentStep"
:is-active="currentStep === 2"
:is-done="currentStep > 2"
:document="document"
:signed-document="signedDocument"
)
AgreementStep(
:current-step="currentStep"
:is-active="currentStep === 3"
:is-done="currentStep > 3"
:html="html"
)
DomainValidationStep(
:current-step="currentStep"
:is-active="currentStep === 4"
:is-done="currentStep > 4"
)
ApprovalWaitingStep(
:current-step="currentStep"
:is-active="currentStep === 5"
:is-done="currentStep > 5"
)
InstallationStep(
:current-step="currentStep"
:is-active="currentStep === 6"
:is-done="false"
)
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper'
import type { StepperStep } from 'src/shared/ui/domain/VerticalStepper'
import UnionMembershipStep from '../Steps/UnionMembershipStep.vue'
import IntroStep from '../Steps/IntroStep.vue'
import DomainStep from '../Steps/DomainStep.vue'
import FinancialParamsStep from '../Steps/FinancialParamsStep.vue'
import AgreementStep from '../Steps/AgreementStep.vue'
import FormStep from '../Steps/FormStep.vue'
import DomainValidationStep from '../Steps/DomainValidationStep.vue'
import ApprovalWaitingStep from '../Steps/ApprovalWaitingStep.vue'
import InstallationStep from '../Steps/InstallationStep.vue'
@@ -74,23 +16,97 @@ import { useSystemStore } from 'src/entities/System/model'
const connectionAgreement = useConnectionAgreementStore()
const system = useSystemStore()
// Данные из стора
const currentStep = computed(() => connectionAgreement.currentStep)
const selectedTariff = computed(() => connectionAgreement.selectedTariff)
const document = computed(() => connectionAgreement.document)
const signedDocument = computed(() => connectionAgreement.signedDocument)
const html = computed(() => document.value?.data?.html)
// Watch для реактивной генерации документа при переходе к шагу 3
watch(() => currentStep.value, async (newStep, oldStep) => {
// Если переходим к шагу 3 (соглашение), всегда генерируем документ заново
if (newStep === 3 && oldStep !== 3) {
console.log('📝 Шаг 3: Генерируем соглашение')
try {
await connectionAgreement.generateDocument()
} catch (error) {
console.error('Ошибка генерации документа:', error)
}
}
// Полный реестр шагов в порядке прохождения. Индекс в массиве = индекс в
// connectionAgreement.currentStep. Все жёсткие переходы в шагах и в
// ConnectionAgreementPage завязаны на эти числа менять синхронно.
const ALL_STEPS: Array<StepperStep & { index: number }> = [
{ key: 'union', index: 0, label: 'Членство в союзе' },
{ key: 'intro', index: 1, label: 'Тариф подключения' },
{ key: 'domain', index: 2, label: 'Домен кооператива' },
{ key: 'financial', index: 3, label: 'Финансовые параметры' },
{ key: 'agreement', index: 4, label: 'Соглашение о подключении' },
{ key: 'dns', index: 5, label: 'Делегирование домена' },
{ key: 'approval', index: 6, label: 'Подтверждение союза' },
{ key: 'installation', index: 7, label: 'Поставка инфраструктуры' },
]
const visibleSteps = computed(() =>
system.info.is_unioned ? ALL_STEPS : ALL_STEPS.filter((s) => s.key !== 'union'),
)
const stepperSteps = computed<StepperStep[]>(() =>
visibleSteps.value.map(({ key, label, description }) => ({ key, label, description })),
)
const activeKey = computed(() => {
const found = visibleSteps.value.find((s) => s.index === currentStep.value)
return found?.key ?? visibleSteps.value[0]?.key ?? 'intro'
})
const completedKeys = computed(() =>
visibleSteps.value.filter((s) => s.index < currentStep.value).map((s) => s.key),
)
function onStepperChange(key: string) {
const found = ALL_STEPS.find((s) => s.key === key)
if (!found) return
if (found.index >= currentStep.value) return
connectionAgreement.setCurrentStep(found.index)
}
watch(
() => currentStep.value,
async (newStep, oldStep) => {
if (newStep === 4 && oldStep !== 4) {
try {
await connectionAgreement.generateDocument()
} catch (error) {
console.error('Ошибка генерации документа:', error)
}
}
},
)
</script>
<template lang="pug">
.connection-stepper
VerticalStepper(
:steps="stepperSteps"
:active-key="activeKey"
:completed="completedKeys"
@change="onStepperChange"
)
template(#active="{ step }")
UnionMembershipStep(v-if="step.key === 'union'")
template(#registration)
slot(name="union-registration")
IntroStep(
v-else-if="step.key === 'intro'"
:selected-tariff="selectedTariff"
)
DomainStep(v-else-if="step.key === 'domain'")
FinancialParamsStep(v-else-if="step.key === 'financial'")
AgreementStep(
v-else-if="step.key === 'agreement'"
:html="html"
:document="document"
:signed-document="signedDocument"
)
DomainValidationStep(v-else-if="step.key === 'dns'")
ApprovalWaitingStep(v-else-if="step.key === 'approval'")
InstallationStep(v-else-if="step.key === 'installation'")
</template>
<style scoped>
.connection-stepper {
max-width: 760px;
margin: 0 auto;
padding: var(--p-6) var(--p-4);
}
</style>
@@ -1,87 +1,78 @@
<template lang="pug">
.axon-wallet
ColorCard(color='purple', @click.stop)
// Заголовок
.wallet-header
.wallet-title
q-icon(name="account_balance_wallet" size="20px").q-mr-sm
| Кошелек AXON
BaseCard(
title="Кошелёк AXON"
subtitle="Для оплаты пакетов документов. Минимально 5 AXON в день, по факту — от использования."
)
.axon-wallet__metric
.axon-wallet__metric-label Доступно
.axon-wallet__metric-value.t-mono {{ formattedBalance }}
// Описание
.wallet-description
.text-body2.text-grey-7
| AXON используется для оплаты пакетов документов. Минимально 5 AXON в день, по факту - от использования.
// Баланс
.balance-section
.balance-value {{ formattedBalance }}
.balance-label Доступно
// Действия
.actions-section
.action-buttons.q-pa-sm
q-btn(
color="primary"
icon="add"
label="Пополнить"
@click.stop="showDepositDialog = true"
)
.axon-wallet__actions
BaseButton(
variant="primary"
size="md"
type="button"
@click="showDepositDialog = true"
)
q-icon(name="add" size="16px").q-mr-xs
| Пополнить
BaseDialog(
v-model='showDepositDialog',
title='Пополнение кошелька AXON',
size='md',
@update:model-value='(v) => !v && clear()'
v-model="showDepositDialog"
title="Пополнение кошелька AXON"
size="md"
@update:model-value="(v) => !v && clear()"
)
.current-balance-section.q-mb-md
.text-body2.text-grey-7.q-mb-xs
| Текущий баланс: {{ formattedRubBalance }}.
| Для оплаты AXON используется паевой взнос на вашем кошельке.
| При недостатке средств на балансе совершите паевой взнос:
q-btn(
flat,
dense,
no-caps,
color="primary",
label="перейти в кошелек",
@click="goToWallet"
)
BaseBanner(variant="info")
| Текущий баланс паевого: <b>{{ formattedRubBalance }}</b>.
| Для оплаты AXON используется паевой взнос на вашем кошельке.
| При недостатке средств совершите паевой взнос в разделе «Кошелёк».
Form(
:handler-submit="handlerSubmit",
:is-submitting="isSubmitting",
button-cancel-txt="Отменить",
button-submit-txt="Пополнить",
@cancel="clear"
)
q-input(
v-model="depositAmount",
standout="bg-teal text-white",
placeholder="Введите сумму в RUB",
type="number",
:min="0",
:step="10",
:hint="depositHint",
:rules="[(val) => val > 0 || 'Сумма должна быть положительной']"
BaseForm.q-mt-md(:loading="isSubmitting" @submit="handlerSubmit")
BaseInput(
v-model="depositAmount"
label="Сумма пополнения"
type="number"
:hint="depositHint"
suffix="RUB"
placeholder="Введите сумму в RUB"
)
template(#append)
span.text-overline RUB
template(#footer="{ loading }")
.axon-wallet__form-actions
BaseButton(
variant="ghost"
size="md"
type="button"
:disabled="loading"
@click="clear"
) Отменить
BaseButton(
variant="primary"
size="md"
type="submit"
:loading="loading"
) Пополнить
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSessionStore } from 'src/entities/Session';
import { useWalletStore } from 'src/entities/Wallet';
import { ColorCard } from 'src/shared/ui';
import { Form } from 'src/shared/ui/Form';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import {
BaseBanner,
BaseButton,
BaseCard,
BaseDialog,
BaseForm,
BaseInput,
} from 'src/shared/ui/base';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { formatToAsset } from 'src/shared/lib/utils/formatToAsset';
import { useProviderAxonConvert, AXON_GOVERN_RATE } from 'src/features/Provider/model';
import { useSystemStore } from 'src/entities/System/model';
const router = useRouter();
const session = useSessionStore();
const walletStore = useWalletStore();
const system = useSystemStore();
@@ -102,10 +93,7 @@ const formattedRubBalance = computed(() => {
});
const depositHint = computed(() => {
if (!depositAmount.value || parseFloat(depositAmount.value) <= 0) {
return '';
}
if (!depositAmount.value || parseFloat(depositAmount.value) <= 0) return '';
const rubAmount = parseFloat(depositAmount.value);
const axonAmount = rubAmount / AXON_GOVERN_RATE;
return `Будет зачислено: ${formatAsset2Digits(`${axonAmount} AXON`)} (курс: 1 AXON = ${AXON_GOVERN_RATE} RUB)`;
@@ -117,110 +105,49 @@ const clear = () => {
isSubmitting.value = false;
};
const goToWallet = () => {
router.push({ name: 'wallet' });
};
const handlerSubmit = async () => {
isSubmitting.value = true;
try {
const success = await convertToAxon({
convertAmount: formatToAsset(depositAmount.value, system.info.symbols.root_govern_symbol, system.info.symbols.root_govern_precision),
convertAmount: formatToAsset(
depositAmount.value,
system.info.symbols.root_govern_symbol,
system.info.symbols.root_govern_precision,
),
username: session.username || '',
coopname: system.info.coopname || ''
coopname: system.info.coopname || '',
});
if (success) {
clear();
} else {
isSubmitting.value = false;
}
} catch (error) {
console.error(error);
if (success) clear();
else isSubmitting.value = false;
} catch {
isSubmitting.value = false;
}
};
</script>
<style lang="scss" scoped>
.axon-wallet {
padding: 8px;
:deep(.color-card) {
margin-bottom: 0 !important;
}
.wallet-header {
margin-bottom: 8px;
.wallet-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
}
}
.wallet-description {
margin-bottom: 12px;
.text-body2 {
font-size: 12px;
line-height: 1.4;
}
}
.balance-section {
padding: 12px;
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
margin-bottom: 12px;
.balance-label {
font-size: 11px;
opacity: 0.8;
margin-bottom: 4px;
}
.balance-value {
font-size: 18px;
font-weight: 700;
}
}
.actions-section {
.action-buttons {
display: flex;
justify-content: center;
.action-btn {
min-width: 120px;
height: 36px;
border-radius: 8px;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
}
.current-balance-section {
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
.text-body2 {
font-weight: 600;
}
}
.contribution-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
<style scoped>
.axon-wallet__metric {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--p-3) 0;
}
.axon-wallet__metric-label {
font-size: var(--p-fs-meta);
color: var(--p-ink-2);
}
.axon-wallet__metric-value {
font-size: var(--p-fs-h1);
font-weight: 700;
color: var(--p-ink);
}
.axon-wallet__actions {
display: flex;
gap: var(--p-2);
}
.axon-wallet__form-actions {
display: flex;
justify-content: flex-end;
gap: var(--p-2);
}
</style>
@@ -1,24 +1,17 @@
<template lang="pug">
div.connection-dashboard
//- Основная информация
.row.q-mb-lg
//- Карточка баланса AXON
.row.q-col-gutter-md
.col-12.col-md-4
AxonWallet
MembershipWallet.q-mt-md
//- Карточка домена
.col-12.col-md-6
.col-12.col-md-8
DomainCard
SubscriptionsCard
SubscriptionsCard.q-mt-md
</template>
<script setup lang="ts">
import { AxonWallet, DomainCard, SubscriptionsCard } from './index'
import { AxonWallet, MembershipWallet, DomainCard, SubscriptionsCard } from './index'
</script>
<style lang="scss" scoped>
@@ -27,4 +20,3 @@ import { AxonWallet, DomainCard, SubscriptionsCard } from './index'
margin: 0 auto;
}
</style>
@@ -1,95 +1,61 @@
<template lang="pug">
.domain-card
ColorCard(color='blue', @click.stop)
// Заголовок
.domain-header
.domain-title
q-icon(name="domain" size="20px").q-mr-sm
| Подключение
BaseCard(title="Подключение" subtitle="Доменное имя цифрового кооператива у провайдера")
template(#actions)
.domain-card__actions
BaseChip(
:variant="isDelegatingLoading ? 'neutral' : (instance?.is_delegated ? 'pos' : 'warn')"
size="sm"
)
q-icon(v-if="isDelegatingLoading" name="autorenew" size="12px").q-mr-xs.domain-card__spin
q-icon(v-else-if="instance?.is_delegated" name="check" size="12px").q-mr-xs
q-icon(v-else name="schedule" size="12px").q-mr-xs
span {{ delegationLabel }}
BaseButton(
v-if="!isEditing"
variant="secondary"
size="sm"
type="button"
aria-label="Изменить домен"
@click="startEdit"
)
q-icon(name="edit" size="14px").q-mr-xs
| Изменить
// Отображение или редактирование домена
.domain-display
.flex.items-center
.domain-text.full-width
template(v-if="!isEditing")
div.q-pa-md.text-h6.q-mr-sm {{ coop.publicCooperativeData?.announce || '—' }}
q-btn(
flat
round
icon="edit"
size="sm"
color="primary"
@click="startEdit"
)
q-tooltip Редактировать домен
div(v-else).q-pa-sm
.domain-warning.q-mb-md.full-width
.text-caption.text-orange-8.q-mb-sm
| Убедитесь, что домен делегирован IP-адрес: {{ SERVER_IP }}.
| Обновление домена перезагрузит цифровой кооператив.
| Все данные будут сохранены.
template(v-if="!isEditing")
.domain-card__view
.domain-card__host.t-mono {{ coop.publicCooperativeData?.announce || '—' }}
q-input(
v-model="domainValue"
placeholder="Введите домен"
outlined
dense
autofocus
@keyup.enter="saveDomain"
@keyup.escape="cancelEdit"
:rules="[(val) => !!val || 'Домен обязателен']"
).full-width.q-mt-md
template(#prepend)
q-btn(
flat
round
icon="close"
size="sm"
color="negative"
@click="cancelEdit"
)
q-tooltip Отменить
template(#append)
q-btn(
flat
round
icon="check"
size="sm"
color="positive"
@click="saveDomain"
)
q-tooltip Сохранить
template(v-else)
BaseBanner(variant="warn").q-mb-md
| Убедитесь, что домен делегирован на IP {{ SERVER_IP }}.
| Обновление перезагрузит цифровой кооператив; данные сохранятся.
BaseInput(
v-model="domainValue"
label="Доменное имя"
placeholder="example.coopenomics.world"
mono
)
.row
//- .col-6
//- .text-caption.text-grey-7 Членство в союзе
//- .text-body2.text-weight-medium
//- q-chip(
//- :color="getMembershipStatusColor"
//- outline
//- size="sm"
//- ) {{ getMembershipStatusLabel }}
.col-6
.text-caption.text-grey-7 Домен делегирован
.text-body2.text-weight-medium
q-chip(
:color="isDelegatingLoading ? 'grey' : (instance?.is_delegated ? 'positive' : 'grey')"
outline
size="sm"
)
template(v-if="isDelegatingLoading")
q-spinner(color="white" size="16px")
template(v-else)
q-icon(
v-if="!instance?.is_delegated"
name="refresh"
size="14px"
class="q-ml-xs rotating-icon"
color="grey-5"
)
span {{ instance?.is_delegated ? 'Да' : 'Обновляем' }}
.domain-card__edit-actions.q-mt-sm
BaseButton(
variant="primary"
size="sm"
type="button"
:loading="isDelegatingLoading"
@click="saveDomain"
)
q-icon(name="check" size="14px").q-mr-xs
| Сохранить
BaseButton(
variant="ghost"
size="sm"
type="button"
@click="cancelEdit"
)
q-icon(name="close" size="14px").q-mr-xs
| Отменить
</template>
<script setup lang="ts">
@@ -99,76 +65,62 @@ import { useCooperativeStore } from 'src/entities/Cooperative'
import { useUpdateCoop } from 'src/features/Cooperative/UpdateCoop'
import { FailAlert, SuccessAlert } from 'src/shared/api'
import { useSessionStore } from 'src/entities/Session'
import { ColorCard } from 'src/shared/ui'
import {
BaseBanner,
BaseButton,
BaseCard,
BaseChip,
BaseInput,
} from 'src/shared/ui/base'
import { useProviderSubscriptions } from 'src/features/Provider/model'
const connectionAgreement = useConnectionAgreementStore()
const coop = useCooperativeStore()
const { updateCoop } = useUpdateCoop()
const session = useSessionStore()
const { SERVER_IP } = useProviderSubscriptions()
// Получаем instance напрямую из store
const instance = computed(() => connectionAgreement.currentInstance)
// Цвет статуса членства
// const getMembershipStatusColor = computed(() => {
// if (instance.value?.blockchain_status === 'active') return 'positive'
// if (instance.value?.blockchain_status === 'pending') return 'warning'
// if (instance.value?.blockchain_status === 'blocked') return 'negative'
// return 'grey'
// })
// // Метка статуса членства
// const getMembershipStatusLabel = computed(() => {
// if (instance.value?.blockchain_status === 'active') return 'Активно'
// if (instance.value?.blockchain_status === 'pending') return 'Ожидает подтверждения'
// if (instance.value?.blockchain_status === 'blocked') return 'Заблокировано'
// return 'Неизвестно'
// })
// Состояние редактирования домена
const isEditing = ref(false)
const domainValue = ref('')
const isDelegatingLoading = ref(false)
// Загружаем данные кооператива при монтировании
const delegationLabel = computed(() => {
if (isDelegatingLoading.value) return 'обновляем'
return instance.value?.is_delegated ? 'домен делегирован' : 'ожидание делегирования'
})
coop.loadPublicCooperativeData(session.username)
// Синхронизируем значение домена при изменении данных кооператива
watch(() => coop?.publicCooperativeData?.announce, (newAnnounce) => {
if (newAnnounce && !isEditing.value) {
domainValue.value = newAnnounce
}
}, { immediate: true })
watch(
() => coop?.publicCooperativeData?.announce,
(newAnnounce) => {
if (newAnnounce && !isEditing.value) domainValue.value = newAnnounce
},
{ immediate: true },
)
// Начать редактирование
const startEdit = () => {
isEditing.value = true
domainValue.value = coop?.publicCooperativeData?.announce || ''
}
// Отменить редактирование
const cancelEdit = () => {
isEditing.value = false
domainValue.value = coop?.publicCooperativeData?.announce || ''
}
// Сохранить домен
const saveDomain = async () => {
if (!domainValue.value.trim()) {
FailAlert('Домен не может быть пустым')
return
}
if (!coop.publicCooperativeData) {
FailAlert('Не удалось получить данные кооператива')
return
}
try {
isDelegatingLoading.value = true
await updateCoop({
coopname: session.username,
username: session.username,
@@ -176,16 +128,13 @@ const saveDomain = async () => {
minimum: coop.publicCooperativeData.minimum,
org_initial: coop.publicCooperativeData.org_initial,
org_minimum: coop.publicCooperativeData.org_minimum,
announce: domainValue.value.trim(), // Обновляем announce (домен)
description: coop.publicCooperativeData.description
announce: domainValue.value.trim(),
description: coop.publicCooperativeData.description,
})
// Перезагружаем данные
await coop.loadPublicCooperativeData(session.username)
await connectionAgreement.loadCurrentInstance()
isEditing.value = false
SuccessAlert('Домен успешно обновлен')
SuccessAlert('Домен успешно обновлён')
} catch (error: any) {
FailAlert(`Ошибка при обновлении домена: ${error.message}`)
} finally {
@@ -194,49 +143,33 @@ const saveDomain = async () => {
}
</script>
<style lang="scss" scoped>
.domain-card {
padding: 8px;
// Переопределяем отступ ColorCard только для этого виджета
:deep(.color-card) {
margin-bottom: 0 !important;
}
.domain-header {
margin-bottom: 12px;
.domain-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
}
}
.domain-display {
.domain-text {
display: flex;
align-items: center;
.text-h6 {
margin-right: 8px;
}
}
}
// Анимация вращения иконки обновления
.rotating-icon {
animation: rotate 2s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
<style scoped>
.domain-card__actions {
display: flex;
align-items: center;
gap: var(--p-2);
}
.domain-card__view {
display: flex;
align-items: center;
gap: var(--p-2);
padding: var(--p-2) 0;
}
.domain-card__host {
font-size: var(--p-fs-h2);
font-weight: 600;
color: var(--p-ink);
word-break: break-all;
flex: 1;
}
.domain-card__edit-actions {
display: flex;
gap: var(--p-2);
}
.domain-card__spin {
animation: domain-card-rotate 1.6s linear infinite;
}
@keyframes domain-card-rotate {
to { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,83 @@
<template lang="pug">
.membership-wallet
BaseCard(
title="Кошелёк членских взносов"
subtitle="Списывается за инфраструктурные подписки кооператива у провайдера. Пополняется конвертацией паевого взноса."
)
.membership-wallet__metric
.membership-wallet__metric-label Доступно
.membership-wallet__metric-value.t-mono(v-if="initialLoading")
.membership-wallet__metric-value.t-mono(v-else) {{ formattedBalance }}
.membership-wallet__actions
BaseButton(
variant="primary"
size="md"
type="button"
@click="openConvert"
)
q-icon(name="sync_alt" size="16px").q-mr-xs
| Конвертировать в членский
ConvertToBillingDialog
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { BaseButton, BaseCard } from 'src/shared/ui/base'
import { useCooperativeMainWallet } from 'src/entities/Wallet/model'
import { useSessionStore } from 'src/entities/Session'
import { useSystemStore } from 'src/entities/System/model'
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits'
import {
ConvertToBillingDialog,
useConvertToBillingVisibility,
} from 'src/features/Billing/ConvertToBilling'
const session = useSessionStore()
const system = useSystemStore()
const { membership, symbol, initialLoading, refresh } = useCooperativeMainWallet(
() => system.info.coopname || '',
() => session.username || '',
)
const displaySymbol = computed(
() => symbol.value || system.info.symbols?.root_govern_symbol || 'RUB',
)
const formattedBalance = computed(() =>
formatAsset2Digits(`${membership.value} ${displaySymbol.value}`),
)
const { isVisible } = useConvertToBillingVisibility()
const openConvert = () => {
isVisible.value = true
}
watch(isVisible, (open, wasOpen) => {
if (wasOpen && !open) void refresh()
})
</script>
<style scoped>
.membership-wallet__metric {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--p-3) 0;
}
.membership-wallet__metric-label {
font-size: var(--p-fs-meta);
color: var(--p-ink-2);
}
.membership-wallet__metric-value {
font-size: var(--p-fs-h1);
font-weight: 700;
color: var(--p-ink);
min-height: 32px;
}
.membership-wallet__actions {
display: flex;
gap: var(--p-2);
}
</style>
@@ -1,97 +1,68 @@
<template lang="pug">
.subscriptions-card
ColorCard(color='orange', @click.stop)
// Заголовок
.subscriptions-header
.subscriptions-title
q-icon(name="subscriptions" size="20px").q-mr-sm
| Подписки
.subs-card
BaseCard(title="Подписки" subtitle="Услуги провайдера, оплачиваемые членскими взносами")
Loader(v-if="isLoading" :text="'Загрузка подписок...'")
// Список подписок
.subscriptions-list
template(v-if="isLoading")
.text-center.q-pa-md
q-spinner(color="orange" size="24px")
.text-caption.text-grey-7.q-mt-sm Загрузка подписок...
template(v-else-if="error")
BaseBanner(variant="neg") Ошибка загрузки подписок: {{ error }}
template(v-else-if="subscriptions.length > 0")
q-list(separator)
q-item(
v-for="subscription in subscriptions"
:key="subscription.id"
)
q-item-section(avatar)
q-icon(
:name="getSubscriptionIcon(subscription)"
:color="getSubscriptionColor(subscription)"
size="20px"
)
template(v-else-if="subscriptions.length")
.subs-card__list
.subs-card__row(
v-for="sub in subscriptions"
:key="sub.id"
)
.subs-card__icon
q-icon(
:name="getSubscriptionIcon(sub)"
size="20px"
:class="iconColorClass(sub)"
)
.subs-card__body
.subs-card__title {{ sub.subscription_type_name }}
.subs-card__sub {{ sub.subscription_type_description }}
.subs-card__price
.subs-card__price-value.t-mono {{ formatPrice(sub.price) }}
.subs-card__price-period {{ currencySymbol }}/месяц
q-item-section
q-item-label {{ subscription.subscription_type_name }}
q-item-label.caption.text-grey-7 {{ subscription.subscription_type_description }}
q-item-section(side)
.text-weight-medium {{ formatPrice(subscription.price) }}
.text-caption.text-grey-7 {{ currencySymbol }}/месяц
template(v-else-if="error")
.text-center.q-pa-md
.text-negative Ошибка загрузки подписок
.text-caption.text-grey-7.q-mt-sm {{ error }}
template(v-else)
.text-center.q-pa-md
.text-grey-6 Нет активных подписок
.text-caption.text-grey-7.q-mt-sm Подписки появятся после подключения услуг платформы
EmptyState(
v-else
title="Нет активных подписок"
body="Подписки появятся после подключения услуг платформы"
)
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useProviderSubscriptions } from 'src/features/Provider/model'
import { useSystemStore } from 'src/entities/System/model'
import { ColorCard } from 'src/shared/ui'
import { BaseBanner, BaseCard, EmptyState } from 'src/shared/ui/base'
import Loader from 'src/shared/ui/Loader/Loader.vue'
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits'
const {
subscriptions,
isLoading,
error,
loadSubscriptions
} = useProviderSubscriptions()
const { subscriptions, isLoading, error, loadSubscriptions } = useProviderSubscriptions()
const { info } = useSystemStore()
// Загружаем подписки при монтировании
onMounted(async () => {
await loadSubscriptions()
})
// Форматирование цены
const formatPrice = (price: number | string) => {
const priceStr = typeof price === 'number' ? price.toString() : price
const currencySymbol = info.symbols?.root_govern_symbol || 'AXON'
return formatAsset2Digits(`${priceStr} ${currencySymbol}`)
const sym = info.symbols?.root_govern_symbol || 'AXON'
return formatAsset2Digits(`${priceStr} ${sym}`)
}
// Получение символа валюты для отображения
const currencySymbol = computed(() => info.symbols?.root_govern_symbol || 'AXON')
// Получение иконки для статуса подписки
const getSubscriptionIcon = (subscription: any) => {
// Если подписка триальная, показываем специальную иконку
if (subscription.is_trial) {
return 'local_offer'
}
// Для хостинга проверяем specific_data
if (subscription.is_trial) return 'local_offer'
if (subscription.subscription_type_id === 1) {
const specificData = subscription.specific_data
if (specificData?.is_valid && specificData?.is_delegated) return 'check_circle'
if (specificData?.progress > 0 && specificData?.progress < 100) return 'hourglass_top'
const sd = subscription.specific_data
if (sd?.is_valid && sd?.is_delegated) return 'check_circle'
if (sd?.progress > 0 && sd?.progress < 100) return 'hourglass_top'
return 'schedule'
}
// Для других типов подписок проверяем instance_status
switch (subscription.instance_status) {
case 'active': return 'check_circle'
case 'pending': return 'schedule'
@@ -102,68 +73,64 @@ const getSubscriptionIcon = (subscription: any) => {
}
}
// Получение цвета для статуса подписки
const getSubscriptionColor = (subscription: any) => {
// Если подписка триальная, показываем специальный цвет
if (subscription.is_trial) {
return 'info'
}
// Для хостинга проверяем specific_data
const iconColorClass = (subscription: any): string => {
if (subscription.is_trial) return 'subs-card__icon--info'
if (subscription.subscription_type_id === 1) {
const specificData = subscription.specific_data
if (specificData?.is_valid && specificData?.is_delegated) return 'positive'
if (specificData?.progress > 0 && specificData?.progress < 100) return 'warning'
return 'grey'
const sd = subscription.specific_data
if (sd?.is_valid && sd?.is_delegated) return 'subs-card__icon--pos'
if (sd?.progress > 0 && sd?.progress < 100) return 'subs-card__icon--warn'
return 'subs-card__icon--mute'
}
// Для других типов подписок проверяем instance_status
switch (subscription.instance_status) {
case 'active': return 'positive'
case 'pending': return 'grey'
case 'installing': return 'warning'
case 'error': return 'negative'
case 'inactive': return 'grey'
default: return 'grey'
case 'active': return 'subs-card__icon--pos'
case 'installing': return 'subs-card__icon--warn'
case 'error': return 'subs-card__icon--neg'
default: return 'subs-card__icon--mute'
}
}
</script>
<style lang="scss" scoped>
.subscriptions-card {
padding: 8px;
// Переопределяем отступ ColorCard только для этого виджета
:deep(.color-card) {
margin-bottom: 0 !important;
}
.subscriptions-header {
margin-bottom: 16px;
.subscriptions-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
}
}
.subscriptions-list {
.q-list {
background: transparent;
border-radius: 8px;
.q-item {
padding: 12px 16px;
border-radius: 8px;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}
}
}
<style scoped>
.subs-card__list {
display: flex;
flex-direction: column;
}
.subs-card__row {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: var(--p-3);
padding: var(--p-3) 0;
border-bottom: 1px solid var(--p-line);
}
.subs-card__row:last-child {
border-bottom: 0;
}
.subs-card__icon--pos { color: var(--p-pos); }
.subs-card__icon--neg { color: var(--p-neg); }
.subs-card__icon--warn { color: var(--p-warn); }
.subs-card__icon--info { color: var(--p-primary); }
.subs-card__icon--mute { color: var(--p-ink-2); }
.subs-card__title {
font-size: var(--p-fs-body);
font-weight: 600;
color: var(--p-ink);
}
.subs-card__sub {
font-size: var(--p-fs-meta);
color: var(--p-ink-2);
margin-top: 2px;
}
.subs-card__price {
text-align: right;
}
.subs-card__price-value {
font-size: var(--p-fs-body);
font-weight: 700;
color: var(--p-ink);
}
.subs-card__price-period {
font-size: var(--p-fs-meta);
color: var(--p-ink-2);
}
</style>
@@ -1,5 +1,6 @@
export { default as ConnectionDashboard } from './ConnectionDashboard.vue'
export { default as AxonWallet } from './AxonWallet.vue'
export { default as MembershipWallet } from './MembershipWallet.vue'
export { default as DomainCard } from './DomainCard.vue'
export { default as SubscriptionsCard } from './SubscriptionsCard.vue'
@@ -0,0 +1,45 @@
import { DraftContract } from 'cooptypes'
import { BillingConversionStatement } from '../Templates'
import { DocFactory } from '../Factory'
import type { IGeneratedDocument, IGenerationOptions, IMetaDocument, ITemplate } from '../Interfaces'
import type { MongoDBConnector } from '../Services/Databazor'
export { BillingConversionStatement as Template } from '../Templates'
export class Factory extends DocFactory<BillingConversionStatement.Action> {
constructor(storage: MongoDBConnector) {
super(storage)
}
async generateDocument(data: BillingConversionStatement.Action, options?: IGenerationOptions): Promise<IGeneratedDocument> {
let template: ITemplate<BillingConversionStatement.Model>
if (process.env.SOURCE === 'local') {
template = BillingConversionStatement.Template
}
else {
template = await this.getTemplate(DraftContract.contractName.production, BillingConversionStatement.registry_id, data.block_num)
}
const meta: IMetaDocument = await super.getMeta({ title: template.title, ...data })
const coop = await super.getCooperative(data.coopname, data.block_num)
const vars = await super.getVars(data.coopname, data.block_num)
const user = await super.getUser(data.username, data.block_num)
const commonUser = super.getCommonUser(user)
const combinedData: BillingConversionStatement.Model = {
meta,
coop,
vars,
commonUser,
}
await super.validate(combinedData, template.model)
const translation = template.translations[meta.lang]
const document: IGeneratedDocument = await super.generatePDF('', template.context, combinedData, translation, meta, options?.skip_save)
return document
}
}
+1
View File
@@ -6,6 +6,7 @@ export * as PrivacyPolicy from './3.PrivacyPolicy'
export * as UserAgreement from './4.UserAgreement'
export * as CoopenomicsAgreement from './50.CoopenomicsAgreement'
export * as ConvertToAxonStatement from './51.ConvertToAxonStatement'
export * as BillingConversionStatement from './1095.BillingConversionStatement'
export * as SelectBranchStatement from './101.SelectBranchStatement'
export * as ParticipantApplication from './100.ParticipantApplication'
export * as DecisionOfParticipantApplication from './501.DecisionOfParticipantApplication'

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