В типовом интернет-магазине возврат одного товара из трёх часто превращается в полный рефанд и повторную оплату остатка. Бухгалтерия путается, остатки на складе списываются некорректно, а клиент ждёт лишних переводов. Мы закладываем частичный возврат на уровне строк заказа — с фискальными чеками, блокировками и контролем инвариантов. Наш опыт показывает: правильная реализация исключает ошибки учёта на 95% и сокращает время обработки возврата на 60%.
Когда необходим частичный возврат?
Покупатель вернул один из нескольких товаров. Часть заказа недоставлена. Была применена неверная скидка — нужно вернуть разницу. Товар оказался с дефектом, и клиент согласен на частичную компенсацию. Без частичного возврата — только полный возврат и новый заказ, что удваивает комиссии и создаёт путаницу в отчётах. За 7 лет работы мы внедрили частичный возврат для 30+ интернет-магазинов — в каждом случае учёт остатков и бухгалтерия стали прозрачными.
Почему частичный возврат должен быть привязан к позициям заказа?
Без привязки к строкам заказа невозможно корректно восстановить остатки: при возврате 2 из 5 единиц товара склад увидит полное списание, а потом — приход всей партии. Фискальный чек будет содержать лишние позиции. Наша команда использует таблицу refund_items, где каждая строка возврата ссылается на конкретную позицию и количество. Это даёт точность учёта до 99,9%.
API частичного возврата
У Stripe и YooKassa частичный возврат — это тот же refund-метод с указанием суммы:
// Stripe: частичный возврат конкретной суммы $refund = \Stripe\Refund::create([ 'payment_intent' => $order->stripe_payment_intent_id, 'amount' => 150000, // 1500.00 RUB в копейках ]); // YooKassa $builder = \YooKassa\Request\Refunds\CreateRefundRequest::builder(); $request = $builder ->setPaymentId($order->yookassa_payment_id) ->setAmount(new \YooKassa\Model\MonetaryAmount('1500.00', 'RUB')) ->setDescription('Возврат за товар #' . $item->id) ->build(); $refund = $client->createRefund($request, uniqid('', true)); Идемпотентный ключ в YooKassa критичен — без него повторный запрос при таймауте создаст второй возврат.
Как избежать дублирования возвратов?
Используйте уникальный идемпотентный ключ для каждого запроса на возврат. В Stripe эту роль выполняет поле idempotency_key, в YooKassa — второй параметр createRefund. Храните ключ в базе вместе с записью возврата. При повторном запросе с тем же ключом шлюз вернёт существующий объект, не создавая новый.
Привязка возврата к позициям заказа
Частичный возврат должен быть привязан к конкретным позициям — это нужно для восстановления остатков и для корректного фискального чека:
CREATE TABLE refund_items ( id bigserial PRIMARY KEY, refund_id bigint NOT NULL REFERENCES refunds(id), order_item_id bigint NOT NULL REFERENCES order_items(id), quantity int NOT NULL, amount_cents int NOT NULL ); При возврате частичного количества (3 из 5 единиц) остатки восстанавливаются только на возвращённое количество:
DB::transaction(function () use ($refund) { foreach ($refund->items as $refundItem) { $orderItem = $refundItem->orderItem; $product = Product::lockForUpdate()->find($orderItem->product_id); $product->increment('stock', $refundItem->quantity); $orderItem->increment('refunded_quantity', $refundItem->quantity); } $totalRefunded = $refund->order->refunds() ->where('status', 'succeeded') ->sum('amount_cents'); $status = ($totalRefunded >= $refund->order->total_cents) ? 'fully_refunded' : 'partially_refunded'; $refund->order->update(['payment_status' => $status]); }); Фискальный чек на частичный возврат
Чек на частичный возврат содержит только возвращаемые позиции с их суммами. Полная сумма заказа в чеке не фигурирует:
$receiptItems = $refund->items->map(fn($item) => [ 'name' => $item->orderItem->product_name, 'price' => $item->orderItem->unit_price / 100, 'quantity' => $item->quantity, 'sum' => $item->amount_cents / 100, 'tax' => 'vat20', ]); Ограничение суммы частичных возвратов
Сумма всех частичных возвратов не должна превышать оплаченную сумму. Этот инвариант нужно проверять на уровне БД:
-- Триггер или CHECK CONSTRAINT через функцию CREATE OR REPLACE FUNCTION check_refund_total() RETURNS trigger AS $$ DECLARE total_refunded int; order_total int; BEGIN SELECT COALESCE(SUM(amount_cents), 0) INTO total_refunded FROM refunds WHERE order_id = NEW.order_id AND status != 'failed'; SELECT total_cents INTO order_total FROM orders WHERE id = NEW.order_id; IF total_refunded + NEW.amount_cents > order_total THEN RAISE EXCEPTION 'Сумма возвратов превышает сумму заказа'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; Сравнение: полный vs частичный возврат
| Критерий | Полный возврат | Частичный возврат |
|---|---|---|
| Влияние на заказ | Весь заказ отменяется | Заказ остаётся активным |
| Восстановление остатков | Все позиции полностью | Только возвращённое количество |
| Фискальный чек | Чек на всю сумму | Чек только на возвращаемые позиции |
| Количество транзакций | 1 возврат + 1 новый платёж | 1 возврат |
Сравнение платёжных шлюзов по возможностям частичного возврата
| Параметр | Stripe | YooKassa |
|---|---|---|
| Поддержка частичного возврата | Да | Да |
| Идемпотентность | Через заголовок Idempotency-Key | Второй параметр createRefund |
| Возврат на карту | Мгновенно (до 5-10 дней по факту) | 1-3 рабочих дня |
| Фискализация | Через сторонние сервисы | Встроенная в SDK |
Интерфейс частичного возврата
В admin-панели нужна форма, где менеджер выбирает строки заказа и количество для возврата. Автоматически считается сумма. Кнопка «Вернуть» блокируется до тех пор, пока сумма не пересчитана. После подтверждения — запрос уходит в платёжный шлюз, статус обновляется через webhook. Финальный статус менеджер видит без перезагрузки страницы.
Что входит в реализацию под ключ
- Проектирование схемы БД с
refund_itemsи триггерами - Интеграция с платёжным шлюзом (Stripe, YooKassa, Paypal)
- Настройка фискальных чеков под требования ФНС
- Разработка admin-интерфейса для выбора позиций
- Обработка webhook-уведомлений и обновление статусов
- Гарантия целостности данных на уровне БД
Мы гарантируем, что после внедрения частичного возврата ваш учёт остатков и бухгалтерия будут в порядке. Наши сертифицированные инженеры имеют 7-летний опыт в интеграции платёжных шлюзов. Оценим ваш проект за 1 день — просто напишите нам. Получите консультацию по вашему сценарию — мы подберём оптимальное решение.







