Merge branch 'payd'

This commit is contained in:
Kirill
2026-05-21 12:03:23 +05:00
27 changed files with 2858 additions and 337 deletions
+11 -13
View File
@@ -69,24 +69,22 @@ export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
return data
}
export async function postOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`me/orders/${id}/messages`, { text })
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
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(
orderId: string,
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)
/** Получить статус платежа для заказа. */
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
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 }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
return data
@@ -1,2 +1 @@
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 Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { PaymentDialog } from './PaymentDialog'
type Props = {
status: string
@@ -12,7 +10,7 @@ type Props = {
totalCents: number
isPayPending: boolean
payError: unknown
onPay: (params: { detail: string; receiptFile: File | null }) => void
onPay: () => void
}
export function OrderPaymentSection({
@@ -24,7 +22,6 @@ export function OrderPaymentSection({
onPay,
}: Props) {
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
const [payModalOpen, setPayModalOpen] = useState(false)
if (payOnPickup) {
return (
@@ -52,30 +49,24 @@ export function OrderPaymentSection({
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
После перевода подтвердите оплату откроется форма для комментария и фото чека. Заказ получит статус «
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
{orderStatusLabelRu('PAID')}».
</Typography>
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
Оплатить
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
</Button>
</>
)}
{status !== 'PENDING_PAYMENT' && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется.
{status === 'PAID' && (
<Typography color="success.main" variant="body1">
Оплачено. Спасибо!
</Typography>
)}
{status !== 'PENDING_PAYMENT' && status !== 'PAID' && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате не требуется.
</Typography>
)}
<PaymentDialog
open={payModalOpen}
isPending={isPayPending}
error={payError}
onClose={() => setPayModalOpen(false)}
onSubmit={(params) => {
onPay(params)
setPayModalOpen(false)
}}
/>
</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
uploadError: unknown
onClose: () => void
onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => void
onUploadImage: (file: File) => void
onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => Promise<void>
onUploadImage: (file: File) => Promise<{ url: string }>
}
function reviewSubmitErrorMessage(err: unknown): string {
@@ -55,11 +55,13 @@ export function ReviewDialog({
const [rating, setRating] = useState<number>(5)
const [text, setText] = useState('')
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [localUploadError, setLocalUploadError] = useState<string | null>(null)
const reset = () => {
setRating(5)
setText('')
setImageUrl(null)
setLocalUploadError(null)
}
const handleClose = () => {
@@ -68,9 +70,9 @@ export function ReviewDialog({
onClose()
}
const handleSubmit = () => {
const handleSubmit = async () => {
if (isPending) return
onSubmit({ rating, text: text.trim(), imageUrl })
await onSubmit({ rating, text: text.trim(), imageUrl })
}
return (
@@ -96,11 +98,19 @@ export function ReviewDialog({
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
onUploadImage(file)
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>
@@ -126,9 +136,11 @@ export function ReviewDialog({
}}
/>
)}
{uploadError ? (
{uploadError || localUploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{uploadError instanceof Error
{localUploadError
? localUploadError
: uploadError instanceof Error
? uploadError.message
: 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
@@ -17,7 +17,12 @@ type Props = {
isUploadPending: boolean
submitError: 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 }>
}
@@ -75,17 +80,20 @@ export function ReviewSection({
setTarget(null)
setUploadedImageUrl(null)
}}
onSubmit={(params) => {
onSubmit={async (params) => {
if (!target) return
onSubmitReview({
await onSubmitReview({
productId: target.productId,
...params,
imageUrl: uploadedImageUrl,
})
setTarget(null)
setUploadedImageUrl(null)
}}
onUploadImage={async (file) => {
const result = await onUploadImage(file)
setUploadedImageUrl(result.url)
return result
}}
/>
</Box>
@@ -9,6 +9,7 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
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 { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { $user } from '@/shared/model/auth'
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
import { AdminTable } from '@/shared/ui/AdminTable'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
@@ -44,6 +46,8 @@ export function AdminUsersPage() {
const [q, setQ] = useState('')
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(20)
const currentUser = useUnit($user)
const currentUserId = currentUser?.id
const userForm = useForm<UserFormState>({
defaultValues: emptyUserForm(),
@@ -192,7 +196,7 @@ export function AdminUsersPage() {
<TableCell align="right">
<EntityRowActions
onEdit={() => openEdit(u)}
onDelete={() => deleteMut.mutate(u.id)}
onDelete={u.id === currentUserId ? undefined : () => deleteMut.mutate(u.id)}
deleteDisabled={deleteMut.isPending}
confirmDeleteMessage={`Удалить пользователя ${u.email}?`}
/>
@@ -237,7 +241,15 @@ export function AdminUsersPage() {
<Controller
control={userForm.control}
name="email"
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
render={({ field }) => (
<TextField
label="Почта"
fullWidth
required
disabled={Boolean(editing && editing.id === currentUserId)}
{...field}
/>
)}
/>
<Controller
control={userForm.control}
@@ -10,14 +10,18 @@ import {
fetchUserNotificationSettings,
updateUserNotificationSettings,
} from '@/entities/notification/api/notifications-api'
import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api'
const eventFields = [
{ key: 'orderCreated' as const, label: 'Заказ создан' },
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
{ key: 'deliveryFeeAdjusted' as const, label: 'Корректировка стоимости доставки' },
]
function isOrderStatusChangesOn(s: UserNotificationSettings): boolean {
return s.orderCreated && s.orderStatusChanged && s.paymentStatusChanged && s.deliveryFeeAdjusted
}
const orderStatusChangesPayload = (on: boolean) => ({
orderCreated: on,
orderStatusChanged: on,
paymentStatusChanged: on,
deliveryFeeAdjusted: on,
})
export function NotificationsPage() {
const queryClient = useQueryClient()
@@ -45,9 +49,11 @@ export function NotificationsPage() {
const handleToggle = (field: string, value: boolean) => {
setError(null)
mutation.mutate({ [field]: value } as Record<string, boolean>)
mutation.mutate({ [field]: value })
}
const statusChangesOn = isOrderStatusChangesOn(settings)
return (
<Box>
<Typography variant="h4" gutterBottom>
@@ -80,19 +86,26 @@ export function NotificationsPage() {
</Box>
<Box sx={{ pl: 4 }}>
{eventFields.map(({ key, label }) => (
<FormControlLabel
key={key}
control={
<Switch
checked={settings[key]}
checked={statusChangesOn}
disabled={!settings.globalEnabled}
onChange={(e) => handleToggle(key, e.target.checked)}
onChange={(e) => mutation.mutate(orderStatusChangesPayload(e.target.checked))}
/>
}
label={label}
label="Изменения статуса заказа"
/>
<FormControlLabel
control={
<Switch
checked={settings.orderMessageReceived}
disabled={!settings.globalEnabled}
onChange={(e) => handleToggle('orderMessageReceived', e.target.checked)}
/>
}
label="Сообщения в чате заказа"
/>
))}
</Box>
</Stack>
</Box>
@@ -7,12 +7,13 @@ import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
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 {
confirmOrderReceived,
createOrderPayment,
fetchMyOrder,
getOrderPaymentStatus,
postOrderMessage,
submitOrderPayment,
fetchOrderReviewEligibility,
} from '@/entities/order/api/order-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() {
const { id } = useParams()
const qc = useQueryClient()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const paidParam = searchParams.get('paid')
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
@@ -37,16 +42,31 @@ export function OrderDetailPage() {
})
const payMut = useMutation({
mutationFn: (params: { detail: string; receiptFile: File | null }) => submitOrderPayment(id!, params),
onSuccess: async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
qc.invalidateQueries({ queryKey: ['me', 'conversations'] }),
])
mutationFn: () => createOrderPayment(id!),
onSuccess: async (data) => {
window.location.href = data.confirmationUrl
},
})
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({
mutationFn: () => confirmOrderReceived(id!),
onSuccess: () =>
@@ -117,6 +137,25 @@ export function OrderDetailPage() {
</Button>
</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 }}>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
@@ -194,7 +233,7 @@ export function OrderDetailPage() {
{PICKUP_ADDRESS_FULL}
</Typography>
<Typography color="text.secondary" variant="body2">
Заберите заказ точно ко времени, которое согласуем по телефону или в чате заказа.
Заберите заказ ко времени, которое согласуем в чате заказа.
</Typography>
</Stack>
)}
@@ -212,7 +251,7 @@ export function OrderDetailPage() {
totalCents={order.totalCents}
isPayPending={payMut.isPending}
payError={payMut.error}
onPay={(params) => payMut.mutate(params)}
onPay={() => payMut.mutate()}
/>
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
@@ -244,7 +283,9 @@ export function OrderDetailPage() {
isUploadPending={uploadReviewImageMut.isPending}
submitError={reviewMut.error}
uploadError={uploadReviewImageMut.error}
onSubmitReview={(params) => reviewMut.mutate(params)}
onSubmitReview={async (params) => {
await reviewMut.mutateAsync(params)
}}
onUploadImage={async (file) => {
const result = await uploadReviewImageMut.mutateAsync(file)
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
src={r.imageUrl}
alt="Фото к отзыву"
widths={[160, 320]}
widths={[320, 640]}
sizes="80px"
sx={{
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
+4
View File
@@ -32,3 +32,7 @@ YANDEX_CLIENT_SECRET=
# Telegram Bot (оповещения админа)
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";
+17
View File
@@ -153,12 +153,29 @@ model Order {
items OrderItem[]
messages OrderMessage[]
payments Payment[]
messageReadStates UserOrderMessageReadState[]
@@index([userId, createdAt])
@@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 {
id String @id @default(cuid())
qty Int
+3
View File
@@ -28,6 +28,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js'
import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js'
import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'
const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '')
@@ -38,6 +39,7 @@ const origin = (process.env.CORS_ORIGIN ?? '')
const fastify = Fastify({
logger: true,
bodyLimit: getMaxUploadBodyBytes(),
trustProxy: true,
})
await fastify.register(cors, {
@@ -93,6 +95,7 @@ await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerYookassaWebhookRoute(fastify)
await registerApiRoutes(fastify)
await ensureAdminUser()
await getOrCreateUnspecifiedCategory()
+257
View File
@@ -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()
})
})
+182
View File
@@ -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()
})
})
+14
View File
@@ -92,6 +92,7 @@ export async function registerAdminUserRoutes(fastify) {
fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const adminUserId = request.user.sub
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
@@ -99,9 +100,15 @@ export async function registerAdminUserRoutes(fastify) {
return
}
const isSelf = id === adminUserId
const data = {}
if (body.email !== undefined) {
if (isSelf) {
reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' })
return
}
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
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) => {
const { id } = request.params
const adminUserId = request.user.sub
if (id === adminUserId) {
reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' })
return
}
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
+2 -2
View File
@@ -29,7 +29,7 @@ export async function registerUserCartRoutes(fastify) {
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
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 nextQty = (existing?.qty ?? 0) + Math.floor(qty)
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
@@ -57,7 +57,7 @@ export async function registerUserCartRoutes(fastify) {
return reply.code(204).send()
}
const available = existing.product.inStock ? existing.product.quantity : 1
const available = existing.product.quantity
const nextQty = Math.floor(qty)
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
+1 -3
View File
@@ -65,7 +65,7 @@ export async function registerUserOrderRoutes(fastify) {
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
for (const ci of cartItems) {
const available = ci.product.inStock ? ci.product.quantity : 1
const available = ci.product.quantity
if (ci.qty > available) {
return reply.code(409).send({
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
@@ -112,8 +112,6 @@ export async function registerUserOrderRoutes(fastify) {
try {
created = await prisma.$transaction(async (tx) => {
for (const ci of cartItems) {
if (!ci.product.inStock) continue
const res = await tx.product.updateMany({
where: { id: ci.productId, quantity: { gte: ci.qty } },
data: { quantity: { decrement: ci.qty } },
+110 -77
View File
@@ -1,20 +1,27 @@
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { escapeHtml } from '../lib/escape-html.js'
import { prisma } from '../lib/prisma.js'
import { saveImageBufferToUploads } from '../lib/upload-images.js'
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
export async function registerUserPaymentRoutes(fastify) {
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
const userEmail = request.user.email
if (!userEmail) {
return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' })
}
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: 'Заказ не найден' })
const paymentMethod = order.paymentMethod ?? 'online'
if (paymentMethod === 'on_pickup') {
if (order.paymentMethod === 'on_pickup') {
return reply.code(409).send({
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
})
}
@@ -22,93 +29,119 @@ export async function registerUserPaymentRoutes(fastify) {
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
}
if (!request.isMultipart()) {
return reply.code(400).send({
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
if (!order.deliveryFeeLocked) {
return reply.code(409).send({
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
})
}
let detail = ''
let receiptBuffer = null
let receiptFilename = ''
try {
const otherLimit = getOtherUploadMaxFileBytes()
const parts = request.parts({
limits: {
fileSize: otherLimit,
files: 2,
const existingPayment = await prisma.payment.findFirst({
where: {
orderId: id,
status: { in: ['pending', 'waiting_for_capture'] },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
orderBy: { createdAt: 'desc' },
})
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()
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) {
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: 'Укажите текст о платеже и/или прикрепите изображение чека',
request.log.error({ err, orderId: id }, 'YooKassa createPayment failed')
return reply.code(502).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({
await prisma.payment.create({
data: {
orderId: id,
authorType: 'user',
text: messageText,
attachmentUrl,
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 }
})
} catch {
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
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: id,
userId,
paymentStatus: 'pending',
})
return { ok: true, status: 'PENDING_PAYMENT' }
orderId,
userId: order.userId,
paymentStatus: 'paid',
})
}
}
}
return { status: ykPayment.status, paid: ykPayment.paid }
} catch {
return { status: payment.status, paid: payment.status === 'succeeded' }
}
})
}
+60
View File
@@ -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 }
})
}