NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Purchases for Laravel

Refunds & chargebacks

Refund and dispute notifications from every store decode into a ResultType::Refund result and are recorded as a first-class PurchaseRefund:

StoreNotificationRecorded as
AppleREFUND, REVOKEOne refund per refunded transaction (keyed on its transactionId) — refunding the subscription’s current period also flips the Subscription to Refunded
GoogleSubscription-revoked RTDN, voided-purchase notificationRefund — a revoked subscription, or a void of its latest order, flips it to Refunded; a quantity-based partial void keeps the purchase Completed
Stripecharge.refundedOne refund per charge (keyed on its PaymentIntent) whose price is the running total refunded — a charge not fully refunded keeps the purchase Completed
Stripecharge.dispute.createdChargeback, keyed on the dispute id
Stripecharge.dispute.closed as wonThe same chargeback row is reversed and the purchase it was linked to reinstated (PurchaseCompleted) — only a purchase the package recorded; none is ever created
StripeInquiries (warning_*), charge.dispute.updatedAudited only — nothing changes

handle() records the refund idempotently, links it to the originating purchase — matched on the provider plus the transaction id or provider id — flips that purchase to Status::Refunded, and dispatches PurchaseRefunded, or ChargebackReceived for disputes. A refund of a subscription’s current period — the Apple transaction it is in, or its latest Google order — and a revoked Google subscription also flip the Subscription to Refunded, so it stops being active; refunding an earlier period leaves it alone. Each refunded Apple transaction is its own PurchaseRefund, so refunds of two periods are two rows and two events; an Apple refund’s refunded_at is its revocationDate. A partial refund (a Stripe charge not fully refunded, a Google quantity-based partial void) is recorded and fires PurchaseRefunded but leaves the purchase Completed:

use RoundlyConsulting\Purchases\Models\PurchaseRefund;

$purchase->refunds;                    // HasMany<PurchaseRefund>
$purchase->status;                     // Status::Refunded once a refund is matched

PurchaseRefund::chargebacks()->get();  // disputes only

$refund->chargeback;                   // bool
$refund->reason;                       // ?string
$refund->price;                        // ?Money — the refunded amount
$refund->refunded_at;                  // ?Carbon
$refund->purchase;                     // the Purchase it reverses, when matched

reason holds what the store reports — Stripe’s reason field, Apple’s notification type, Google’s refund type or notification name. A prorated Apple refund records only the refunded share; a Family Sharing revocation records no amount.

Stripe refunds are a running total

All refunds of one Stripe charge share one PurchaseRefund, keyed on its PaymentIntent, and its price is the charge’s cumulative amount_refunded. Each further partial refund fires PurchaseRefunded again with the new total, not the amount of that refund — so never add up $event->refund->price across events; read it as “refunded so far”. An Apple refund row is one refunded transaction, a Google one one voided order.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.