Merge branch 'payd'
This commit is contained in:
@@ -69,24 +69,22 @@ export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postOrderMessage(id: string, text: string): Promise<void> {
|
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
|
||||||
await apiClient.post(`me/orders/${id}/messages`, { text })
|
export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> {
|
||||||
|
const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подтверждение оплаты переводом: multipart detail + необязательный файл receipt (хотя бы одно нужно на сервере). */
|
/** Получить статус платежа для заказа. */
|
||||||
export async function submitOrderPayment(
|
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
|
||||||
orderId: string,
|
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
|
||||||
payload: { detail: string; receiptFile: File | null },
|
|
||||||
): Promise<{ ok: boolean; status: string }> {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('detail', payload.detail)
|
|
||||||
if (payload.receiptFile) {
|
|
||||||
formData.append('receipt', payload.receiptFile)
|
|
||||||
}
|
|
||||||
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${orderId}/pay`, formData)
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postOrderMessage(id: string, text: string): Promise<void> {
|
||||||
|
await apiClient.post(`me/orders/${id}/messages`, { text })
|
||||||
|
}
|
||||||
|
|
||||||
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
|
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
|
||||||
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
|
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { OrderPaymentSection } from './ui/OrderPaymentSection'
|
export { OrderPaymentSection } from './ui/OrderPaymentSection'
|
||||||
export { PaymentDialog } from './ui/PaymentDialog'
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
import { PaymentDialog } from './PaymentDialog'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
status: string
|
status: string
|
||||||
@@ -12,7 +10,7 @@ type Props = {
|
|||||||
totalCents: number
|
totalCents: number
|
||||||
isPayPending: boolean
|
isPayPending: boolean
|
||||||
payError: unknown
|
payError: unknown
|
||||||
onPay: (params: { detail: string; receiptFile: File | null }) => void
|
onPay: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderPaymentSection({
|
export function OrderPaymentSection({
|
||||||
@@ -24,7 +22,6 @@ export function OrderPaymentSection({
|
|||||||
onPay,
|
onPay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false)
|
|
||||||
|
|
||||||
if (payOnPickup) {
|
if (payOnPickup) {
|
||||||
return (
|
return (
|
||||||
@@ -52,30 +49,24 @@ export function OrderPaymentSection({
|
|||||||
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
|
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
|
||||||
<>
|
<>
|
||||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||||
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
|
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
|
||||||
{orderStatusLabelRu('PAID')}».
|
{orderStatusLabelRu('PAID')}».
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
|
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
|
||||||
Оплатить
|
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status !== 'PENDING_PAYMENT' && (
|
{status === 'PAID' && (
|
||||||
<Typography color="text.secondary" variant="body2">
|
<Typography color="success.main" variant="body1">
|
||||||
На этом этапе действий по оплате в этом блоке не требуется.
|
Оплачено. Спасибо!
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{status !== 'PENDING_PAYMENT' && status !== 'PAID' && (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
На этом этапе действий по оплате не требуется.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PaymentDialog
|
|
||||||
open={payModalOpen}
|
|
||||||
isPending={isPayPending}
|
|
||||||
error={payError}
|
|
||||||
onClose={() => setPayModalOpen(false)}
|
|
||||||
onSubmit={(params) => {
|
|
||||||
onPay(params)
|
|
||||||
setPayModalOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import Alert from '@mui/material/Alert'
|
|
||||||
import Box from '@mui/material/Box'
|
|
||||||
import Button from '@mui/material/Button'
|
|
||||||
import Dialog from '@mui/material/Dialog'
|
|
||||||
import DialogActions from '@mui/material/DialogActions'
|
|
||||||
import DialogContent from '@mui/material/DialogContent'
|
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
|
||||||
import Stack from '@mui/material/Stack'
|
|
||||||
import TextField from '@mui/material/TextField'
|
|
||||||
import Typography from '@mui/material/Typography'
|
|
||||||
import axios from 'axios'
|
|
||||||
import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean
|
|
||||||
isPending: boolean
|
|
||||||
error: unknown
|
|
||||||
onClose: () => void
|
|
||||||
onSubmit: (params: { detail: string; receiptFile: File | null }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function paySubmitErrorMessage(err: unknown): string {
|
|
||||||
if (axios.isAxiosError(err)) {
|
|
||||||
const raw = err.response?.data
|
|
||||||
const apiMsg =
|
|
||||||
raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string'
|
|
||||||
? (raw as { error: string }).error
|
|
||||||
: null
|
|
||||||
return apiMsg || err.message || 'Не удалось отправить данные оплаты'
|
|
||||||
}
|
|
||||||
if (err instanceof Error) return err.message
|
|
||||||
return 'Не удалось отправить данные оплаты'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentDialog({ open, isPending, error, onClose, onSubmit }: Props) {
|
|
||||||
const [detail, setDetail] = useState('')
|
|
||||||
const [receiptFile, setReceiptFile] = useState<File | null>(null)
|
|
||||||
const [clientError, setClientError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const receiptPreviewUrl = useMemo(() => {
|
|
||||||
if (!receiptFile) return null
|
|
||||||
return URL.createObjectURL(receiptFile)
|
|
||||||
}, [receiptFile])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!receiptPreviewUrl) return
|
|
||||||
return () => URL.revokeObjectURL(receiptPreviewUrl)
|
|
||||||
}, [receiptPreviewUrl])
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setDetail('')
|
|
||||||
setReceiptFile(null)
|
|
||||||
setClientError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (isPending) return
|
|
||||||
reset()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const hasText = detail.trim().length > 0
|
|
||||||
const hasFile = Boolean(receiptFile)
|
|
||||||
if (!hasText && !hasFile) {
|
|
||||||
setClientError('Укажите комментарий и/или прикрепите чек.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setClientError(null)
|
|
||||||
onSubmit({ detail: detail.trim(), receiptFile })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
|
||||||
<DialogTitle>Подтверждение оплаты</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 2 }}>
|
|
||||||
{PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN}
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
label="Комментарий об оплате (сумма, время перевода и т.д.)"
|
|
||||||
value={detail}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDetail(e.target.value)
|
|
||||||
setClientError(null)
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mb: 1, alignItems: { sm: 'center' } }}>
|
|
||||||
<Button component="label" variant="outlined">
|
|
||||||
Прикрепить чек (png, jpg, webp)
|
|
||||||
<input
|
|
||||||
hidden
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
setReceiptFile(file ?? null)
|
|
||||||
setClientError(null)
|
|
||||||
e.currentTarget.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
{receiptFile && (
|
|
||||||
<Button color="error" variant="text" onClick={() => setReceiptFile(null)}>
|
|
||||||
Убрать файл
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
|
||||||
Нужен текст комментария и/или изображение чека.
|
|
||||||
</Typography>
|
|
||||||
{receiptPreviewUrl && (
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src={receiptPreviewUrl}
|
|
||||||
alt="Предпросмотр чека"
|
|
||||||
sx={{ maxWidth: '100%', maxHeight: 200, borderRadius: 1, border: 1, borderColor: 'divider', mb: 1 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{clientError && (
|
|
||||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
|
||||||
{clientError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{error ? (
|
|
||||||
<Alert severity="error" sx={{ mt: 1 }}>
|
|
||||||
{paySubmitErrorMessage(error)}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleClose} disabled={isPending}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
|
|
||||||
Подтвердить оплату
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,8 @@ type Props = {
|
|||||||
error: unknown
|
error: unknown
|
||||||
uploadError: unknown
|
uploadError: unknown
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => void
|
onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => Promise<void>
|
||||||
onUploadImage: (file: File) => void
|
onUploadImage: (file: File) => Promise<{ url: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewSubmitErrorMessage(err: unknown): string {
|
function reviewSubmitErrorMessage(err: unknown): string {
|
||||||
@@ -55,11 +55,13 @@ export function ReviewDialog({
|
|||||||
const [rating, setRating] = useState<number>(5)
|
const [rating, setRating] = useState<number>(5)
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
|
const [localUploadError, setLocalUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setRating(5)
|
setRating(5)
|
||||||
setText('')
|
setText('')
|
||||||
setImageUrl(null)
|
setImageUrl(null)
|
||||||
|
setLocalUploadError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -68,9 +70,9 @@ export function ReviewDialog({
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
if (isPending) return
|
if (isPending) return
|
||||||
onSubmit({ rating, text: text.trim(), imageUrl })
|
await onSubmit({ rating, text: text.trim(), imageUrl })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,11 +98,19 @@ export function ReviewDialog({
|
|||||||
hidden
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
onUploadImage(file)
|
|
||||||
e.currentTarget.value = ''
|
e.currentTarget.value = ''
|
||||||
|
setLocalUploadError(null)
|
||||||
|
try {
|
||||||
|
const result = await onUploadImage(file)
|
||||||
|
setImageUrl(result.url)
|
||||||
|
} catch (err) {
|
||||||
|
setLocalUploadError(
|
||||||
|
err instanceof Error ? err.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.',
|
||||||
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -126,11 +136,13 @@ export function ReviewDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{uploadError ? (
|
{uploadError || localUploadError ? (
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
{uploadError instanceof Error
|
{localUploadError
|
||||||
? uploadError.message
|
? localUploadError
|
||||||
: 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
|
: uploadError instanceof Error
|
||||||
|
? uploadError.message
|
||||||
|
: 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ type Props = {
|
|||||||
isUploadPending: boolean
|
isUploadPending: boolean
|
||||||
submitError: unknown
|
submitError: unknown
|
||||||
uploadError: unknown
|
uploadError: unknown
|
||||||
onSubmitReview: (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => void
|
onSubmitReview: (params: {
|
||||||
|
productId: string
|
||||||
|
rating: number
|
||||||
|
text: string
|
||||||
|
imageUrl: string | null
|
||||||
|
}) => Promise<void>
|
||||||
onUploadImage: (file: File) => Promise<{ url: string }>
|
onUploadImage: (file: File) => Promise<{ url: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +80,20 @@ export function ReviewSection({
|
|||||||
setTarget(null)
|
setTarget(null)
|
||||||
setUploadedImageUrl(null)
|
setUploadedImageUrl(null)
|
||||||
}}
|
}}
|
||||||
onSubmit={(params) => {
|
onSubmit={async (params) => {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
onSubmitReview({
|
await onSubmitReview({
|
||||||
productId: target.productId,
|
productId: target.productId,
|
||||||
...params,
|
...params,
|
||||||
imageUrl: uploadedImageUrl,
|
imageUrl: uploadedImageUrl,
|
||||||
})
|
})
|
||||||
|
setTarget(null)
|
||||||
|
setUploadedImageUrl(null)
|
||||||
}}
|
}}
|
||||||
onUploadImage={async (file) => {
|
onUploadImage={async (file) => {
|
||||||
const result = await onUploadImage(file)
|
const result = await onUploadImage(file)
|
||||||
setUploadedImageUrl(result.url)
|
setUploadedImageUrl(result.url)
|
||||||
|
return result
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import TableRow from '@mui/material/TableRow'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
|
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
|
||||||
@@ -16,6 +17,7 @@ import type { AdminUser } from '@/entities/user/model/types'
|
|||||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||||
import { AdminTable } from '@/shared/ui/AdminTable'
|
import { AdminTable } from '@/shared/ui/AdminTable'
|
||||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||||
@@ -44,6 +46,8 @@ export function AdminUsersPage() {
|
|||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(20)
|
const [rowsPerPage, setRowsPerPage] = useState(20)
|
||||||
|
const currentUser = useUnit($user)
|
||||||
|
const currentUserId = currentUser?.id
|
||||||
|
|
||||||
const userForm = useForm<UserFormState>({
|
const userForm = useForm<UserFormState>({
|
||||||
defaultValues: emptyUserForm(),
|
defaultValues: emptyUserForm(),
|
||||||
@@ -192,7 +196,7 @@ export function AdminUsersPage() {
|
|||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<EntityRowActions
|
<EntityRowActions
|
||||||
onEdit={() => openEdit(u)}
|
onEdit={() => openEdit(u)}
|
||||||
onDelete={() => deleteMut.mutate(u.id)}
|
onDelete={u.id === currentUserId ? undefined : () => deleteMut.mutate(u.id)}
|
||||||
deleteDisabled={deleteMut.isPending}
|
deleteDisabled={deleteMut.isPending}
|
||||||
confirmDeleteMessage={`Удалить пользователя ${u.email}?`}
|
confirmDeleteMessage={`Удалить пользователя ${u.email}?`}
|
||||||
/>
|
/>
|
||||||
@@ -237,7 +241,15 @@ export function AdminUsersPage() {
|
|||||||
<Controller
|
<Controller
|
||||||
control={userForm.control}
|
control={userForm.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
label="Почта"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
disabled={Boolean(editing && editing.id === currentUserId)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={userForm.control}
|
control={userForm.control}
|
||||||
|
|||||||
@@ -10,14 +10,18 @@ import {
|
|||||||
fetchUserNotificationSettings,
|
fetchUserNotificationSettings,
|
||||||
updateUserNotificationSettings,
|
updateUserNotificationSettings,
|
||||||
} from '@/entities/notification/api/notifications-api'
|
} from '@/entities/notification/api/notifications-api'
|
||||||
|
import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api'
|
||||||
|
|
||||||
const eventFields = [
|
function isOrderStatusChangesOn(s: UserNotificationSettings): boolean {
|
||||||
{ key: 'orderCreated' as const, label: 'Заказ создан' },
|
return s.orderCreated && s.orderStatusChanged && s.paymentStatusChanged && s.deliveryFeeAdjusted
|
||||||
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
|
}
|
||||||
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
|
|
||||||
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
|
const orderStatusChangesPayload = (on: boolean) => ({
|
||||||
{ key: 'deliveryFeeAdjusted' as const, label: 'Корректировка стоимости доставки' },
|
orderCreated: on,
|
||||||
]
|
orderStatusChanged: on,
|
||||||
|
paymentStatusChanged: on,
|
||||||
|
deliveryFeeAdjusted: on,
|
||||||
|
})
|
||||||
|
|
||||||
export function NotificationsPage() {
|
export function NotificationsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -45,9 +49,11 @@ export function NotificationsPage() {
|
|||||||
|
|
||||||
const handleToggle = (field: string, value: boolean) => {
|
const handleToggle = (field: string, value: boolean) => {
|
||||||
setError(null)
|
setError(null)
|
||||||
mutation.mutate({ [field]: value } as Record<string, boolean>)
|
mutation.mutate({ [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusChangesOn = isOrderStatusChangesOn(settings)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
@@ -80,19 +86,26 @@ export function NotificationsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ pl: 4 }}>
|
<Box sx={{ pl: 4 }}>
|
||||||
{eventFields.map(({ key, label }) => (
|
<FormControlLabel
|
||||||
<FormControlLabel
|
control={
|
||||||
key={key}
|
<Switch
|
||||||
control={
|
checked={statusChangesOn}
|
||||||
<Switch
|
disabled={!settings.globalEnabled}
|
||||||
checked={settings[key]}
|
onChange={(e) => mutation.mutate(orderStatusChangesPayload(e.target.checked))}
|
||||||
disabled={!settings.globalEnabled}
|
/>
|
||||||
onChange={(e) => handleToggle(key, e.target.checked)}
|
}
|
||||||
/>
|
label="Изменения статуса заказа"
|
||||||
}
|
/>
|
||||||
label={label}
|
<FormControlLabel
|
||||||
/>
|
control={
|
||||||
))}
|
<Switch
|
||||||
|
checked={settings.orderMessageReceived}
|
||||||
|
disabled={!settings.globalEnabled}
|
||||||
|
onChange={(e) => handleToggle('orderMessageReceived', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Сообщения в чате заказа"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import Link from '@mui/material/Link'
|
|||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
confirmOrderReceived,
|
confirmOrderReceived,
|
||||||
|
createOrderPayment,
|
||||||
fetchMyOrder,
|
fetchMyOrder,
|
||||||
|
getOrderPaymentStatus,
|
||||||
postOrderMessage,
|
postOrderMessage,
|
||||||
submitOrderPayment,
|
|
||||||
fetchOrderReviewEligibility,
|
fetchOrderReviewEligibility,
|
||||||
} from '@/entities/order/api/order-api'
|
} from '@/entities/order/api/order-api'
|
||||||
import { postProductReview, uploadReviewImage } from '@/entities/review/api/reviews-api'
|
import { postProductReview, uploadReviewImage } from '@/entities/review/api/reviews-api'
|
||||||
@@ -29,6 +30,10 @@ import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
|||||||
export function OrderDetailPage() {
|
export function OrderDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const paidParam = searchParams.get('paid')
|
||||||
|
|
||||||
const orderQuery = useQuery({
|
const orderQuery = useQuery({
|
||||||
queryKey: ['me', 'orders', id],
|
queryKey: ['me', 'orders', id],
|
||||||
@@ -37,16 +42,31 @@ export function OrderDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const payMut = useMutation({
|
const payMut = useMutation({
|
||||||
mutationFn: (params: { detail: string; receiptFile: File | null }) => submitOrderPayment(id!, params),
|
mutationFn: () => createOrderPayment(id!),
|
||||||
onSuccess: async () => {
|
onSuccess: async (data) => {
|
||||||
await Promise.all([
|
window.location.href = data.confirmationUrl
|
||||||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
|
||||||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
|
||||||
qc.invalidateQueries({ queryKey: ['me', 'conversations'] }),
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const paymentStatusQuery = useQuery({
|
||||||
|
queryKey: ['me', 'orders', id, 'payment-status'],
|
||||||
|
queryFn: () => getOrderPaymentStatus(id!),
|
||||||
|
enabled: Boolean(id && paidParam === '1'),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data
|
||||||
|
if (data && (data.paid || data.status === 'canceled')) return false
|
||||||
|
return 3000
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = paymentStatusQuery.data
|
||||||
|
if (data && (data.paid || data.status === 'canceled') && paidParam === '1') {
|
||||||
|
qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
|
||||||
|
navigate(`/me/orders/${id}`, { replace: true })
|
||||||
|
}
|
||||||
|
}, [paymentStatusQuery.data, paidParam, qc, id, navigate])
|
||||||
|
|
||||||
const confirmMut = useMutation({
|
const confirmMut = useMutation({
|
||||||
mutationFn: () => confirmOrderReceived(id!),
|
mutationFn: () => confirmOrderReceived(id!),
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
@@ -117,6 +137,25 @@ export function OrderDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{paidParam === '1' && paymentStatusQuery.data && (
|
||||||
|
<Alert
|
||||||
|
severity={
|
||||||
|
paymentStatusQuery.data.paid
|
||||||
|
? 'success'
|
||||||
|
: paymentStatusQuery.data.status === 'canceled'
|
||||||
|
? 'warning'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
{paymentStatusQuery.data.paid
|
||||||
|
? 'Оплата прошла успешно!'
|
||||||
|
: paymentStatusQuery.data.status === 'canceled'
|
||||||
|
? 'Оплата отмена. Вы можете попробовать снова.'
|
||||||
|
: 'Ожидаем подтверждения оплаты…'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack spacing={2} sx={{ maxWidth: 900 }}>
|
<Stack spacing={2} sx={{ maxWidth: 900 }}>
|
||||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
@@ -194,7 +233,7 @@ export function OrderDetailPage() {
|
|||||||
{PICKUP_ADDRESS_FULL}
|
{PICKUP_ADDRESS_FULL}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary" variant="body2">
|
<Typography color="text.secondary" variant="body2">
|
||||||
Заберите заказ точно ко времени, которое согласуем по телефону или в чате заказа.
|
Заберите заказ ко времени, которое согласуем в чате заказа.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -212,7 +251,7 @@ export function OrderDetailPage() {
|
|||||||
totalCents={order.totalCents}
|
totalCents={order.totalCents}
|
||||||
isPayPending={payMut.isPending}
|
isPayPending={payMut.isPending}
|
||||||
payError={payMut.error}
|
payError={payMut.error}
|
||||||
onPay={(params) => payMut.mutate(params)}
|
onPay={() => payMut.mutate()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
||||||
@@ -244,7 +283,9 @@ export function OrderDetailPage() {
|
|||||||
isUploadPending={uploadReviewImageMut.isPending}
|
isUploadPending={uploadReviewImageMut.isPending}
|
||||||
submitError={reviewMut.error}
|
submitError={reviewMut.error}
|
||||||
uploadError={uploadReviewImageMut.error}
|
uploadError={uploadReviewImageMut.error}
|
||||||
onSubmitReview={(params) => reviewMut.mutate(params)}
|
onSubmitReview={async (params) => {
|
||||||
|
await reviewMut.mutateAsync(params)
|
||||||
|
}}
|
||||||
onUploadImage={async (file) => {
|
onUploadImage={async (file) => {
|
||||||
const result = await uploadReviewImageMut.mutateAsync(file)
|
const result = await uploadReviewImageMut.mutateAsync(file)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/** Текст модалки оплаты (можно переопределить через VITE_PAYMENT_INSTRUCTIONS — многострочная строка \n). */
|
|
||||||
const fromEnv =
|
|
||||||
typeof import.meta.env.VITE_PAYMENT_INSTRUCTIONS === 'string' ? import.meta.env.VITE_PAYMENT_INSTRUCTIONS.trim() : ''
|
|
||||||
|
|
||||||
export const PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN =
|
|
||||||
fromEnv ||
|
|
||||||
[
|
|
||||||
'Временно оплата доступна только переводом на ВТБ / Сбербанк.',
|
|
||||||
'',
|
|
||||||
'По номеру +79524181624',
|
|
||||||
'Получатель: Лариса К',
|
|
||||||
].join('\n')
|
|
||||||
@@ -96,7 +96,7 @@ export function ReviewsBlock() {
|
|||||||
<OptimizedImage
|
<OptimizedImage
|
||||||
src={r.imageUrl}
|
src={r.imageUrl}
|
||||||
alt="Фото к отзыву"
|
alt="Фото к отзыву"
|
||||||
widths={[160, 320]}
|
widths={[320, 640]}
|
||||||
sizes="80px"
|
sizes="80px"
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
# YooKassa Payment Integration — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-20
|
||||||
|
**Status:** approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the current manual bank transfer payment flow (receipt upload + admin confirmation) with YooKassa (ЮKassa) online payment gateway integration using the redirect scenario.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Integration scenario | Redirect to YooKassa payment form |
|
||||||
|
| Webhooks | Accept `payment.succeeded` and `payment.canceled` |
|
||||||
|
| Receipts (54-ФЗ) | Send receipt data with order items |
|
||||||
|
| Payment methods | Bank cards + SBP (Faster Payments System) |
|
||||||
|
| Legacy manual method | Remove entirely |
|
||||||
|
| Refunds via API | Not implemented (manual via YooKassa dashboard) |
|
||||||
|
| Architecture pattern | Dedicated `lib/yookassa.js` module (Approach 2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Database Changes
|
||||||
|
|
||||||
|
### New model: `Payment`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
yookassaPaymentId String @unique
|
||||||
|
status String // pending | waiting_for_capture | succeeded | canceled
|
||||||
|
amountCents Int
|
||||||
|
currency String @default("RUB")
|
||||||
|
confirmationUrl String?
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([yookassaPaymentId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model `Order` — no changes
|
||||||
|
|
||||||
|
Existing `paymentMethod` (`online` | `on_pickup`) and status flow (`PENDING_PAYMENT` → `PAID`) remain unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Environment Variables
|
||||||
|
|
||||||
|
Add to `server/.env.example` and `server/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
YOOKASSA_SHOP_ID=123456
|
||||||
|
YOOKASSA_SECRET_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
For production (`SERVER_PUBLIC_URL` will be used for webhook URL construction):
|
||||||
|
```bash
|
||||||
|
YOOKASSA_SHOP_ID=123456
|
||||||
|
YOOKASSA_SECRET_KEY=live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server: `server/src/lib/yookassa.js`
|
||||||
|
|
||||||
|
Dedicated module isolating all YooKassa API interaction. Routes remain thin HTTP wrappers.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```js
|
||||||
|
const config = {
|
||||||
|
baseUrl: 'https://api.yookassa.ru/v3',
|
||||||
|
shopId: process.env.YOOKASSA_SHOP_ID,
|
||||||
|
secretKey: process.env.YOOKASSA_SECRET_KEY,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
HTTP Basic Auth: `Authorization: Basic base64(shopId:secretKey)`, set via `Idempotence-Key` header for POST requests.
|
||||||
|
|
||||||
|
### Exported functions
|
||||||
|
|
||||||
|
```
|
||||||
|
createPayment({ order, orderItems, userEmail, idempotencyKey, returnUrl, clientIp }) → PaymentResponse
|
||||||
|
```
|
||||||
|
- `amount`: `order.totalCents` → `{ value: "1234.00", currency: "RUB" }`
|
||||||
|
- `capture`: `true` (one-stage payment)
|
||||||
|
- `confirmation`: `{ type: "redirect", return_url: returnUrl }`
|
||||||
|
- `receipt`: `{ customer: { email: userEmail }, items: [...], tax_system_code: 1 }`
|
||||||
|
- Each item: `description` (from `titleSnapshot`, max 128 chars), `quantity`, `amount` (unit price), `vat_code: 1`, `measure: "piece"`, `payment_subject: "commodity"`, `payment_mode: "full_prepayment"`
|
||||||
|
- If `order.deliveryFeeCents > 0`: add a separate receipt item for delivery
|
||||||
|
- `description`: `"Оплата заказа №{order.id}"`
|
||||||
|
- `metadata`: `{ orderId: order.id }`
|
||||||
|
- `payment_method_data`: not specified — YooKassa auto-selects from available methods (cards + SBP)
|
||||||
|
- `client_ip`: forwarded from request
|
||||||
|
- **Returns:** `{ paymentId, confirmationUrl, status, expiresAt }`
|
||||||
|
|
||||||
|
```
|
||||||
|
getPayment(yookassaPaymentId) → PaymentResponse
|
||||||
|
```
|
||||||
|
- GET `/payments/{paymentId}` — fetch current payment status from YooKassa.
|
||||||
|
- **Returns:** `{ id, status, paid, ... }`
|
||||||
|
|
||||||
|
```
|
||||||
|
validateWebhook(body, headers) → { event, paymentObject }
|
||||||
|
```
|
||||||
|
- **Production only:** validate source IP against YooKassa IP ranges (`185.71.76.0/27`, `185.71.77.0/27`, `77.75.153.0/25`, `77.75.154.128/25`, `2a02:5180::/32`)
|
||||||
|
- Skip IP check in test mode (when `secretKey` starts with `test_`)
|
||||||
|
- Parse body: validate `type === "notification"`, extract `event` and `object`
|
||||||
|
- **Returns:** parsed notification data
|
||||||
|
- **Throws:** on validation failure
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- 5xx: retry up to 3 times with exponential backoff (500ms, 1s, 2s)
|
||||||
|
- 4xx: throw descriptive error (includes YooKassa error code and description)
|
||||||
|
- Timeout: 10 seconds per request
|
||||||
|
- Uses Node.js built-in `fetch` (Node 18+)
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Use `fastify.log` passed via context or a simple console-based approach. Log payment creation and webhook receipt at `info` level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Server Routes
|
||||||
|
|
||||||
|
### 4a. `POST /api/me/orders/:id/pay` — Create payment (replaces existing)
|
||||||
|
|
||||||
|
**Auth:** `{ preHandler: [fastify.authenticate] }`
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Find order by `id`, verify it belongs to `request.user.id`
|
||||||
|
2. Validate: `status === PENDING_PAYMENT` AND `paymentMethod === 'online'` AND `deliveryFeeLocked === true`
|
||||||
|
3. Check for existing active Payment (`status IN ('pending', 'waiting_for_capture')`):
|
||||||
|
- If exists and not expired: return existing `confirmationUrl`
|
||||||
|
- If exists but expired or canceled: proceed to create new one
|
||||||
|
4. Generate `idempotencyKey`: `${orderId}-v1` (same key means same payment; if status check fails, append timestamp)
|
||||||
|
5. Build `returnUrl`: `${CLIENT_PUBLIC_URL}/me/orders/${orderId}?paid=1`
|
||||||
|
6. Call `yookassa.createPayment(...)`
|
||||||
|
7. Save `Payment` to DB
|
||||||
|
8. Return `{ confirmationUrl }`
|
||||||
|
9. Emit event: `PAYMENT_CREATED`
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
- `400`: order not in payable state
|
||||||
|
- `409`: conflicting payment attempt (should be handled by idempotency)
|
||||||
|
- `502`: YooKassa unavailable
|
||||||
|
|
||||||
|
### 4b. `GET /api/me/orders/:orderId/payment` — Check payment status
|
||||||
|
|
||||||
|
**Auth:** `{ preHandler: [fastify.authenticate] }`
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Find latest `Payment` for the order
|
||||||
|
2. If status is terminal (`succeeded`, `canceled`): return cached status
|
||||||
|
3. Otherwise: call `yookassa.getPayment(yookassaPaymentId)`
|
||||||
|
4. If status changed: update local `Payment` + transition `Order` if needed
|
||||||
|
5. Return `{ status, paid }`
|
||||||
|
|
||||||
|
### 4c. `POST /api/webhooks/yookassa` — Receive YooKassa notifications
|
||||||
|
|
||||||
|
**Auth:** None (public endpoint, validated by IP + request signature)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Parse body and headers
|
||||||
|
2. Call `yookassa.validateWebhook(body, headers)` — validates IP on production
|
||||||
|
3. Find `Payment` by `yookassaPaymentId = object.id`
|
||||||
|
4. Handle event:
|
||||||
|
- `payment.succeeded`:
|
||||||
|
- Update `Payment.status = 'succeeded'`
|
||||||
|
- Transition `Order` from `PENDING_PAYMENT` to `PAID`
|
||||||
|
- Emit `PAYMENT_STATUS_CHANGED` event → notification system
|
||||||
|
- `payment.canceled`:
|
||||||
|
- Update `Payment.status = 'canceled'`
|
||||||
|
- Order stays `PENDING_PAYMENT` (user can retry)
|
||||||
|
5. Return `200 OK`
|
||||||
|
|
||||||
|
### 4d. Remove old endpoint
|
||||||
|
|
||||||
|
The existing `POST /api/me/orders/:id/pay` (multipart receipt upload) is completely removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Client Changes
|
||||||
|
|
||||||
|
### 5a. `OrderPaymentSection` (features/order-payment)
|
||||||
|
|
||||||
|
- **State `PENDING_PAYMENT` + `deliveryFeeLocked=true` + `paymentMethod=online`:**
|
||||||
|
- Show "Оплатить" button
|
||||||
|
- On click: call `createOrderPayment(orderId)`, get `confirmationUrl`, redirect: `window.location.href = confirmationUrl`
|
||||||
|
- **State `PENDING_PAYMENT` + `deliveryFeeLocked=false`:** unchanged (waiting for delivery fee adjustment)
|
||||||
|
- **State `PAID`:** show "Оплачено" badge, hide payment button
|
||||||
|
- **State `paymentMethod=on_pickup`:** unchanged (message about paying at pickup)
|
||||||
|
|
||||||
|
### 5b. Remove `PaymentDialog`
|
||||||
|
|
||||||
|
Delete `PaymentDialog.tsx` and all related code (manual payment instructions, receipt upload form). Remove `PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN` constant if present.
|
||||||
|
|
||||||
|
### 5c. Return URL handling (OrderDetailPage)
|
||||||
|
|
||||||
|
When order page loads with `?paid=1` query param:
|
||||||
|
1. Call `getOrderPaymentStatus(orderId)`
|
||||||
|
2. Show result toast/alert:
|
||||||
|
- `paid === true`: "Оплата прошла успешно"
|
||||||
|
- `paid === false` + payment pending: "Ожидаем подтверждения оплаты"
|
||||||
|
- `canceled`: "Оплата отменена, вы можете попробовать снова"
|
||||||
|
|
||||||
|
### 5d. API client additions (shared/api or entities/order)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// New API endpoints to add to the client apiClient:
|
||||||
|
createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }>
|
||||||
|
getOrderPaymentStatus(orderId: string): Promise<{ status: string, paid: boolean }>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5e. Shared constants
|
||||||
|
|
||||||
|
Remove `online` from `PAYMENT_METHODS` if it was only used for distinction, or keep it since YooKassa IS the online payment. The constant stays as-is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Edge Cases & Error Handling
|
||||||
|
|
||||||
|
### Payment creation failures
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|---|---|
|
||||||
|
| YooKassa unavailable (5xx) | Retry 3x with backoff, then show user error "Платёжный сервис временно недоступен, попробуйте позже" |
|
||||||
|
| Invalid credentials (401) | Log error, show "Ошибка конфигурации платежей" |
|
||||||
|
| Duplicate idempotency key | YooKassa returns existing payment — reuse it |
|
||||||
|
|
||||||
|
### Status synchronization
|
||||||
|
|
||||||
|
- **Primary:** webhook `payment.succeeded` triggers order → `PAID` transition
|
||||||
|
- **Fallback:** user returning via `return_url` triggers `GET /api/me/orders/:orderId/payment` which syncs via API
|
||||||
|
- **Stale payments:** no periodic cron needed initially; the fallback on page load is sufficient
|
||||||
|
|
||||||
|
### Payment expiration
|
||||||
|
|
||||||
|
YooKassa cancels payments after ~1 hour (for one-stage with `capture: true`). Webhook `payment.canceled` updates local state.
|
||||||
|
|
||||||
|
### User closes browser after paying, before return
|
||||||
|
|
||||||
|
Webhook handles this — order transitions to `PAID` without user action. On next visit, order shows as paid.
|
||||||
|
|
||||||
|
### Retry after cancellation
|
||||||
|
|
||||||
|
User can click "Оплатить" again. New `Payment` record created with new `yookassaPaymentId`. Old payment remains in DB with `canceled` status.
|
||||||
|
|
||||||
|
### Idempotency
|
||||||
|
|
||||||
|
- Key format: `${orderId}-v1` — if user clicks "Оплатить" twice quickly, YooKassa returns the same payment (idempotency protection)
|
||||||
|
- If payment exists and is terminal (canceled/expired), generate new key: `${orderId}-v${retryCount}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Files Changed / Created / Deleted
|
||||||
|
|
||||||
|
### Created
|
||||||
|
- `server/src/lib/yookassa.js` — YooKassa API client module
|
||||||
|
- `server/prisma/migrations/*_add_payment.sql` — migration for Payment model
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- `server/prisma/schema.prisma` — add `Payment` model
|
||||||
|
- `server/src/routes/user-payments.js` — rewrite to use YooKassa
|
||||||
|
- `server/src/index.js` — register webhook route
|
||||||
|
- `server/.env.example` — add YooKassa env vars
|
||||||
|
- `client/src/features/order-payment/OrderPaymentSection.tsx` — redirect to YooKassa instead of manual dialog
|
||||||
|
- `client/src/pages/order/OrderDetailPage.tsx` — handle `?paid=1` return URL
|
||||||
|
- `client/src/shared/api/` or `entities/order/` — add new API methods
|
||||||
|
|
||||||
|
### Deleted
|
||||||
|
- `client/src/features/order-payment/PaymentDialog.tsx` — manual payment dialog
|
||||||
|
- Any related payment instructions constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
### Server tests (`server/src/__tests__/` or `server/src/lib/__tests__/`)
|
||||||
|
|
||||||
|
- `yookassa.test.js` — unit tests for `createPayment`, `getPayment`, `validateWebhook` with mocked `fetch`
|
||||||
|
- `user-payments.test.js` — integration tests for `POST /api/me/orders/:id/pay` with mocked YooKassa module
|
||||||
|
- Webhook route test — validate IP check, event handling, order transition
|
||||||
|
|
||||||
|
### Client tests (`client/src/features/order-payment/__tests__/`)
|
||||||
|
|
||||||
|
- `OrderPaymentSection.test.tsx` — test button shows/hides based on order state, redirect on click
|
||||||
|
- Remove `PaymentDialog.test.tsx` if it exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Migration & Rollout
|
||||||
|
|
||||||
|
1. Add env vars to `.env`
|
||||||
|
2. Run Prisma migration: `prisma migrate dev --name add_payment`
|
||||||
|
3. Deploy server changes first (new routes + webhook)
|
||||||
|
4. Deploy client changes (redirect behavior)
|
||||||
|
5. Configure webhook in YooKassa dashboard: `{SERVER_PUBLIC_URL}/api/webhooks/yookassa`
|
||||||
|
6. Test with YooKassa test credentials
|
||||||
|
7. Switch to live credentials after successful testing
|
||||||
@@ -32,3 +32,7 @@ YANDEX_CLIENT_SECRET=
|
|||||||
|
|
||||||
# Telegram Bot (оповещения админа)
|
# Telegram Bot (оповещения админа)
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# YooKassa payment integration
|
||||||
|
YOOKASSA_SHOP_ID=
|
||||||
|
YOOKASSA_SECRET_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Payment" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"yookassaPaymentId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"confirmationUrl" TEXT,
|
||||||
|
"expiresAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Payment_yookassaPaymentId_key" ON "Payment"("yookassaPaymentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Payment_orderId_idx" ON "Payment"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Payment_yookassaPaymentId_idx" ON "Payment"("yookassaPaymentId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Payment_yookassaPaymentId_idx";
|
||||||
@@ -153,12 +153,29 @@ model Order {
|
|||||||
|
|
||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
messages OrderMessage[]
|
messages OrderMessage[]
|
||||||
|
payments Payment[]
|
||||||
messageReadStates UserOrderMessageReadState[]
|
messageReadStates UserOrderMessageReadState[]
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@index([status, updatedAt])
|
@@index([status, updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
yookassaPaymentId String @unique
|
||||||
|
status String
|
||||||
|
amountCents Int
|
||||||
|
currency String @default("RUB")
|
||||||
|
confirmationUrl String?
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
}
|
||||||
|
|
||||||
model OrderItem {
|
model OrderItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
qty Int
|
qty Int
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js'
|
|||||||
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||||
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||||
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||||
|
import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3333
|
const port = Number(process.env.PORT) || 3333
|
||||||
const origin = (process.env.CORS_ORIGIN ?? '')
|
const origin = (process.env.CORS_ORIGIN ?? '')
|
||||||
@@ -38,6 +39,7 @@ const origin = (process.env.CORS_ORIGIN ?? '')
|
|||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
bodyLimit: getMaxUploadBodyBytes(),
|
bodyLimit: getMaxUploadBodyBytes(),
|
||||||
|
trustProxy: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
await fastify.register(cors, {
|
await fastify.register(cors, {
|
||||||
@@ -93,6 +95,7 @@ await registerUserOrderRoutes(fastify)
|
|||||||
await registerUserPaymentRoutes(fastify)
|
await registerUserPaymentRoutes(fastify)
|
||||||
await registerUserNotificationRoutes(fastify)
|
await registerUserNotificationRoutes(fastify)
|
||||||
await registerOAuthSocialRoutes(fastify)
|
await registerOAuthSocialRoutes(fastify)
|
||||||
|
await registerYookassaWebhookRoute(fastify)
|
||||||
await registerApiRoutes(fastify)
|
await registerApiRoutes(fastify)
|
||||||
await ensureAdminUser()
|
await ensureAdminUser()
|
||||||
await getOrCreateUnspecifiedCategory()
|
await getOrCreateUnspecifiedCategory()
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js'
|
||||||
|
|
||||||
|
describe('yookassa createPayment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.YOOKASSA_SHOP_ID = '123456'
|
||||||
|
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
delete process.env.YOOKASSA_SHOP_ID
|
||||||
|
delete process.env.YOOKASSA_SECRET_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /payments with Basic auth and Idempotence-Key', async () => {
|
||||||
|
const mockPayment = {
|
||||||
|
id: '2d0c6f35-000f-5000-8000-1234567890ab',
|
||||||
|
status: 'pending',
|
||||||
|
paid: false,
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/...' },
|
||||||
|
created_at: '2026-05-20T12:00:00.000Z',
|
||||||
|
test: true,
|
||||||
|
refundable: false,
|
||||||
|
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||||
|
}
|
||||||
|
fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockPayment),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await createPayment({
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
description: 'Order #test',
|
||||||
|
receipt: {
|
||||||
|
customer: { email: 'test@example.com' },
|
||||||
|
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
|
||||||
|
tax_system_code: 1,
|
||||||
|
},
|
||||||
|
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test?paid=1' },
|
||||||
|
metadata: { orderId: 'test' },
|
||||||
|
idempotencyKey: 'test-v1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, opts] = fetch.mock.calls[0]
|
||||||
|
expect(url).toBe('https://api.yookassa.ru/v3/payments')
|
||||||
|
expect(opts.method).toBe('POST')
|
||||||
|
expect(opts.headers['Idempotence-Key']).toBe('test-v1')
|
||||||
|
expect(opts.headers['Authorization']).toBe('Basic MTIzNDU2OnRlc3Rfc2VjcmV0')
|
||||||
|
expect(result.paymentId).toBe('2d0c6f35-000f-5000-8000-1234567890ab')
|
||||||
|
expect(result.confirmationUrl).toBe('https://yoomoney.ru/checkout/...')
|
||||||
|
expect(result.status).toBe('pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries on 5xx error', async () => {
|
||||||
|
fetch
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 500 })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503 })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'retry-id',
|
||||||
|
status: 'pending',
|
||||||
|
paid: false,
|
||||||
|
amount: { value: '500.00', currency: 'RUB' },
|
||||||
|
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/retry' },
|
||||||
|
created_at: '2026-05-20T12:00:00.000Z',
|
||||||
|
test: true,
|
||||||
|
refundable: false,
|
||||||
|
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await createPayment({
|
||||||
|
amount: { value: '500.00', currency: 'RUB' },
|
||||||
|
description: 'Retry test',
|
||||||
|
receipt: {
|
||||||
|
customer: { email: 'test@example.com' },
|
||||||
|
items: [{ description: 'Item', quantity: 1, amount: { value: '500.00', currency: 'RUB' }, vat_code: 1 }],
|
||||||
|
tax_system_code: 1,
|
||||||
|
},
|
||||||
|
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test' },
|
||||||
|
metadata: {},
|
||||||
|
idempotencyKey: 'retry-v1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3)
|
||||||
|
expect(result.paymentId).toBe('retry-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on 4xx error', async () => {
|
||||||
|
fetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
type: 'error',
|
||||||
|
id: 'err-id',
|
||||||
|
code: 'invalid_request',
|
||||||
|
description: 'Missing required field',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPayment({
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
description: 'Bad request',
|
||||||
|
receipt: {
|
||||||
|
customer: { email: 'test@example.com' },
|
||||||
|
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
|
||||||
|
tax_system_code: 1,
|
||||||
|
},
|
||||||
|
confirmation: { type: 'redirect', return_url: 'http://localhost:5173' },
|
||||||
|
metadata: {},
|
||||||
|
idempotencyKey: 'bad-v1',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('YooKassa API error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yookassa getPayment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.YOOKASSA_SHOP_ID = '123456'
|
||||||
|
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
delete process.env.YOOKASSA_SHOP_ID
|
||||||
|
delete process.env.YOOKASSA_SECRET_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls GET /payments/{id} and returns payment data', async () => {
|
||||||
|
fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'payment-id',
|
||||||
|
status: 'succeeded',
|
||||||
|
paid: true,
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
created_at: '2026-05-20T12:00:00.000Z',
|
||||||
|
test: true,
|
||||||
|
refundable: true,
|
||||||
|
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getPayment('payment-id')
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
|
expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id')
|
||||||
|
expect(result.paymentId).toBe('payment-id')
|
||||||
|
expect(result.status).toBe('succeeded')
|
||||||
|
expect(result.paid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yookassa buildReceipt', () => {
|
||||||
|
it('builds receipt with order items', () => {
|
||||||
|
const result = buildReceipt({
|
||||||
|
orderItems: [{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }],
|
||||||
|
deliveryFeeCents: 0,
|
||||||
|
userEmail: 'user@test.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.customer.email).toBe('user@test.ru')
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0].description).toBe('Test Product')
|
||||||
|
expect(result.items[0].quantity).toBe(2)
|
||||||
|
expect(result.items[0].amount.value).toBe('1000.00')
|
||||||
|
expect(result.items[0].vat_code).toBe(1)
|
||||||
|
expect(result.items[0].measure).toBe('piece')
|
||||||
|
expect(result.items[0].payment_subject).toBe('commodity')
|
||||||
|
expect(result.items[0].payment_mode).toBe('full_prepayment')
|
||||||
|
expect(result.tax_system_code).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds delivery item when deliveryFeeCents > 0', () => {
|
||||||
|
const result = buildReceipt({
|
||||||
|
orderItems: [{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }],
|
||||||
|
deliveryFeeCents: 35000,
|
||||||
|
userEmail: 'user@test.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items[1].description).toBe('Доставка')
|
||||||
|
expect(result.items[1].amount.value).toBe('350.00')
|
||||||
|
expect(result.items[1].payment_subject).toBe('service')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through taxSystemCode', () => {
|
||||||
|
const result = buildReceipt({
|
||||||
|
orderItems: [{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }],
|
||||||
|
deliveryFeeCents: 0,
|
||||||
|
userEmail: 'user@test.ru',
|
||||||
|
taxSystemCode: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.tax_system_code).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yookassa validateWebhook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.YOOKASSA_SECRET_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns event and paymentObject for valid notification', () => {
|
||||||
|
const body = {
|
||||||
|
type: 'notification',
|
||||||
|
event: 'payment.succeeded',
|
||||||
|
object: { id: 'yk-id', status: 'succeeded', paid: true },
|
||||||
|
}
|
||||||
|
const result = validateWebhook('127.0.0.1', body)
|
||||||
|
expect(result.event).toBe('payment.succeeded')
|
||||||
|
expect(result.paymentObject.id).toBe('yk-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if type is not notification', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow(
|
||||||
|
'Expected notification type',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if missing event', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow('Missing event or object')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if missing object', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow('Missing event or object')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for invalid body type', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for null body', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips IP validation in test mode (test_ key)', () => {
|
||||||
|
const body = { type: 'notification', event: 'payment.succeeded', object: {} }
|
||||||
|
expect(() => validateWebhook('1.2.3.4', body)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3'
|
||||||
|
|
||||||
|
function getAuthHeader() {
|
||||||
|
const shopId = process.env.YOOKASSA_SHOP_ID
|
||||||
|
const secretKey = process.env.YOOKASSA_SECRET_KEY
|
||||||
|
if (!shopId || !secretKey) {
|
||||||
|
throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required')
|
||||||
|
}
|
||||||
|
const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64')
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryable(status) {
|
||||||
|
return status >= 500 || status === 429
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(url, opts, maxRetries = 3) {
|
||||||
|
let lastError
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const delay = 500 * 2 ** (attempt - 1)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
if (res.ok) return res
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
if (isRetryable(res.status)) {
|
||||||
|
lastError = new Error(`YooKassa API error: ${res.status} — ${body.description || 'unknown'}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`YooKassa API error: ${res.status} — ${body.description || body.code || 'unknown'} (${body.parameter || 'n/a'})`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err
|
||||||
|
lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
if (attempt === maxRetries) throw lastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment({
|
||||||
|
amount,
|
||||||
|
description,
|
||||||
|
receipt,
|
||||||
|
confirmation,
|
||||||
|
metadata,
|
||||||
|
idempotencyKey,
|
||||||
|
clientIp,
|
||||||
|
}) {
|
||||||
|
const headers = {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
'Idempotence-Key': idempotencyKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
amount,
|
||||||
|
capture: true,
|
||||||
|
description,
|
||||||
|
confirmation,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt) {
|
||||||
|
body.receipt = receipt
|
||||||
|
}
|
||||||
|
if (clientIp) {
|
||||||
|
body.client_ip = clientIp
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
return {
|
||||||
|
paymentId: data.id,
|
||||||
|
status: data.status,
|
||||||
|
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||||
|
expiresAt: data.expires_at || null,
|
||||||
|
paid: data.paid,
|
||||||
|
test: data.test,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPayment(paymentId) {
|
||||||
|
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return {
|
||||||
|
paymentId: data.id,
|
||||||
|
status: data.status,
|
||||||
|
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||||
|
expiresAt: data.expires_at || null,
|
||||||
|
paid: data.paid,
|
||||||
|
test: data.test,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOOKASSA_IP_RANGES_V4 = ['185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.154.128/25']
|
||||||
|
|
||||||
|
function ip4ToInt(ip) {
|
||||||
|
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function cidrMatch(ip, cidr) {
|
||||||
|
const [range, bits] = cidr.split('/')
|
||||||
|
const mask = ~(2 ** (32 - parseInt(bits, 10)) - 1) >>> 0
|
||||||
|
const ipInt = ip4ToInt(ip)
|
||||||
|
const rangeInt = ip4ToInt(range)
|
||||||
|
return (ipInt & mask) === (rangeInt & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYookassaIp(ip) {
|
||||||
|
const v4 = ip.replace(/^::ffff:/, '')
|
||||||
|
if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4)) return false
|
||||||
|
return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestMode() {
|
||||||
|
return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWebhook(ip, body) {
|
||||||
|
if (!isTestMode() && !isYookassaIp(ip)) {
|
||||||
|
throw new Error('Invalid webhook source IP')
|
||||||
|
}
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
throw new Error('Invalid webhook body')
|
||||||
|
}
|
||||||
|
if (body.type !== 'notification') {
|
||||||
|
throw new Error('Expected notification type in webhook body')
|
||||||
|
}
|
||||||
|
if (!body.event || !body.object) {
|
||||||
|
throw new Error('Missing event or object in webhook body')
|
||||||
|
}
|
||||||
|
return { event: body.event, paymentObject: body.object }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReceipt({ orderItems, deliveryFeeCents, userEmail, taxSystemCode = 1 }) {
|
||||||
|
const items = orderItems.map((item) => ({
|
||||||
|
description: (item.titleSnapshot || 'Товар').slice(0, 128),
|
||||||
|
quantity: item.qty,
|
||||||
|
amount: {
|
||||||
|
value: (item.priceCentsSnapshot / 100).toFixed(2),
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
vat_code: 1,
|
||||||
|
measure: 'piece',
|
||||||
|
payment_subject: 'commodity',
|
||||||
|
payment_mode: 'full_prepayment',
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (deliveryFeeCents > 0) {
|
||||||
|
items.push({
|
||||||
|
description: 'Доставка',
|
||||||
|
quantity: 1,
|
||||||
|
amount: {
|
||||||
|
value: (deliveryFeeCents / 100).toFixed(2),
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
vat_code: 1,
|
||||||
|
measure: 'piece',
|
||||||
|
payment_subject: 'service',
|
||||||
|
payment_mode: 'full_prepayment',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = {
|
||||||
|
customer: { email: userEmail },
|
||||||
|
items,
|
||||||
|
tax_system_code: taxSystemCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
return receipt
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { registerUserPaymentRoutes } from '../user-payments.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
const TEST_USER_EMAIL = `test-pay-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
let testUserId
|
||||||
|
let testOrderId
|
||||||
|
|
||||||
|
async function signToken(userId, email = TEST_USER_EMAIL) {
|
||||||
|
const fastify = Fastify()
|
||||||
|
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||||
|
await fastify.ready()
|
||||||
|
return fastify.jwt.sign({ sub: userId, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
app.decorate('authenticate', async function (request, reply) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
|
await registerUserPaymentRoutes(app)
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/me/orders/:id/pay', () => {
|
||||||
|
let app
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.payment.deleteMany()
|
||||||
|
await prisma.order.deleteMany({ where: { user: { email: TEST_USER_EMAIL } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } })
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: TEST_USER_EMAIL },
|
||||||
|
})
|
||||||
|
testUserId = user.id
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: testUserId,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
paymentMethod: 'online',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
totalCents: 100000,
|
||||||
|
currency: 'RUB',
|
||||||
|
deliveryFeeCents: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testOrderId = order.id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.payment.deleteMany({ where: { orderId: testOrderId } })
|
||||||
|
await prisma.order.deleteMany({ where: { userId: testUserId } })
|
||||||
|
await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: testOrderId },
|
||||||
|
data: {
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
paymentMethod: 'online',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 401 without auth', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/me/orders/${testOrderId}/pay`,
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when order not found', async () => {
|
||||||
|
const token = await signToken(testUserId)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/me/orders/nonexistent-id/pay',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 when payment method is on_pickup', async () => {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: testOrderId },
|
||||||
|
data: { paymentMethod: 'on_pickup' },
|
||||||
|
})
|
||||||
|
const token = await signToken(testUserId)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/me/orders/${testOrderId}/pay`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 when order not in PENDING_PAYMENT status', async () => {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: testOrderId },
|
||||||
|
data: { status: 'PAID' },
|
||||||
|
})
|
||||||
|
const token = await signToken(testUserId)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/me/orders/${testOrderId}/pay`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 when deliveryFeeLocked is false', async () => {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: testOrderId },
|
||||||
|
data: { deliveryFeeLocked: false },
|
||||||
|
})
|
||||||
|
const token = await signToken(testUserId)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/me/orders/${testOrderId}/pay`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 when user has no email', async () => {
|
||||||
|
const noEmailUser = await prisma.user.create({
|
||||||
|
data: { email: `noemail-${Date.now()}@test.com` },
|
||||||
|
})
|
||||||
|
const noEmailOrder = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: noEmailUser.id,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
paymentMethod: 'online',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
totalCents: 100000,
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fastify = Fastify()
|
||||||
|
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||||
|
const token = fastify.jwt.sign({ sub: noEmailUser.id })
|
||||||
|
await fastify.close()
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/me/orders/${noEmailOrder.id}/pay`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(422)
|
||||||
|
|
||||||
|
await prisma.order.deleteMany({ where: { userId: noEmailUser.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: noEmailUser.id } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/me/orders/:orderId/payment', () => {
|
||||||
|
let app
|
||||||
|
let getTestUserId
|
||||||
|
let getTestOrderId
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const getEmail = `get-pay-${Date.now()}@example.com`
|
||||||
|
const user = await prisma.user.create({ data: { email: getEmail } })
|
||||||
|
getTestUserId = user.id
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: getTestUserId,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
paymentMethod: 'online',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
totalCents: 100000,
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
getTestOrderId = order.id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.payment.deleteMany({ where: { orderId: getTestOrderId } })
|
||||||
|
await prisma.order.deleteMany({ where: { userId: getTestUserId } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: getTestUserId } })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 401 without auth', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/me/orders/${getTestOrderId}/payment`,
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when order not found', async () => {
|
||||||
|
const token = await signToken(getTestUserId)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me/orders/nonexistent-id/payment',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns status null when no payment exists', async () => {
|
||||||
|
const token = await signToken(getTestUserId)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/me/orders/${getTestOrderId}/payment`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = JSON.parse(res.payload)
|
||||||
|
expect(body.status).toBeNull()
|
||||||
|
expect(body.paid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||||
|
|
||||||
|
const { mockPrisma } = vi.hoisted(() => ({
|
||||||
|
mockPrisma: {
|
||||||
|
payment: { findFirst: vi.fn(), update: vi.fn() },
|
||||||
|
order: { findFirst: vi.fn(), updateMany: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma.js', () => ({
|
||||||
|
prisma: mockPrisma,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../lib/yookassa.js', () => ({
|
||||||
|
validateWebhook: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { validateWebhook } from '../../lib/yookassa.js'
|
||||||
|
import { registerYookassaWebhookRoute } from '../webhook-yookassa.js'
|
||||||
|
|
||||||
|
function buildApp(eventBusMock) {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
app.decorate('eventBus', eventBusMock || { emit: () => {} })
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/webhooks/yookassa', () => {
|
||||||
|
let app
|
||||||
|
let eventBus
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
eventBus = { emit: vi.fn() }
|
||||||
|
validateWebhook.mockImplementation((_ip, body) => {
|
||||||
|
if (!body || typeof body !== 'object') throw new Error('Invalid webhook body')
|
||||||
|
if (body.type !== 'notification') throw new Error('Expected notification type in webhook body')
|
||||||
|
if (!body.event || !body.object) throw new Error('Missing event or object in webhook body')
|
||||||
|
return { event: body.event, paymentObject: body.object }
|
||||||
|
})
|
||||||
|
app = buildApp(eventBus)
|
||||||
|
await registerYookassaWebhookRoute(app)
|
||||||
|
await app.ready()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 for invalid body', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/webhooks/yookassa',
|
||||||
|
payload: { not: 'valid' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when payment not found', async () => {
|
||||||
|
mockPrisma.payment.findFirst.mockResolvedValue(null)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/webhooks/yookassa',
|
||||||
|
payload: {
|
||||||
|
type: 'notification',
|
||||||
|
event: 'payment.succeeded',
|
||||||
|
object: { id: 'unknown-id', status: 'succeeded', paid: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates payment and order on payment.succeeded', async () => {
|
||||||
|
mockPrisma.payment.findFirst.mockResolvedValue({
|
||||||
|
id: 'payment-1',
|
||||||
|
yookassaPaymentId: 'yk-id',
|
||||||
|
status: 'pending',
|
||||||
|
orderId: 'order-1',
|
||||||
|
})
|
||||||
|
mockPrisma.payment.update.mockResolvedValue({})
|
||||||
|
mockPrisma.order.findFirst.mockResolvedValue({
|
||||||
|
id: 'order-1',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
userId: 'user-1',
|
||||||
|
})
|
||||||
|
mockPrisma.order.updateMany.mockResolvedValue({ count: 1 })
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/webhooks/yookassa',
|
||||||
|
payload: {
|
||||||
|
type: 'notification',
|
||||||
|
event: 'payment.succeeded',
|
||||||
|
object: { id: 'yk-id', status: 'succeeded', paid: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const updateData = mockPrisma.payment.update.mock.calls[0][0].data
|
||||||
|
expect(updateData.status).toBe('succeeded')
|
||||||
|
|
||||||
|
const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data
|
||||||
|
expect(orderUpdateData.status).toBe('PAID')
|
||||||
|
expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||||
|
orderId: 'order-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates payment on payment.canceled without changing order', async () => {
|
||||||
|
mockPrisma.payment.findFirst.mockResolvedValue({
|
||||||
|
id: 'payment-1',
|
||||||
|
yookassaPaymentId: 'yk-id',
|
||||||
|
status: 'pending',
|
||||||
|
orderId: 'order-1',
|
||||||
|
})
|
||||||
|
mockPrisma.payment.update.mockResolvedValue({})
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/webhooks/yookassa',
|
||||||
|
payload: {
|
||||||
|
type: 'notification',
|
||||||
|
event: 'payment.canceled',
|
||||||
|
object: { id: 'yk-id', status: 'canceled', paid: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(mockPrisma.order.findFirst).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -92,6 +92,7 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
|
const adminUserId = request.user.sub
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { id } })
|
const existing = await prisma.user.findUnique({ where: { id } })
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -99,9 +100,15 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSelf = id === adminUserId
|
||||||
|
|
||||||
const data = {}
|
const data = {}
|
||||||
|
|
||||||
if (body.email !== undefined) {
|
if (body.email !== undefined) {
|
||||||
|
if (isSelf) {
|
||||||
|
reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const email = normalizeEmail(body.email)
|
const email = normalizeEmail(body.email)
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
reply.code(400).send({ error: 'Некорректная почта' })
|
reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
@@ -139,6 +146,13 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
|
|
||||||
fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
|
const adminUserId = request.user.sub
|
||||||
|
|
||||||
|
if (id === adminUserId) {
|
||||||
|
reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.user.delete({ where: { id } })
|
await prisma.user.delete({ where: { id } })
|
||||||
reply.code(204).send()
|
reply.code(204).send()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function registerUserCartRoutes(fastify) {
|
|||||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
const available = product.inStock ? product.quantity : 1
|
const available = product.quantity
|
||||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
@@ -57,7 +57,7 @@ export async function registerUserCartRoutes(fastify) {
|
|||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
const available = existing.product.quantity
|
||||||
const nextQty = Math.floor(qty)
|
const nextQty = Math.floor(qty)
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
||||||
|
|
||||||
for (const ci of cartItems) {
|
for (const ci of cartItems) {
|
||||||
const available = ci.product.inStock ? ci.product.quantity : 1
|
const available = ci.product.quantity
|
||||||
if (ci.qty > available) {
|
if (ci.qty > available) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||||
@@ -112,8 +112,6 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
try {
|
try {
|
||||||
created = await prisma.$transaction(async (tx) => {
|
created = await prisma.$transaction(async (tx) => {
|
||||||
for (const ci of cartItems) {
|
for (const ci of cartItems) {
|
||||||
if (!ci.product.inStock) continue
|
|
||||||
|
|
||||||
const res = await tx.product.updateMany({
|
const res = await tx.product.updateMany({
|
||||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||||
data: { quantity: { decrement: ci.qty } },
|
data: { quantity: { decrement: ci.qty } },
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||||
import { escapeHtml } from '../lib/escape-html.js'
|
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
|
||||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
|
||||||
|
|
||||||
export async function registerUserPaymentRoutes(fastify) {
|
export async function registerUserPaymentRoutes(fastify) {
|
||||||
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
|
const userEmail = request.user.email
|
||||||
|
|
||||||
|
if (!userEmail) {
|
||||||
|
return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
include: { items: true },
|
||||||
|
})
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
const paymentMethod = order.paymentMethod ?? 'online'
|
if (order.paymentMethod === 'on_pickup') {
|
||||||
if (paymentMethod === 'on_pickup') {
|
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
|
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,93 +29,119 @@ export async function registerUserPaymentRoutes(fastify) {
|
|||||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.isMultipart()) {
|
if (!order.deliveryFeeLocked) {
|
||||||
return reply.code(400).send({
|
return reply.code(409).send({
|
||||||
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
|
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = ''
|
const existingPayment = await prisma.payment.findFirst({
|
||||||
let receiptBuffer = null
|
where: {
|
||||||
let receiptFilename = ''
|
orderId: id,
|
||||||
try {
|
status: { in: ['pending', 'waiting_for_capture'] },
|
||||||
const otherLimit = getOtherUploadMaxFileBytes()
|
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||||
const parts = request.parts({
|
},
|
||||||
limits: {
|
orderBy: { createdAt: 'desc' },
|
||||||
fileSize: otherLimit,
|
|
||||||
files: 2,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.file) {
|
|
||||||
if (part.fieldname === 'receipt') {
|
|
||||||
if (receiptBuffer !== null) {
|
|
||||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
|
||||||
}
|
|
||||||
receiptBuffer = await part.toBuffer()
|
|
||||||
receiptFilename = part.filename ?? 'receipt'
|
|
||||||
}
|
|
||||||
} else if (part.fieldname === 'detail') {
|
|
||||||
detail = String(part.value ?? '').trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
|
||||||
return reply.code(400).send({ error: msg })
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDetail = detail.length > 0
|
|
||||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
|
||||||
|
|
||||||
if (!hasDetail && !hasReceipt) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxDetail = 2000
|
|
||||||
if (detail.length > maxDetail) {
|
|
||||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachmentUrl = null
|
|
||||||
if (hasReceipt) {
|
|
||||||
try {
|
|
||||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
|
||||||
const statusCode =
|
|
||||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
|
||||||
? Number(err.statusCode)
|
|
||||||
: 400
|
|
||||||
return reply.code(statusCode).send({ error: message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyHtml = hasDetail ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
|
|
||||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.orderMessage.create({
|
|
||||||
data: {
|
|
||||||
orderId: id,
|
|
||||||
authorType: 'user',
|
|
||||||
text: messageText,
|
|
||||||
attachmentUrl,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
|
||||||
}
|
|
||||||
|
|
||||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
|
||||||
orderId: id,
|
|
||||||
userId,
|
|
||||||
paymentStatus: 'pending',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return { ok: true, status: 'PENDING_PAYMENT' }
|
if (existingPayment && existingPayment.confirmationUrl) {
|
||||||
|
return { confirmationUrl: existingPayment.confirmationUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = `${id}-${Date.now()}`
|
||||||
|
const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1`
|
||||||
|
const clientIp = request.ip
|
||||||
|
|
||||||
|
const amount = {
|
||||||
|
value: (order.totalCents / 100).toFixed(2),
|
||||||
|
currency: order.currency,
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = buildReceipt({
|
||||||
|
orderItems: order.items,
|
||||||
|
deliveryFeeCents: order.deliveryFeeCents,
|
||||||
|
userEmail: userEmail,
|
||||||
|
})
|
||||||
|
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = await createPayment({
|
||||||
|
amount,
|
||||||
|
description: `Оплата заказа №${order.id.slice(-6)}`,
|
||||||
|
receipt,
|
||||||
|
confirmation: { type: 'redirect', return_url: returnUrl },
|
||||||
|
metadata: { orderId: order.id },
|
||||||
|
idempotencyKey,
|
||||||
|
clientIp,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, orderId: id }, 'YooKassa createPayment failed')
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
yookassaPaymentId: result.paymentId,
|
||||||
|
status: result.status,
|
||||||
|
amountCents: order.totalCents,
|
||||||
|
currency: order.currency,
|
||||||
|
confirmationUrl: result.confirmationUrl,
|
||||||
|
expiresAt: result.expiresAt ? new Date(result.expiresAt) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { confirmationUrl: result.confirmationUrl }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.get('/api/me/orders/:orderId/payment', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { orderId } = request.params
|
||||||
|
|
||||||
|
const order = await prisma.order.findFirst({ where: { id: orderId, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: { orderId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
if (!payment) {
|
||||||
|
return { status: null, paid: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.status === 'succeeded' || payment.status === 'canceled') {
|
||||||
|
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ykPayment = await getPayment(payment.yookassaPaymentId)
|
||||||
|
|
||||||
|
if (ykPayment.status !== payment.status) {
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: { status: ykPayment.status },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') {
|
||||||
|
const updated = await prisma.order.updateMany({
|
||||||
|
where: { id: orderId, status: 'PENDING_PAYMENT' },
|
||||||
|
data: { status: 'PAID' },
|
||||||
|
})
|
||||||
|
if (updated.count > 0) {
|
||||||
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||||
|
orderId,
|
||||||
|
userId: order.userId,
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: ykPayment.status, paid: ykPayment.paid }
|
||||||
|
} catch {
|
||||||
|
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
import { validateWebhook } from '../lib/yookassa.js'
|
||||||
|
|
||||||
|
export async function registerYookassaWebhookRoute(fastify) {
|
||||||
|
fastify.post('/api/webhooks/yookassa', async (request, reply) => {
|
||||||
|
let body
|
||||||
|
try {
|
||||||
|
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: 'Invalid JSON body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let event, paymentObject
|
||||||
|
try {
|
||||||
|
const clientIp = request.ip
|
||||||
|
;({ event, paymentObject } = validateWebhook(clientIp, body))
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const yookassaPaymentId = paymentObject.id
|
||||||
|
if (!yookassaPaymentId) {
|
||||||
|
return reply.code(400).send({ error: 'Missing payment id in webhook object' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: { yookassaPaymentId },
|
||||||
|
})
|
||||||
|
if (!payment) {
|
||||||
|
return reply.code(404).send({ error: 'Payment not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: { status: paymentObject.status },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (event === 'payment.succeeded') {
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id: payment.orderId },
|
||||||
|
})
|
||||||
|
if (order && order.status === 'PENDING_PAYMENT') {
|
||||||
|
const updated = await prisma.order.updateMany({
|
||||||
|
where: { id: payment.orderId, status: 'PENDING_PAYMENT' },
|
||||||
|
data: { status: 'PAID' },
|
||||||
|
})
|
||||||
|
if (updated.count > 0) {
|
||||||
|
fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||||
|
orderId: payment.orderId,
|
||||||
|
userId: order.userId,
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user