Refunds & chargebacks
Refund and dispute notifications from every store decode into a ResultType::Refund result and are recorded as a first-class PurchaseRefund:
| Store | Notification | Recorded as |
|---|---|---|
| Apple | REFUND, REVOKE | One refund per refunded transaction (keyed on its transactionId) — refunding the subscription’s current period also flips the Subscription to Refunded |
| Subscription-revoked RTDN, voided-purchase notification | Refund — a revoked subscription, or a void of its latest order, flips it to Refunded; a quantity-based partial void keeps the purchase Completed | |
| Stripe | charge.refunded | One refund per charge (keyed on its PaymentIntent) whose price is the running total refunded — a charge not fully refunded keeps the purchase Completed |
| Stripe | charge.dispute.created | Chargeback, keyed on the dispute id |
| Stripe | charge.dispute.closed as won | The same chargeback row is reversed and the purchase it was linked to reinstated (PurchaseCompleted) — only a purchase the package recorded; none is ever created |
| Stripe | Inquiries (warning_*), charge.dispute.updated | Audited 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 matchedreason 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 cryptoBy 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.