Refactor payment request handling to support role change top-ups
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Updated the `PaymentRequest` model to include a new `Kind` property, distinguishing between `Subscription` and `RoleChangeTopUp` requests.
- Modified the `TelegramNotifier` to accommodate the new request type, ensuring accurate notifications for role change top-ups.
- Enhanced the `ConfirmPaymentRequestCommandHandler` to handle role change top-ups without extending the billing period, reflecting the new payment logic.
- Updated various application components and tests to support the new payment request structure and ensure proper functionality.
- Revised API documentation to clarify the behavior of role change top-ups and their impact on billing.
This commit is contained in:
Leonid Pershin
2026-07-19 16:45:17 +03:00
parent 0dcaf1203f
commit 5ff5224935
27 changed files with 1656 additions and 76 deletions
+52 -12
View File
@@ -417,33 +417,73 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
### PaymentRequest — заявка на оплату
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки на пользователя — инвариант
проверяется в `CreatePaymentRequestCommandHandler`.
одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки **`Kind.Subscription`** на
пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на
`Kind.RoleChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
| Поле | Тип | Заметки |
| ------------------ | ----------------------- | ------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) |
| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) |
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `RoleChangeTopUp` (доплата за апгрейд роли, см. ниже) |
| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.RoleChangeTopUp` — доплата не привязана к тарифному периоду |
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `RoleChangeTopUp`: см. `RoleChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
| `Status` | `PaymentRequestStatus` | `AwaitingPayment``AwaitingConfirmation``Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
| `CreatedAt` | `DateTimeOffset` | |
Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле —
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`.
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в
`RoleChangeTopUp.Compute` (`null`, доплата не считается).
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
- `Create(userId, period, amount)` `AwaitingPayment`, показываются реквизиты `BillingSettings`.
Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreateRoleChangeTopUp(userId, amount)`
(`Kind.RoleChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`,
админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}`.
админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}` (текст
уведомления зависит от `Kind` — период или «доплата за смену роли», см. `TelegramNotifier`).
- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
`Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже
оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за
неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет `ExpiresAt` на всех
конфигах пользователя.
Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) +
период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги,
приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет
`ExpiresAt` на всех конфигах пользователя. Для `Kind.RoleChangeTopUp` `Confirm` **только** переводит
заявку в `Confirmed``BillingPaidUntil` не трогает (это не покупка времени, а закрытие долга за уже
выданный апгрейд) — см. `ConfirmPaymentRequestCommandHandler`.
#### RoleChangeTopUp — доплата при апгрейде роли с активным периодом
Пользователь с активным `BillingPaidUntil` меняет роль (тикетом `SupportTicket.RoleRequest`,
`ApproveRoleRequestCommandHandler`) на более дорогую — по-хорошему должен доплатить разницу, а не
доиграть апгрейд бесплатно до конца уже оплаченного срока. **Роль меняется сразу** (не блокируется
ожиданием оплаты); доплата решается отдельно через обычный флоу `PaymentRequest`
(`Kind.RoleChangeTopUp`) — тем же путём, что и обычная оплата: сайт (`billing.tsx`,
`PaymentRequestPanel`) или Telegram (`pay:approve`/`pay:reject`).
Сумма — `RoleChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs`), чистая
функция без I/O:
1. Месячная стоимость роли = `PricingSettings.PricePerConfigPerQuarter × MaxConfigs`, затем скидка по
лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же формула и тот же
базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от того, за какой период
пользователь платил на самом деле — упрощение, чтобы не вводить отдельное понятие «дневная ставка
по фактическому тарифу».
2. Разница месячных стоимостей новой и старой роли, поделённая на 30 (условный «месяц» для
проратирования) и умноженная на число оставшихся до `BillingPaidUntil` дней — округление до целого
рубля (`MidpointRounding.AwayFromZero`).
3. `null` (доплата не создаётся), если: новая роль не дороже старой (в т.ч. понижение — остаётся
грандфазеринг, без доплаты и без возврата), оплаченный период уже истёк, либо старая/новая роль без
лимита конфигов (`MaxConfigs = -1`, цена не считается).
Применяется только к самостоятельной заявке на роль (`ApproveRoleRequestCommandHandler`) — админская
прямая смена роли (`PATCH /api/admin/users/{id}/role`, `UserManageDialog`) доплату не создаёт: это
осознанный инструмент админа, который может быть применён как поощрение.
**Известное ограничение**: `GetMyBillingStatusQueryHandler` отдаёт только одну `activeRequest`
если у пользователя одновременно есть активная `Subscription`-заявка и `RoleChangeTopUp` (редкий
случай: роль сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна из
них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не блокирует
основной сценарий.
### BillingService — приостановка за неуплату (фоновая джоба)
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для