Event reference
The package's own events, all under Pushery\Billing\Events. Listen to these and your app never reads a
provider payload.
A driver's webhook mapper translates each provider event — a signed Stripe event, a bare-id ping, an HMAC batch — into one of the domain events below, and everything downstream listens on those. That is what makes a side effect survive a driver change: the mapper is provider-specific, the event is not.
The marker interface is BillingDomainEvent. It carries no methods; it exists so the effect bus can only be
handed an event that is part of this contract.
Listening
Every domain event goes through Laravel's dispatcher, so listen the ordinary way:
use Illuminate\Support\Facades\Event;
use Pushery\Billing\Events\PaymentFailed;
Event::listen(PaymentFailed::class, function (PaymentFailed $event): void {
// $event->customerReference, $event->amount, $event->reference
});
They are equally ordinary to assert on:
use Illuminate\Support\Facades\Event;
use Pushery\Billing\Events\SubscriptionStateChanged;
Event::fake();
// ... exercise the code under test ...
Event::assertDispatched(SubscriptionStateChanged::class);
See Testing for Billing::fake(), which fakes the driver rather than the events, and
for the cross-engine suites.
The events
customerReference is always the provider's customer id, resolved back to your billable model through
billing.customer.column. Money is always a Money value object — an integer minor amount plus a currency,
never a float.
callerReference is your key, carried through the purchase and given back untouched. Pass whatever
identifies the row you wrote before opening the checkout — a cart id, an order number, sponsorship:4711 —
and read it back here. This package attaches no meaning to it and never looks inside it.
It exists because the other key that travels, declarationReference, is minted by
PurchaseDeclarations::declare(), which records a withdrawal consent while it mints. A business buyer has
no right of withdrawal, so there is nothing to declare and no reference to mint. Without a key of its own,
exactly the purchases without a declaration would arrive with no key at all. For a one-off a session id could
stand in; for a subscription nothing could, because the subscription reference does not exist yet when the
session is opened.
Do not send a correlation id as declarationReference instead. It would come back meaning this buyer
declared, which is the one statement a business checkout must not make. The two are separate keys with
separate meanings, and a checkout may carry both.
subscriptionType names which contract the event is about, and it is null for the ordinary case of one
subscription per owner. A local row is unique per owner, type and merchant, so an owner who holds two
contracts at the same merchant tells them apart by this field alone — pass the same type you passed to
subscribe(), and read it back here. Null is read as the default type wherever none is named, which is why
nothing moves for a consumer that never uses a second contract.
couponCode on SubscriptionStateChanged is the local code of a merchant coupon this package minted —
the one in your own coupons row, not the provider's id — so booking the redemption against that row needs
none of the provider's arithmetic. Its null is as meaningful as its value. A catalog coupon, created by a
human at the provider with its own max_redemptions, is counted by the provider itself: there is nothing for
you to book, so the field is null. It is also null when no discount was applied. Read the value as "this
redemption is yours to count" and the null as "it is not" — counting on a coupon id instead would make the two
cases look alike and have you count a redemption twice.
endsAt on SubscriptionStateChanged is when a canceled subscription ends, in Unix seconds, and once it has
ended, when it did. It is not the period end. An ordinary cancellation ends there, but a cancellation to a date
ends inside the period, and reading periodEnd instead would tell the owner they keep access for weeks they no
longer have. It is null while nothing ends the subscription.
| Event | When it fires | Payload |
|---|---|---|
SubscriptionStateChanged | A subscription moved to a new canonical state | customerReference, state (a SubscriptionState), subscriptionReference?, tierKey?, occurredAt?, periodStart?, periodEnd?, trialEnd?, merchant?, merchantAccountReference?, declarationReference?, startedAt?, couponCode?, subscriptionType?, callerReference?, endsAt? |
TrialEnding | A trial is about to end, a few days out | customerReference, subscriptionReference, trialEndsAt |
PaymentSucceeded | A payment completed | customerReference, amount, reference |
PaymentFailed | A payment attempt failed | customerReference, amount, reference |
AccountPaymentChanged | A one-off payment into a payment account changed, as its provider reported it on the account's webhook address. It carries where the payment stands now, never a delta, and it is the only event an account's webhooks raise | provider, account, paymentReference, status, amount, amountRefunded, amountChargedBack, billingReference?, metadata, refundReference? |
PaymentActionRequired | The bank asked the cardholder to confirm (3-D Secure) | customerReference, reference |
PaymentReminderDue | A marketplace subscription is in arrears and still inside its cure window — today's reminder is due | entry (an ArrearsEntry), daysLeft, subscription? |
SubscriptionExpired | A marketplace subscription ran out its cure window and expired for good | subscription, accessEndsAt |
WebhookDeliveryRefused | A delivery reached a billing webhook endpoint and its verifier refused it | provider, surface, path, userAgent? |
WriteOffRecovered | Money arrived against a receivable written off as uncollectible — the write-off was a judgment the future disagreed with | correction, received, paymentReference |
ChargebackReceived | A chargeback was decided against a settled payment | customerReference, reference, amount, merchantReference?, transferReference?, feeAmount?, cause?, reason?, disputeReference? |
ChargebackReversed | A chargeback the bank took back, so the money is with the merchant again. Raised by billing:money-back:reconcile for a provider that reports a chargeback before the bank decides | customerReference, reference, amount, disputeReference, reversedAt?, standingRefunded? |
DisputeOpened | A buyer disputed a payment and the case can still be answered. Raised when the provider opens it, long before any outcome | disputeReference, paymentReference, amount, reason, reasonCode?, evidenceDueBy?, accountReference?, merchantReference? |
InPersonSalePaid | A buyer paid a sale at the counter with a card, and the provider confirmed it. The moment the sale is documented: until the card was presented nothing had been sold | provider, paymentReference, amount |
InPersonSaleCanceled | A sale was taken off the reader before it was paid, and the provider closed its payment. A declined card does not raise it, because that payment stays open on the reader | provider, paymentReference |
MandateRevoked | A stored mandate is no longer usable | customerReference, mandateId |
DefaultPaymentMethodChanged | The payment method a customer is charged with by default changed, or its details did: set or removed in the provider's portal, or replaced by the bank's card updater. It names the customer, because the method is read again | customerReference |
CustomerDeleted | The provider deleted the customer, so its reference names nothing any more | customerReference |
MandateEstablished | A payment method was granted and can now be charged off-session. A card mandate carries its card where the provider names it | customerReference, mandateId, provider, method?, paymentReference?, card? (a PaymentMethod) |
PaymentMethodCollected | A customer finished the hosted page for adding a payment method. It carries the provider's handle for the collection, not the method, because the notification that the page was completed does not name the method | customerReference, collectionReference, provider |
AddonPurchased | A one-time add-on was bought and paid | customerReference, addonKey, amount, reference, paymentReference?, declarationReference?, buyerCountry?, taxMinor?, provider?, callerReference?, buyerTaxId? |
AddonRefunded | A charge was refunded | paymentReference, cumulativeRefunded, reason?, standingChargebacks |
RefundFailed | A refund will not reach the customer: it failed at the provider, or it was canceled before it was paid out. Raised by billing:money-back:reconcile (Mollie) and by the refund webhooks of Stripe, which name no customer | customerReference, paymentReference, refundReference, amount, failure (a RefundFailure: failed or canceled), reason? |
FanTipPaid | A buyer-chosen tip was paid on a hosted checkout page. Its own event rather than an add-on purchase, because a tip has no catalog key — read as one it would be sent to the grant path, which would look up a key that does not exist and credit nothing | customerReference, amount, reference, paymentReference?, soldAlongside, merchantReference, buyerIsDomestic?, declarationReference? |
InvoiceFinalized | The provider finalized an invoice — it now legally exists | customerReference (the provider's customer the invoice is for), invoice (an InvoiceSnapshot) |
SaleCountryReported | The provider reported the country it taxed a sale in: a settled one-time checkout, and every invoice of a subscription, once finalized and again when paid | customerReference, saleReference, country?, amount, paid, chargeReference?, subscriptionReference? |
TaxIdVerificationReported | The provider reported what a tax authority's register said about a buyer's tax ID: pending first, a verdict once the register answers. A checkout checks only the format while the buyer is on the page | customerReference, provider, taxIdReference, type, value, status, verifiedName?, verifiedAddress? |
InvoiceCorrected | A correcting document was issued against a finalized invoice | customerReference (the provider's customer the correction is for), correction (an InvoiceCorrectionSnapshot) |
InvoiceCredited | Deprecated — the former name of InvoiceCorrected | customerReference, correction |
MerchantAccountUpdated | The provider reported what a merchant's account can do | account (a MerchantAccountReference), occurredAt? |
MerchantTransferReversed | Money came back from a merchant — a refund or a lost dispute | merchant, provider, chargeReference, amount, feeReturned, cause, disputeFee? |
MerchantReversalCameBackShort | Less came back from a merchant than the reversal asked for; the package books nothing | merchant, provider, chargeReference, shortfall, cause |
MerchantAccountDeauthorized | A merchant disconnected their account from the platform | provider, accountReference |
MerchantTransferReversedByProvider | The provider reversed a transfer this platform did not ask it to reverse. It arrives on the platform endpoint, where Stripe sends the events of the platform's transfers | provider, transferReference, amountReversedMinor |
MerchantPayoutFailed | The provider could not move a merchant's money from their connected balance to their bank. A different question from a transfer, and it fails on its own terms — a wrong IBAN, a closed account, a bank that refuses it — with nothing wrong about the transfer that fed it. Only the failure is carried: the success is the ordinary case and the provider's dashboard already shows it. Attributed to the merchant, never to a charge, because a payout bundles many transfers and has no 1:1 relation to a recorded sale | provider, accountReference, payoutReference, amountMinor, currency, failureCode?, failureMessage? |
BuyerProtectionHoldReleased | The protection period ended in the seller's favor and the money is on its way to them | chargeReference, merchantType?, merchantId?, amount, state |
BuyerProtectionHoldRefunded | The protection period ended in the buyer's favor and the money goes back to them | chargeReference, merchantType?, merchantId?, amount, state |
BuyerProtectionResolutionRequired | Nobody decided in time and this package will not decide for them — a disputed hold past its deadline | chargeReference, merchantType?, merchantId?, amount, state |
MerchantRoutingSuspended | The platform stopped routing new money to a merchant | provider, accountReference, reason |
MerchantRoutingReinstated | A suspended merchant may receive again | provider, accountReference |
MerchantTerminated | The relationship ended and cannot be resumed from here | provider, accountReference, reason |
RoutedChargeConfirmed | A routed payment that could not settle at once has cleared. Two cannot: a card demanding 3-D Secure, and a bank debit that clears days later — both return successfully having moved no money, so the merchant's row is written pending until this arrives. A hosted one-off sale is pending until the buyer pays, and its payment carries the sale's own reference. | provider, paymentReference, transferReference?, saleReference? |
RoutedChargeAbandoned | A pending routed payment will not complete. Only ever from pending: a charge that settled and later goes wrong is a refund or a dispute, never a failure, and calling it one would erase the fact that the money was really there. Also raised when a hosted checkout lapses unpaid. | provider, paymentReference, saleReference? |
MerchantShareNotMoved | A paid sale's merchant share failed to move. On a separate transfer the share moves in a second call after the buyer's payment, and that call failed: the buyer has paid, the merchant has not been, and the sale's row stays pending with the failure written beside it. No webhook ever arrives for a transfer that was never created, so this is the only signal. billing:marketplace:retry-transfers moves the share once the cause is fixed, and a retry that fails again dispatches it again | merchant, provider, chargeReference, amount, reason |
CreatorTaxStatusChanged | A merchant's tax standing moved | merchant, previous, current, effectiveFrom, source |
CreatorReattestationDue | A creator's tax attestation is due for renewal: once when the year turns, once more shortly before it runs out (lastReminder). holdFrom is the day selling and payouts pause if nothing is renewed. The package delivers ReattestationDueNotification from it; replace that listener to route the notice your own way | merchant, holdFrom, lastReminder |
SettlementRestated | A settlement was canceled and issued again because its creator's standing was corrected. The package moves no money: the replacement usually pays a different amount, and difference() says by how much, positive when the creator is owed more | original, cancellation, replacement |
SellerDataReminderDue | A seller's record is still missing something and they are asked for it, once per reminder (reminder). missingFields names what is asked for; measureFrom and measure are set only where a measure can follow. The package delivers SellerDataReminderNotification from it; replace that listener to route the notice your own way | merchant, episodeId, reminder, missingFields, measureFrom?, measure? |
SellerDataMeasureChanged | A measure over missing seller data began (inForce) or ended, with the reason it ended: record_completed, no_longer_required or withholding_exhausted. A suspension is also announced by the merchant lifecycle's own events | merchant, measure, inForce, reason? |
DeemedSupplyReceiptWithheld | A supply of goods the platform is treated as making itself was paid for on the provider's page, and the buyer's document was not issued, because the payment contradicts how the sale was classified before the session opened: the buyer entered a tax ID on the page, the destination is outside the Union, or the provider computed a tax at another rate than the one the supply is taxed at. The money has moved; have somebody refund the sale or document by hand what really happened | buyer, provider, chargeReference, supply (a DeemedSupplyOfGoods), buyerTaxId?, reason |
SellerDeclarationContradicted | Something the application reported contradicts what a seller outside the Union declared, so the platform can no longer rely on that declaration (Art. 5c Implementing Regulation 282/2011). The seller's next sale of goods the platform would supply itself waits until the seller has declared again; ask the seller to | merchant, contradiction (a SellerDeclarationContradiction) |
CreatorPlacedOnTaxHold | A merchant can no longer sell or be paid out. Dispatched for both routes into a hold — somebody recording a blocking standing, and an attestation quietly expiring. The second writes nothing at all, so a scheduled sweep finds it; without both, the event's silence would read as "no hold". Fires once per hold. | merchant, reasonKey (a translation key, never a sentence — why a standing blocks is a jurisdiction's rule) |
SaleIntoClosedMarketReversed | A sale the provider taxed in a market billing.tax_markets does not open was undone: the subscription ended, then the payment refunded. Tell the buyer their country is not served yet. A refund the provider refused arrives with refund->successful false, and the buyer is still owed the money | owner, country (unknown when none was reported), state, saleReference, subscriptionEnded, refund? |
TaxIdVerificationFailed | A buyer's tax ID turned out not to be registered. The charge was reversed on the number's format, so a sale under it can owe the tax that was not charged. reverseChargeInvoices lists this owner's invoices issued with the charge reversed under that number, and it can be empty for a one-off checkout without an invoice record | owner, type, value, reverseChargeInvoices |
FilingObligationApproaching | A filing obligation falls due inside the notice window, and this is the one warning it gets. Dispatched per obligation, never per day: the last period's return and the annual seller report share the end-of-January deadline, and a single "something is due" would let whoever handles the one they thought of consider the day dealt with. Two events for one date is the intended shape. | obligation, dueOn, period? (null for the annual report, which covers a year), daysRemaining |
VoucherVolumeThresholdApproaching | Voucher volume is close to the figure at which a supervisory filing is expected. A warning exists because a threshold you learn about on the day you cross it leaves no time to act, and registering with a supervisor has a lead time only the operator can start. The package announces; it notifies no authority and holds credentials for none. | volume, threshold, observedAt |
VoucherVolumeThresholdBreached | Voucher volume has passed that figure. Deliberately a separate event from the warning above rather than a level on one: a recipient treating them as one message would let the early notice stand for the late one, and only the late one has a deadline attached. The figure travels with the event because the window is rolling — what triggered it is not what a recipient would compute later. | volume, threshold, observedAt |
RoutedSubscriptionInvoiceDrafted | A renewal of a subscription was invoiced as a draft, the hour in which a routed cycle's commission can still be set. Its own event for the reason RoutedSubscriptionInvoicePaid is one: the handler reaches the provider, and the subscription reference lets it ask the local row first and stop there. | customerReference, invoiceReference, subscriptionReference |
RoutedSubscriptionInvoicePaid | A subscription cycle was invoiced and paid — the moment a routed subscription's commission becomes a fact. Its own event beside PaymentSucceeded on purpose: the handler reaches the provider, so hanging it on the payment would mean provider calls for every payment on every install, including the great majority that route nothing. The subscription reference is what lets the handler ask the local row first and stop there. | customerReference, invoiceReference, subscriptionReference |
InvoiceUpcoming | The provider is about to finalize the next invoice | customerReference |
SeatQuantityChanged | A team's billed seat quantity actually moved | owner, from, to |
SubscriptionPaused | An owner's subscription was paused, once the driver accepted it | owner |
SubscriptionUnpaused | A paused subscription was taken up again and is collected from now | owner, pausedSeconds? |
UsageBacklogStalled | Usage has sat unreported longer than billing.metering.stall_hours | pendingRollups, pendingUnits, oldestRecordedAt, stalledHours |
UsageReconciliationDrift | Our ledger and the provider's meter disagree | owner, meterKey, period, reported, recorded |
UsageSpendCapReached | The owner's spend cap stopped a metered request, or usage recorded past it took the spend over it; once per billing period, and once more after a raise | owner, cap, spent, period |
UsageSpendCapRaised | An owner's spend cap was raised | owner, from, to |
ProviderJournalDrift | The merchant journal and the provider disagree about money that already moved to a merchant | merchant, chargeReference, transferReference, reason, ours, theirs? |
BillableAccountDeleting | An account is about to be deleted | owner |
AccountBillingUpdated | An owner's billing state changed — a broadcast, for live refresh | owner |
AccountToastNotified | A transient message for the owner — a broadcast | owner, message, level |
A few of these repay a closer look.
ProviderJournalDrift compares one transfer, never a balance. The obvious check — the package's figure
against the connected account's balance at the provider — fires on every legitimate event, because that
balance also moves when the merchant is paid out to their bank, when they take money through another
integration, and when the provider debits a fee. An alarm that fires on all of those is switched off within
a week, and on a money path a muted alarm is worse than none: it is also an alibi. So the comparison is per
transfer, the one object both sides name and only this package creates. A destination charge's transfer carries
the whole payment, and the provider collects the platform's fee back from the merchant afterwards, so the
provider's side is the transfer less the part of that fee the platform still holds.
Which side is right is not symmetric, and the event's job is only the first half of the question. The
provider is authoritative for what moved; the package is authoritative for what was owed. So a drift is
repaired by correcting the local row — never by transferring again to match the journal, which would move
real money to settle a bookkeeping disagreement. billing:merchants:reconcile reports and exits non-zero;
it repairs nothing, deliberately.
SubscriptionStateChanged carries more than the state. occurredAt is the provider event's own
timestamp, which is how a retried or out-of-order delivery is ignored instead of regressing a newer state.
periodStart and periodEnd ride along because metered usage is accounted into the subscription's
cycle, not a calendar month — an owner who renews on the 31st has no calendar month to bill into. Each is
null when the provider conveys no such value, and a null tierKey means the change conveys no tier.
AddonRefunded carries the cumulative total, not the delta. The add-on ledger claws back only the part
it has not already reversed, so two partial refunds and a redelivery each do the right thing. It is keyed on
paymentReference — the payment id, not the checkout session — because that is what a refund event carries.
A refund of anything that is not a tracked add-on matches no purchase and reverses nothing.
Under Mollie, RefundFailed and ChargebackReversed come from a daily reconciliation, not from a webhook. Mollie
calls no webhook for a refund canceled before it was paid out and documents none for a chargeback the bank
reverses, and the webhook of a payment is read for its refunded total, which says nothing about a refund
that failed. billing:money-back:reconcile reads the provider's refunds and chargebacks back, records each
outcome once in the delivery ledger, and raises the event through the same effect bus as a webhook. A
listener hears of each outcome once; one whose effects did not all finish is dispatched again by the next
run. A failed refund acts on nothing by itself: it leaves a customer who was told the money is on its way, and
it is written to the log as well, so an install that listens for nothing still finds it. A reversed chargeback
gives back the access the chargeback ended (RestoreAccessOnChargebackReversal, switched by
billing.content_ownership.restore_on_chargeback_reversal), and restores the add-on purchase it reversed to
standingRefunded, what the payment still has refunded or charged back (RestoreAddonPurchaseOnChargebackReversal):
the credit comes back on the balance, or exactly the units the clawback took. A refund that still stands stays
reversed. A credit note against an invoice this package raised stays, because it is a numbered document, and
ReinstateLocalInvoiceOnChargebackReversal charges what it credited again on an invoice of its own: from the invoice
series, naming the invoice it charges again, and booked and counted like any invoice in the period of the reversal.
A new chargeback of the same amount afterwards acts again: AddonRefunded names the chargebacks its total counts
(standingChargebacks), so the effects tell it apart from a redelivery of the first.
Under Stripe, RefundFailed comes from the webhook instead: refund.failed, or refund.updated and
charge.refund.updated with a failed or canceled refund. Its reason is Stripe's failure_reason, and it names no
customer, because a refund carries none.
BillableAccountDeleting is present-continuous on purpose. The listener runs while the owner still
exists, so the live subscription can be canceled at the provider before the row is gone. The package's own
eraser dispatches it; an app with its own delete flow dispatches it itself, after re-confirming identity and
before deleting the model. Skip it and a deleted account lingers as an active, still-charging subscription.
WebhookDeliveryRefused is a security signal, and its payload is deliberately thin. It carries the
request's facts and never the body: the delivery did not verify, so the body is attacker-controlled input,
and putting it on an event invites it into logs, queues and audit tables sized for trusted data. It also
carries no reason — not which part of the signature failed, because that is a probing oracle, and not a
coarse reason field either, since WebhookVerifier answers yes or no and a field with one possible value
describes nothing while looking like it describes something.
And it carries no network address. This package hands an address to exactly one place — the argument of
the IpCountryResolver you bind — and reads one nowhere else, because a second path is where an address
leaks. An event is the worst of those paths: it travels into queued payloads, audit tables and exception
context. If you want the caller's address in your own audit trail, take it from the request inside your
listener; keeping it is then your decision, in your privacy notice, on your storage.
The dispatch is guarded, so a listener that throws cannot turn the endpoint's deliberate 400 into a 5xx
that tells the sender to retry a request which can never become valid. The package also writes a warning to
the log, so an install that wires no listener still sees the attempt. Nothing is recorded in the delivery
ledger: that table holds provider payloads for replay, and filling it from unauthenticated traffic would let
anybody who can reach the URL write rows into it.
UsageBacklogStalled is the alarm a successful exit code cannot raise. The flusher exits successfully
during a provider outage, because a growing backlog is not a crash — but a backlog that never drains is lost
revenue, since past the provider's acceptance window the usage is not retro-billed at all.
Deprecation aliases
InvoiceCredited was renamed to InvoiceCorrected: the old name conflated a correcting document with a
self-billing credit note, which is a different document with a different type code.
For one deprecation window the old class still fires. InvoiceCorrected implements HasDeprecatedAlias, and
the effect bus dispatches the alias through Laravel's dispatcher alongside the event — so an existing
Event::listen(InvoiceCredited::class) keeps being called instead of going quiet, which is the worst outcome
of a rename. The alias reaches host listeners only and is never re-run through the package's own effects, so
nothing is persisted twice.
Migrate to InvoiceCorrected and read $event->correction. The old class and the alias firing go in a later
release.
The shipped effects
An effect is an invokable class registered against a domain event, and each runs in its own queued job — so a slow or failing effect can neither hold the provider's request open nor take its siblings down with it. Idempotency is per effect, not per delivery, so replaying a delivery whose third effect failed re-runs only that one.
Each run holds a transaction around its effect, and no effect calls a provider inside it. A credit mirrored onto the provider's balance, a payment method made the default, a reversal or a refund goes to a further job that is queued once the run has committed, and a usage flush or a late fee follows the commit in the same job. A run that rolls back leaves nothing at the provider behind it.
| Event | Effect | What it does |
|---|---|---|
PaymentSucceeded | ReopenWriteOffOnLateReceipt | Raises WriteOffRecovered when a payment matches exactly one reopenable write-off — never on an ambiguous match |
SubscriptionStateChanged | SyncPlanFromSubscription | Writes the local subscription row and the owner's tier column |
SubscriptionStateChanged | SendSubscriptionActivatedNotice | Tells the owner the subscription is live, once per subscription |
SubscriptionStateChanged | SendSubscriptionCanceledNotice | Tells the owner when access ends, keyed on the grace state and on endsAt, or the period end when the cancellation named no date |
PaymentSucceeded | SendPaymentReceipt | Tells the owner their money moved |
PaymentSucceeded | SettleCycleOnPayment | Closes a billing cycle whose charge settled after the run that started it had finished, and advances the period. A bank debit is accepted at once and clears days later, so the cycle is held open until this arrives. Registered only where the package runs the billing cycle itself; a provider that runs its own answers through its own events |
PaymentFailed | FailCycleOnPayment | The other direction of the same hold: a debit that was accepted and then bounced becomes a real failure, and dunning starts then rather than when the payment was created. Without it a bounced debit would leave the cycle held with no charge, no ladder and no notice |
PaymentFailed | SendDunningNotice | Starts or advances the dunning conversation |
PaymentActionRequired | SendPaymentActionRequiredNotice | Nudges the owner to confirm at their bank |
AddonPurchased | CreditAddonPurchase | Applies the credit or the prepaid units, exactly once per purchase |
AddonPurchased | GrantPurchasedContent | Writes the ownership row when the thing bought was a work; the shipped content map answers "not a work" to everything, so it writes nothing until you say otherwise |
AddonPurchased | RecordTheDestinationOfARoutedSale | Writes the country the buyer entered on the provider's page onto the routed sale's row, once, which the platform's records of a sale it only arranged read as its destination. A country already on the row is kept |
AddonPurchased | TakeHostedPurchaseCommissionOnNet | Brings the commission of a routed purchase on the provider's hosted page down to the net. The fee was fixed when the session opened, before the buyer's rate existed, so at a tax-inclusive price it was taken on the buyer's tax as well. Where the buyer paid exactly the price the session recorded and the provider computed tax inside it, the rate is read through the same seam the purchase's document reads, and TakeCommissionOnNet gives the difference back as part of the fee after the commit and brings the row to the net. Silent on an unrouted purchase, a separate transfer, a sale with no tax, a tax-exclusive or discounted price, and a tax the provider cannot state as one rate |
AddonPurchased | InvoiceLocalPurchase | Closes the order a local engine wrote when its checkout opened, and raises the invoice from it, once the provider confirms the payment. Mollie issues no invoice of its own, so this is where an add-on bought there gets one. Silent where no such order is in flight: a Stripe purchase, a cycle's order, and a redelivery, which finds the order already closed |
AddonPurchased | IssueDocumentForRoutedHostedPurchase | Issues the buyer's document for a routed sale paid on the provider's own hosted page. The synchronous routed lane ends in a document and this one did not, so a supply happened and nothing said so. Silent on an unrouted purchase, on a posture where the merchant is the seller of record, on a buyer this application does not own, on an add-on nobody classified, and on a sale whose tax the provider already computed — a positive tax figure means the rate came from the buyer's address, and this package holds amounts rather than rates. A supply of goods the platform is treated as making itself gets the document of that supply, with its tax decided from the consignment its row kept, or DeemedSupplyReceiptWithheld where the payment contradicts the classification. Once per charge reference, however often the provider redelivers |
RoutedSubscriptionInvoicePaid | RecordRoutedSubscriptionCharge | Writes the merchant-charge row for the cycle, one per invoice. A routed subscription is priced with a rate, so its commission exists once per cycle and only at the provider — this is the one effect that reads from one. A destination cycle's share moved with the payment, so its row is settled as it is written and its transfer is asked for after the webhook commits; one whose fee was withheld on the invoice total, the buyer's tax included, also has the commission on the tax given back after the commit, through TakeCommissionOnNet. A separate-transfer cycle is priced from the terms frozen on its subscription and stays pending until its share moves, in a job after the webhook commits. An unrouted subscription stops at the local row and reaches nothing |
RoutedSubscriptionInvoiceDrafted | TakeRoutedCycleCommissionOnNet | Sets a routed renewal's commission on the net before the provider charges it. A destination subscription states its rate as a percentage of the invoice total, the buyer's tax included, and a draft still takes an absolute fee: the fee is set on the net the invoice's own tax rate separates and noted in the invoice's metadata, and the paid cycle's row takes that rate only where the fee withheld is that fee. A first cycle is paid at checkout with no draft, and a draft charged before the fee was set keeps the percentage until TakeCommissionOnNet gives the commission on the tax back after the payment; an invoice that charged no tax or names more than one rate keeps it for good. An unrouted subscription stops at the local row and reaches nothing. With the marketplace enabled, the platform's endpoint has to receive invoice.created |
AddonRefunded | ReverseAddonPurchase | Claws back the unspent part of the purchase |
AddonRefunded | RevokeAccessOnRefund | Ends access to the refunded work, if your install says a refund should end access — on by default and genuinely switchable, because both answers are somebody's policy |
AddonRefunded | IssueLocalCreditNote | Issues the credit note for an invoice this package raised itself. A provider that issues its own documents announces the correction too, and that path is handled by PersistInvoiceCorrection; a local engine has nobody to announce it, so without this a refunded cycle leaves the books overstating turnover. It acts only where the refunded payment leads to a locally raised invoice, so it is silent on a provider-issued one. Amounts are positive — a correcting document's type inverts the accounting direction, not a minus sign — and the credited total is capped at the invoice it corrects. The refunded gross is stated as net and tax at the rate of the invoice it corrects, so the return reduces both; a note against an invoice that states no tax states none either. What an invoice has been credited is net of an invoice that charged a credited amount again after a reversed chargeback, so a later refund credits the right difference. |
InvoiceFinalized | PersistInvoice | Writes the immutable invoice record the e-invoice and DATEV exports render from |
InvoiceFinalized | DebitCreditAppliedByProvider | Takes credit the provider spent on this invoice off this package's ledger. A driver with a customer balance mirrors credit onto the provider so it reduces the next invoice by itself; without this the mirror points one way only, and the local balance — the one the account hub shows and a locally collected charge spends from — stays too high from that invoice on. Runs after PersistInvoice, because the movement is keyed on the invoice record that effect writes, and debits once however often the provider redelivers. Silent where the invoice consumed nothing, which is also every invoice on a driver that holds no provider balance. |
SaleCountryReported | ReverseSaleIntoClosedMarket | Undoes a sale taxed in a market that is not open: ends the subscription, refunds the payment and raises SaleIntoClosedMarketReversed, in a job that is queued once the run has committed. Inert until billing.tax_markets is configured, and a sale without consideration is left alone unless it starts a subscription |
TaxIdVerificationReported | RecordTaxIdVerification | Keeps each answer as a row of its own, retained and unlinked on erasure, and raises TaxIdVerificationFailed for a number the register does not know. Once per tax ID and answer, so a repeated report adds nothing |
InvoiceCorrected | PersistInvoiceCorrection | Writes the correction row, linked to the invoice it corrects |
InvoiceUpcoming | FlushUpcomingUsage | Force-flushes the usage outbox so a closing cycle is billed in that cycle |
MandateRevoked | RevokeMandate | Drops the stored mandate so nothing is charged against it |
DefaultPaymentMethodChanged | MirrorDefaultPaymentMethod | Reads the customer's default method from the provider once the run has committed and copies its brand and last four digits into the owner's pm_type and pm_last_four, or clears them when there is no default. An owner whose table has neither column is left alone. Registered by the Stripe driver |
CustomerDeleted | ForgetDeletedCustomer | Clears the owner's customer reference (billing.customer.column) and the card copy, so the next checkout creates a new customer instead of failing on a deleted one. Registered by the Stripe driver |
PaymentMethodCollected | AdoptCollectedPaymentMethod | Makes the collected method the default, for the customer and for every subscription that names a method of its own, so the next charge and the provider's next retry take it. The hosted page only attaches the method, and a subscription opened through a checkout is charged with the card it was paid with before the customer's default is ever read. Registered by a driver whose hosted page leaves a new method merely attached, and silent for an owner this application does not know |
MandateEstablished | StoreMandate | Stores the granted mandate so the engine can charge it, with the card's brand, last four digits and expiry when the event carries one, so the expiring-card warning can read them; idempotent, and it claims default only when the owner holds none |
MandateEstablished | StartSubscriptionOnMandate | Turns a pending subscription intent into the subscription it was for. Registered only by a driver whose mandates arrive this way, and matched on paymentReference — a customer merely adding a second card grants a mandate too, and matching on the customer would hand them a subscription they never asked for |
TrialEnding | SendTrialEndingNotice | Reminds the owner before the first charge |
MerchantAccountUpdated | RefreshMerchantCapabilities | Stores the reported capabilities, unless the report is older than the one they came from — the only path by which one ever moves |
MerchantAccountDeauthorized | MarkMerchantDeauthorized | Stamps when the platform lost reach, keeping the first date |
MerchantTransferReversedByProvider | RecordProviderTransferReversal | Records the provider's cumulative reversal onto the sale, never lowering a figure already booked |
MerchantPayoutFailed | RecordFailedMerchantPayout | Writes a merchant.payout_failed entry on the merchant's own audit trail — the answer to "I have not been paid". Idempotent by the payout id, so a redelivery adds nothing, while a second failed payout is recorded on its own. An account with no merchant on file is left alone |
RoutedChargeConfirmed | SettleRoutedChargeOnConfirmation | Settles the matching pending routed charge, found under the payment or under the hosted sale's own reference, which it then replaces with the payment; an event naming a payment this package did not route does nothing |
RoutedChargeAbandoned | SettleRoutedChargeOnConfirmation | Fails it instead, and only from pending |
InPersonSalePaid | DocumentInPersonSale | Marks the sale paid and issues its receipt, with the tax decided when it went onto the reader: a simplified invoice up to billing.card_present.small_amount_threshold_minor, a payment record above it. Refuses a payment that collected another amount than the sale went up with. A redelivery issues nothing a second time, and a payment the counter path did not put up is left alone |
InPersonSaleCanceled | CloseInPersonSale | Closes a pending sale without a receipt. A paid one keeps its receipt |
DisputeOpened | RecordOpenedDispute | Keeps the case, once per dispute and whatever its outcome, so DisputeRates can read the rate the card networks measure. It sends nothing to the provider |
ChargebackReceived | RecordProviderFee | Writes the provider's dispute fee as its own row, once per dispute — it is an inbound supply, never a deduction |
ChargebackReceived | RevokeAccessOnChargeback | Takes back the access the disputed payment bought, on your configured coupling |
ChargebackReceived | CorrectChainOnChargeback | Issues the correcting documents the lost dispute owes — both legs where the supply was undone, the buyer's leg only where the creator delivered and the payment was disputed as fraud |
ChargebackReceived | ClaimChargebackClawback | Claims the merchant's share back and hands the provider call to a queued job — an effect cannot reach a provider from inside the transaction it runs in |
ChargebackReversed | RestoreAccessOnChargebackReversal | Gives back the access the chargeback ended, and only that, once the bank takes the chargeback back. On by default and switchable with billing.content_ownership.restore_on_chargeback_reversal |
ChargebackReversed | RestoreAddonPurchaseOnChargebackReversal | Restores the add-on purchase the chargeback reversed to what the payment still has refunded or charged back, and gives back its credit or exactly the units the clawback took. The credit note is answered by ReinstateLocalInvoiceOnChargebackReversal |
ChargebackReversed | ReinstateLocalInvoiceOnChargebackReversal | Charges again, on an invoice of its own, what an invoice this package raised itself was credited for the chargeback. The invoice comes from the invoice series, carries the original's amounts and lines when it charges the whole again and one line naming them otherwise, names the original in BT-25 and on the page, and states the date of supply. What the payment still has refunded or charged back stays credited. Silent on a provider-issued invoice; without standingRefunded it issues nothing and logs a warning |
Register your own the same way the shipped ones are registered, against the event rather than against a provider string:
use Pushery\Billing\Events\PaymentSucceeded;
use Pushery\Billing\Webhooks\WebhookEffectRegistry;
app(WebhookEffectRegistry::class)->on(PaymentSucceeded::class, NotifyAccounting::class);
Events with no shipped effect — SeatQuantityChanged, SubscriptionPaused, SubscriptionUnpaused, UsageBacklogStalled, UsageReconciliationDrift, ProviderJournalDrift, BuyerProtectionHoldReleased, BuyerProtectionHoldRefunded, BuyerProtectionResolutionRequired —
are dispatched for your app to act on. A reconciliation drift in particular is a decision the package will
not make for you.
ChargebackReceived is not among them: four effects are registered against it, in the table above,
and the money side is one of them. ClaimChargebackClawback takes the merchant's share back, so an app
that reverses its own payout entry should do it from
MerchantTransferReversed, which says what actually moved, rather than from the chargeback itself, which
only says one was decided.
Two things are worth knowing before adding your own effect to this event. Registering a second fee recorder double-books the dispute fee. And a second effect that reverses the merchant's share reverses it twice — the provider idempotency key belongs to the attempt row this package writes, and a second claim gets a second key the provider has never seen.
Answering a dispute
DisputeOpened arrives when Stripe opens a case, so you hear about it while the case can still be won.
evidenceDueBy is the last moment Stripe accepts evidence. The package records the case and sends nothing on
its own: whether a case is worth answering, and with what, is your decision, and you answer through SubmitsDisputeEvidence
with the two references the event carries.
use Illuminate\Support\Facades\Event;
use Pushery\Billing\Contracts\SubmitsDisputeEvidence;
use Pushery\Billing\Enums\DisputeDocument;
use Pushery\Billing\Events\DisputeOpened;
use Pushery\Billing\ValueObjects\DisputeEvidence;
Event::listen(function (DisputeOpened $dispute): void {
if (! app()->bound(SubmitsDisputeEvidence::class)) {
return; // the active driver takes no evidence through its API
}
$evidence = new DisputeEvidence(
productDescription: 'Annual membership, billed yearly',
accessActivityLog: 'Signed in 14 times after the purchase, last on 2026-09-18',
)->withDocument(DisputeDocument::Receipt, storage_path("receipts/{$dispute->paymentReference}.pdf"));
app(SubmitsDisputeEvidence::class)->submit($dispute->disputeReference, $evidence, $dispute->accountReference);
});
- Submitting is final. Stripe decides the case on what arrived, and a submitted case cannot be amended. The same evidence submitted again is answered by Stripe as the first submission, so a retried job cannot answer a case twice. Evidence that says nothing is refused, because an empty submission gives the case up.
- Choose the evidence by
reasonCode.reasonfolds every code the package does not act on intoUnknown;reasonCodeis Stripe's own. Aproduct_not_receivedcase is answered with the shipping fields, aduplicatewithduplicatePaymentReferenceand its explanation, asubscription_canceledwith the cancellation fields. - Documents are local files the submitting process can read. Each is uploaded to Stripe as a dispute document on the account the dispute lives on, and a path that cannot be read fails when the evidence is built, before anything reaches Stripe.
- On a marketplace, a dispute on a merchant's own charge lives on the merchant's account, and
accountReferencenames it. A routed sale whose payment the platform took whole is answered on the platform;merchantReferencestill says whose sale it was, because the evidence is usually theirs. - Your Stripe endpoint has to send
charge.dispute.created, and the Connect endpoint too where merchants take their own charges. An endpoint that sends onlycharge.dispute.closedreports the outcome and never the opening.
Mollie takes no dispute evidence through its API, so under Mollie nothing is bound and no case is raised.
Queued listeners
A listener that implements ShouldQueue receives its event after a round trip through the queue. Every event
that carries a model stores it there by its class and key, as Laravel's SerializesModels does: the listener
reads the row back when it runs, and neither the queue nor failed_jobs holds the model's attributes. What an
event reports, such as a status before and after, an amount or a date, travels as a value and arrives as it was
dispatched. A row deleted in between fails the job with ModelNotFoundException, unless the listener sets
$deleteWhenMissingModels = true or carries the DeleteWhenMissingModels attribute, which deletes the job
instead.
BillableAccountDeleting is the exception, because the account is gone by the time a queued listener runs.
Its owner arrives as an instance of the owner's class that carries only the key, enough to clean up what your
application keeps under it. A listener that needs the owner's other attributes runs synchronously.
Broadcast events
AccountBillingUpdated and AccountToastNotified are broadcast on the owner's private channel so the
account-hub screens refresh live. Both are a no-op unless billing.realtime.enabled is on and a
broadcaster is configured; without that the screens fall back to a bounded poll.
The channel is billing.{morph class}.{key}, and the package authorizes it itself: a signed-in user may listen
on it only when their billing owner, resolved through BillingEntityResolver the way the hub resolves it, is the
owner the channel names, and only while realtime is on. You do not register a rule for it in
routes/channels.php. A rule of your own that also matches billing.* and is registered first is asked first,
so do not write a wider one.
AccountBillingUpdated deliberately carries no payload — the client re-fetches — so nothing sensitive is on
the wire. A queued broadcast of either event holds the owner as its key and reads the row back when it runs,
so neither the queue nor failed_jobs stores the owner's attributes.
The package dispatches both of them itself, from the webhook spine:
| Broadcast | Raised when | Level |
|---|---|---|
AccountBillingUpdated | the plan sync applied a provider event that actually moved something — a state, a tier, a period | — |
AccountBillingUpdated | a paid add-on purchase was recorded, on the delivery that recorded it | — |
AccountToastNotified | a payment failed | danger |
AccountToastNotified | a subscription went live | success |
AccountToastNotified | a paid add-on was credited to the account or granted its units, naming the amount or the count | success |
Two things follow from that, and both are deliberate.
A redelivery that changes nothing broadcasts nothing. Providers retry freely, and an event that moved no row must not make every open screen re-fetch.
The toast message arrives as a finished sentence in the owner's own language, resolved at dispatch
against the owner's stored locale rather than the ambient one. A broadcast is raised from a webhook, where
there is no request and therefore no locale but the application default — so unlike a notification, which
Laravel localizes for you, a toast has to be translated where it is raised. The strings live under
billing::account.toast.* and are publishable like the rest.
The severity changes key on its way to the browser
The broadcast payload is { message, level }. The headless bridge in the account hub receives it and
re-dispatches it to the browser as a wirekit-toast event whose payload is { message, variant } — the
same severity, under the name a toast region reads. The bridge is <livewire:billing.account-realtime />: the
hub's layout mounts it, and an application with account screens of its own mounts it in its own layout. It stays
registered with the hub switched off.
That rename is the one thing to know if you write your own listener: read event.detail.variant, not
detail.level. An unrecognized key here does not fail loudly. It falls through to the default severity, so
a failed payment renders in the neutral style and is announced to a screen reader without urgency — wrong
in both channels, and visibly wrong in neither.
Something has to be listening, and by default that is not this package
The bridge dispatches the event. It does not render toasts, and neither does anything else here unless you ask for it. Where the message actually appears is one of three answers, and choosing none of them is a silent no-op: the broadcast goes out, the event fires, and nothing is drawn.
-
A WireKit host. WireKit renders its own toast region and reads exactly this event. Nothing to do.
-
This package's minimal region. Set
billing.realtime.render_toast_regiontotrue(BILLING_REALTIME_TOAST_REGION=true). You get twoaria-livecontainers — polite forinfoandsuccess, assertive forwarninganddanger— and a small inline listener that appends the message and dismisses it after a few seconds. No UI kit, no build step. Leave it off on a WireKit host, or every toast appears twice. -
Your own listener, which is one line:
window.addEventListener('wirekit-toast', (event) => {yourToaster(event.detail.message, event.detail.variant);});
event.detail.variant is always one of info, success, warning, danger — the bridge clamps an
unknown or missing severity back to info rather than trusting the wire. event.detail.message is
whatever your application broadcast, so render it as TEXT.