Реализация частичного возврата платежа на сайте
Частичный возврат — отдельный сценарий, который часто реализуется небрежно: делают полный возврат и новый платёж, или просто выдают купон. Оба варианта создают проблемы с бухгалтерией и отчётностью. Правильная реализация частичного рефанда работает на уровне строк заказа.
Когда нужен частичный возврат
Покупатель вернул один из нескольких товаров. Часть заказа недоставлена. Была применена неверная скидка — нужно вернуть разницу. Товар оказался с дефектом и клиент согласен на частичную компенсацию.
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 критичен — без него повторный запрос при таймауте создаст второй возврат.
Привязка возврата к позициям заказа
Частичный возврат должен быть привязан к конкретным позициям — это нужно для восстановления остатков и для корректного фискального чека:
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;
Интерфейс частичного возврата
В admin-панели нужна форма, где менеджер выбирает строки заказа и количество для возврата. Автоматически считается сумма. Кнопка «Вернуть» блокируется до тех пор, пока сумма не пересчитана. После подтверждения — запрос уходит в платёжный шлюз, статус обновляется через webhook. Финальный статус менеджер видит без перезагрузки страницы.







