Повернення коштів та диспути
Повернення коштів та диспути
Термінологія: чотири різні поняття
Розробники часто плутають наступні поняття, використовуючи їх як синоніми. Це призводить до помилок у логіці: наприклад, спроба «зробити refund» на авторизований, але ще не списаний платіж, коли правильна дія — Void.
Void (відміна авторизації)
Void — відміна зарезервованих (авторизованих) коштів до того, як відбувся capture. Кошти не рухаються — знімається лише бронь.
- Відбувається того ж дня, часто миттєво
- Не з'являється у виписці клієнта (бронь зникає)
- Доступно тільки якщо платіж ще у статусі
Authorized(до settlement) - У LiqPay:
action = "cancel"дляhold_waitплатежів
Refund (повернення коштів)
Refund — повернення вже розрахованих коштів покупцю. Це нова транзакція у зворотньому напрямку.
- Займає 1–5 робочих днів для покупця
- З'являється у виписці як окремий запис
- Може бути повним (
Full Refund) або частковим (Partial Refund) - Комісія PSP зазвичай не повертається
Chargeback (опротестування)
Chargeback — примусове повернення коштів, ініційоване банком-емітентом за заявою покупця. Відбувається поза вашим контролем.
Банк-емітент має право на chargeback якщо:
- Покупець не отримав товар
- Транзакція шахрайська (картку вкрали)
- Товар значно відрізнявся від опису
Для продавця chargeback несе подвійні втрати: кошти повертаються покупцю + штраф PSP ($15–50 за кожен chargeback).
Dispute (диспут)
Dispute — загальний термін для оскарження транзакції. Chargeback — один із видів dispute. У Stripe ecosystem «dispute» — це весь lifecycle процесу оскарження.
| Void | Refund | Chargeback | |
|---|---|---|---|
| Ініціатор | Продавець | Продавець | Покупець → банк-емітент |
| Час | Миттєво | 1–5 днів | 3–6 тижнів |
| Умова | До capture/settlement | Після settlement | Будь-коли (до 120 днів) |
| Штраф | Немає | Немає | $15–50 від PSP |
| Комісія back? | Так | Ні | Ні + штраф |
Реалізація Refund у PaymentService
public async Task<RefundResult> InitiateRefundAsync(
Guid paymentId,
decimal? amount = null, // null = повне повернення
string? reason = null,
CancellationToken ct = default)
{
var payment = await _db.Payments
.FirstOrDefaultAsync(p => p.Id == paymentId, ct)
?? throw new NotFoundException($"Payment {paymentId} not found");
// Перевіряємо, що повернення взагалі можливе
if (payment.Status != PaymentStatus.Settled
&& payment.Status != PaymentStatus.PartiallyRefunded)
{
throw new InvalidOperationException(
$"Cannot refund payment in status {payment.Status}. " +
$"Only Settled or PartiallyRefunded payments can be refunded.");
}
// Визначаємо суму повернення
var refundAmount = amount ?? payment.Amount;
if (refundAmount <= 0 || refundAmount > payment.Amount)
throw new ArgumentException("Invalid refund amount");
// Ідемпотентний ключ для refund
var refundKey = $"refund_{paymentId}_{refundAmount}_{DateTimeOffset.UtcNow:yyyyMMddHH}";
// Делегуємо провайдеру
var provider = _providerFactory.GetProvider(payment.Provider);
var result = await provider.RefundAsync(
payment.ProviderTransactionId!, refundAmount, ct);
if (!result.Success)
{
throw new PaymentRefundException($"Refund failed: {result.ErrorMessage}");
}
// Оновлюємо статус платежу
var isFullRefund = refundAmount == payment.Amount;
payment.Status = isFullRefund
? PaymentStatus.Refunded
: PaymentStatus.PartiallyRefunded;
payment.UpdatedAt = DateTimeOffset.UtcNow;
// Зберігаємо запис про повернення
_db.Refunds.Add(new Refund
{
Id = Guid.NewGuid(),
PaymentId = paymentId,
Amount = refundAmount,
ProviderRefundId = result.RefundId,
Reason = reason,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync(ct);
return result;
}
Entity для зберігання повернень
public class Refund
{
public Guid Id { get; set; }
public Guid PaymentId { get; set; }
public Payment Payment { get; set; } = null!;
public decimal Amount { get; set; }
public string? ProviderRefundId { get; set; } // ID повернення від PSP
public string? Reason { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
Refund endpoint
group.MapPost("/{id}/refund", RefundPayment)
.WithName("RefundPayment")
.WithSummary("Ініціювати повернення коштів");
private static async Task<IResult> RefundPayment(
Guid id,
[FromBody] RefundRequest? request,
PaymentService paymentService,
CancellationToken ct)
{
var result = await paymentService.InitiateRefundAsync(
paymentId: id,
amount: request?.Amount,
reason: request?.Reason,
ct: ct);
return Results.Ok(new
{
RefundId = result.RefundId,
Message = result.Success ? "Повернення ініційовано" : result.ErrorMessage
});
}
public record RefundRequest(decimal? Amount, string? Reason);
Chargeback: процес та реагування
Коли покупець ініціює chargeback, PSP надсилає вам повідомлення (через webhook або email). У вас є обмежений час (зазвичай 7–21 день) для подачі «доказів» (evidence).
Докази для оскарження chargeback:
- Підтвердження доставки (треккінг-номер, підпис отримувача)
- Логи авторизації (IP-адреса, user agent, timestamp)
- Скриншоти спілкування з покупцем
- Умови публічної оферти (підпис при реєстрації)
Anti-fraud стратегії
Найкращий refund — той, якого не довелося робити. Запобігіть шахрайським транзакціям:
3-D Secure 2.x
Velocity checks
AVS (Address Verification System)
Чорний список
Права споживачів в Україні
Як розробник платіжного модуля, вам необхідно знати правову рамку:
Закон України «Про захист прав споживачів» (Закон № 1023-XII) надає споживачам право на:
- Повернення товару належної якості протягом 14 днів без пояснення причини (для дистанційних продажів)
- Повернення товару неналежної якості в будь-який час
Для digital-продуктів (SaaS, ліцензії):
- 14-денне «право на відмову» технічно застосовується, але може бути обмежено умовами оферти для вже «використаних» цифрових продуктів
- Публічна оферта повинна чітко визначати умови повернення
Строки повернення коштів:
- Закон вимагає повернення коштів протягом 10 днів з моменту отримання заяви
- PSP технічно виконують refund за 1–5 банківських днів
Підсумок
Правильна обробка повернень вимагає чіткого розуміння різниці між Void (до settlement), Refund (після settlement) та Chargeback (примусове, ініційоване покупцем). Реалізуйте refund через абстракцію IPaymentProvider, зберігайте всі повернення у таблиці Refunds для аудиту. Anti-fraud заходи (3DS, velocity checks) знижують необхідність у поверненнях та захищають від перевищення ліміту chargebacks.
Підписки та рекурентні платежі
Архітектура підписних платежів (subscriptions) — токенізація карток, Merchant-Initiated Transactions, billing cycle, dunning та реалізація через LiqPay і Stripe.
Тестування платіжних інтеграцій
Стратегії тестування платіжної підсистеми — Sandbox режими, тестові картки, unit/integration тести з mock-провайдерами, webhook тестування та CI/CD.