= {
+ DRAFT: 'Черновик',
+ PENDING_PAYMENT: 'Ожидает оплаты',
+ PAID: 'Оплачен',
+ IN_PROGRESS: 'В работе',
+ SHIPPED: 'Отправлен',
+ READY_FOR_PICKUP: 'Готово к получению',
+ DONE: 'Завершён',
+ CANCELLED: 'Отменён',
+ }
+ return map[code] ?? code
+}
+```
+
+- [ ] **Step 3: Run client lint and typecheck**
+
+```bash
+cd client && npm run lint && npx tsc -b
+```
+
+Expected: ESLint passes. TypeScript may show errors in files that still reference removed statuses — those will be fixed in later tasks.
+
+---
+
+### Task 5: Update server checkout — remove `DELIVERY_FEE_ADJUSTMENT` initial status
+
+**Files:**
+- Modify: `server/src/routes/user-orders.js:103-106`
+
+- [ ] **Step 1: Write test for checkout status logic**
+
+Create `server/src/routes/__tests__/user-orders-checkout.test.js`:
+
+```js
+import { describe, expect, it, beforeEach, afterEach } from 'vitest'
+import { prisma } from '../../lib/prisma.js'
+
+describe('checkout initial status', () => {
+ beforeEach(async () => {
+ await prisma.cartItem.deleteMany()
+ await prisma.orderItem.deleteMany()
+ await prisma.order.deleteMany()
+ })
+
+ afterEach(async () => {
+ await prisma.cartItem.deleteMany()
+ await prisma.orderItem.deleteMany()
+ await prisma.order.deleteMany()
+ })
+
+ it('delivery + online → PENDING_PAYMENT with deliveryFeeLocked=false', async () => {
+ // This is an integration-style test checking the status logic
+ // The actual checkout requires full auth setup, so we verify the status assignment logic directly
+ const paymentMethod = 'online'
+ const deliveryType = 'delivery'
+
+ let initialStatus = 'PENDING_PAYMENT'
+ let deliveryFeeLocked = false
+
+ if (paymentMethod === 'on_pickup') {
+ initialStatus = 'IN_PROGRESS'
+ } else if (deliveryType === 'delivery') {
+ initialStatus = 'PENDING_PAYMENT'
+ deliveryFeeLocked = false
+ }
+
+ expect(initialStatus).toBe('PENDING_PAYMENT')
+ expect(deliveryFeeLocked).toBe(false)
+ })
+
+ it('pickup + online → PENDING_PAYMENT with deliveryFeeLocked=true', async () => {
+ const paymentMethod = 'online'
+ const deliveryType = 'pickup'
+
+ let initialStatus = 'PENDING_PAYMENT'
+ let deliveryFeeLocked = true
+
+ if (paymentMethod === 'on_pickup') {
+ initialStatus = 'IN_PROGRESS'
+ }
+
+ expect(initialStatus).toBe('PENDING_PAYMENT')
+ expect(deliveryFeeLocked).toBe(true)
+ })
+
+ it('pickup + on_pickup → IN_PROGRESS', async () => {
+ const paymentMethod = 'on_pickup'
+ const deliveryType = 'pickup'
+
+ let initialStatus = 'PENDING_PAYMENT'
+
+ if (paymentMethod === 'on_pickup') {
+ initialStatus = 'IN_PROGRESS'
+ }
+
+ expect(initialStatus).toBe('IN_PROGRESS')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+```bash
+cd server && npm test -- --run src/routes/__tests__/user-orders-checkout.test.js
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 3: Update checkout logic**
+
+In `server/src/routes/user-orders.js`, replace lines 103-106:
+
+**Before:**
+```js
+ let initialStatus = 'PENDING_PAYMENT'
+ if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
+ else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
+```
+
+**After:**
+```js
+ let initialStatus = 'PENDING_PAYMENT'
+ let deliveryFeeLocked = true
+ if (paymentMethod === 'on_pickup') {
+ initialStatus = 'IN_PROGRESS'
+ } else if (deliveryType === 'delivery') {
+ initialStatus = 'PENDING_PAYMENT'
+ deliveryFeeLocked = false
+ }
+```
+
+- [ ] **Step 4: Add `deliveryFeeLocked` to order creation data**
+
+In the same file, find the `order = await tx.order.create({ data: { ... } })` block (around line 123-144). Add `deliveryFeeLocked` to the data object:
+
+**Before (line 126):**
+```js
+ status: initialStatus,
+```
+
+**After:**
+```js
+ status: initialStatus,
+ deliveryFeeLocked,
+```
+
+- [ ] **Step 5: Run server tests**
+
+```bash
+cd server && npm test
+```
+
+Expected: All tests pass.
+
+---
+
+### Task 6: Update server payment routes — remove `PAYMENT_VERIFICATION`
+
+**Files:**
+- Modify: `server/src/routes/user-payments.js`
+
+- [ ] **Step 1: Write updated `user-payments.js`**
+
+Replace the entire file:
+
+```js
+import { prisma } from '../lib/prisma.js'
+import { escapeHtml } from '../lib/escape-html.js'
+import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
+import { saveImageBufferToUploads } from '../lib/upload-images.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 { id } = request.params
+ const order = await prisma.order.findFirst({ where: { id, userId } })
+ if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
+
+ const paymentMethod = order.paymentMethod ?? 'online'
+ if (paymentMethod === 'on_pickup') {
+ return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
+ }
+
+ if (order.status !== 'PENDING_PAYMENT') {
+ return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
+ }
+
+ if (!request.isMultipart()) {
+ return reply
+ .code(400)
+ .send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
+ }
+
+ 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 (err) {
+ return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
+ }
+
+ return { ok: true, status: 'PENDING_PAYMENT' }
+ },
+ )
+}
+```
+
+Key changes:
+- Removed `DELIVERY_FEE_ADJUSTMENT` check (line 21-28)
+- Removed `DRAFT → PENDING_PAYMENT` transition (line 31-34)
+- Removed `PAYMENT_VERIFICATION` check (line 37-39)
+- Removed `PAYMENT_VERIFICATION` status update (line 112)
+- Simplified to: only `PENDING_PAYMENT` allowed, stays in `PENDING_PAYMENT` after payment submission
+- Changed final error message from "Сейчас нельзя выполнить оплату" to be the only guard
+
+- [ ] **Step 2: Run server tests**
+
+```bash
+cd server && npm test
+```
+
+Expected: All tests pass.
+
+---
+
+### Task 7: Update server admin order routes
+
+**Files:**
+- Modify: `server/src/routes/api/admin-orders.js`
+
+- [ ] **Step 1: Update summary endpoint**
+
+In line 11, replace the status filter:
+
+**Before:**
+```js
+ status: { in: ['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] },
+```
+
+**After:**
+```js
+ status: 'PENDING_PAYMENT',
+```
+
+- [ ] **Step 2: Update delivery-fee endpoint**
+
+Replace the entire `PATCH /api/admin/orders/:id/delivery-fee` handler (lines 115-144):
+
+**Before:**
+```js
+ fastify.patch(
+ '/api/admin/orders/:id/delivery-fee',
+ { preHandler: [fastify.verifyAdmin] },
+ async (request, reply) => {
+ const { id } = request.params
+ const feeRaw = request.body?.deliveryFeeCents
+ const parsed =
+ typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
+ if (!Number.isInteger(parsed) || parsed < 0) {
+ return reply.code(400).send({ error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)' })
+ }
+
+ const existing = await prisma.order.findUnique({ where: { id } })
+ if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
+ if (existing.status !== 'DELIVERY_FEE_ADJUSTMENT') {
+ return reply.code(409).send({ error: 'Корректировка доставки доступна только в статусе DELIVERY_FEE_ADJUSTMENT' })
+ }
+
+ const totalCents = existing.itemsSubtotalCents + parsed
+ const updated = await prisma.order.update({
+ where: { id },
+ data: {
+ deliveryFeeCents: parsed,
+ totalCents,
+ status: 'PENDING_PAYMENT',
+ },
+ })
+ return { item: updated }
+ },
+ )
+```
+
+**After:**
+```js
+ fastify.patch(
+ '/api/admin/orders/:id/delivery-fee',
+ { preHandler: [fastify.verifyAdmin] },
+ async (request, reply) => {
+ const { id } = request.params
+ const feeRaw = request.body?.deliveryFeeCents
+ const parsed =
+ typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
+ if (!Number.isInteger(parsed) || parsed < 0) {
+ return reply.code(400).send({ error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)' })
+ }
+
+ const existing = await prisma.order.findUnique({ where: { id } })
+ if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
+ if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
+ return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' })
+ }
+
+ const totalCents = existing.itemsSubtotalCents + parsed
+ const updated = await prisma.order.update({
+ where: { id },
+ data: {
+ deliveryFeeCents: parsed,
+ totalCents,
+ deliveryFeeLocked: true,
+ },
+ })
+ return { item: updated }
+ },
+ )
+```
+
+- [ ] **Step 3: Run server tests**
+
+```bash
+cd server && npm test
+```
+
+Expected: All tests pass.
+
+---
+
+### Task 8: Update client admin orders page
+
+**Files:**
+- Modify: `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx`
+
+- [ ] **Step 1: Replace `DELIVERY_FEE_ADJUSTMENT` UI blocks**
+
+Find and replace lines 321-334:
+
+**Before:**
+```tsx
+ {detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
+
+ Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
+ {orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
+
+ )}
+
+ {detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
+
+ )}
+```
+
+**After:**
+```tsx
+ {detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
+
+ Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
+
+ )}
+
+ {detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
+
+ )}
+```
+
+- [ ] **Step 2: Add `deliveryFeeLocked` to the detail type**
+
+The `AdminOrderDetailResponse` type in `client/src/entities/order/api/admin-order-api.ts` needs `deliveryFeeLocked`. Add it to the type definition (after line 32):
+
+**Before:**
+```ts
+ deliveryFeeCents: number
+```
+
+**After:**
+```ts
+ deliveryFeeCents: number
+ deliveryFeeLocked: boolean
+```
+
+- [ ] **Step 3: Run client typecheck**
+
+```bash
+cd client && npx tsc -b
+```
+
+Expected: No errors.
+
+---
+
+### Task 9: Update client payment section
+
+**Files:**
+- Modify: `client/src/features/order-payment/ui/OrderPaymentSection.tsx`
+
+- [ ] **Step 1: Write updated component**
+
+Replace the entire file:
+
+```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'
+import { PaymentDialog } from './PaymentDialog'
+
+type Props = {
+ status: string
+ deliveryFeeLocked: boolean
+ paymentMethod: string | null
+ totalCents: number
+ isPayPending: boolean
+ payError: unknown
+ onPay: (params: { detail: string; receiptFile: File | null }) => void
+}
+
+export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod, isPayPending, payError, onPay }: Props) {
+ const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
+ const [payModalOpen, setPayModalOpen] = useState(false)
+
+ if (payOnPickup) {
+ return (
+
+
+ Оплата
+
+
+ Оплата при получении на точке самовывоза (наличные или карта — по договорённости).
+
+
+ )
+ }
+
+ return (
+
+
+ Оплата
+
+ {status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && (
+
+ Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости.
+
+ )}
+ {status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
+ <>
+
+ После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
+ {orderStatusLabelRu('PAID')}».
+
+
+ >
+ )}
+ {status !== 'PENDING_PAYMENT' && (
+
+ На этом этапе действий по оплате в этом блоке не требуется.
+
+ )}
+
+ setPayModalOpen(false)}
+ onSubmit={(params) => {
+ onPay(params)
+ setPayModalOpen(false)
+ }}
+ />
+
+ )
+}
+```
+
+Key changes:
+- Added `deliveryFeeLocked` to Props type
+- Replaced `DELIVERY_FEE_ADJUSTMENT` check with `PENDING_PAYMENT && deliveryFeeLocked === false`
+- Replaced `PENDING_PAYMENT` check with `PENDING_PAYMENT && deliveryFeeLocked === true`
+- Removed `PAYMENT_VERIFICATION` block
+- Changed "PAYMENT_VERIFICATION" label to "PAID" in the message
+
+- [ ] **Step 2: Update callers of `OrderPaymentSection`**
+
+Find where `OrderPaymentSection` is used (in `OrderDetailPage.tsx`) and add `deliveryFeeLocked` prop.
+
+In `client/src/pages/me/ui/sections/OrderDetailPage.tsx`, find the `OrderPaymentSection` usage and add:
+
+```tsx
+deliveryFeeLocked={order.deliveryFeeLocked}
+```
+
+Also update the `OrderDetailResponse` type in `client/src/entities/order/api/order-api.ts` to include `deliveryFeeLocked`:
+
+Add after line 26:
+```ts
+ deliveryFeeLocked: boolean
+```
+
+- [ ] **Step 3: Run client typecheck and lint**
+
+```bash
+cd client && npm run lint && npx tsc -b
+```
+
+Expected: No errors.
+
+---
+
+### Task 10: Update client admin order API
+
+**Files:**
+- Modify: `client/src/entities/order/api/admin-order-api.ts`
+
+- [ ] **Step 1: Add `deliveryFeeLocked` to type**
+
+Add to `AdminOrderDetailResponse.item` (after `deliveryFeeCents`):
+
+```ts
+ deliveryFeeCents: number
+ deliveryFeeLocked: boolean
+```
+
+- [ ] **Step 2: Run client typecheck**
+
+```bash
+cd client && npx tsc -b
+```
+
+Expected: No errors.
+
+---
+
+### Task 11: Run full verification
+
+**Files:** All
+
+- [ ] **Step 1: Run server tests**
+
+```bash
+cd server && npm test
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 2: Run client lint**
+
+```bash
+cd client && npm run lint
+```
+
+Expected: No errors.
+
+- [ ] **Step 3: Run client format check**
+
+```bash
+cd client && npm run format:check
+```
+
+Expected: No errors.
+
+- [ ] **Step 4: Run client typecheck**
+
+```bash
+cd client && npx tsc -b
+```
+
+Expected: No errors.
+
+- [ ] **Step 5: Run client build**
+
+```bash
+cd client && npm run build
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 6: Commit all changes**
+
+```bash
+git add .
+git commit -m "refactor: simplify order status model — remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION
+
+- Add deliveryFeeLocked field to Order model
+- Remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION statuses
+- Update checkout to use PENDING_PAYMENT + deliveryFeeLocked
+- Update payment flow to stay in PENDING_PAYMENT until admin confirms
+- Update admin UI to use deliveryFeeLocked instead of status check
+- Migrate existing orders to new status model"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:**
+- ✅ Remove `DELIVERY_FEE_ADJUSTMENT` — Tasks 2, 3, 4, 5, 6, 7, 8, 9, 11
+- ✅ Remove `PAYMENT_VERIFICATION` — Tasks 2, 3, 4, 6, 9, 11
+- ✅ Add `deliveryFeeLocked` field — Tasks 1, 5, 7, 8, 9, 10
+- ✅ Update checkout logic — Task 5
+- ✅ Update payment flow — Task 6
+- ✅ Update admin routes — Task 7
+- ✅ Update admin UI — Task 8
+- ✅ Update client payment UI — Task 9
+- ✅ Migrate existing data — Task 11
+
+**2. Placeholder scan:** No TBD, TODO, or incomplete sections. All steps contain actual code.
+
+**3. Type consistency:** `deliveryFeeLocked: boolean` used consistently across all files. `AdminOrderDetailResponse` and `OrderDetailResponse` both updated. All status references use the new 8-status list.
diff --git a/docs/superpowers/specs/2026-05-15-order-status-simplification-design.md b/docs/superpowers/specs/2026-05-15-order-status-simplification-design.md
new file mode 100644
index 0000000..6a16835
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-15-order-status-simplification-design.md
@@ -0,0 +1,72 @@
+# Дизайн: Упрощение статусной модели заказов (A+B)
+
+**Дата:** 2026-05-15
+**Статус:** Утверждено
+
+## Проблема
+
+Текущая модель заказов содержит 11 статусов, из которых два (`DELIVERY_FEE_ADJUSTMENT`, `PAYMENT_VERIFICATION`) являются промежуточными и могут быть заменены флагами/логикой.
+
+## Решение
+
+Убрать 2 статуса, добавить флаг `deliveryFeeLocked`. Итого: 9 → 8 статусов (было 11).
+
+### Удалённые статусы
+
+| Было | Чем заменяется |
+|---|---|
+| `DELIVERY_FEE_ADJUSTMENT` | Флаг `deliveryFeeLocked: Boolean` на заказе |
+| `PAYMENT_VERIFICATION` | Заказ остаётся в `PENDING_PAYMENT` до ручного подтверждения |
+
+### Новая статусная модель
+
+```
+DRAFT ──→ PENDING_PAYMENT ──→ PAID ──→ IN_PROGRESS ──┬─→ SHIPPED ──────────┐
+ │ │
+ └─→ READY_FOR_PICKUP ──┤
+ ▼
+ DONE
+
+CANCELLED ←── (из любого статуса, кроме DONE и CANCELLED)
+```
+
+### Логика `deliveryFeeLocked`
+
+| Значение | Админ | Клиент |
+|---|---|---|
+| `false` | Может менять `deliveryFeeCents` | Видит "ожидает утверждения доставки", оплата недоступна |
+| `true` | Не может менять доставку | Видит кнопку оплаты |
+
+### Переходы (admin)
+
+| Из | В |
+|---|---|
+| `DRAFT` | `PENDING_PAYMENT`, `CANCELLED` |
+| `PENDING_PAYMENT` | `PAID`, `CANCELLED` |
+| `PAID` | `IN_PROGRESS`, `CANCELLED` |
+| `IN_PROGRESS` (delivery) | `SHIPPED`, `CANCELLED` |
+| `IN_PROGRESS` (pickup) | `READY_FOR_PICKUP`, `CANCELLED` |
+| `SHIPPED` | *(нет)* |
+| `READY_FOR_PICKUP` | *(нет)* |
+| `DONE` | *(нет)* |
+| `CANCELLED` | *(нет)* |
+
+### Миграция данных
+
+Все заказы в статусе `DELIVERY_FEE_ADJUSTMENT` → `PENDING_PAYMENT` + `deliveryFeeLocked: false`.
+Все заказы в статусе `PAYMENT_VERIFICATION` → `PENDING_PAYMENT`.
+
+### Изменяемые файлы
+
+1. `server/prisma/schema.prisma` — добавить `deliveryFeeLocked Boolean @default(false)`
+2. `shared/constants/order-status.js` — убрать 2 статуса
+3. `shared/constants/order-status.d.ts` — убрать 2 статуса
+4. `server/src/lib/order-status.js` — обновить `canTransitionAdminOrderStatus`
+5. `client/src/shared/constants/order.ts` — обновить `getAdminNextOrderStatuses`
+6. `client/src/shared/lib/order-status-labels.ts` — убрать подписи
+7. `server/src/routes/user-orders.js` — убрать `DELIVERY_FEE_ADJUSTMENT`
+8. `server/src/routes/user-payments.js` — убрать `PAYMENT_VERIFICATION`
+9. `server/src/routes/api/admin-orders.js` — добавить эндпоинт `deliveryFeeLocked`, убрать проверки
+10. `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx` — обновить UI
+11. `client/src/features/order-payment/ui/OrderPaymentSection.tsx` — обновить UI
+12. `server/src/lib/__tests__/order-status.test.js` — обновить тесты
diff --git a/server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql b/server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql
new file mode 100644
index 0000000..d331b29
--- /dev/null
+++ b/server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql
@@ -0,0 +1,28 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_Order" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "status" TEXT NOT NULL DEFAULT 'DRAFT',
+ "deliveryFeeLocked" BOOLEAN NOT NULL DEFAULT false,
+ "deliveryType" TEXT NOT NULL DEFAULT 'delivery',
+ "deliveryCarrier" TEXT,
+ "paymentMethod" TEXT NOT NULL DEFAULT 'online',
+ "itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
+ "deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
+ "totalCents" INTEGER NOT NULL DEFAULT 0,
+ "currency" TEXT NOT NULL DEFAULT 'RUB',
+ "addressSnapshotJson" TEXT,
+ "comment" TEXT,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ "userId" TEXT NOT NULL,
+ CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "deliveryCarrier", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "paymentMethod", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "deliveryCarrier", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "paymentMethod", "status", "totalCents", "updatedAt", "userId" FROM "Order";
+DROP TABLE "Order";
+ALTER TABLE "new_Order" RENAME TO "Order";
+CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
+CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 12eb1bb..572c360 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -125,6 +125,7 @@ model Order {
id String @id @default(cuid())
/// Статус заказа (валидация переходов на уровне API)
status String @default("DRAFT")
+ deliveryFeeLocked Boolean @default(false)
/// 'delivery' | 'pickup'
deliveryType String @default("delivery")
/// RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST при deliveryType=delivery
diff --git a/server/src/lib/__tests__/order-status.test.js b/server/src/lib/__tests__/order-status.test.js
index 0205856..886755a 100644
--- a/server/src/lib/__tests__/order-status.test.js
+++ b/server/src/lib/__tests__/order-status.test.js
@@ -17,6 +17,14 @@ describe('canTransitionAdminOrderStatus', () => {
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PAID')).toBe(false)
})
+ it('PENDING_PAYMENT → PAID', () => {
+ expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'PAID')).toBe(true)
+ })
+
+ it('PENDING_PAYMENT → CANCELLED', () => {
+ expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'CANCELLED')).toBe(true)
+ })
+
it('PAID → IN_PROGRESS', () => {
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
})
diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js
index aeb8d71..474f518 100644
--- a/server/src/lib/order-status.js
+++ b/server/src/lib/order-status.js
@@ -12,11 +12,7 @@ export function canTransitionAdminOrderStatus(order, next) {
switch (from) {
case 'DRAFT':
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
- case 'DELIVERY_FEE_ADJUSTMENT':
- return next === 'CANCELLED'
case 'PENDING_PAYMENT':
- return next === 'CANCELLED'
- case 'PAYMENT_VERIFICATION':
return next === 'PAID' || next === 'CANCELLED'
case 'PAID':
return next === 'IN_PROGRESS' || next === 'CANCELLED'
diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js
index 769e06a..bc31d89 100644
--- a/server/src/routes/api/admin-orders.js
+++ b/server/src/routes/api/admin-orders.js
@@ -8,7 +8,7 @@ export async function registerAdminOrderRoutes(fastify) {
async () => {
const attentionCount = await prisma.order.count({
where: {
- status: { in: ['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] },
+ status: 'PENDING_PAYMENT',
},
})
return { attentionCount }
@@ -126,8 +126,8 @@ export async function registerAdminOrderRoutes(fastify) {
const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
- if (existing.status !== 'DELIVERY_FEE_ADJUSTMENT') {
- return reply.code(409).send({ error: 'Корректировка доставки доступна только в статусе DELIVERY_FEE_ADJUSTMENT' })
+ if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
+ return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' })
}
const totalCents = existing.itemsSubtotalCents + parsed
@@ -136,7 +136,7 @@ export async function registerAdminOrderRoutes(fastify) {
data: {
deliveryFeeCents: parsed,
totalCents,
- status: 'PENDING_PAYMENT',
+ deliveryFeeLocked: true,
},
})
return { item: updated }
diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js
index addeade..9089bc5 100644
--- a/server/src/routes/user-orders.js
+++ b/server/src/routes/user-orders.js
@@ -101,8 +101,13 @@ export async function registerUserOrderRoutes(fastify) {
})
let initialStatus = 'PENDING_PAYMENT'
- if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
- else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
+ let deliveryFeeLocked = true
+ if (paymentMethod === 'on_pickup') {
+ initialStatus = 'IN_PROGRESS'
+ } else if (deliveryType === 'delivery') {
+ initialStatus = 'PENDING_PAYMENT'
+ deliveryFeeLocked = false
+ }
let created
try {
@@ -124,6 +129,7 @@ export async function registerUserOrderRoutes(fastify) {
data: {
userId,
status: initialStatus,
+ deliveryFeeLocked,
deliveryType,
deliveryCarrier,
paymentMethod,
diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js
index 1d3a3d0..058cad7 100644
--- a/server/src/routes/user-payments.js
+++ b/server/src/routes/user-payments.js
@@ -18,115 +18,94 @@ export async function registerUserPaymentRoutes(fastify) {
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
}
- if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
+ if (order.status !== 'PENDING_PAYMENT') {
+ return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
+ }
+
+ if (!request.isMultipart()) {
return reply
- .code(409)
- .send({
- error:
- 'Оплата станет доступна после корректировки стоимости доставки администратором.',
- })
+ .code(400)
+ .send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
}
- let nextStatus = order.status
- if (order.status === 'DRAFT') {
- await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
- nextStatus = 'PENDING_PAYMENT'
- return { ok: true, status: nextStatus }
- }
-
- if (order.status === 'PAYMENT_VERIFICATION') {
- return { ok: true, status: nextStatus }
- }
-
- if (order.status === 'PENDING_PAYMENT') {
- if (!request.isMultipart()) {
- return reply
- .code(400)
- .send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
+ 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 })
+ }
- let detail = ''
- let receiptBuffer = null
- let receiptFilename = ''
+ 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 {
- const otherLimit = getOtherUploadMaxFileBytes()
- const parts = request.parts({
- limits: {
- fileSize: otherLimit,
- files: 2,
+ 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,
},
})
- 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.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
- await tx.orderMessage.create({
- data: {
- orderId: id,
- authorType: 'user',
- text: messageText,
- attachmentUrl,
- },
- })
- })
- } catch (err) {
- return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
- }
-
- return { ok: true, status: 'PAYMENT_VERIFICATION' }
+ })
+ } catch (err) {
+ return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
}
- return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
+ return { ok: true, status: 'PENDING_PAYMENT' }
},
)
}
diff --git a/shared/constants/order-status.d.ts b/shared/constants/order-status.d.ts
index c3178f2..74aaa24 100644
--- a/shared/constants/order-status.d.ts
+++ b/shared/constants/order-status.d.ts
@@ -1,8 +1,6 @@
export declare const ORDER_STATUSES: readonly [
'DRAFT',
- 'DELIVERY_FEE_ADJUSTMENT',
'PENDING_PAYMENT',
- 'PAYMENT_VERIFICATION',
'PAID',
'IN_PROGRESS',
'SHIPPED',
diff --git a/shared/constants/order-status.js b/shared/constants/order-status.js
index d5e25c0..186a3fe 100644
--- a/shared/constants/order-status.js
+++ b/shared/constants/order-status.js
@@ -1,8 +1,6 @@
export const ORDER_STATUSES = Object.freeze([
'DRAFT',
- 'DELIVERY_FEE_ADJUSTMENT',
'PENDING_PAYMENT',
- 'PAYMENT_VERIFICATION',
'PAID',
'IN_PROGRESS',
'SHIPPED',