- 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.
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
import { useState } from 'react'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from '@/shared/ui/toast-store'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
|
import { Badge } from '@/shared/ui/badge'
|
|
import { Button } from '@/shared/ui/button'
|
|
import { Label } from '@/shared/ui/label'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
|
import { HttpError } from '@/shared/api/client'
|
|
import type { BillingStatusDto, PaymentPeriod } from '@/shared/api/types'
|
|
import { cancelPaymentRequest, createPaymentRequest, markPaymentSent, sendRequisitesToTelegram } from './api'
|
|
|
|
const PERIODS: PaymentPeriod[] = ['Quarter', 'HalfYear', 'Year']
|
|
|
|
export function PaymentRequestPanel({ status }: { status: BillingStatusDto }) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const [period, setPeriod] = useState<PaymentPeriod>('Quarter')
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['my-billing-status'] })
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () => createPaymentRequest(period),
|
|
onSuccess: async () => {
|
|
toast.success(t('billing.requestCreated'))
|
|
await invalidate()
|
|
},
|
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
|
})
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: (id: string) => cancelPaymentRequest(id),
|
|
onSuccess: async () => {
|
|
toast.success(t('billing.requestCancelled'))
|
|
await invalidate()
|
|
},
|
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
|
})
|
|
|
|
const markPaidMutation = useMutation({
|
|
mutationFn: (id: string) => markPaymentSent(id),
|
|
onSuccess: async () => {
|
|
toast.success(t('billing.markedPaid'))
|
|
await invalidate()
|
|
},
|
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
|
})
|
|
|
|
const sendToTelegramMutation = useMutation({
|
|
mutationFn: (id: string) => sendRequisitesToTelegram(id),
|
|
onSuccess: () => toast.success(t('billing.requisitesSent')),
|
|
onError: (error) => {
|
|
const message =
|
|
error instanceof HttpError && error.status === 409 ? t('billing.telegramNotLinked') : t('auth.genericError')
|
|
toast.error(message)
|
|
},
|
|
})
|
|
|
|
const request = status.activeRequest
|
|
|
|
if (!request) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{t('billing.newRequest')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>{t('billing.period')}</Label>
|
|
<Select value={period} onValueChange={(v) => setPeriod(v as PaymentPeriod)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PERIODS.map((p) => (
|
|
<SelectItem key={p} value={p}>
|
|
{t(`billing.periods.${p}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
|
|
{t('billing.createRequest')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const isAwaitingConfirmation = request.status === 'AwaitingConfirmation'
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
|
<CardTitle className="text-base">{t('billing.activeRequest')}</CardTitle>
|
|
<Badge variant={isAwaitingConfirmation ? 'warning' : 'outline'}>
|
|
{t(`billing.requestStatus.${request.status}`)}
|
|
</Badge>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<p className="text-sm">
|
|
{request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)} —{' '}
|
|
<span className="font-medium">{request.amountSnapshot} ₽</span>
|
|
</p>
|
|
{request.kind === 'RoleChangeTopUp' && (
|
|
<p className="text-xs text-muted-foreground">{t('billing.roleChangeTopUpHint')}</p>
|
|
)}
|
|
|
|
{!isAwaitingConfirmation && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>{t('billing.requisites')}</Label>
|
|
<p className="whitespace-pre-wrap rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
{status.requisitesText || t('billing.requisitesMissing')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => sendToTelegramMutation.mutate(request.id)}
|
|
disabled={sendToTelegramMutation.isPending}
|
|
>
|
|
{t('billing.sendToTelegram')}
|
|
</Button>
|
|
{!isAwaitingConfirmation && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => markPaidMutation.mutate(request.id)}
|
|
disabled={markPaidMutation.isPending}
|
|
>
|
|
{t('billing.iPaid')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (confirm(t('billing.confirmCancel'))) cancelMutation.mutate(request.id)
|
|
}}
|
|
disabled={cancelMutation.isPending}
|
|
>
|
|
{t('billing.cancelRequest')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{isAwaitingConfirmation && <p className="text-xs text-muted-foreground">{t('billing.awaitingAdminHint')}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|