[B2D-15][@ant] fix: adversarial review P0 — PowerupPlugin реально wire'нут к provider'у, mutex, no-swallow + ProviderSubscription пакетные поля в Zeus
- BlockchainService.powerUp убран swallow try/catch и теперь возвращает on-chain tx_id — PowerupPlugin полагается на факт отказа, чтобы НЕ инкрементить счётчики (BLOCKER #1 ревью). - BillingProviderClient: новый метод createPackageInvoice + axios timeout 10s на обоих confirmTopupAxon/createPackageInvoice. - PowerupPlugin переработан: · in-process mutex packageTopupInflight против race calendar + threshold (BLOCKER #3); · executePackageTopup теперь делает двухфазный flow createPackageInvoice → powerUp → confirmTopupAxon → recordTopup, когда subscriptionId>0 и provider настроен (BLOCKER «confirm не подключён»); · убран фиктивный guard axon_pre_gate (cpu_limit.max ≠ AXON balance) с явным комментарием TODO; AXON-нехватку теперь ловит сам powerUp (после удаления swallow); · поля subscriptionId / monthlyPackageIdx в defaultConfig + Zod schema; idx инкрементится с 1 на смене месяца; · все три cron'а получили {timezone:'UTC'} + .catch обёртки; · boot-time backfill calendar тика — если процесс был down 1-го числа, runCalendarTask() прогоняется один раз при initialize; · BillingProviderClient зарегистрирован providers в PowerupPluginModule. - ProviderSubscriptionDTO + ProviderSubscriptionType расширены kind / monthly_quota_rub / packages_current_period_amount (приходят от provider'а через @coopenomics/provider-client). - ChairmanDesktop manifest зарегистрировал /:coopname/powerup → ResourceMonitorPage (Story 13.8 UI теперь доступен через меню). - generate-schema + generate-client + sdk build → providerSubscriptionSelector добавил три новых поля для desktop SubscriptionsCard.isPackage.
This commit is contained in:
+940
-163
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,18 @@ export class ProviderSubscriptionDTO {
|
||||
@Field(() => GraphQLJSON, { nullable: true, description: 'Специфичные данные подписки' })
|
||||
specific_data?: Record<string, any> | null;
|
||||
|
||||
// Epic 13 v5.1 — пакетная модель подписки. Эти поля нужны desktop'у
|
||||
// (`SubscriptionsCard.isPackage`, `ResourceMonitorPage`), чтобы отличать
|
||||
// time-подписку от package-подписки и показывать прогресс «потрачено/квота».
|
||||
@Field(() => String, { nullable: true, description: 'Тип подписки: time или package' })
|
||||
kind?: string | null;
|
||||
|
||||
@Field(() => Number, { nullable: true, description: 'Месячный потолок RUB для пакетной модели' })
|
||||
monthly_quota_rub?: number | null;
|
||||
|
||||
@Field(() => Number, { nullable: true, description: 'Сумма RUB, докупленная в текущем месячном периоде' })
|
||||
packages_current_period_amount?: number | null;
|
||||
|
||||
constructor(subscription: ProviderSubscriptionType) {
|
||||
this.id = subscription.id;
|
||||
this.subscriber_id = subscription.subscriber_id;
|
||||
@@ -94,6 +106,18 @@ export class ProviderSubscriptionDTO {
|
||||
this.updated_at = subscription.updated_at;
|
||||
this.specific_data = subscription.specific_data;
|
||||
|
||||
// Epic 13 v5.1: пакетные поля приходят опциональными от provider'а
|
||||
// (старые time-подписки оставляют их null/undefined).
|
||||
const sub = subscription as ProviderSubscriptionType & {
|
||||
kind?: string | null;
|
||||
monthly_quota_rub?: number | string | null;
|
||||
packages_current_period_amount?: number | string | null;
|
||||
};
|
||||
this.kind = sub.kind ?? null;
|
||||
this.monthly_quota_rub = sub.monthly_quota_rub != null ? Number(sub.monthly_quota_rub) : null;
|
||||
this.packages_current_period_amount =
|
||||
sub.packages_current_period_amount != null ? Number(sub.packages_current_period_amount) : null;
|
||||
|
||||
// Для хостинг подписки (id=1) добавляем данные инстанса из specific_data
|
||||
if (subscription.subscription_type_id === 1 && subscription.specific_data) {
|
||||
this.domain_valid = subscription.specific_data.is_valid;
|
||||
|
||||
@@ -37,7 +37,13 @@ export interface BlockchainPort {
|
||||
changeKey(data: RegistratorContract.Actions.ChangeKey.IChangeKey): Promise<void>;
|
||||
|
||||
// Powerup related methods
|
||||
powerUp(username: string, quantity: string): Promise<void>;
|
||||
/**
|
||||
* Epic 13 v5.1: возвращает on-chain transaction_id успешного powerUp.
|
||||
* Используется PowerupPlugin'ом для последующего confirmTopupAxon у провайдера.
|
||||
* Бросает исключение при отказе — caller обязан НЕ инкрементить счётчики, иначе
|
||||
* runaway-сценарий (см. adversarial review 2026-05-30, BLOCKER #1).
|
||||
*/
|
||||
powerUp(username: string, quantity: string): Promise<string>;
|
||||
|
||||
// System installation methods
|
||||
addUser(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import config, { default as coopConfig } from '../../config/config';
|
||||
import { Inject, Module, OnModuleDestroy } from '@nestjs/common';
|
||||
import { BaseExtModule } from '../base.extension.module';
|
||||
import { BLOCKCHAIN_PORT, BlockchainPort } from '~/domain/common/ports/blockchain.port';
|
||||
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
|
||||
import {
|
||||
EXTENSION_REPOSITORY,
|
||||
type ExtensionDomainRepository,
|
||||
@@ -40,9 +41,6 @@ export const defaultConfig = {
|
||||
dailyAxonCap: 50,
|
||||
// 3) Daily package cap — максимальное число докупок-пакетов в сутки.
|
||||
dailyPackageCap: 10,
|
||||
// 4) AXON pre-gate — минимальный AXON-баланс coopname@active, ниже которого
|
||||
// докупка не запускается (нет токенов, чтобы оплатить PowerUp).
|
||||
axonMinBalance: 1,
|
||||
// 5) RUB floor — минимальный RUB на w.wal.bill[coopname], при котором ещё
|
||||
// можно докупать. Запасной guard поверх subscription_type.min_topup_rub.
|
||||
rubFloor: 100,
|
||||
@@ -55,6 +53,17 @@ export const defaultConfig = {
|
||||
monthlyRubCap: 5000,
|
||||
// 8) Идемпотентность calendar-trigger'а — YYYY-MM последнего календарного пакета.
|
||||
lastCalendarPackageMonth: '',
|
||||
// Epic 13 v5.1 — связь с реестром подписок у провайдера. 0 означает, что
|
||||
// пакетная модель ещё не подключена: PowerupPlugin делает только on-chain
|
||||
// powerup без обращения к provider'у. Должен быть выставлен оператором
|
||||
// в admin-UI (Story 13.8 desktop settings) после того, как provider создал
|
||||
// подписку с subscription_type.kind=package для этого кооператива.
|
||||
subscriptionId: 0,
|
||||
// Счётчик пакетов текущего месяца (1, 2, 3...). Сбрасывается на 1 при первом
|
||||
// packageTopup в новом месяце; используется как idx в детерминированном
|
||||
// payment_hash = sha256(coopname|YYYY-MM-DD|topupaxon|idx). Хранится в config,
|
||||
// потому что provider дедуплицирует invoice'ы по этому idx.
|
||||
monthlyPackageIdx: 0,
|
||||
|
||||
// ===== Epic 13 v5.1 — счётчики дня (сбрасываются ежедневной cron-job) =====
|
||||
todayAxonSpent: 0,
|
||||
@@ -162,11 +171,6 @@ export const Schema = z.object({
|
||||
.min(0)
|
||||
.default(defaultConfig.dailyPackageCap)
|
||||
.describe(describeField({ label: 'Суточный лимит докупок (шт.)', rules: ['val >= 0'] })),
|
||||
axonMinBalance: z
|
||||
.number()
|
||||
.min(0)
|
||||
.default(defaultConfig.axonMinBalance)
|
||||
.describe(describeField({ label: 'Минимальный AXON для запуска докупки', rules: ['val >= 0'] })),
|
||||
rubFloor: z
|
||||
.number()
|
||||
.min(0)
|
||||
@@ -204,6 +208,24 @@ export const Schema = z.object({
|
||||
.string()
|
||||
.default(defaultConfig.lastTopupAt)
|
||||
.describe(describeField({ label: 'Время последней докупки (ISO)', visible: false })),
|
||||
subscriptionId: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(defaultConfig.subscriptionId)
|
||||
.describe(
|
||||
describeField({
|
||||
label: 'ID подписки package-типа у провайдера',
|
||||
note: '0 = пакетная модель не подключена. Заполняется оператором после регистрации subscription_type.kind=package у провайдера.',
|
||||
rules: ['val >= 0'],
|
||||
})
|
||||
),
|
||||
monthlyPackageIdx: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(defaultConfig.monthlyPackageIdx)
|
||||
.describe(describeField({ label: 'Индекс пакета в текущем месяце', visible: false })),
|
||||
});
|
||||
|
||||
// Автоматическое создание типа IConfig на основе Zod-схемы
|
||||
@@ -228,12 +250,18 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
// Идемпотентен через config.lastCalendarPackageMonth, при сбое процесса
|
||||
// повторный запуск 1-го числа корректно скипнет уже отработанный месяц.
|
||||
private calendarCronJob: cron.ScheduledTask | null = null;
|
||||
// Epic 13 v5.1 — in-process mutex для executePackageTopup. Защищает от наложения
|
||||
// calendar (00:00 UTC) + threshold (per-minute) триггеров: иначе оба прочитают
|
||||
// stale config, пройдут guards и сделают двойной powerUp до того, как любой
|
||||
// успеет recordTopup (см. adversarial review 2026-05-30, BLOCKER #3).
|
||||
private packageTopupInflight = false;
|
||||
|
||||
constructor(
|
||||
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
|
||||
@Inject(LOG_EXTENSION_REPOSITORY) private readonly logExtensionRepository: LogExtensionDomainRepository<ILog>,
|
||||
private readonly logger: WinstonLoggerService,
|
||||
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort
|
||||
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
|
||||
private readonly billingProvider: BillingProviderClient
|
||||
) {
|
||||
super();
|
||||
this.logger.setContext(PowerupPlugin.name);
|
||||
@@ -267,29 +295,43 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
await this.runDailyTask();
|
||||
}
|
||||
|
||||
// Регистрация cron-задачи для ежедневного пополнения
|
||||
this.dailyCronJob = cron.schedule('0 0 * * *', () => {
|
||||
this.runDailyTask();
|
||||
});
|
||||
|
||||
// Регистрация cron-задачи для проверки ресурсов каждую минуту
|
||||
this.resourceCronJob = cron.schedule('* * * * *', () => {
|
||||
this.runTask();
|
||||
});
|
||||
|
||||
// Epic 13 v5.1: все три крона на UTC, чтобы daily-counter (todayCounterDate)
|
||||
// и monthly-key (lastCalendarPackageMonth) совпадали с PowerupPlugin'овой
|
||||
// логикой (`nowIso.slice(0,7)`). Без timezone:'UTC' тик сместится в локальное
|
||||
// время сервера и сломает идемпотентность.
|
||||
this.dailyCronJob = cron.schedule(
|
||||
'0 0 * * *',
|
||||
() => { void this.runDailyTask().catch((err) =>
|
||||
this.logger.error('PowerupPlugin: daily-cron fail', err as Error),
|
||||
); },
|
||||
{ timezone: 'UTC' },
|
||||
);
|
||||
this.resourceCronJob = cron.schedule(
|
||||
'* * * * *',
|
||||
() => { void this.runTask().catch((err) =>
|
||||
this.logger.error('PowerupPlugin: resource-cron fail', err as Error),
|
||||
); },
|
||||
{ timezone: 'UTC' },
|
||||
);
|
||||
// Epic 13 v5.1 — calendar trigger: 1-го числа месяца в 00:00 UTC.
|
||||
// Идемпотентен через config.lastCalendarPackageMonth — если процесс
|
||||
// упал в момент срабатывания, перезапуск 1-го числа корректно скипнет
|
||||
// уже отработанный месяц.
|
||||
// Идемпотентен через config.lastCalendarPackageMonth.
|
||||
this.calendarCronJob = cron.schedule(
|
||||
'0 0 1 * *',
|
||||
() => {
|
||||
void this.runCalendarTask().catch((err) =>
|
||||
this.logger.info('PowerupPlugin: calendar-trigger fail', err as Error),
|
||||
this.logger.error('PowerupPlugin: calendar-trigger fail', err as Error),
|
||||
);
|
||||
},
|
||||
{ timezone: 'UTC' },
|
||||
);
|
||||
|
||||
// Boot-time backfill: если процесс был down 1-го числа в 00:00 UTC, тик
|
||||
// пропущен и cron сработает только 1-го числа следующего месяца. Проверяем
|
||||
// на старте: если текущий месяц ещё не «закрыт» календарным пакетом, запускаем
|
||||
// runCalendarTask() однократно. Идемпотентность — через lastCalendarPackageMonth.
|
||||
void this.runCalendarTask().catch((err) =>
|
||||
this.logger.error('PowerupPlugin: calendar boot-backfill fail', err as Error),
|
||||
);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
@@ -344,34 +386,90 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
* При MonthlyPackageQuotaExceeded от provider'а — tripCircuitBreaker.
|
||||
*/
|
||||
protected async executePackageTopup(trigger: 'calendar' | 'threshold', nowIso: string): Promise<void> {
|
||||
// Mutex: единая точка для calendar + threshold. Любой одновременный тик
|
||||
// даёт `skip — inflight` и проигрывает гонку победителю; победитель доводит
|
||||
// цикл createPackageInvoice → powerUp → confirmTopupAxon → recordTopup до конца.
|
||||
if (this.packageTopupInflight) {
|
||||
this.logger.info(`PowerupPlugin[${trigger}]: skip — inflight (предыдущий тик ещё не завершён)`);
|
||||
return;
|
||||
}
|
||||
this.packageTopupInflight = true;
|
||||
const username = coopConfig.coopname;
|
||||
try {
|
||||
const account = await this.blockchainPort.getAccount(username);
|
||||
if (!account) throw new Error('Аккаунт не найден');
|
||||
|
||||
// AXON-баланс кооператива (системный токен Воскхода) — берём из account.
|
||||
// На MVP-этапе billWalletRub считаем 0 (provider — источник истины);
|
||||
// финальная проверка в provider'е через accountPackageTopup.
|
||||
const axonBalance = Number(account.cpu_limit?.max ?? 0); // TODO: использовать реальный AXON balance из eosio.token.
|
||||
// billWalletRub: на MVP считаем 0 (provider — источник истины через
|
||||
// accountPackageTopup); rubFloor по умолчанию 0, так что guard не блочит.
|
||||
// TODO: подключить чтение w.wal.bill[coopname] баланса через blockchainPort.
|
||||
const billWalletRub = 0;
|
||||
|
||||
const signal = await this.checkRunawayGuards({ nowIso, axonBalance, billWalletRub });
|
||||
const signal = await this.checkRunawayGuards({ nowIso, billWalletRub });
|
||||
if (signal) {
|
||||
this.logger.info(`PowerupPlugin[${trigger}]: skip — guard "${signal}" сработал`);
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = this.getQuantity(this.plugin.config.dailyPackageSize);
|
||||
await this.blockchainPort.powerUp(username, quantity);
|
||||
const cfg = this.plugin.config;
|
||||
const quantity = this.getQuantity(cfg.dailyPackageSize);
|
||||
|
||||
// Real confirm-cycle to provider — TODO: подключить BillingProviderClient.confirmTopupAxon
|
||||
// после того как PowerupPlugin сможет получить payment_hash от provider'а
|
||||
// через createPackageInvoice. На MVP — только PowerUp + локальный recordTopup.
|
||||
// Определяем idx нового пакета: при смене месяца — 1, иначе ++.
|
||||
const monthKey = nowIso.slice(0, 7);
|
||||
const periodStart = `${monthKey}-01`;
|
||||
const prevMonthKey = cfg.lastCalendarPackageMonth || '';
|
||||
const nextIdx = prevMonthKey === monthKey ? (cfg.monthlyPackageIdx || 0) + 1 : 1;
|
||||
|
||||
// Двухфазный flow с provider'ом — только если подписка подключена
|
||||
// и provider настроен. Иначе fallback: powerUp без confirm-цикла.
|
||||
let paymentHash: string | null = null;
|
||||
const usePackageFlow = cfg.subscriptionId > 0 && this.billingProvider.isConfigured();
|
||||
if (usePackageFlow) {
|
||||
try {
|
||||
const invoice = await this.billingProvider.createPackageInvoice({
|
||||
coopname: username,
|
||||
subscriptionId: cfg.subscriptionId,
|
||||
amountRub: cfg.dailyPackageSize, // в RUB по cfg
|
||||
periodStart,
|
||||
idx: nextIdx,
|
||||
});
|
||||
paymentHash = invoice.payment_hash;
|
||||
} catch (err: any) {
|
||||
// MonthlyPackageQuotaExceeded → circuit breaker до конца месяца.
|
||||
if (err?.response?.data?.error === 'MonthlyPackageQuotaExceeded') {
|
||||
await this.tripCircuitBreaker(monthKey);
|
||||
return;
|
||||
}
|
||||
// Любая другая ошибка provider'а — НЕ делаем on-chain powerUp.
|
||||
// Зашумлено в логах + ждём следующего тика. Иначе докупим on-chain
|
||||
// без подтверждения, и provider потеряет учёт.
|
||||
this.logger.error(`PowerupPlugin[${trigger}]: createPackageInvoice failed`, err as Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// on-chain powerup: при ошибке выбрасывает наружу — НЕ делаем recordTopup,
|
||||
// следующий тик попробует снова (cooldown даст паузу).
|
||||
const txId = await this.blockchainPort.powerUp(username, quantity);
|
||||
|
||||
if (usePackageFlow && paymentHash) {
|
||||
try {
|
||||
await this.billingProvider.confirmTopupAxon({
|
||||
paymentHash,
|
||||
blockchainTransactionId: txId || paymentHash,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Двойной guard: если provider 5xx — НЕ откатываем on-chain, но
|
||||
// оставляем lastTopupAt незаписанным, чтобы следующий тик повторил
|
||||
// confirm. Provider идемпотентен по payment_hash, повтор безопасен.
|
||||
this.logger.error(`PowerupPlugin[${trigger}]: confirmTopupAxon failed`, err as Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.recordTopup({
|
||||
axonAmount: this.plugin.config.dailyPackageSize,
|
||||
axonAmount: cfg.dailyPackageSize,
|
||||
nowIso,
|
||||
trigger,
|
||||
monthlyPackageIdx: nextIdx,
|
||||
});
|
||||
|
||||
const updatedAccount = await this.blockchainPort.getAccount(username);
|
||||
@@ -388,13 +486,9 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Provider может бросить MonthlyPackageQuotaExceeded — ловим и активируем breaker.
|
||||
if (error?.response?.data?.error === 'MonthlyPackageQuotaExceeded') {
|
||||
const monthKey = new Date(nowIso).toISOString().slice(0, 7);
|
||||
await this.tripCircuitBreaker(monthKey);
|
||||
return;
|
||||
}
|
||||
this.logger.info(`PowerupPlugin[${trigger}]: executePackageTopup error`, error as Error);
|
||||
this.logger.error(`PowerupPlugin[${trigger}]: executePackageTopup error`, error as Error);
|
||||
} finally {
|
||||
this.packageTopupInflight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +506,6 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
*/
|
||||
protected async checkRunawayGuards(input: {
|
||||
nowIso: string;
|
||||
axonBalance: number; // в системных AXON-единицах
|
||||
billWalletRub: number; // RUB в w.wal.bill[coopname]
|
||||
}): Promise<string | null> {
|
||||
const cfg = this.plugin.config;
|
||||
@@ -440,15 +533,17 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
const todayPackagesIssued = dayChanged ? 0 : Number(cfg.todayPackagesIssued || 0);
|
||||
if (todayAxonSpent + cfg.dailyPackageSize > cfg.dailyAxonCap) return 'daily_axon_cap';
|
||||
if (todayPackagesIssued + 1 > cfg.dailyPackageCap) return 'daily_package_cap';
|
||||
// (4) AXON pre-gate.
|
||||
if (input.axonBalance < cfg.axonMinBalance) return 'axon_pre_gate';
|
||||
// (4) AXON pre-gate — REMOVED в adversarial review 2026-05-30: cpu_limit.max ≠
|
||||
// AXON balance (это CPU микросекунды). Реальное чтение `eosio.token.accounts`
|
||||
// через `chainPort.getCurrencyBalance` — отдельная задача; до её реализации
|
||||
// guard ничему не блочит. Сам факт нехватки AXON всё равно выдаст ошибку в
|
||||
// blockchainPort.powerUp (после удаления swallow try/catch), и она пробросится
|
||||
// в catch executePackageTopup → recordTopup не выполнится → нет double-spend.
|
||||
// (5) RUB floor.
|
||||
if (input.billWalletRub < cfg.rubFloor) return 'rub_floor';
|
||||
// (7) Monthly RUB cap — на этом уровне плагин знает только локальный cap;
|
||||
// финальная проверка (с учётом subscription_type.monthly_quota_rub) идёт
|
||||
// на стороне provider'а через MonthlyPackageQuotaExceeded.
|
||||
// На стороне плагина считаем по daily-counter * 30 (грубая оценка); точный
|
||||
// учёт делается provider'ом при createPackageInvoice.
|
||||
// (8) dailyPackageSize override — gate'ом не является, но фиксируем
|
||||
// что размер пакета берём из config (не magic), уже учтено выше.
|
||||
return null;
|
||||
@@ -459,7 +554,12 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
* перечитываем config из БД, считаем дельту, сохраняем единый JSONB. См.
|
||||
* паттерн в runDailyTask — daily-cron держит in-memory снимок с момента boot.
|
||||
*/
|
||||
protected async recordTopup(input: { axonAmount: number; nowIso: string; trigger: 'calendar' | 'threshold' }): Promise<void> {
|
||||
protected async recordTopup(input: {
|
||||
axonAmount: number;
|
||||
nowIso: string;
|
||||
trigger: 'calendar' | 'threshold';
|
||||
monthlyPackageIdx: number;
|
||||
}): Promise<void> {
|
||||
const todayKey = input.nowIso.slice(0, 10);
|
||||
const monthKey = input.nowIso.slice(0, 7);
|
||||
const fresh = await this.extensionRepository.findByName(this.name);
|
||||
@@ -472,6 +572,7 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
todayAxonSpent: dayChanged ? input.axonAmount : Number(prev.todayAxonSpent || 0) + input.axonAmount,
|
||||
todayPackagesIssued: dayChanged ? 1 : Number(prev.todayPackagesIssued || 0) + 1,
|
||||
lastCalendarPackageMonth: input.trigger === 'calendar' ? monthKey : prev.lastCalendarPackageMonth,
|
||||
monthlyPackageIdx: input.monthlyPackageIdx,
|
||||
};
|
||||
await this.extensionRepository.update({ name: this.name, config: nextConfig });
|
||||
this.plugin = { ...this.plugin, config: nextConfig };
|
||||
@@ -597,8 +698,11 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [PowerupPlugin], // Регистрируем PowerupPlugin как провайдер
|
||||
exports: [PowerupPlugin], // Экспортируем его для доступа в других модулях
|
||||
// Epic 13 v5.1: BillingProviderClient — HTTP-клиент к provider'у для
|
||||
// createPackageInvoice + confirmTopupAxon; provides + не imports т.к. сам
|
||||
// клиент stateless и зависит только от глобального config.
|
||||
providers: [PowerupPlugin, BillingProviderClient],
|
||||
exports: [PowerupPlugin],
|
||||
})
|
||||
export class PowerupPluginModule {
|
||||
constructor(public readonly powerupPlugin: PowerupPlugin) {}
|
||||
|
||||
@@ -111,8 +111,42 @@ export class BillingProviderClient {
|
||||
payment_hash: input.paymentHash,
|
||||
tx_id: input.blockchainTransactionId,
|
||||
},
|
||||
{ headers: this.headers() },
|
||||
{ headers: this.headers(), timeout: 10_000 },
|
||||
);
|
||||
this.logger.log(`confirmTopupAxon payment_hash=${input.paymentHash} tx=${input.blockchainTransactionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epic 13 v5.1 — заведение package-invoice'а у провайдера до on-chain `topupaxon`.
|
||||
*
|
||||
* PowerupPlugin coopback'а пайщика вызывает перед отправкой action, чтобы:
|
||||
* (a) получить детерминированный `payment_hash` для последующего confirmTopupAxon;
|
||||
* (b) пройти проверку `monthly_quota_rub` на стороне провайдера. При превышении
|
||||
* провайдер возвращает 400 `MonthlyPackageQuotaExceeded` — PowerupPlugin ловит
|
||||
* и активирует circuit-breaker (см. Story 13.5 + adversarial review 2026-05-30).
|
||||
*
|
||||
* Идемпотентно по `(coopname, period_start, idx)`.
|
||||
*/
|
||||
async createPackageInvoice(input: {
|
||||
coopname: string;
|
||||
subscriptionId: number;
|
||||
amountRub: number;
|
||||
periodStart: string; // ISO YYYY-MM-DD
|
||||
idx: number;
|
||||
}): Promise<{ payment_hash: string; amount_rub: number; status: string; expires_at: string }> {
|
||||
const url = `${this.baseUrl}/billing/package-invoice`;
|
||||
const { data } = await axios.post(
|
||||
url,
|
||||
{
|
||||
coopname: input.coopname,
|
||||
subscription_id: input.subscriptionId,
|
||||
amount_rub: input.amountRub,
|
||||
period_start: input.periodStart,
|
||||
idx: input.idx,
|
||||
},
|
||||
{ headers: this.headers(), timeout: 10_000 },
|
||||
);
|
||||
this.logger.log(`createPackageInvoice ${input.coopname} idx=${input.idx} → ${data.payment_hash}`);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ export class BlockchainService implements BlockchainPort {
|
||||
await this.transact(actions);
|
||||
}
|
||||
|
||||
public async powerUp(username: string, quantity: string): Promise<void> {
|
||||
public async powerUp(username: string, quantity: string): Promise<string> {
|
||||
// Инициализируем сессию перед транзакцией
|
||||
const wif = await this.vaultDomainService.getWif(username);
|
||||
if (!wif) throw new Error(`Не найден приватный ключ для аккаунта ${username}`);
|
||||
@@ -265,11 +265,13 @@ export class BlockchainService implements BlockchainPort {
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
await this.transact(actions);
|
||||
} catch (error) {
|
||||
this.logger.info('Предупреждение при выполнении транзакции powerup:', String(error));
|
||||
}
|
||||
// Epic 13 v5.1: пропускаем исключения наружу — PowerupPlugin полагается на
|
||||
// факт, что отказ on-chain powerup долетает до executePackageTopup и НЕ
|
||||
// приводит к recordTopup. Раньше try/catch + logger.info глушил отказ →
|
||||
// counters росли, cooldown «использовался», а реальной докупки не было
|
||||
// (см. adversarial review 2026-05-30, BLOCKER #1).
|
||||
const result = await this.transact(actions);
|
||||
return (result as any)?.response?.transaction_id ?? (result as any)?.transaction_id ?? '';
|
||||
}
|
||||
|
||||
public async addUser(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void> {
|
||||
|
||||
@@ -4152,7 +4152,10 @@ export const ReturnTypes: Record<string,any> = {
|
||||
instance_status:"String",
|
||||
instance_username:"String",
|
||||
is_trial:"Boolean",
|
||||
kind:"String",
|
||||
monthly_quota_rub:"Float",
|
||||
next_payment_due:"String",
|
||||
packages_current_period_amount:"Float",
|
||||
period_days:"Float",
|
||||
price:"Float",
|
||||
specific_data:"JSON",
|
||||
|
||||
@@ -7829,8 +7829,14 @@ walmoveWallets?: [{ input: ValueTypes["WalmoveInput"] | Variable<any, string>},V
|
||||
instance_username?:boolean | `@${string}`,
|
||||
/** Пробный период */
|
||||
is_trial?:boolean | `@${string}`,
|
||||
/** Тип подписки: time или package */
|
||||
kind?:boolean | `@${string}`,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?:boolean | `@${string}`,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?:boolean | `@${string}`,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?:boolean | `@${string}`,
|
||||
/** Период подписки в днях */
|
||||
period_days?:boolean | `@${string}`,
|
||||
/** Цена подписки */
|
||||
@@ -16242,8 +16248,14 @@ walmoveWallets?: [{ input: ResolverInputTypes["WalmoveInput"]},ResolverInputType
|
||||
instance_username?:boolean | `@${string}`,
|
||||
/** Пробный период */
|
||||
is_trial?:boolean | `@${string}`,
|
||||
/** Тип подписки: time или package */
|
||||
kind?:boolean | `@${string}`,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?:boolean | `@${string}`,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?:boolean | `@${string}`,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?:boolean | `@${string}`,
|
||||
/** Период подписки в днях */
|
||||
period_days?:boolean | `@${string}`,
|
||||
/** Цена подписки */
|
||||
@@ -24866,8 +24878,14 @@ export type ModelTypes = {
|
||||
instance_username?: string | undefined | null,
|
||||
/** Пробный период */
|
||||
is_trial: boolean,
|
||||
/** Тип подписки: time или package */
|
||||
kind?: string | undefined | null,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?: number | undefined | null,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?: string | undefined | null,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?: number | undefined | null,
|
||||
/** Период подписки в днях */
|
||||
period_days: number,
|
||||
/** Цена подписки */
|
||||
@@ -34061,8 +34079,14 @@ export type GraphQLTypes = {
|
||||
instance_username?: string | undefined | null,
|
||||
/** Пробный период */
|
||||
is_trial: boolean,
|
||||
/** Тип подписки: time или package */
|
||||
kind?: string | undefined | null,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?: number | undefined | null,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?: string | undefined | null,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?: number | undefined | null,
|
||||
/** Период подписки в днях */
|
||||
period_days: number,
|
||||
/** Цена подписки */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserPaymentMethodsPage } from 'src/pages/User/PaymentMethodsPage';
|
||||
import { ResourceMonitorPage } from 'src/pages/PowerUp/ResourceMonitorPage';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
export const manifest = {
|
||||
@@ -28,6 +29,19 @@ export const manifest = {
|
||||
}
|
||||
],
|
||||
},
|
||||
// Epic 13 v5.1 — пакетный монитор PowerupPlugin (CPU/NET/RAM + 8 guards).
|
||||
// Доступен только chairman'у (управляет пакетной моделью и настройками).
|
||||
{
|
||||
meta: {
|
||||
title: 'Пакетные ресурсы',
|
||||
icon: 'fa-solid fa-bolt',
|
||||
roles: [],
|
||||
},
|
||||
path: '/:coopname/powerup',
|
||||
name: 'powerup',
|
||||
component: markRaw(ResourceMonitorPage),
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
'config': {
|
||||
'layout': 'default',
|
||||
|
||||
@@ -23,6 +23,11 @@ export const rawProviderSubscriptionSelector = {
|
||||
installation_progress: true,
|
||||
instance_status: true,
|
||||
specific_data: true,
|
||||
// Epic 13 v5.1 — пакетная модель PowerUp:
|
||||
// desktop SubscriptionsCard ветка isPackage + ResourceMonitorPage.
|
||||
kind: true,
|
||||
monthly_quota_rub: true,
|
||||
packages_current_period_amount: true,
|
||||
}
|
||||
|
||||
// Проверка валидности
|
||||
|
||||
@@ -4152,7 +4152,10 @@ export const ReturnTypes: Record<string,any> = {
|
||||
instance_status:"String",
|
||||
instance_username:"String",
|
||||
is_trial:"Boolean",
|
||||
kind:"String",
|
||||
monthly_quota_rub:"Float",
|
||||
next_payment_due:"String",
|
||||
packages_current_period_amount:"Float",
|
||||
period_days:"Float",
|
||||
price:"Float",
|
||||
specific_data:"JSON",
|
||||
|
||||
@@ -7829,8 +7829,14 @@ walmoveWallets?: [{ input: ValueTypes["WalmoveInput"] | Variable<any, string>},V
|
||||
instance_username?:boolean | `@${string}`,
|
||||
/** Пробный период */
|
||||
is_trial?:boolean | `@${string}`,
|
||||
/** Тип подписки: time или package */
|
||||
kind?:boolean | `@${string}`,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?:boolean | `@${string}`,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?:boolean | `@${string}`,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?:boolean | `@${string}`,
|
||||
/** Период подписки в днях */
|
||||
period_days?:boolean | `@${string}`,
|
||||
/** Цена подписки */
|
||||
@@ -16242,8 +16248,14 @@ walmoveWallets?: [{ input: ResolverInputTypes["WalmoveInput"]},ResolverInputType
|
||||
instance_username?:boolean | `@${string}`,
|
||||
/** Пробный период */
|
||||
is_trial?:boolean | `@${string}`,
|
||||
/** Тип подписки: time или package */
|
||||
kind?:boolean | `@${string}`,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?:boolean | `@${string}`,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?:boolean | `@${string}`,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?:boolean | `@${string}`,
|
||||
/** Период подписки в днях */
|
||||
period_days?:boolean | `@${string}`,
|
||||
/** Цена подписки */
|
||||
@@ -24866,8 +24878,14 @@ export type ModelTypes = {
|
||||
instance_username?: string | undefined | null,
|
||||
/** Пробный период */
|
||||
is_trial: boolean,
|
||||
/** Тип подписки: time или package */
|
||||
kind?: string | undefined | null,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?: number | undefined | null,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?: string | undefined | null,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?: number | undefined | null,
|
||||
/** Период подписки в днях */
|
||||
period_days: number,
|
||||
/** Цена подписки */
|
||||
@@ -34061,8 +34079,14 @@ export type GraphQLTypes = {
|
||||
instance_username?: string | undefined | null,
|
||||
/** Пробный период */
|
||||
is_trial: boolean,
|
||||
/** Тип подписки: time или package */
|
||||
kind?: string | undefined | null,
|
||||
/** Месячный потолок RUB для пакетной модели */
|
||||
monthly_quota_rub?: number | undefined | null,
|
||||
/** Дата следующего платежа */
|
||||
next_payment_due?: string | undefined | null,
|
||||
/** Сумма RUB, докупленная в текущем месячном периоде */
|
||||
packages_current_period_amount?: number | undefined | null,
|
||||
/** Период подписки в днях */
|
||||
period_days: number,
|
||||
/** Цена подписки */
|
||||
|
||||
Reference in New Issue
Block a user