Платежі

Повернення коштів та диспути

Void, Refund, Chargeback — термінологія та практична реалізація повернень через LiqPay, Monobank та Stripe. Права споживачів в Україні та anti-fraud стратегії.

Повернення коштів та диспути

Термінологія: чотири різні поняття

Розробники часто плутають наступні поняття, використовуючи їх як синоніми. Це призводить до помилок у логіці: наприклад, спроба «зробити 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 процесу оскарження.

VoidRefundChargeback
ІніціаторПродавецьПродавецьПокупець → банк-емітент
ЧасМиттєво1–5 днів3–6 тижнів
УмоваДо capture/settlementПісля settlementБудь-коли (до 120 днів)
ШтрафНемаєНемає$15–50 від PSP
Комісія back?ТакНіНі + штраф

Реалізація Refund у PaymentService

Services/PaymentService.cs
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 для зберігання повернень

Entities/Refund.cs
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

Endpoints/PaymentEndpoints.cs
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)
  • Скриншоти спілкування з покупцем
  • Умови публічної оферти (підпис при реєстрації)
Якщо рівень chargeback перевищує 1% від транзакцій, PSP може заморозити виплати або розірвати договір. Це жорсткий ліміт більшості PSP.

Anti-fraud стратегії

Найкращий refund — той, якого не довелося робити. Запобігіть шахрайським транзакціям:

3-D Secure 2.x

Обов'язково увімкніть 3DS. При успішному 3DS відповідальність за chargeback переходить на банк-емітент (liability shift).

Velocity checks

Обмежте кількість спроб оплати з одного IP / email / картки за одиницю часу. Простий Rate Limiter з Redis запобігає brute-force атакам на картки.

AVS (Address Verification System)

Перевірка поштового індексу та адреси власника картки. Доступно для Stripe та деяких конфігурацій інших PSP.

Чорний список

Зберігайте IP, email, device fingerprint замовлень, що завершилися chargebacks. Відхиляйте нові замовлення від цих ідентифікаторів.

Права споживачів в Україні

Як розробник платіжного модуля, вам необхідно знати правову рамку:

Закон України «Про захист прав споживачів» (Закон № 1023-XII) надає споживачам право на:

  • Повернення товару належної якості протягом 14 днів без пояснення причини (для дистанційних продажів)
  • Повернення товару неналежної якості в будь-який час

Для digital-продуктів (SaaS, ліцензії):

  • 14-денне «право на відмову» технічно застосовується, але може бути обмежено умовами оферти для вже «використаних» цифрових продуктів
  • Публічна оферта повинна чітко визначати умови повернення

Строки повернення коштів:

  • Закон вимагає повернення коштів протягом 10 днів з моменту отримання заяви
  • PSP технічно виконують refund за 1–5 банківських днів
Включіть у публічну оферту чіткий розділ про умови повернення. Це захищає вас юридично і зменшує кількість chargebacks — клієнт читає умови przed покупкою.

Підсумок

Правильна обробка повернень вимагає чіткого розуміння різниці між Void (до settlement), Refund (після settlement) та Chargeback (примусове, ініційоване покупцем). Реалізуйте refund через абстракцію IPaymentProvider, зберігайте всі повернення у таблиці Refunds для аудиту. Anti-fraud заходи (3DS, velocity checks) знижують необхідність у поверненнях та захищають від перевищення ліміту chargebacks.