Реалізація частинного повернення платежу на веб-сайті
Часткове повернення — окремий сценарій, який часто реалізується небрежно: роблять повне повернення і новий платіж, або просто видають купон. Обидва варіанти створюють проблеми з бухгалтерією і звітністю. Правильна реалізація часткового рефанду працює на рівні рядків замовлення.
Коли потрібне часткове повернення
Покупець повернув один з кількох товарів. Частина замовлення не доставлена. Була застосована невірна скидка — потрібно вернути різницю. Товар виявився з дефектом і клієнт погодився на часткову компенсацію.
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. Фінальний статус менеджер бачить без перезагрузки сторінки.







