Skip to main content

Upgrading

What each released version asks of you, newest first. Most ask for nothing beyond running the migrations.

Before 1.0, a minor may break

The package is pre-1.0, so a minor version is allowed to change a published API. Every such change is listed in the CHANGELOG under a heading that says so, and repeated here with what to do about it. Pin a minor if you want no surprises:

"pushery/billing-for-laravel": "~0.13.0"

Every upgrade ends the same way, because a minor may add tables or columns:

composer update pushery/billing-for-laravel
php artisan migrate

0.14.0

The sale's tax characteristics travel as one object

Affects you if you call FanReceiptIssuer::issue(), SelfBillingEngine::issue() or any of SubscriptionCycleBilling's three issuing methods directly. Through the hosted lanes there is nothing to do.

Those five signatures each took the same run of tax primitives — ?TaxArchetype $archetype, ?PlaceOfSupplyRule $placeOfSupply, ?TaxRateCategory $rateCategory and, on the receipt issuer, five more. They now take one SupplyTaxCharacteristics:

// before
$receipts->issue($buyer, $tier, $gross, $bps, $soldOn, $ref, null, $period, $archetype, $place, $band);

// after
$receipts->issue($buyer, $tier, $gross, $bps, $soldOn, $ref, period: $period, characteristics: new SupplyTaxCharacteristics(
archetype: $archetype,
placeOfSupply: $place,
rateCategory: $band,
));

Every field is nullable and defaults to null, so a caller that passed none of them needs no change at all: SupplyTaxCharacteristics::unknown() and simply omitting the argument write the same row.

Why this is worth the edit. The receipt issuer's signature was seventeen parameters wide and two callers filled it positionally. A parameter inserted in its middle shifted every argument after it past two type-compatible pairs — ?CarbonImmutable $deliveredOn against CarbonImmutable $soldOn, and ?string $chargeReference against ?string $provider. Static analysis cannot tell those apart, and a document that comes out with the wrong date is a tax error rather than a display one.

It also widens what a subscription cycle can state: it could reach three of the eight characteristics and now reaches all eight, so a cycle that knows its delivery date or its exemption reason can say so.

Support\Navigation and NavItem are gone

Affects you if you resolved either class. Use Pushery\Billing\Account\Navigationvisible() for the grouped form the sidebar renders, visibleItems() for a flat list.

They were a second parser of config('billing.navigation'), and the two disagreed: the surviving one knows the web_only flag and drops such an item on a native runtime, the removed one had no property to carry it. An operator who hid the account-deletion flow saw it leave the sidebar and stay on the hub's landing page, one click from a working deletion. Removing the second parser is what stops that recurring; teaching it the flag would have left two readings of one key in place.

Some value objects and events carry more required arguments

Affects you only if you construct any of these by hand. Resolved from the container, or read from an event you receive, nothing changes — the package fills the new arguments itself.

Each gained required constructor arguments because it now carries a fact it previously had nowhere to put:

ClassRequired arguments
ValueObjects\InboundTaxTreatment5 → 11
ValueObjects\WithdrawalConsent4 → 8
ValueObjects\RetentionRule13 → 15
Events\AddonPurchased4 → 7
Notifications\PaymentSucceededNotification2 → 9
Marketplace\BuyerProtectionClock1 → 7

Service constructors widened as well — StripeDriver, StripeOneTimeCharge, StripePaymentMethods, BillingAdmin, EuOssTaxCalculator, TaxCalculatorFactory — but every documented usage resolves those from the container, so they are named here for completeness rather than as something to do.

Three methods were removed because nothing read them: SellerActivityThreshold::isExemptFromReporting(), InvoiceNumberSequence::format() and RateChangeExclusions::dryRunRequired(). The purging() helpers on BillingEvent and TaxReturnExportRecord did not disappear — they moved into the shared AppendOnly concern and are still callable on both models.

ConsumerWithdrawal takes two collaborators fewer

Affects you only if you construct it by hand — resolved from the container, nothing changes.

It no longer takes a RoutedRefundCorrector or a CreatorTaxStatusResolver. The chain correction moved into BillingAdmin::refund(), which is where it belonged: that verb is the package's only refund entry point and was the one path of four that corrected no documents. Leaving the correction in both places would have written two correcting documents per leg for one event, out of a gapless number series.

0.13.0

This release carries the work stamped 0.10.0, 0.11.0 and 0.12.0. None of the three was ever tagged, so v0.9.0 is the last published version before this one and upgrading from it lands you here in one step. The changelog folds them the same way and for the same reason: there is no release a reader could be sitting on in between, so three sets of instructions were three descriptions of one upgrade.

Six items, and all but one need a decision before you deploy — the toast change matters only if you listen for the browser event yourself. Anything not listed here is additive. Run the migrations last.

1. A routed one-time sale now issues the buyer's receipt, and needs to be told who the buyer is

Affects you if you call RoutedPayment::charge(), FanPayment::tip() or FanPayment::payWhatYouWant() directly. If you only use the hosted checkout, there is nothing to do.

Until now this lane charged the buyer, settled the sale and paid the merchant their share without producing any document. A fan who bought once had no receipt, and the sale's supply regime was written down nowhere — the charge table has no column for it, so the document is the only place it can be frozen.

Issuing it needs two facts the package cannot invent: who the buyer is, and whether the small-value rules of your own country apply to them (which decides the document tier). Both are now required parameters:

// before
$payments->charge($merchant, $gross, $fee, $taxBps, $token, $routing, $archetype);

// after
$payments->charge($merchant, $buyer, $gross, $fee, $taxBps, $buyerIsDomestic, $token, $routing, $archetype);

On FanPayment the buyer sits after the merchant, and the flag after the existing TaxContext:

$fan->tip($merchant, $buyer, $chosen, $normalFee, $taxContext, $buyerIsDomestic, $token, $routing, $soldAlongside);
$fan->payWhatYouWant($merchant, $buyer, $chosen, $fee, $taxContext, $buyerIsDomestic, $token, $routing, $archetype);

$buyer is the model the receipt belongs to — the same one you would pass to the subscription lane. $buyerIsDomestic is supplied rather than derived, exactly as SubscriptionCycleBilling already takes it: the package has no second opinion about where your buyer is, and inventing one would give the two receipt-issuing lanes two different answers.

Why both are mandatory rather than optional with a default. An optional buyer would mean the receipt goes on being skipped for every call site not yet updated — silently, and for precisely the sales that already work today. Required, your existing positional call raises a TypeError on the first run instead, which is the one failure mode you cannot miss.

The document is issued only after the provider confirms the payment succeeded, so a declined or still pending charge produces nothing. It is idempotent on the charge reference: a redelivered webhook returns the document already written rather than drawing a second number from a series that must have no gaps.

One posture issues nothing, by design. Where you have declared the merchant as seller of record, the platform is not a party to the supply and has no document to issue. Where the platform merely arranges, the intermediation receipt is not wired yet — it states the commission's own tax rate, which is still an open question at that seam.

2. The realtime toast names its severity differently

Only affects you if you listen for the wirekit-toast browser event yourself. If a WireKit host or this package's own opt-in region renders your toasts, there is nothing to do.

The bridge used to dispatch the severity as detail.level. A toast region reads detail.variant, so the severity never arrived: every toast rendered in the neutral style, and a failed payment was announced to a screen reader without urgency. It now dispatches detail.variant. Change your handler to read that.

The broadcast payload is unchanged — AccountToastNotified still sends { message, level }. Only the browser event moved, because only that end has a reader that decides what the key means.

3. A routed subscription on the shipped defaults now refuses

Only affects you if billing.marketplace.enabled is true. With it false — the default — nothing changes.

StripeCheckout used to check the configured charge type and then assemble a payload that ignored it. The shipped charge_type is separate_transfer, which the posture table permits for platform_deemed_supplier, so the guard passed — while the session it opened carried transfer_data.destination, which is a destination charge, and destination + platform_deemed_supplier is precisely the pairing billing.marketplace.charge_type_by_posture forbids. The money went straight to the merchant while the documents named the platform as seller.

That combination now throws MarketplaceUnsupported instead of opening a session. Two ways forward:

  • Move the merchant's share yourself. Keep charge_type on separate_transfer and route the sale through Pushery\Billing\Marketplace\RoutedPayment, which makes both provider calls and records them.
  • Use a destination charge honestly. Set charge_type to destination and a posture the table permits for it — platform_intermediary, or seller_of_record if the Art. 9a rebuttal genuinely holds for you (the package refuses seller_of_record for an electronically-supplied service unless you assert it).

If you were running the old default and your Stripe payouts reconciled, they were reconciling against a shape the package's own configuration disallowed. That is the change worth reviewing before you deploy.

4. ExchangeRateBasis::MinistryMonthlyAverage is renamed

It is now CentralBankMonthlyAverage, named after the source rather than the place of publication. The old name described a table nobody could actually fetch, which is what made an unsupplied basis look supplied.

No data migration is needed: nothing ever wrote the old ministry_monthly_average value, so no stored row carries it. Update any code that names the case; a match over the enum will fail to compile rather than fall through, which is the intended way to find them.

The monthly average is now computed from the daily series the package already imports — the arithmetic mean of the month's published rates, averaged over what was published rather than divided by the calendar.

5. billing.tax_oss.required_signals is removed

It was read in exactly one place, and only to describe a decision it had no part in — and the expression could not represent 3 at all, so a valid standard of three sources was written as 2. Both halves now read the same standard through one shared reader.

Delete the key from your published config. Leaving it does nothing; the package no longer reads it.

6. Run the migrations

This release adds columns rather than changing existing ones — a subdivision on the place evidence, a rounding direction on the merchant charge, a seller posture and a tax-exemption reason on issued documents:

composer update pushery/billing-for-laravel
php artisan migrate

billing_place_evidence.resolved_subdivision is written only for a country listed in billing.tax_evidence.subdivision_countries (shipped: the US alone) and only from a subdivision you already supply — the package has no input finer than the country and does not go looking for one. Every non-US sale is byte-identical to before, and billing.tax_evidence.collect_subdivision switches the whole thing off.

0.9.0

Nothing to do. Documentation only: the pages that described unshipped features are gone, and the configuration, database, event and troubleshooting references are written from the code. No code, config or schema changed.

One key that the boot guard already read is now declared in the published config — billing.retention.allow_below_statutory_minimum, default false. Behavior is unchanged; it was previously discoverable only from the exception message. Re-publish the config to pick up the declaration, or ignore it: the package merges its own defaults underneath yours.

0.8.0

If you mapped Money to a decimal-string amount shape, that pair of methods is gone. It had no shipped consumer, so it was removed before anything could depend on it. Use Money::toDecimal() and Money::fromDecimal(), which are unchanged.

Everything else: nothing to do. Amounts remain integer minor units end to end.

0.7.0

If you ship your own driver implementing SubscriptionActions, add the new optional parameter:

public function cancel(Model $billable, ?CancellationSurvey $survey = null): void

Callers are unaffected — the argument defaults to null — and the built-in drivers already have it. A driver that ignores the survey is a valid driver; the parameter only has to exist so the contract is satisfied.

Run the migrations: 0.7.0 adds the cancellation-survey table.

0.6.0

The largest upgrade so far. Three things need attention.

The cancellation "credit note" is now an "invoice correction". Credit note is reserved for the self-billing document, which is a different document with a different type code. Rename your references:

RemovedUse instead
ValueObjects\CreditNoteSnapshotValueObjects\InvoiceCorrectionSnapshot
Events\InvoiceCreditedEvents\InvoiceCorrected, reading $event->correction (was $event->creditNote)
Webhooks\Effects\PersistCreditNoteWebhooks\Effects\PersistInvoiceCorrection
InvoiceRecord::isCreditNote()InvoiceRecord::isCorrection()
translation key billing::invoice.credit_notebilling::invoice.correction

The event is the gentle one: InvoiceCorrected also fires InvoiceCredited for one deprecation window, so an existing listener keeps being called rather than going quiet. The value object, effect and model method are hard renames — a stale reference is a loud "class not found", not a silent no-op. See the event reference.

The invoice retention floor dropped from ten years to eight, and the clock now runs from the end of the issue year rather than the issue instant. An erased owner's retained invoices become prunable up to two years earlier than before. That is the point: keeping them the full ten years over-retains personal data past its obligation. If your jurisdiction requires longer, set billing.retention.erased_financial_days higher — a longer window is always allowed. The separate audit window stays at ten years.

InvoiceCorrectionSnapshot now validates itself. It refuses a negative amount (a correction carries positive magnitudes; the document's nature inverts the meaning, not the sign) and refuses an amendment with no reference to the invoice it corrects. If you construct snapshots yourself, pass absolute amounts.

Also worth knowing, though neither needs action: the documentation moved out of the README into docs/, and several tables were added — run the migrations.

0.5.0

If you keep golden copies of DATEV exports, regenerate them. The EXTF header's Festschreibekennzeichen was emitted as 0, marking a booking batch as still alterable after import. It is now 1, which changes the bytes of every generated file.

Your app may now refuse to boot where it previously started. Two silent failures became loud:

  • an unresolvable billing.tax — a typo, or the key turned into an array by adding a sub-key under it — now raises TaxModeUnsupported at boot instead of falling through to "no tax" and issuing every invoice at 0%
  • billing.tax = 'stripe' is now correctly classified as provider tax, so it is accepted on the driver that needs it and refused on one that cannot apply it

A malformed country code now throws instead of zero-rating. EuOssTaxCalculator treated any code it had no rate for as zero-rated, so "DEU" or an empty string was indistinguishable from a genuine supply outside the EU VAT area. An unassigned code now raises UnknownTaxCountry. Real countries outside the EU VAT area are still zero-rated. If your data carries three-letter or full-name country codes, normalize them to ISO 3166-1 alpha-2 before this upgrade.

0.4.0 and 0.4.1

Nothing to do. 0.4.1 is release-note housekeeping with no functional change.

0.4.0 adds the opt-in billing.marketplace config block, off by default, so single-merchant behavior is unchanged. It also adds a billing umbrella publish tag — php artisan vendor:publish --tag=billing now publishes config, migrations, views and translations in one go, and the specific tags still work.

Contributors only: the static-analysis composer script was renamed to analyze.

0.3.0

Nothing to do. The admin console and the ZUGFeRD PDF/A-3 writer are both additive and both optional.

The hybrid PDF/A-3 needs a real PDF toolchain, so it is a suggested dependency: run composer require horstoeko/zugferd to use it. Without it the method throws MissingPdfEmbedder rather than fataling on an undefined class. The XML writers need none of it.

0.2.0

Check your VAT setup — this release closed two under-charging holes, and both change what customers are billed.

  • The EU reverse charge now requires a validated VAT id. Previously any supplied id, verified or not, earned the zero-rate. The default VatIdValidator proves nothing, so the zero-rate is not granted until you bind a real validator. Bind ViesVatIdValidator if you sell B2B across EU borders; otherwise your business customers will now be charged domestic VAT.
  • A domestic B2B sale is no longer zero-rated. The reverse charge applies only when the buyer's country differs from billing.company.country. Set that key — when the seller country is unknown, nothing is zero-rated, which is the safe direction but probably not what you want.

Also in this release: reverse-charge invoices no longer leak VAT into their totals (an EN 16931 violation a validator rejects), the EU-OSS table is matched case-insensitively, and a VIES outage is treated as unavailable rather than invalid. Every provider link-out is now scheme-validated before the redirect.

Run the migrations: 0.2.0 adds the coupon tables and the e-invoicing columns.

0.1.1

If your app deletes accounts from its own flow, dispatch BillableAccountDeleting before you delete the model:

use Pushery\Billing\Events\BillableAccountDeleting;

event(new BillableAccountDeleting($user));
$user->delete();

Without it, a deleted owner stays active and charging at the provider. The package's own eraser dispatches it for you; a custom delete button does not. Dispatch it after re-confirming identity and before the delete, so the listener can still resolve the owner's provider reference.

If you ship your own driver

A driver is a set of contract implementations, so what an upgrade asks of you is exactly which contracts moved. Across the versions above:

  • 0.7.0SubscriptionActions::cancel() gained ?CancellationSurvey $survey = null
  • 0.6.0PersistCreditNote became PersistInvoiceCorrection, and InvoiceCredited became InvoiceCorrected; a driver's webhook mapper that produced the old event must produce the new one
  • 0.5.0 — nothing on the driver contracts, but a driver that reports no provider tax will now be refused at boot if billing.tax is provider, and one that defers tax will be refused on a local mode

Two contracts are worth re-reading after any upgrade because their guarantees are what the fail-closed guards check: PaymentRails (moves money, stores mandates) and BillingEngine (the recurring cycle). PaymentRails is deliberately not eligibility-gated — the gate belongs at the entry seams where a payment begins, so that a dunning retry for a subscriber who was eligible when they subscribed is never refused later.

See the contract reference for what each seam guarantees.

The marketplace surface is opt-in

The multi-merchant marketplace (Stripe Connect) is additive and does nothing until you turn it on. Three things a driver author needs to know:

  • Nothing existing was widened to make room for it. PaymentRails, BillingDriver and the argument order of ChargeResult are untouched. The routing dimension arrives as an optional trailing ?ChargeRouting $routing = null on the money methods, defaulting to null — which is exactly today's behavior. A payment with no routing reaches the provider with exactly the fields it always has.
  • The routing capability is opt-in, at the driver. A driver joins the marketplace by implementing the RoutesMoney contract; one that does not implement it cannot produce a rails object, so no configuration can route through it. If you have written your own driver, it keeps working unchanged as a single-seller driver until you choose to implement RoutesMoney.
  • A driver that cannot serve a routing must THROW, never no-op. This is the one that loses money if you get it wrong. A routing the driver silently ignores settles the whole payment on the platform account, and the merchant is never paid — with nothing in the result saying so. The failure has to be loud: refuse the operation rather than complete it as an unrouted charge.

And two things an APPLICATION needs to know, not just a driver

The three points above are for whoever writes a driver. If you are adopting the marketplace in an app, the part that costs money is different:

  • Charge through RoutedPayment, not through the rails. It is the recommended path and not an enforced one, because nothing in this package calls the payment verbs — only your application knows when a sale happens. Going through the rails directly skips three things at once: the routed-charge row, the receiving gate and the tax-standing gate. The row is the one that bites quietly, because the reversal cap, the merchant's earnings total and the small-business threshold verdict are all computed from it — and their readers do not fail when it is missing, they answer zero.
  • The tax-standing hold arrives with a date, and it refuses everybody until you set one. A merchant nobody has declared for is Unclarified, which is the standing that blocks — so billing.marketplace.tax_status_hold.enforce_from starts null and nothing is refused. Pick a date far enough out to collect declarations from the merchants you already have, tell them, and let it arrive. billing:marketplace:preflight reports an unset date as outstanding rather than as configured.

The mechanics are in the marketplace overview; the byte-identical single-seller guarantee is stated there once and holds for every marketplace release.


← Back to the documentation index