Refactor payment request handling to support role change top-ups
- 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:
+5
-5
@@ -154,8 +154,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
||||
|
||||
| Метод | Путь | Тело запроса | Тело ответа |
|
||||
| ----- | -------------------------------------------- | ---------------------- | ------------- |
|
||||
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }` |
|
||||
| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`409 Billing.ActiveRequestExists`, если уже есть активная заявка; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
|
||||
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`RoleChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `RoleChangeTopUp` — см. domain-model.md#rolechangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) |
|
||||
| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `RoleChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
|
||||
| POST | `/api/billing/requests/{id}/cancel` | — | `204 No Content` (только из `AwaitingPayment`) |
|
||||
| POST | `/api/billing/requests/{id}/mark-paid` | — | `204 No Content` (`AwaitingPayment → AwaitingConfirmation`, уведомляет админов в Telegram) |
|
||||
| POST | `/api/billing/requests/{id}/send-requisites` | — | `204 No Content` (дублирует реквизиты в свой Telegram; `409 Telegram.NotLinked`, если Telegram не привязан) |
|
||||
@@ -203,7 +203,7 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
|
||||
| POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
|
||||
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) |
|
||||
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
|
||||
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
|
||||
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль сразу; если новая роль дороже старой и у пользователя активен `BillingPaidUntil` — дополнительно создаёт `PaymentRequest(Kind.RoleChangeTopUp)` на разницу в цене, см. domain-model.md#rolechangetopup) |
|
||||
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
|
||||
| POST | `/api/admin/support/tickets/{id}/approve-extension` | — | `204 No Content` (только `ExtensionRequest`/`Open`; продлевает `BillingPaidUntil` на `RequestedDays`) |
|
||||
| POST | `/api/admin/support/tickets/{id}/reject-extension` | `{ reason? }` | `204 No Content` (только `ExtensionRequest`; `reason` уходит комментарием) |
|
||||
@@ -278,8 +278,8 @@ approve/reject над `ActivationRequest`.
|
||||
| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- |
|
||||
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` |
|
||||
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` |
|
||||
| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList<AdminPaymentRequestDto>` (включает `userName`) |
|
||||
| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) |
|
||||
| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList<AdminPaymentRequestDto>` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) |
|
||||
| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.RoleChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#rolechangetopup) |
|
||||
| POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||
| POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) |
|
||||
|
||||
|
||||
+52
-12
@@ -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`). Для
|
||||
|
||||
Reference in New Issue
Block a user