Implementation of Payment Refund on Website
Payment refund is not just calling one API method. Correct implementation includes order status verification, balance updates, notifications, fiscal receipts and edge case handling. Without this get appearance of refund, but money either doesn't return or returns twice.
Development takes 2–4 business days depending on payment methods count and fiscalization requirements.
Refund Lifecycle
Refund goes through states: pending → processing → succeeded or failed. User initiates request, manager (or automation) confirms, system sends request to gateway, gateway processes and returns result via webhook.
Never show customer "refund completed" before receiving payment gateway confirmation.
Stripe Refund API
Basic refund via Stripe:
$refund = \Stripe\Refund::create([
'payment_intent' => $order->stripe_payment_intent_id,
'amount' => $refundAmountCents, // omit for full refund
'reason' => 'requested_by_customer', // duplicate, fraudulent
'metadata' => ['order_id' => $order->id, 'reason_text' => $reason],
]);
Stripe processes refunds asynchronously. Final status comes in webhook charge.refund.updated. Must handle this, not rely on sync response:
public function handleRefundUpdated(array $payload): void
{
$refund = $payload['data']['object'];
$localRefund = Refund::where('stripe_refund_id', $refund['id'])->firstOrFail();
$localRefund->update(['status' => $refund['status']]);
if ($refund['status'] === 'succeeded') {
$localRefund->order->update(['refund_status' => 'refunded']);
$this->restoreStock($localRefund->order);
$this->sendRefundConfirmation($localRefund->order);
$this->issueFiscalRefundReceipt($localRefund);
}
if ($refund['status'] === 'failed') {
Log::error('Refund failed', ['stripe_refund_id' => $refund['id'], 'failure_reason' => $refund['failure_reason']]);
$this->notifySupport($localRefund);
}
}
Refund DB Model
CREATE TABLE refunds (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
stripe_refund_id varchar(100) UNIQUE,
amount_cents int NOT NULL,
currency char(3) NOT NULL DEFAULT 'rub',
status varchar(20) NOT NULL DEFAULT 'pending',
reason text,
initiated_by bigint REFERENCES users(id), -- null = automatic
fiscal_receipt_id varchar(100),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Separate refunds table, not flag in orders — allows multiple partial refunds and history.
Validation and Checks Before Refund
public function validateRefundRequest(Order $order, int $amountCents): void
{
if (!in_array($order->payment_status, ['paid', 'partially_refunded'])) {
throw new RefundException('Order not paid or fully refunded');
}
$alreadyRefunded = $order->refunds()->where('status', 'succeeded')->sum('amount_cents');
$available = $order->total_cents - $alreadyRefunded;
if ($amountCents > $available) {
throw new RefundException("Maximum refund amount: {$available} kopeks");
}
$daysSincePurchase = now()->diffInDays($order->paid_at);
if ($daysSincePurchase > 365) {
throw new RefundException('Refunds only possible within 365 days of payment');
}
}
Stripe limits refunds to 1 year. CloudPayments 13 months. YooKassa 3 years. Each provider has limits, check docs.
Fiscal Receipt on Refund
In Russia when returning money, cash register must print receipt with "return of receipt" indicator. For Atol Online / OFD integrations:
$receipt = [
'type' => 'refund',
'items' => array_map(fn($item) => [
'name' => $item->product_name,
'price' => $item->unit_price / 100,
'quantity' => $item->quantity,
'sum' => $item->total_price / 100,
'tax' => 'vat20',
'payment_method' => 'full_payment',
'payment_object' => 'commodity',
], $order->refundItems),
'payments' => [['type' => 1, 'sum' => $refundAmount / 100]],
'total' => $refundAmount / 100,
'email' => $order->customer_email,
];
Refund receipt must be issued regardless of whether refund initiated by customer or store.
Refund for Non-Card Methods
For cash, bank transfer, SBP — automatic API refund impossible. Implement scenario with manual processing: manager confirms return fact, system updates status. For COD — often means sending cash by courier or bank transfer — must be documented in interface.







