Implementation of Partial Payment Refund on Website
Partial refund is separate scenario often implemented carelessly: do full refund then new payment, or just issue voucher. Both create accounting and reporting problems. Correct partial refund implementation works on order lines level.
When Partial Refund Needed
Customer returned one of several items. Part of order not delivered. Wrong discount applied — need to return difference. Item had defect and client agreed to partial compensation.
Partial Refund API
Stripe, YooKassa and most providers support partial refund — same refund method with amount specified:
// Stripe: partial refund of specific amount
$refund = \Stripe\Refund::create([
'payment_intent' => $order->stripe_payment_intent_id,
'amount' => 150000, // 1500.00 RUB in kopeks
]);
// YooKassa
$builder = \YooKassa\Request\Refunds\CreateRefundRequest::builder();
$request = $builder
->setPaymentId($order->yookassa_payment_id)
->setAmount(new \YooKassa\Model\MonetaryAmount('1500.00', 'RUB'))
->setDescription('Refund for product #' . $item->id)
->build();
$refund = $client->createRefund($request, uniqid('', true));
Idempotent key in YooKassa critical — without it retry on timeout creates second refund.
Binding Refund to Order Items
Partial refund must be linked to specific items — needed for stock restoration and correct fiscal receipt:
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
);
When returning partial quantity (3 of 5), stocks restored only for returned quantity:
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]);
});
Fiscal Receipt for Partial Refund
Partial refund receipt contains only returned items with their amounts. Total order amount not in receipt:
$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',
]);
Limiting Partial Refunds Sum
Sum of all partial refunds must not exceed paid amount. Invariant must be checked at DB level:
-- Trigger or CHECK CONSTRAINT via function
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 'Sum of refunds exceeds order amount';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Partial Refund Interface
In admin panel need form where manager selects order lines and return quantity. Amount auto-calculated. "Refund" button blocked until amount recalculated. After confirmation — request sent to gateway, status updated via webhook. Manager sees final status without page reload.







