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.
| Event | When it fires | Payload |
|---|---|---|
SubscriptionStateChanged | A subscription moved to a new canonical state | customerReference, state (a SubscriptionState), subscriptionReference?, tierKey?, occurredAt?, periodStart?, periodEnd?, trialEnd? |
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 |
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 | subscription, daysLeft |
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 judgement 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? |
MandateRevoked | A stored mandate is no longer usable | customerReference, mandateId |
MandateEstablished | A payment method was granted and can now be charged off-session | customerReference, mandateId, provider, method?, paymentReference? |
AddonPurchased | A one-time add-on was bought and paid | customerReference, addonKey, amount, reference, paymentReference? |
AddonRefunded | A charge was refunded | paymentReference, cumulativeRefunded, reason? |
InvoiceFinalized | The provider finalized an invoice — it now legally exists | invoice (an InvoiceSnapshot) |
InvoiceCorrected | A correcting document was issued against a finalized invoice | correction (an InvoiceCorrectionSnapshot) |
InvoiceCredited | Deprecated — the former name of InvoiceCorrected | correction |
MerchantAccountUpdated | The provider reported what a merchant's account can do | account (a MerchantAccountReference) |
MerchantTransferReversed | Money came back from a merchant — a refund or a lost dispute | merchant, provider, chargeReference, amount, feeReturned, cause, disputeFee? |
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 | 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. | provider, paymentReference |
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. | provider, paymentReference |
CreatorTaxStatusChanged | A merchant's tax standing moved | merchant, previous, current, effectiveFrom, source |
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) |
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 |
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 |
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 |
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.
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.
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.
| 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 |
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 |
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. An unrouted subscription stops at the local row and reaches nothing |
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. |
InvoiceFinalized | PersistInvoice | Writes the immutable invoice record the e-invoice and DATEV exports render from |
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 |
MandateEstablished | StoreMandate | Stores the granted mandate so the engine can charge it; 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 — 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; an event naming a payment this package did not route does nothing |
RoutedChargeAbandoned | SettleRoutedChargeOnConfirmation | Fails it instead, and only from pending |
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 |
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, 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 used to be listed here and no longer belongs: four effects are registered against
it, in the table above — and the money side is now among 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.
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.
AccountBillingUpdated deliberately carries no payload — the client re-fetches — so nothing sensitive is on
the wire.
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 | — |
AccountToastNotified | a payment failed | danger |
AccountToastNotified | a subscription went live | 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.
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.