Реализация возврата платежей (Refund) на сайте
Возврат платежа — это не просто вызов одного API-метода. Корректная реализация включает проверку состояния заказа, обновление остатков, уведомления, фискальные чеки и обработку граничных случаев. Без этого получается видимость возврата, а деньги или не возвращаются, или возвращаются дважды.
На реализацию уходит 2–4 рабочих дня в зависимости от количества платёжных методов и требований к фискализации.
Жизненный цикл возврата
Возврат проходит через несколько состояний: pending → processing → succeeded или failed. Пользователь инициирует запрос, менеджер (или автоматика) подтверждает, система отправляет запрос в платёжный шлюз, шлюз обрабатывает и возвращает результат через webhook.
Ни в коем случае не показывать покупателю «возврат выполнен» до получения подтверждения от платёжного шлюза.
Stripe Refund API
Базовый возврат через Stripe:
$refund = \Stripe\Refund::create([
'payment_intent' => $order->stripe_payment_intent_id,
'amount' => $refundAmountCents, // пропустить для полного возврата
'reason' => 'requested_by_customer', // duplicate, fraudulent
'metadata' => ['order_id' => $order->id, 'reason_text' => $reason],
]);
Stripe обрабатывает возвраты асинхронно. Финальный статус приходит в webhook charge.refund.updated. Обрабатывать нужно именно его, а не полагаться на синхронный ответ:
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);
}
}
Модель возврата в БД
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 = автоматически
fiscal_receipt_id varchar(100),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Отдельная таблица refunds, а не флаг в orders — позволяет делать несколько частичных возвратов и хранить историю.
Ограничения и проверки перед возвратом
public function validateRefundRequest(Order $order, int $amountCents): void
{
if (!in_array($order->payment_status, ['paid', 'partially_refunded'])) {
throw new RefundException('Заказ не оплачен или уже полностью возвращён');
}
$alreadyRefunded = $order->refunds()->where('status', 'succeeded')->sum('amount_cents');
$available = $order->total_cents - $alreadyRefunded;
if ($amountCents > $available) {
throw new RefundException("Максимальная сумма возврата: {$available} коп.");
}
$daysSincePurchase = now()->diffInDays($order->paid_at);
if ($daysSincePurchase > 365) {
throw new RefundException('Возврат возможен только в течение 365 дней с момента оплаты');
}
}
Stripe ограничивает возвраты периодом в 1 год. CloudPayments — 13 месяцев. YooKassa — 3 года. Каждый провайдер имеет свои лимиты, их нужно проверять в документации.
Фискальный чек на возврат
В России при возврате денег кассовый аппарат должен выбить чек с признаком «возврат прихода». Для Атол Онлайн / OFD-интеграций:
$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,
];
Чек на возврат должен выбиваться независимо от того, инициирован ли возврат со стороны клиента или по инициативе магазина.
Возврат для методов без карты
Для наличных, банковского перевода, СБП — автоматический API-возврат невозможен. В этих случаях реализуется сценарий с ручной обработкой: менеджер подтверждает факт возврата, система обновляет статус. Для COD (наличные при доставке) возврат часто означает отправку наличных курьером или банковский перевод — это должно быть задокументировано в интерфейсе.







