diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts index 6217aea..a57bc29 100644 --- a/client/src/entities/order/api/order-api.ts +++ b/client/src/entities/order/api/order-api.ts @@ -69,24 +69,22 @@ export async function fetchMyOrder(id: string): Promise { return data } -export async function postOrderMessage(id: string, text: string): Promise { - 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 { + 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 diff --git a/client/src/features/order-payment/index.ts b/client/src/features/order-payment/index.ts index a7a90a5..e9b76a3 100644 --- a/client/src/features/order-payment/index.ts +++ b/client/src/features/order-payment/index.ts @@ -1,2 +1 @@ export { OrderPaymentSection } from './ui/OrderPaymentSection' -export { PaymentDialog } from './ui/PaymentDialog' diff --git a/client/src/features/order-payment/ui/OrderPaymentSection.tsx b/client/src/features/order-payment/ui/OrderPaymentSection.tsx index 35873a2..4f13e7b 100644 --- a/client/src/features/order-payment/ui/OrderPaymentSection.tsx +++ b/client/src/features/order-payment/ui/OrderPaymentSection.tsx @@ -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 && ( <> - После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус « + Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус « {orderStatusLabelRu('PAID')}». - )} - {status !== 'PENDING_PAYMENT' && ( - - На этом этапе действий по оплате в этом блоке не требуется. + {status === 'PAID' && ( + + Оплачено. Спасибо! + + )} + {status !== 'PENDING_PAYMENT' && status !== 'PAID' && ( + + На этом этапе действий по оплате не требуется. )} - - setPayModalOpen(false)} - onSubmit={(params) => { - onPay(params) - setPayModalOpen(false) - }} - /> ) } diff --git a/client/src/features/order-payment/ui/PaymentDialog.tsx b/client/src/features/order-payment/ui/PaymentDialog.tsx deleted file mode 100644 index 767b62a..0000000 --- a/client/src/features/order-payment/ui/PaymentDialog.tsx +++ /dev/null @@ -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(null) - const [clientError, setClientError] = useState(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 ( - - Подтверждение оплаты - - - {PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN} - - { - setDetail(e.target.value) - setClientError(null) - }} - fullWidth - multiline - minRows={3} - sx={{ mb: 2 }} - /> - - - {receiptFile && ( - - )} - - - Нужен текст комментария и/или изображение чека. - - {receiptPreviewUrl && ( - - )} - {clientError && ( - - {clientError} - - )} - {error ? ( - - {paySubmitErrorMessage(error)} - - ) : null} - - - - - - - ) -} diff --git a/client/src/features/product-review/ui/ReviewDialog.tsx b/client/src/features/product-review/ui/ReviewDialog.tsx index 2f9a01c..959ad2c 100644 --- a/client/src/features/product-review/ui/ReviewDialog.tsx +++ b/client/src/features/product-review/ui/ReviewDialog.tsx @@ -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 + onUploadImage: (file: File) => Promise<{ url: string }> } function reviewSubmitErrorMessage(err: unknown): string { @@ -55,11 +55,13 @@ export function ReviewDialog({ const [rating, setRating] = useState(5) const [text, setText] = useState('') const [imageUrl, setImageUrl] = useState(null) + const [localUploadError, setLocalUploadError] = useState(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.', + ) + } }} /> @@ -126,11 +136,13 @@ export function ReviewDialog({ }} /> )} - {uploadError ? ( + {uploadError || localUploadError ? ( - {uploadError instanceof Error - ? uploadError.message - : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} + {localUploadError + ? localUploadError + : uploadError instanceof Error + ? uploadError.message + : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} ) : null} {error ? ( diff --git a/client/src/features/product-review/ui/ReviewSection.tsx b/client/src/features/product-review/ui/ReviewSection.tsx index a9191e8..4cc1db4 100644 --- a/client/src/features/product-review/ui/ReviewSection.tsx +++ b/client/src/features/product-review/ui/ReviewSection.tsx @@ -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 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 }} /> diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 90ca7b9..6a18d72 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -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({ defaultValues: emptyUserForm(), @@ -192,7 +196,7 @@ export function AdminUsersPage() { 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() { } + render={({ field }) => ( + + )} /> ({ + 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) + mutation.mutate({ [field]: value }) } + const statusChangesOn = isOrderStatusChangesOn(settings) + return ( @@ -80,19 +86,26 @@ export function NotificationsPage() { - {eventFields.map(({ key, label }) => ( - handleToggle(key, e.target.checked)} - /> - } - label={label} - /> - ))} + mutation.mutate(orderStatusChangesPayload(e.target.checked))} + /> + } + label="Изменения статуса заказа" + /> + handleToggle('orderMessageReceived', e.target.checked)} + /> + } + label="Сообщения в чате заказа" + /> diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index d409012..ae3d180 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -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() { + {paidParam === '1' && paymentStatusQuery.data && ( + + {paymentStatusQuery.data.paid + ? 'Оплата прошла успешно!' + : paymentStatusQuery.data.status === 'canceled' + ? 'Оплата отмена. Вы можете попробовать снова.' + : 'Ожидаем подтверждения оплаты…'} + + )} + @@ -194,7 +233,7 @@ export function OrderDetailPage() { {PICKUP_ADDRESS_FULL} - Заберите заказ точно ко времени, которое согласуем по телефону или в чате заказа. + Заберите заказ ко времени, которое согласуем в чате заказа. )} @@ -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 diff --git a/client/src/shared/constants/payment-instructions.ts b/client/src/shared/constants/payment-instructions.ts deleted file mode 100644 index d1694e5..0000000 --- a/client/src/shared/constants/payment-instructions.ts +++ /dev/null @@ -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') diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 5d204db..d5a463b 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -96,7 +96,7 @@ export function ReviewsBlock() { **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace manual bank transfer payment flow with YooKassa (ЮKassa) online payment gateway using redirect scenario + webhooks. + +**Architecture:** Dedicated `server/src/lib/yookassa.js` module isolates all YooKassa API interaction (createPayment, getPayment, webhook validation). Server routes use this module. Client replaces PaymentDialog with a redirect to YooKassa payment form. + +**Tech Stack:** Fastify, Prisma/SQLite, React + MUI + React Query, Node 18+ built-in fetch. + +--- + +### Task 1: Add Payment model to Prisma schema and run migration + +**Files:** +- Modify: `server/prisma/schema.prisma` (add Payment model after Order model) +- Create: migration via `prisma migrate dev` + +- [ ] **Step 1: Add Payment model to schema** + +Add after the `Order` model (line 160): + +```prisma +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]) + @@index([yookassaPaymentId]) +} +``` + +Also add `payments Payment[]` relation field to the `Order` model (insert at line 155 after `messages OrderMessage[]`): + +```prisma + payments Payment[] +``` + +- [ ] **Step 2: Run Prisma migration** + +```bash +cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_payment +``` +Expected: migration created and applied, no errors. + +- [ ] **Step 3: Generate Prisma client** + +```bash +cd /mnt/d/my_projects/shop/server && npx prisma generate +``` +Expected: client regenerated with new Payment model. + +- [ ] **Step 4: Verify schema is valid** + +```bash +cd /mnt/d/my_projects/shop/server && npx prisma validate +``` +Expected: "The datasource is valid." + +- [ ] **Step 5: Commit** + +```bash +git add server/prisma/schema.prisma server/prisma/migrations && git commit -m "feat: add Payment model for yookassa integration" +``` + +--- + +### Task 2: Add YooKassa environment variables + +**Files:** +- Modify: `server/.env.example` + +- [ ] **Step 1: Add YooKassa env vars to .env.example** + +Add after line 34 (`TELEGRAM_BOT_TOKEN=`): + +```bash +# YooKassa payment integration +YOOKASSA_SHOP_ID= +YOOKASSA_SECRET_KEY= +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/.env.example && git commit -m "feat: add yookassa env vars to .env.example" +``` + +--- + +### Task 3: Create YooKassa API client library + +**Files:** +- Create: `server/src/lib/yookassa.js` +- Create: `server/src/lib/__tests__/yookassa.test.js` + +- [ ] **Step 1: Write failing tests for yookassa module** + +Create `server/src/lib/__tests__/yookassa.test.js`: + +```js +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPayment, getPayment } 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.id).toBe('payment-id') + expect(result.status).toBe('succeeded') + expect(result.paid).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js +``` +Expected: FAIL — "createPayment is not a function" / "getPayment is not a function" + +- [ ] **Step 3: Implement yookassa.js module** + +Create `server/src/lib/yookassa.js`: + +```js +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 + 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') && !isRetryable) throw err + lastError = 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 fetch(`${YOOKASSA_API_URL}/payments/${paymentId}`, { + headers: { Authorization: getAuthHeader() }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(`YooKassa getPayment error: ${res.status} — ${body.description || 'unknown'}`) + } + return res.json() +} + +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)) +} + +const TEST_MODE = process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') + +export function validateWebhook(ip, body) { + if (!TEST_MODE && !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 +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js +``` +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/lib/yookassa.js server/src/lib/__tests__/yookassa.test.js && git commit -m "feat: add yookassa API client library with tests" +``` + +--- + +### Task 4: Rewrite server payment route (POST /api/me/orders/:id/pay) + +**Files:** +- Modify: `server/src/routes/user-payments.js` +- Create: `server/src/routes/__tests__/user-payments.test.js` + +- [ ] **Step 1: Write failing integration tests for the payment route** + +Create `server/src/routes/__tests__/user-payments.test.js`: + +```js +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { registerUserPaymentRoutes } from '../user-payments.js' + +const JWT_SECRET = 'test-secret' +const USER_EMAIL = 'user@example.com' + +function signToken(userId) { + const fastify = Fastify() + fastify.register(jwt, { secret: JWT_SECRET }) + return fastify.jwt.sign({ sub: userId, email: USER_EMAIL }) +} + +function buildApp(overrides = {}) { + const app = Fastify({ logger: false }) + 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', overrides.eventBus || { emit: () => {} }) + app.decorate('prisma', overrides.prisma || {}) + return app +} + +describe('POST /api/me/orders/:id/pay', () => { + let app + let prisma + let eventBus + + beforeEach(async () => { + eventBus = { emit: vi.fn() } + prisma = { + order: { findFirst: vi.fn() }, + payment: { findFirst: vi.fn(), create: vi.fn() }, + $transaction: vi.fn((fn) => fn(prisma)), + } + app = buildApp({ prisma, eventBus }) + await registerUserPaymentRoutes(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + }) + + it('returns 401 without auth', async () => { + const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 404 when order not found', async () => { + prisma.order.findFirst.mockResolvedValue(null) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns 409 when payment method is on_pickup', async () => { + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + userId: 'user-1', + status: 'PENDING_PAYMENT', + paymentMethod: 'on_pickup', + }) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when order not in PENDING_PAYMENT status', async () => { + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + userId: 'user-1', + status: 'PAID', + paymentMethod: 'online', + }) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when deliveryFeeLocked is false', async () => { + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + userId: 'user-1', + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: false, + }) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) +}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/user-payments.test.js +``` +Expected: tests fail because the route is not updated yet. + +- [ ] **Step 3: Rewrite user-payments.js** + +Replace `server/src/routes/user-payments.js`: + +```js +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { prisma } from '../lib/prisma.js' +import { createPayment, buildReceipt } 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 + const { id } = request.params + + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + if (order.paymentMethod === 'on_pickup') { + return reply.code(409).send({ error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна' }) + } + + if (order.status !== 'PENDING_PAYMENT') { + return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + } + + if (!order.deliveryFeeLocked) { + return reply.code(409).send({ error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже' }) + } + + const existingPayment = await prisma.payment.findFirst({ + where: { orderId: id, status: { in: ['pending', 'waiting_for_capture'] } }, + orderBy: { createdAt: 'desc' }, + }) + + if (existingPayment && existingPayment.confirmationUrl) { + return { confirmationUrl: existingPayment.confirmationUrl } + } + + const idempotencyKey = `${id}-v1` + 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 || 'noemail@example.com', + }) + + 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 { getPayment } = await import('../lib/yookassa.js') + 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') { + await prisma.order.update({ + where: { id: orderId }, + data: { status: 'PAID' }, + }) + fastify.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' } + } + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/user-payments.test.js +``` +Expected: all tests PASS. + +- [ ] **Step 5: Verify server starts without errors** + +```bash +cd /mnt/d/my_projects/shop/server && timeout 5 node --env-file=.env src/index.js 2>&1 || true +``` +Expected: server starts and logs "Server listening at http://0.0.0.0:3333" + +- [ ] **Step 6: Commit** + +```bash +git add server/src/routes/user-payments.js server/src/routes/__tests__/user-payments.test.js && git commit -m "feat: rewrite payment route for yookassa redirect flow" +``` + +--- + +### Task 5: Add webhook endpoint for YooKassa notifications + +**Files:** +- Modify: `server/src/index.js` (register webhook route) +- Create: `server/src/routes/webhook-yookassa.js` +- Create: `server/src/routes/__tests__/webhook-yookassa.test.js` + +- [ ] **Step 1: Write failing tests for webhook route** + +Create `server/src/routes/__tests__/webhook-yookassa.test.js`: + +```js +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { registerYookassaWebhookRoute } from '../webhook-yookassa.js' + +function buildApp(overrides = {}) { + const app = Fastify({ logger: false }) + app.decorate('eventBus', overrides.eventBus || { emit: () => {} }) + app.decorate('prisma', overrides.prisma || {}) + return app +} + +describe('POST /api/webhooks/yookassa', () => { + let app + let prisma + let eventBus + + beforeEach(async () => { + process.env.YOOKASSA_SECRET_KEY = 'test_secret' + eventBus = { emit: vi.fn() } + prisma = { + payment: { findFirst: vi.fn(), update: vi.fn() }, + order: { findFirst: vi.fn(), update: vi.fn() }, + } + app = buildApp({ prisma, eventBus }) + await registerYookassaWebhookRoute(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + delete process.env.YOOKASSA_SECRET_KEY + }) + + 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 () => { + prisma.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 () => { + prisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + prisma.payment.update.mockResolvedValue({}) + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + status: 'PENDING_PAYMENT', + userId: 'user-1', + }) + prisma.order.update.mockResolvedValue({}) + + 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 = prisma.payment.update.mock.calls[0][0].data + expect(updateData.status).toBe('succeeded') + + const orderUpdateData = prisma.order.update.mock.calls[0][0].data + expect(orderUpdateData.status).toBe('PAID') + expect(eventBus.emit).toHaveBeenCalled() + }) + + it('updates payment on payment.canceled without changing order', async () => { + prisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + prisma.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(prisma.order.update).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/webhook-yookassa.test.js +``` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create webhook route file** + +Create `server/src/routes/webhook-yookassa.js`: + +```js +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') { + await prisma.order.update({ + where: { id: payment.orderId }, + data: { status: 'PAID' }, + }) + + fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: payment.orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + + return { ok: true } + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/webhook-yookassa.test.js +``` +Expected: all tests PASS. + +- [ ] **Step 5: Register webhook route in server/src/index.js** + +Add import (after line 30): +```js +import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js' +``` + +Add registration (after line 95 — `registerOAuthSocialRoutes`): +```js +await registerYookassaWebhookRoute(fastify) +``` + +- [ ] **Step 6: Run all server tests** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run +``` +Expected: all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/routes/webhook-yookassa.js server/src/routes/__tests__/webhook-yookassa.test.js server/src/index.js && git commit -m "feat: add yookassa webhook endpoint" +``` + +--- + +### Task 6: Client — remove old manual payment code + +**Files:** +- Delete: `client/src/features/order-payment/ui/PaymentDialog.tsx` +- Delete: `client/src/shared/constants/payment-instructions.ts` +- Modify: `client/src/features/order-payment/index.ts` +- Modify: `client/src/entities/order/api/order-api.ts` (remove `submitOrderPayment`) + +- [ ] **Step 1: Delete PaymentDialog.tsx** + +```bash +rm /mnt/d/my_projects/shop/client/src/features/order-payment/ui/PaymentDialog.tsx +``` + +- [ ] **Step 2: Delete payment-instructions.ts** + +```bash +rm /mnt/d/my_projects/shop/client/src/shared/constants/payment-instructions.ts +``` + +- [ ] **Step 3: Update features/order-payment/index.ts** + +Remove the PaymentDialog export: + +```ts +export { OrderPaymentSection } from './ui/OrderPaymentSection' +``` + +- [ ] **Step 4: Remove submitOrderPayment from order-api.ts** + +Remove lines 76-88 from `client/src/entities/order/api/order-api.ts` (the `submitOrderPayment` function and its JSDoc comment). + +- [ ] **Step 5: Commit** + +```bash +git add client/src/features/order-payment/ui/PaymentDialog.tsx client/src/shared/constants/payment-instructions.ts client/src/features/order-payment/index.ts client/src/entities/order/api/order-api.ts && git commit -m "feat: remove old manual payment dialog and api method" +``` + +--- + +### Task 7: Client — add new API methods and rewrite OrderPaymentSection + +**Files:** +- Modify: `client/src/entities/order/api/order-api.ts` +- Modify: `client/src/features/order-payment/ui/OrderPaymentSection.tsx` +- Modify: `client/src/pages/me/ui/sections/OrderDetailPage.tsx` + +- [ ] **Step 1: Add new API methods to order-api.ts** + +Add after `fetchMyOrder` function (after line 70): + +```ts +/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */ +export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> { + const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`) + return data +} + +/** Получить статус платежа для заказа. */ +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 +} +``` + +- [ ] **Step 2: Rewrite OrderPaymentSection.tsx** + +Replace `client/src/features/order-payment/ui/OrderPaymentSection.tsx`: + +```tsx +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' + +type Props = { + status: string + deliveryFeeLocked: boolean + paymentMethod: string | null + totalCents: number + isPayPending: boolean + payError: unknown + onPay: () => void +} + +export function OrderPaymentSection({ + status, + deliveryFeeLocked, + paymentMethod, + isPayPending, + payError, + onPay, +}: Props) { + const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup' + + if (payOnPickup) { + return ( + + + Оплата + + + Оплата при получении на точке самовывоза (наличные или карта — по договорённости). + + + ) + } + + return ( + + + Оплата + + {status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && ( + + Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости. + + )} + {status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && ( + <> + + Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус « + {orderStatusLabelRu('PAID')}». + + + + )} + {status === 'PAID' && ( + + Оплачено. Спасибо! + + )} + {status !== 'PENDING_PAYMENT' && status !== 'PAID' && ( + + На этом этапе действий по оплате не требуется. + + )} + + ) +} +``` + +- [ ] **Step 3: Update OrderDetailPage.tsx** + +Replace the `payMut` mutation (lines 39-48) with: + +```tsx +const payMut = useMutation({ + mutationFn: () => createOrderPayment(id!), + onSuccess: async (data: { confirmationUrl: string }) => { + window.location.href = data.confirmationUrl + }, +}) +``` + +Update the import in line 15 — replace `submitOrderPayment` with `createOrderPayment`: + +```tsx +import { + confirmOrderReceived, + createOrderPayment, + fetchMyOrder, + postOrderMessage, + fetchOrderReviewEligibility, +} from '@/entities/order/api/order-api' +``` + +Update `OrderPaymentSection` usage (lines 208-216) — change `onPay` from `(params) => payMut.mutate(params)` to just `() => payMut.mutate()`: + +```tsx + payMut.mutate()} +/> +``` + +- [ ] **Step 4: Add return URL handling to OrderDetailPage.tsx** + +Add `useSearchParams` import (top of file, from `react-router-dom`): + +```tsx +import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' +``` + +Add import for `getOrderPaymentStatus`: + +```tsx +import { + confirmOrderReceived, + createOrderPayment, + fetchMyOrder, + getOrderPaymentStatus, + postOrderMessage, + fetchOrderReviewEligibility, +} from '@/entities/order/api/order-api' +``` + +Add payment status check effect after line 31 (`const qc = useQueryClient()`): + +```tsx +const [searchParams] = useSearchParams() +const paidParam = searchParams.get('paid') +``` + +Add a query for payment status when returned from YooKassa (after the `orderQuery` block, around line 38): + +```tsx +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 + }, +}) +``` + +Add a status banner after the top `Stack` with order info (after line 118, before "Позиции"): + +```tsx +{paidParam === '1' && paymentStatusQuery.data && ( + + {paymentStatusQuery.data.paid + ? 'Оплата прошла успешно!' + : paymentStatusQuery.data.status === 'canceled' + ? 'Оплата отменена. Вы можете попробовать снова.' + : 'Ожидаем подтверждения оплаты…'} + +)} +``` + +- [ ] **Step 5: Verify client compiles** + +```bash +cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -30 +``` +Expected: no TypeScript errors. + +- [ ] **Step 6: Run client lint** + +```bash +cd /mnt/d/my_projects/shop/client && npm run lint +``` +Expected: no lint errors. + +- [ ] **Step 7: Commit** + +```bash +git add client/src/entities/order/api/order-api.ts client/src/features/order-payment/ui/OrderPaymentSection.tsx client/src/pages/me/ui/sections/OrderDetailPage.tsx && git commit -m "feat: implement yookassa redirect payment flow on client" +``` + +--- + +### Task 8: Final verification and cleanup + +**Files:** (none, verification only) + +- [ ] **Step 1: Run all server tests** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run +``` +Expected: all tests PASS. + +- [ ] **Step 2: Run all client tests** + +```bash +cd /mnt/d/my_projects/shop/client && npx vitest run +``` +Expected: all tests PASS. + +- [ ] **Step 3: Run server lint and format check** + +```bash +cd /mnt/d/my_projects/shop/server && npm run lint && npm run format:check +``` +Expected: no lint errors, no format issues. + +- [ ] **Step 4: Run client lint, format check, and build** + +```bash +cd /mnt/d/my_projects/shop/client && npm run lint && npm run format:check && npm run build +``` +Expected: no lint errors, no format issues, build succeeds. + +- [ ] **Step 5: Final commit (if any fixes were needed)** + +If Step 1-4 required fixes, commit them. +Otherwise, confirm: "All verifications passed, no additional changes needed." + +--- + +## Summary + +| Task | Files | Description | +|---|---|---| +| 1 | `schema.prisma` + migration | Add Payment model | +| 2 | `.env.example` | Add YooKassa env vars | +| 3 | `lib/yookassa.js` + tests | YooKassa API client module | +| 4 | `routes/user-payments.js` + tests | Rewrite payment route | +| 5 | `routes/webhook-yookassa.js` + tests + `index.js` | Webhook endpoint | +| 6 | Delete `PaymentDialog.tsx`, `payment-instructions.ts` | Remove old manual payment | +| 7 | `order-api.ts`, `OrderPaymentSection.tsx`, `OrderDetailPage.tsx` | Client redirect flow | +| 8 | Verification | Lint, format, test, build | + +**Total commits:** 7 (Tasks 1-7) + optional cleanup commit from Task 8. diff --git a/docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md b/docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md new file mode 100644 index 0000000..e466ce9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md @@ -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 diff --git a/server/.env.example b/server/.env.example index c1c4c99..78a9ec4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -32,3 +32,7 @@ YANDEX_CLIENT_SECRET= # Telegram Bot (оповещения админа) TELEGRAM_BOT_TOKEN= + +# YooKassa payment integration +YOOKASSA_SHOP_ID= +YOOKASSA_SECRET_KEY= diff --git a/server/prisma/migrations/20260520124831_add_payment/migration.sql b/server/prisma/migrations/20260520124831_add_payment/migration.sql new file mode 100644 index 0000000..121dd2e --- /dev/null +++ b/server/prisma/migrations/20260520124831_add_payment/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql b/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql new file mode 100644 index 0000000..84f0f45 --- /dev/null +++ b/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Payment_yookassaPaymentId_idx"; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5403c8c..60d79a7 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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 diff --git a/server/src/index.js b/server/src/index.js index 9b57aa3..19cd72e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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() diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js new file mode 100644 index 0000000..f15be2a --- /dev/null +++ b/server/src/lib/__tests__/yookassa.test.js @@ -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() + }) +}) diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js new file mode 100644 index 0000000..029274f --- /dev/null +++ b/server/src/lib/yookassa.js @@ -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 +} diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js new file mode 100644 index 0000000..d46af81 --- /dev/null +++ b/server/src/routes/__tests__/user-payments.test.js @@ -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) + }) +}) diff --git a/server/src/routes/__tests__/webhook-yookassa.test.js b/server/src/routes/__tests__/webhook-yookassa.test.js new file mode 100644 index 0000000..4227434 --- /dev/null +++ b/server/src/routes/__tests__/webhook-yookassa.test.js @@ -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() + }) +}) diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index f6b3f6d..cc26341 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -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() diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index b432d1c..136453b 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -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} шт.` }) diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 0e20eb1..8ced32d 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -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 } }, diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 98ccd4d..8b8e99b 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -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, - }, - }) - 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 ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}

` : '' - const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${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', + 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' }, }) - 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' } + } }) } diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js new file mode 100644 index 0000000..6362de5 --- /dev/null +++ b/server/src/routes/webhook-yookassa.js @@ -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 } + }) +}