В типовом интернет-магазине возврат одного товара из трёх часто превращается в полный рефанд и повторную оплату остатка. Бухгалтерия путается, остатки на складе списываются некорректно, а клиент ждёт лишних переводов. Мы закладываем частичный возврат на уровне строк заказа — с фискальными чеками, блокировками и контролем инвариантов. Наш опыт показывает: правильная реализация исключает ошибки учёта на 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 день — просто напишите нам. Получите консультацию по вашему сценарию — мы подберём оптимальное решение.







