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.49.0
Run the migrations
php artisan migrate. Two migrations since 0.48.0 add three columns for the fallback payment method:
- New columns:
is_fallbackonbilling_payment_mandates, false on every existing mandate, andfallback_attemptandfallback_mandate_referenceonbilling_orders, null on every existing order. Nothing changes until an owner chooses a fallback.
Billing notices are tried three times
Affects you if your queue worker tries each job once and billing notices should stay at one try. A billing notice
now sets its own tries (3) and backoff (60, then 300 seconds), and a job's own setting wins over the worker's
--tries. Set billing.notifications.tries to 1 to keep a single try.
0.48.0
Run the migrations
php artisan migrate. Four migrations since 0.47.0 add one table and three columns:
- New table for a spend cap per owner on usage past the included allowance:
billing_spend_caps. Nothing reads it untilbilling.usage.spend_cap.enabledis set. - New columns:
erased_owner_keyonbilling_invoicesanderased_merchant_keyonbilling_merchant_creditor_accounts, empty on existing rows, andtrial_daysonbilling_coupons, null on every coupon issued before.
Account screens of your own spend from the provider budget
Affects you if you build account screens of your own on Invoices, UpcomingInvoice, SubscriptionActions or
PaymentMethods. Call ProviderCallBudget::spend($owner) before each call to the provider, so one payer's calls
share one count with the package's screens. An application that limits these requests itself sets
billing.provider_budget.calls to null.
A class of your own that implements EnabledPaymentMethods takes the wallets
Affects you if a class of your own implements Contracts\EnabledPaymentMethods. Its all() takes a fourth
parameter, array $wallets = [], and PHP refuses to load a class whose signature lacks it. Add the parameter; code that
calls all() is unaffected.
A connected Mollie account needs more scopes
Affects you if merchants connect their Mollie accounts through OAuth. Add the scopes the new calls need to the list
your MollieConnect::authorizeUrl() call asks for:
refunds.readto list the refunds of a payment, andpayments.readfor what is left to refundpayments.writeto cancel a payment the customer has not completedonboarding.readforMollieConnect::onboarding()
An account connected before keeps the scopes it granted then, so its merchant grants the new ones by connecting again.
0.47.0
Run the migrations
php artisan migrate. Seven migrations since 0.46.0 add two tables and six columns, and one rewrites stored data:
- New tables for the declarations of sellers outside the Union and the contradictions observed against them:
billing_seller_declarationsandbilling_seller_declaration_contradictions. - New columns:
ioss,deemed_supplyandgoods_located_inonbilling_invoices, anddeemed_supply,goods_consignmentanddestination_countryonbilling_merchant_charges.iossis false on every existing document, and the others stay empty on existing rows. - One migration rewrites the webhook ledger: it removes the
client_secretof a Stripe PaymentIntent from the payloadsbilling_webhook_eventsalready stores. No effect reads the field, so a replay runs the same effects as before.
Sales under the deemed-supplier rules wait for the seller's declaration
Affects you if your platform sells goods of sellers outside the Union under the deemed-supplier rules. Each
seller's declaration has to be recorded with SellerDeclarations::record() before its goods are sold that way. Sales
of a seller without one are refused from this version on.
The DATEV chart needs accounts for goods and for the one-stop shop
Affects you if you published the configuration and export to DATEV. Add goods_revenue_standard,
goods_revenue_reduced and oss_goods_revenue under billing.datev.accounts.<chart>, the last with an account for
every member state the platform sells goods into. Only the documents of a deemed supply of goods book to them, so an
export without such documents is unchanged.
A receipt of a routed sale that belongs in the one-stop-shop return is declared there now, and it books to the
one-stop-shop revenue account of its member state, so billing.datev.accounts.<chart>.oss_revenue needs an account
for every member state the platform sells into. Receipts issued before this release keep the flag they were written
with, so a return for an earlier period still leaves those sales out.
BillingActionUrls has a new case
Affects you if your BillingActionUrls answers with a match over every BillingAction. Add the new
Subscription case. An implementation that hands the other cases to HubBillingActionUrls, as the contract's example
does, needs nothing.
The Stripe webhook endpoint needs three more events
Affects you if you take payments with Stripe. Add customer.updated, payment_method.automatically_updated and
customer.deleted to the webhook endpoint. billing:doctor names them as missing until they are there.
0.46.0
Run the migrations
php artisan migrate. Fourteen migrations since 0.45.0 add six tables and seven columns:
- New tables for SEPA mandate consents, direct debit notices, declared cancellations, declared withdrawals, usage grants and AI spend.
- New columns:
collection_timingandpaused_atonbilling_subscriptions,debtonbilling_prepaid_units,graceonbilling_usage_reservations,reinstated_atonbilling_access_grants,clawed_back_unitsonbilling_addon_purchasesandreinstates_invoice_idonbilling_invoices. - One migration moves data: the key of a credit note for a refund and of a proration correction moves from
provider_idintocorrection_keyonbilling_invoices. See below. - Two give payments-only mode the owner of a webhook delivery and, on PostgreSQL with uuid host keys, its native uuid type. Full mode has both already, and they leave it alone.
New subscriptions are collected in advance
Affects you if you bill subscriptions on a driver the package bills itself. A subscription started after the
update pays each period when it begins: the first at the next billing:run after the mandate exists, or when the
trial ends. Before, every period was charged at its end, so a yearly subscriber paid after a year. A subscription
started before the update keeps being collected in arrears, so nothing already running changes. To keep the old
timing for new subscriptions as well, set BILLING_COLLECTION_TIMING=in_arrears.
SEPA direct debits wait for their pre-notification
Affects you if the package's own engine collected SEPA direct debits before this release. Every debit is now
announced to the payer billing.direct_debit.notice_days ahead (BILLING_DIRECT_DEBIT_NOTICE_DAYS, 14 by default),
and a debit that comes due without a notice naming it is announced then and waits for that period. The first debit
of each subscription after the update can therefore come up to fourteen days later than it would have. Mandates a
first payment established count as well, since Mollie collects iDEAL, Bancontact and the other bank methods as
direct debits.
If your own code calls PreNotifications::announce(), handle null: it is returned where the notice has nobody to
go to, no address from the consent and no owner, and nothing is recorded then.
Corrections keep their key in correction_key
Affects you if your code reads provider_id on a credit note for a refund or on a proration correction. That
column names a provider's own document, and the key of a document this package issues is correction_key now. The
migration moves the key on the documents already issued. InvoiceRecord::locallyGenerated() answers true for
both, and a provider's own credit note keeps its id in provider_id.
Payments-only mode migrates two tables
Affects you if you run BILLING_MODE=payments. The mode now migrates billing_webhook_events and
billing_webhook_effect_runs only, and vendor:publish --tag=billing-migrations publishes their migrations alone.
An installation that migrated in this mode before keeps the tables it has; the package no longer writes them, and
dropping them is your decision. Switching to full mode later, run php artisan migrate afterwards to create the
rest.
0.45.0
Run the migration
php artisan migrate. One migration makes the merchant columns of billing_merchant_charges nullable, so erasing a
merchant with a routed charge no longer fails on the constraint.
CardPresentPayments::cancel() on Mollie throws when the terminal cannot take a sale off
Affects you if you take card-present payments with the Mollie driver. Mollie takes an open terminal payment off
only at the device, and cancel() used to return quietly while the buyer could still pay. It now throws
SaleNotCancelable with the reader and the payment; catch it and tell the operator to press the cancel button on
the device. Without a sale on the reader cancel() still does nothing, and Stripe is unchanged.
PaymentRails::charge() takes options
Affects you if you implement PaymentRails yourself. charge() takes ?ChargeOptions $options = null as its
last parameter, and an implementation outside the package needs it in its signature. A caller changes nothing.
A refused refund comes back as a result
Affects you if you catch the SDK's exception when the provider refuses a refund. The refund comes back as a
RefundResult; check failed() on it. An error that says nothing about the refund itself, such as a rejected
credential, a rate limit or an outage, still throws.
A Stripe custom domain goes into billing.redirect_hosts
Affects you if your Stripe account serves Checkout or the customer portal from a custom domain. A page of the
payment provider is handed out only on a host its driver names, and a custom domain is a subdomain of your
application that no driver can know. List it in billing.redirect_hosts in the published config; until then
Checkout and the portal open nowhere, and each refusal is logged with the host.
0.43.0
Run the migrations
php artisan migrate. Twenty-six migrations since 0.42.1 add two tables, columns and indexes, and eleven of them do more:
- Stored error texts of failed webhook effects are replaced by their redacted form, since a failed insert's message can carry the values it tried to write.
billing_credit_ledger_entries.source_idbecomes an integer where an application'sSchema::morphUsingUuids()ormorphUsingUlids()made it something else. On PostgreSQL such a column cannot have held a value and is retyped empty; a value in it after all is refused rather than dropped.- German withdrawal windows that were frozen exactly fourteen days after provision are extended to the end of the fourteenth day, moved past a weekend or public holiday. No window is shortened.
- Amount columns widen to 64 bits on the buyer-protection holds and the vouchers, and the contents of kept documents and tax return exports widen on MySQL. On a large table that rewrites the table.
- The dates that are set years ahead become
DATETIMEon MySQL, where aTIMESTAMPends at 2038-01-19 03:14:07 UTC: the expiry of vouchers, coupons and access grants, the ends of an access grant's update and conformity windows, and the end and attestation of a creator's tax status. Every stored value reads back as it did. On a large table the change rewrites the table. Rolling it back leaves the wider columns in place, because narrowing them would refuse or zero a date past 2038. - Every moment the package keeps in UTC becomes
DATETIMEon MySQL, so that it no longer passes through the session time zone. On a connection that is not in UTC aTIMESTAMPstored each such moment off by the offset, and where the zone observes daylight saving time it refused a moment in the hour skipped in spring. Every stored value reads back as it did, as long as the migration runs on the connection the application writes with. The change touches 86 columns in 36 tables, the usage events among them, and rewrites each table. Rolling it back leaves theDATETIMEin place. - On PostgreSQL, the columns that reference your models become the native
uuidtype when your models are keyed by UUID (billing.schema.host_key_typeisuuid), the type Laravel's ownuuidMorphs()declares. They werechar(36), and PostgreSQL compares auuidwith no character column, so awhereHas()from one of your models into a package table failed withoperator does not exist: uuid = character. Every value in them has to be a UUID: a value that is not stops the migration, and every column stays as it was. MySQL, SQLite and integer or ULID keys are unchanged, and the rollback turns the columns back intochar(36). - The credit movements of an erased owner are kept.
billing_credit_ledger_entriesgainsowner_erased_atanderased_owner_key, and its owner columns become nullable. An erasure deleted these movements, and the booking batch of a period reads them, so a batch built after an owner was erased in it lacked every top-up and spend of theirs. They are now unlinked and kept like an invoice, andbilling:pruneremoves them with the other erased financial records. Rolling it back drops both columns and leaves the owner columns nullable. - A register's verdict on a tax ID can come back.
billing_tax_id_verificationsgainsfollows, the answer a row changed, and its unique index moves onto it. Rolling this back fails once a verdict has come back, because the old index cannot hold both answers. - Twenty-one indexes that another index on the same table already leads with are dropped, most of them the (type, id) pair of a model reference beside a composite that starts with the same pair. Every query they served reads the longer index, and each of them cost a write on every insert and update. An index is dropped only where the one covering it exists, and rolling back recreates them under their old names.
billing_creator_tax_status_anchors is new: one row per merchant whose tax standing was recorded, which the
recordings lock so that two of them take turns. So is billing_payment_mandate_anchors: one row per owner and
provider whose mandate was stored, which the storing locks so that only the first of two mandates stored at once
becomes the default.
billing_invoices gains locale, the language a document is written in. Documents issued before it have none and
render in the current language, as they did.
billing_addon_purchases gains money_credit, whether a purchase put money on the buyer's credit balance, and
granted_meter_key and granted_units, the usage units it granted. The prepaid volume and the refund read them
instead of asking today's catalog. Purchases made before them have none and are read against the catalog as before.
A classic Mollie ping has to name a payment
Affects you if something other than Mollie posts form pings to your webhook endpoint. A classic Mollie ping
whose id is not a payment id (tr_ followed by letters and digits) is now refused with 400 and no longer
recorded; Mollie sends no other. To limit how often one address may post to the webhook endpoints, name a throttle
in the new billing.webhook_middleware. The package ships none, because it reads no network address.
The webhook endpoints answer 200
Affects you if a test or a monitor in your application expects 204 from billing/webhook or the marketplace
endpoint. Both endpoints answer a handled delivery with 200 OK and an empty body. Mollie reads every status
other than 200 as a failed delivery and calls again, up to ten calls in all over 26 hours, so under the Mollie driver
each ping used to come back: the package recorded it once, but fetched the payment back from Mollie on every
attempt. A test that asserts assertNoContent() against these routes asserts assertOk() instead.
Another owner's invoice download answers 404
Affects you if a test or a monitor in your application expects 403 from billing/invoices/{id}/download.
The route answers another owner's invoice with 404, exactly like an id that names no invoice, so a signed-in user
can no longer learn which ids of the shared sequence exist. An id that cannot be a key of the invoice table, such as
a provider's own invoice id, is also a 404, where PostgreSQL used to end the request in a server error.
An issued document's buyer no longer changes
Affects you if your application writes buyer on an issued InvoiceRecord. The buyer is frozen from the moment
it names somebody, like lines and seller: saving another buyer, or an empty one, throws a RuntimeException.
The recipient's name, address and VAT id are mandatory on the document, and a reverse-charge invoice rests on that
VAT id. Correct a wrong buyer with a document of its own that refers to the original, the way a reissue does. A
buyer that is still empty may be filled once.
A US tax form keeps its calendar days
Affects you if you record US tax forms on PostgreSQL or MySQL with an app.timezone east of UTC. signed_on
and expires_on are stored as the calendar day passed to UsTaxFormRegistry::record(). Before, both were
converted to UTC first, so a date given as a local midnight, which is what a parsed date is, reached its column as
the day before, and the registry dropped the form a day before it expired. Rows written that way keep the earlier
day: compare them with the dates on the signed forms and correct them. A form now counts from the day it was signed
through the whole of the day it expires on.
The recapitulative statement refuses a sale in another currency it cannot state
Affects you if you export the recapitulative statement and issue reverse-charged sales in other currencies.
The export used to read the documents of one currency and left a sale in SEK or USD out of the euro statement.
Under a jurisdiction profile that names a rule for the statement (the German one does), such a sale is now stated
at the statement's own rate, which billing:exchange-rates:freeze-statement freezes once the quarter or month has
closed. Run it before the export; until it has run for the period, the export refuses and names it. Under a profile
that names no rule, the export refuses as long as the period holds such a sale.
A Mollie chargeback names the payment, and a lost dispute follows the chargeback switch
Affects you if you listen to ChargebackReceived under the Mollie driver, or set the two revocation switches
differently. The Mollie driver carried the chargeback's id (chb_…) as the event's reference. It now
carries the payment's id (tr_…) there, as every driver does, and the chargeback's id as disputeReference. A
listener that read the chargeback from reference reads disputeReference instead. A chargeback Mollie has
reversed raises no event.
A Mollie payment with a chargeback that still stands also raises AddonRefunded, its refunds and its standing
chargebacks as one cumulative figure, with the reason dispute_lost. An add-on's credit is taken back and its
local invoice gets a credit note, as for a refund.
RevokeAccessOnRefund reads a refund with that reason as the chargeback it is, on every driver:
billing.content_ownership.revoke_on_chargeback decides it instead of revoke_on_refund, and the revocation
records chargeback. With both switches on, the shipped default, nothing changes but the reason.
The host key type follows the framework when it is not set
Affects you if your application calls Schema::morphUsingUuids() or morphUsingUlids() and leaves
BILLING_HOST_KEY_TYPE unset. billing.schema.host_key_type is unset by default now and then reads the
framework's morph key type, so the package's host keys are uuid or ulid like your morphs. Before, they were
integers beside uuid morph columns, and a fresh installation could not finish its migrations on MySQL or
PostgreSQL. Tables that already exist keep their types, and billing:doctor reports one that disagrees with the
setting. An explicit BILLING_HOST_KEY_TYPE=int keeps every reference numeric.
A migration of this release turns billing_credit_ledger_entries.source_id into an integer where the framework
made it a uuid. On PostgreSQL it refuses to run while that column holds a value, because nothing this package
writes can be in it; inspect such a value before you migrate.
Webhook effects call providers after their run commits
Affects you if you construct CreditAddonPurchase, AddonRefunds or AdoptCollectedPaymentMethod yourself,
or run the webhook queue on a connection of its own. The provider calls these made inside a webhook run now go
to jobs queued once the run has committed: PushCreditToProvider mirrors a credit or its clawback onto the
provider's balance, MakeCollectedMethodTheDefault makes a collected method the default. Both use
billing.webhooks.connection, billing.webhooks.queue and billing.webhooks.tries, like the effects.
- The three constructors lose their provider seam (
CreditSync,AdoptsCollectedPaymentMethod); the jobs resolve it from the container. Drop the argument where you build them by hand. - A test that bound a recording
CreditSyncinto one of them binds it in the container instead.
A merchant delivery's effects are recorded under its account
Affects you if you read billing_webhook_effect_runs yourself. The run of an effect that keys on the event id is
now recorded under the account the delivery came from (acct_…:evt_…), as the delivery itself has been since it
gained account_reference, so two accounts whose events share an id each run their effects. A platform delivery keeps
the bare event id, and a job queued before the upgrade keeps it too.
A delivery that already has a run under the bare event id, one whose effect failed before the upgrade among them,
keeps that key, so its retry or billing:webhooks:replay meets the run instead of starting a second one beside it.
Money credit only for an add-on its catalog classifies as credit
Affects you if you sell works as add-ons, or bind an AddonCatalog of your own. An add-on that granted no
units was credited to the buyer at its price, whatever it was: a work its buyer also received, or a sale from a
catalog of yours. Such an add-on was also treated as a voucher for tax and for the voucher volume. Now:
- a
billing.addonsentry with neithergrantsnorarchetypeis money credit, as before, and so is one with thevoucherarchetype. One with any other archetype is a product and credits nothing; - a work the register of works maps (
AddonContentMap) credits nothing; - a catalog of your own decides for its keys by implementing
ClassifiesMoneyCredit, or by stating thevoucherarchetype throughSuppliesProductArchetypes. A key it does not classify credits nothing, and a refund of it takes nothing from the balance; - the units of an add-on only your catalog knows are granted: the purchase reads the bound catalog now.
If a catalog of your own sells money credit, implement ClassifiesMoneyCredit on it before you upgrade.
A routed hosted purchase and a hosted tip open again
Affects you if you sell add-ons or take tips through a hosted checkout routed to a merchant. Under the API version the package pins, a checkout session creates its payment only when the buyer confirms, and the hosted one-off purchase and the hosted tip refused every routed sale because the session named no payment. They open again: the sale is written down under a reference of the package's own, carried on the session and on the payment, and the row takes the payment's id once the payment confirms.
If your Stripe webhook endpoint receives a selected list of events rather than all of them, add
checkout.session.expired. It closes the sale of a checkout the buyer abandoned; without it that sale stays
pending. RoutedChargeConfirmed and RoutedChargeAbandoned carry the new saleReference, null for any payment
that is not a hosted sale.
Canceling a prepaid term refunds the unused part
Affects you if you call PrepaidTermCancellation::cancel(). Run the migrations: billing_merchant_charges
gains term_canceled_at, and billing_invoices gains correction_key. The method corrected both links of the chain
but moved no money. It now refunds the unused part through BillingAdmin::refund(), which corrects the chain once
the provider confirms, and a term is canceled once per charge. The signature changes:
cancel(Model $owner, MerchantCharge $charge, Money $term, int $periodsUsed, int $periodsInTerm, ?string $reason = null, ?Model $actor = null): ?RefundResult
Pass the buyer the term was sold to as $owner; the creator is read from the charge. The method returns the
provider's RefundResult, or null when the term was used up. Remove any refund you issue yourself next to
cancel(): the cancellation is the refund, and a second one would move the money twice.
An erasure reaches the person as a merchant too
Affects you if you run a marketplace and erase accounts that sell. billing:erase and BillingEraser now
erase the merchant side as well: the provider account and the tax-hold reminders go, the financial records stay
unlinked and stamped with merchant_erased_at. Before the rows go, the subscriptions fans hold with the merchant
end at the provider, immediately by default. Set BILLING_ERASURE_MERCHANT_SUBSCRIPTIONS=period_end to let each
run to the end of the period the fan paid for. ErasureReport::$purged and $retained count both sides
together, and ErasureReport::$axes holds the counts per side. billing:export now holds the merchant side too.
A marketplace's own tiers come from billing.tiers first
Affects you if you bind DatabaseMerchantCatalog and sell tiers of the platform. The platform scope now answers
from the bound tier and plan catalogs, the ones the checkout prices a platform sale from, and from your platform
rows for every key they do not declare. A subscription to a platform tier from billing.tiers now resolves to its
tier in the webhook. A platform row whose key billing.tiers also declares is priced from the configuration. A
DatabaseMerchantCatalog you construct yourself takes the bound catalogs as its second and third argument.
The banner names a merchant by the name buyers know
Affects you if you bound a MerchantPartyResolver and your banner named merchants. The banner took the
merchant's name from the invoice party, which on a creator marketplace is the creator's legal name. It now asks
the new MerchantDisplayName seam and shows no name until you bind one. Bind it to the name your buyers know,
the way the marketplace page shows. BillingBanner takes the seam in place of the party resolver, so a banner you
construct yourself passes a MerchantDisplayName as its fourth argument.
The danger zone bills the days a customer already had, under a driver the package bills itself
Affects you if Mollie is your driver and your customers can reach the account hub's danger zone. Its immediate
cancel stopped billing and billed nothing more, and under a driver that collects a period at its end the days of
the period in progress had not been paid yet, so ending at once left them unbilled. The danger zone now ends the
subscription through EndsNowOnTheOwnersRequest::endNow(): the next billing:run bills the days of the period the
customer already had and then ends the subscription. Until that run the subscription is running and cannot be
replaced by a new one. Under Stripe nothing changes, because the period is paid in advance.
SubscriptionActions::cancelNow() is unchanged and bills nothing more; account deletion, a withdrawal and a
reversed sale keep ending subscriptions that way. If a screen of your own lets customers end their subscription
at once, call endNow() where the bound SubscriptionActions implements EndsNowOnTheOwnersRequest, the way the
danger zone does. If you override the billing::account.danger.explanation translation, check that it does not
promise that nothing more is billed.
An XRechnung states the 3.0 identifier and can carry a contact and payment instructions
Affects you if you send the package's XRechnung (the default EInvoice) to recipients that validate it. Its
specification identifier was urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0, a form
only the versions up to 2.3 used, and it is now urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0.
A check of your own that matches the old string needs the new one. XRechnung also requires a seller contact and
payment instructions on every document: set contact_name, contact_phone, contact_email and iban (with
bic) under billing.company, as Invoices and e-invoicing shows.
An amount collected on behalf of another party is stated as one
Affects you if you issue documents with FanReceiptIssuer::issueIntermediated(), store lines whose tax_rate
is null, read Line::$taxRate, or published the package's views. A line whose rate is null is an amount the
issuer collected in the name and on behalf of another party. Both e-invoice syntaxes state it in category E with
the exemption code VATEX-EU-79-C, where they stated a zero-rated supply (Z) of the issuer's, and the page prints
a dash instead of 0 %. On such a document the band of the issuer's own supply carries the tax it was charged, so the
document asks what the buyer paid. Line::$taxRate is ?float and is null for such a line, which
Line::collectedOnBehalf() also says; code that reads the rate as a number decides what a missing rate means to it.
A line that names no rate still reads as 0 %.
The page also states the amount apart from the figures the rate and the tax are stated over. A short receipt states
its rate over the issuer's supply alone, where it stated the rate over everything that was paid, and both tiers name
the collected amount before the total. A published invoice view keeps the old totals: copy the rows that read
$collected and $supplyTotal from the package's view.
The commission invoice to a merchant names the merchant
Affects you if you sell under intermediation and call FanReceiptIssuer::issueSellerCommission(). The
platform's commission invoice to a merchant names the merchant as its buyer, read from the MerchantPartyResolver
your marketplace binds, where it named nobody. Without a binding it is refused with MerchantPartyUnavailable
before it draws a number, as a self-billed document is, rather than issued without a recipient. Bind a resolver that
reads your merchants before you charge one a commission. The platform's invoice for an intermediated sale's fee
also takes the buyer's details for a full invoice now: issueIntermediated() has a $buyer parameter, and
reissueAsFullInvoice() restates a short one in its own series.
Send transfer.reversed to your platform endpoint
Affects you if you run a marketplace on Stripe. A transfer the provider reverses on its own, from its dashboard
or in a recovery, takes money back from a merchant, and the package records it so the merchant's available
balance shows what is left. It read transfer.reversed on the marketplace endpoint, where Stripe never sends it:
a transfer is the platform's object, and Stripe delivers its events to the platform's endpoint. It is read there
now. Add transfer.reversed to the events your platform endpoint receives; the marketplace endpoint no longer
needs it.
A routed hosted purchase that Stripe taxed is issued its document
Affects you if you run a marketplace on Stripe, the platform is the deemed supplier, and buyers pay through the hosted checkout with Stripe Tax. Such a sale's document states the rate Stripe applied, which the package reads off the session's line items. From 0.40.0 the request left out the line items' taxes, which Stripe only returns when they are asked for, so every such sale with tax was issued no document. From this release they are issued. To issue the documents of the sales before it, replay their deliveries and run the document effect again, from the day you installed 0.40.0, released on 2026-09-25:
php artisan billing:webhooks:replay --type=checkout.session.completed --since=2026-09-25 --limit=1000 \
--rerun='Pushery\Billing\Webhooks\Effects\IssueDocumentForRoutedHostedPurchase'
A sale that has its document keeps it, and the other effects of each delivery are skipped as done. A delivery is
kept for billing.retention.webhook_payload_days (90 days by default), and a sale whose delivery has aged out
cannot be replayed.
The danger zone asks an account without a password for its email
Affects you if people sign in to your application without a password, or if you published the package's views.
Before it ends a subscription at once, the danger zone asks the signed-in user to prove who they are. An account
without a password proves its account email there, while the field asked for a password. It now asks for the email.
An email is no secret to someone who took the session over, so if that matters to you, bind the new
ReconfirmsIdentity seam to a factor of your own, as The account hub shows. A
published danger-zone view keeps its old label: replace __('billing::account.reconfirm.prompt') there with
$prompt to show what the bound seam asks for.
A plan change under Mollie is billed by the days each tier held
Affects you if Mollie is your driver. Run the migrations: billing_subscriptions gains
tier_adjustment_accrued. A plan change credited the unused remainder of the old tier to the customer's balance,
as if the period had been paid in advance. The engine bills a period when it closes, so that credit gave back days
nobody had paid for, and a downgrade scheduled to the period end credited nearly a whole period of the old tier
right after the cycle had billed it. The driver now binds ArrearsProrationStrategy: a swap moves no balance, and
the cycle that closes the period charges each tier for the days it held. The preview on the plan screen still
shows what the swap adds to that bill or takes off it. Credits booked before the upgrade stay on the balance and
are spent as before.
If you bound CreditBalanceProrationStrategy yourself for the Mollie driver, remove that binding.
Send customer.tax_id.deleted to your Stripe endpoint
Affects you if a driver the package bills itself reverse-charges cycles on tax IDs Stripe verified. The reverse
charge now ends when the buyer removes the ID, and Stripe reports that only with customer.tax_id.deleted. An
endpoint that receives only the events it was set up with keeps reverse-charging on a removed ID until the event
is added. TaxIdVerificationStatus has a new case, Removed; code that matches over the enum without a default arm
needs one for it.
The hosted portal returns to the hub, and a caller can name the way back
Affects you if you implement HostedPortal yourself, or rely on the portal returning to
billing.checkout.success_url. HostedPortal::url() takes an optional second argument, the URL the portal sends
the customer back to, for a screen of yours outside the account hub. An implementation of your own adds
?string $returnUrl = null to its signature. Without the argument the portal returns to
billing.checkout.portal_return_url and then to the hub's subscription screen. It no longer falls back to
success_url, the page that expects a purchase that has just completed; set BILLING_PORTAL_RETURN_URL if the
portal should return there.
A queued listener receives an event's models by their key
Affects you if a listener of yours implements ShouldQueue and handles one of the events below. These
events held a model in a public property, and the queue stored it with every attribute. They now store it by
its key, and the listener receives the row read back when it runs rather than the instance that was dispatched:
CreatorPlacedOnTaxHold, CreatorReattestationDue, CreatorTaxStatusChanged, MerchantReversalCameBackShort,
MerchantShareNotMoved, MerchantTransferReversed, PaymentReminderDue (the ArrearsEntry in entry too),
ProviderJournalDrift, SaleIntoClosedMarketReversed, SeatQuantityChanged, SellerDataMeasureChanged,
SellerDataReminderDue, SettlementRestated, SubscriptionExpired, TaxIdVerificationFailed,
UsageReconciliationDrift and WriteOffRecovered.
- A row deleted between the dispatch and the run fails the job with
ModelNotFoundException. Set$deleteWhenMissingModels = trueon the listener if the job should be deleted instead. BillableAccountDeletinghands a queued listener an owner that carries only its key, because the row is gone by then. A queued listener that read other attributes of the owner runs synchronously now, or reads what it needs before the deletion.
Listeners that run synchronously are not affected.
A switch set to 1 or 0 in .env is read as on or off
Affects you if you set one of the variables below to 1, 0 or a number, or if you published
config/billing.php or config/account.php. env() turns only true, false, null and empty into PHP
values, and these keys passed every other value on as a string: BILLING_TAX_EVIDENCE_SUBDIVISION=1 switched the
evidence off, BILLING_REVOKE_ON_REFUND=0 left revocation on, and an empty BILLING_MARKETPLACE_TIPS_MINIMUM_MINOR=
became a floor of 0. The shipped files now cast every switch and number they read, and an empty number reads as
not set. A published file keeps its own lines, so take the shipped expressions over for these keys:
account.csp.enabledbilling.mandate_verification_minorbilling.checkout.promotion_codesandbilling.checkout.tax_id_collectionbilling.erasure.forget_customerbilling.realtime.enabledandbilling.realtime.render_toast_regionbilling.trial.requires_payment_methodbilling.tax_counters.us_state_gmv.enabledbilling.consumer_rights.conformity_update_period_daysandbilling.consumer_rights.allow_conformity_waiverbilling.tax_evidence.collect_subdivisionbilling.content_ownership.bundle_additive_default,revoke_on_refundandrevoke_on_chargebackbilling.marketplace.tips.commission_bpsandbilling.marketplace.tips.minimum_minor
The Stripe webhook endpoints also read STRIPE_WEBHOOK_TOLERANCE as the number it names, as Cashier's own
endpoint does. Set in .env, it used to be ignored, and 300 seconds applied.
The fallback lane compares the currency of a submitted invoice
Affects you if your payout run calls FallbackLane::reconcile(). It takes the expected net and tax as
Money instead of minor units, and refuses the two in different currencies:
$lane->reconcile($submitted, Money::of(27_000, 'EUR'), Money::of(5_130, 'EUR'));
An invoice in another currency than the expected figures now fails with a currency finding, and its amounts
are not compared. Before, 27,000 yen passed against 270.00 euros and released the payout.
A Billing assertion needs Billing::fake() first
Affects your test suite if a test calls a Billing::assert…() method without Billing::fake() before it.
Such a test used to pass, because the facade built a fake nothing was bound to while the code under test
reached the real seams. It now throws a LogicException. Add Billing::fake() where the test begins; the
test was not proving anything before.
Buyer protection that cannot hold a payout stops the marketplace from booting
Affects you if buyer protection is on with an account type other than express or custom, or with a
decide_after_days too close to provider_limit_days. The go-live checklist gains
configuration.buyer_protection, a blocking point that cannot be waived, so such a marketplace no longer boots
and billing:marketplace:preflight names the reason. Every protected sale under that configuration already
failed after the buyer was charged; it is now refused before the charge. Fix the configuration the message
names.
0.42.0
A Mollie install sells add-ons
Affects you if Mollie is your driver and your catalog prices an add-on. OneTimeCharge is bound on Mollie
now, so the subscription screen offers every add-on with a price_display, and buying one opens Mollie's hosted
checkout for that price. The buyer comes back to billing.checkout.success_url, or to the add-ons on the plan
screen where it is unset, as on Stripe. The package writes an order when the checkout opens and raises the invoice
from it once Mollie confirms the payment. A refund reverses the purchase and issues a credit note against that
invoice.
A Mollie install collects late fees
Affects you if Mollie is your driver and a rung of billing.dunning carries a fee, or if you catch
LateFeesUnsupported. Such a rung no longer refuses to boot, and the exception is gone: nothing throws it. The
advance opens the fee as an order of its own, and the engine charges it as a separate payment once a cycle of the
owner has been paid. Its document states no tax, because a late fee compensates the delay rather than paying for
a supply, and the DATEV export books it to the chart's other-income account. The migration adds a reference
column to billing_orders, unique where it is set, which keeps a fee from being opened twice.
Dunning and suspension has the details.
A late fee on Stripe is neither taxed nor discounted
Affects you if a rung of billing.dunning carries a fee on Stripe, or if you implement LateFees
yourself. Under the provider tax mode Stripe Tax taxed the fee's invoice item with the account's preset tax
code, like the plan, and a coupon on the subscription reduced it. The item now carries Stripe's nontaxable code,
is not discountable, and lands on the invoice of the subscription in arrears. LateFees::apply() takes that
subscription as a new last parameter, so an implementation of your own declares it:
public function apply(Model $owner, Money $fee, string $reference, string $description, ?Subscription $subscription = null): void
The DATEV export books the goods of an intermediated sale as passing through
Affects you if you export to DATEV and sell under intermediation. The buyer's receipt of an intermediated
sale states the goods and the fee, and the export booked the whole of it to the revenue account, where an
automatic account derived tax from the goods as well. It now books two rows: the fee to revenue as before, and
the goods to the chart's transit_items account. That account comes from the chart, so an export without
billing.datev.chart refuses such a receipt with DatevTransactionUnresolvable instead of booking it. Select
a chart, as the commission chain already requires for money in transit.
A reversal that comes back short is announced
Affects you if you route payments to merchants and refund them, or if you call
RoutedChargeLedger::completeRefund() yourself. When the provider takes back less of a merchant's share than
the reversal asked for, MerchantReversalCameBackShort now carries the difference; before, it was only recorded
on the refund attempt. The package books nothing for it. Where your terms put the difference on the merchant,
charge it with MerchantSubLedger::chargeShortfall(), as the marketplace page
shows. completeRefund() takes a new last parameter, $reversalRequested: pass false where the provider was
not asked to reverse the share, as on a separate transfer, and nothing is announced.
0.41.0
Pairing a Stripe reader moved to its own contract
Affects you if you pair readers through CardPresentPayments::pairReader(). Pairing is no longer part of
CardPresentPayments, because the providers pair in opposite directions: Stripe claims the code a reader shows,
Mollie issues a code the merchant types into the terminal. Resolve PairsReadersByTheirCode instead. The Stripe
driver binds it, and the call itself is unchanged:
use Pushery\Billing\Contracts\PairsReadersByTheirCode;
$reader = app(PairsReadersByTheirCode::class)->pairReader('purple-dog-cat', 'tml_123', label: 'Till 1');
A Mollie install takes payments at the counter
Affects you if Mollie is your driver and you offer a sale at the counter when CardPresentPayments is
bound. The contract is bound to Mollie's point-of-sale terminals now. It was bound before this release as well,
to the Stripe implementation, which asked Stripe about a terminal it never saw. Before you offer a sale, declare
where each terminal stands in billing.mollie.terminal_countries, by the terminal's id or its profile's: Mollie
reports no address for a terminal, and a terminal without a country takes no sale. Pair a terminal with
IssuesReaderPairingCodes. Payment in person has the details.
A Mollie install bills seats itself and refuses a late fee it cannot collect
Affects you if Mollie is your driver and you name billing.seats.membership_events or put a fee on a rung of
billing.dunning. Before this release a seat change and a fee went to the Stripe implementation, which asked
Stripe about a customer Mollie had issued. Seats are now billed by the local engine, each count for the days it
held; the migration adds the three columns it needs to billing_subscriptions. A late fee is not collected on
Mollie yet, so a rung that carries one now refuses to boot with LateFeesUnsupported: take the fee off the rung.
The subscription screen offers no add-on on a Mollie install either, because there is no one-time checkout for it.
The suspension warning carries the late fee, not an overdue amount
Affects you if you read the suspension warning's database payload, or implement SuspensionNotifier
yourself. The payload key amount_due is now late_fee, and it is null when the rung added no fee. The
second argument of SuspensionNotifier::suspensionWarning() was always the rung's fee and is now named
$lateFee; a notifier of your own that printed it as the amount to settle printed 0.00 on every rung without a
fee.
0.40.0
A published DATEV chart needs two more revenue accounts
Affects you only if you export to DATEV with billing.datev.chart set and have published the
configuration. A reverse-charged sale to a business in another member state now books to
fan_revenue_eu_reverse_charge, and a sale to a business outside the union to fan_revenue_third_country.
A published billing.php does not carry them, and until it does the export refuses such a sale with
DatevTransactionUnresolvable instead of booking it as domestic. Add both to the chart you selected:
// SKR03
'fan_revenue_eu_reverse_charge' => ['account' => '8336', 'automatic' => true],
'fan_revenue_third_country' => ['account' => '8338', 'automatic' => true],
// SKR04
'fan_revenue_eu_reverse_charge' => ['account' => '4336', 'automatic' => true],
'fan_revenue_third_country' => ['account' => '4338', 'automatic' => true],
Until now these sales were booked to the standard revenue account, where the automatic function derived domestic tax from them. Batches you already imported are not rewritten; ask your accountant how to correct the periods concerned.
An invoice of the local engine names its buyer
Affects you if you bill through the local engine, Mollie included. Invoices are now raised with a buyer:
the VAT id a register confirmed and the country it registers the customer in, and the name and address a
BuyerPartyResolver of yours returns. Nothing is bound by default, so bind one if you invoice businesses;
php artisan billing:doctor says whether you have. Invoices issued before the upgrade are not changed.
A settlement records the rate of its supply
Affects you if you issue settlements through SelfBillingEngine::issue(). Run the migration: it adds
supply_rate_bps to billing_invoices. Settlements issued from this version on record the rate they state
in tax_rate_bps and the rate of the supply in supply_rate_bps, and the DATEV export books a 7% input and a
7% reverse charge by them. Settlements issued before the upgrade keep both columns empty and are booked as
before, at the standard rate. If you export a period that contains 7% settlements from before the upgrade,
ask your accountant which of those bookings to correct.
A routed sale is booked once
Affects you if you export DATEV batches from a marketplace. A period batch carries the buyer's receipt and the creator's settlement of every routed sale, and both of them booked the sale, so the revenue account received each routed sale twice. From this version the receipt books the sale, against money in transit, and the settlement books the creator's input and the payout. Batches you exported before the upgrade contain the doubled revenue; ask your accountant how to correct the periods concerned. A sale that has no buyer's receipt no longer books its revenue from the settlement.
A collective settlement is booked by rate
Affects you if you settle creators collectively and export to DATEV. The lines of a collective settlement issued from this version record the rate of each supply, and the export books one input row per rate instead of one row for the document. A month with 7% supplies is refused until you confirm a reduced input account, as a single 7% settlement already is. Collective settlements issued before the upgrade keep booking as one row at the standard rate; ask your accountant whether any of them covered 7% supplies.
A corrected creator standing issues the settlements again
Affects you if you run the marketplace with self-billing. Run the migrations: they add
billing_settlement_restatements and billing_invoices.restates_invoice_id, and billing:settlements:restate
is scheduled daily. From this version, recording a creator standing with a start date in the past cancels
the settlements issued since that date and issues them again. A replacement usually pays a different amount
from the settlement it replaces, and the package moves no money: listen for SettlementRestated and settle
the difference in your payout process.
Standings corrected before the upgrade queued nothing. To correct the settlements behind one of them, queue
them yourself with app(SettlementRestater::class)->queue($creator, $from) and run the command.
Creators are reminded before their attestation runs out
Affects you if you run the marketplace. Run the migration: it adds two reminder markers to
billing_creator_tax_statuses, and billing:tax-holds:remind is scheduled daily. Creators whose attestation
is due at the next year boundary receive ReattestationDueNotification. Set
BILLING_MARKETPLACE_TAX_STANDING_ROUTE to the route name of your renewal screen and the notice links to it.
If you route notices yourself, listen for CreatorReattestationDue and replace
NotifyMerchantOfReattestationDue.
A seller whose record stays incomplete is reminded, then held
Affects you if you run the marketplace. Run the migrations: one creates billing_seller_data_escalations,
the other adds transfer_withheld_at and transfer_withheld_reason to billing_merchant_charges.
billing:seller-data:escalate is scheduled daily with the marketplace on, and it assesses nobody until you
bind SuppliesSellerRecords. The default measure is withhold_payout; set
BILLING_MARKETPLACE_DATA_MEASURE=suspend_sales for a suspension instead, and
BILLING_MARKETPLACE_ON_WITHHOLDING_EXHAUSTED for what follows a withholding that ran its course. Set
BILLING_MARKETPLACE_SELLER_RECORD_ROUTE to the route name of the screen where a seller completes their
record, and the reminder links to it.
A creator on a tax-standing hold is not paid out
Affects you if you run the marketplace with the tax-status hold in force. A share released from buyer
protection, or retried after a failed transfer, now waits while its creator's standing is unestablished, as
blocks_payouts always said, and so does the share of a sale made on a separate transfer while the standing is
unestablished. It moves once the standing is recorded, or when the money reaches payout_deadline_days. A
share that is pending for such a creator today waits from this version on.
ClassifiesReportability is bound by default
Affects you only if you bound ReportingProfile to a profile of your own. The reporting rule is now bound
to GermanReportingProfile too, which is what made billing:reporting:run resolvable on an installation that
had bound nothing. If a profile of your own answers ReportingProfile, bind ClassifiesReportability to it as
well, or the reporting run classifies by the German rule.
A seller of goods who trades regularly is asked to declare
Affects you if you run the marketplace with sales of goods under intermediation. Run the migration: it adds
tax_archetype to billing_merchant_charges, which RoutedPayment and the hosted one-time checkout now write.
Rows from before carry none and are not counted. Once a seller reaches the activity threshold for goods in the
calendar year, their next sale of goods is refused with TradingStandingUndeclared until they declare their
standing; catch it where you catch TaxStandingUnestablished and send the seller to your declaration flow. If
you bind SuppliesSellerRecords, a private individual's sale of goods is also refused with
SellerRecordIncomplete while their record lacks name, address or date of birth. Ask
SellerStandingDisclosure at your checkout for what the buyer must be told about the seller.
A goods seller paid exactly EUR 2,000.00 is reportable
Affects you only if you run the marketplace with the German reporting profile. The small-scale
exemption for sales of goods now ends below the figure, as § 4 Abs. 5 PStTG words it: fewer than 30 sales
and less than EUR 2,000.00. A seller with fewer than 30 sales and exactly EUR 2,000.00 was left out until
now and is classified as reportable from this version. If your statute follows the directive's wording
instead, set BILLING_REPORTING_COMPENSATION_OPERATOR=<= to keep the previous comparison. Check whether a
report you already filed left out a seller on exactly that amount.
The reporting period names sellers who sold through the platform as an intermediary
Affects you if you run the reporting duty on a marketplace that sells under intermediation. Such a sale
produces no settlement document, and the reporting period read its sellers and their figures from settlement
documents only, so a seller who sold only that way appeared in no report. From this version they are in it:
with a line per kind of thing sold, their four quarters, and in feesWithheld the fee the platform charged
them, net of refunds. A sale recorded before its kind was comes back as an unclassified line, which
reportable() refuses until you resolve it from your own catalog. Check whether a report you already filed
left out sellers of this kind.
The quarter check no longer flags a commission-chain seller's margin
Affects you if you file with billing:reporting:run and acknowledged quarters_do_not_sum_to_the_year for
a commission-chain seller. The check compared the quarters with what the platform kept, so every chain
seller whose sales carried a margin was flagged. Those findings are gone from this version; nothing is to be
done about the ones you acknowledged. QuarterCoverageRule and UndocumentedSettlementRule take an
IntermediatedSalesCounter now, which matters only if you build either by hand.
A credit note against a reverse-charged sale is booked like the sale
Affects you if you export to DATEV and refund sales to businesses in other member states. A local credit
note, and the correction a redeemed proration credit raises, now carry the whole tax position of the invoice
they reduce. A note against a reverse-charged sale books to fan_revenue_eu_reverse_charge with the buyer's
VAT id, where it used to book as a domestic reduction. Notes issued before the upgrade keep the columns they
were issued with; ask your accountant how to correct the periods concerned.
The recapitulative statement, for reporting without DATEV
Affects you only if you want the file. Run the migration: it adds return_type to
billing_tax_return_exports, which tells a one-stop-shop run from a recapitulative statement.
billing:recapitulative-statement:export writes a quarter's reverse-charged sales per VAT id, or a month's with
--month. A jurisdiction profile of your own can state the deadline by implementing
SuppliesRecapitulativeStatementDeadline, and a limit on goods with a monthly deadline by implementing
SuppliesMonthlyRecapitulativeStatement. Under the German profile a quarterly export refuses once your
supplies of goods to businesses abroad passed 50,000 euros in the quarter or one of the four before it; if
you schedule the quarterly export, schedule a monthly one for that case.
0.39.0
PlaceOfSupplyRule has a third case
Affects you only if you match over the enum without a default arm. PlaceOfSupplyRule::PointOfSale
places a sale made in person where it is made. A match that names only Destination and Domestic throws
UnhandledMatchError on it, so give it an arm. A ProductTaxonomy of your own needs nothing:
ArchetypeClassification takes the in-person place as an optional sixth argument, and without it a sale made
in person is placed as an online one.
The rate band comes from the product, not from the buyer's context
Affects you if you set rateCategory on the TaxContext you hand SaleTaxDecision. The band is now read
from the product's classification, like the place of supply, and a band on the context no longer decides. An
ebook sold with a plain context is taxed at the reduced band where billing.tax_matrix grants one, instead of
the standard rate its document contradicted. A download handed over with a reduced band is taxed at the
standard band its classification gives it. hasAudioVisualComponent still comes from the context.
A verified tax id places a local cycle where the id registers the business
Affects you if you bill through the local engine and a customer has a verified tax id. The country of a local cycle now comes from the id rather than from the place evidence. A German business whose evidence read another member state pays German tax instead of being zero-rated, and a business whose id a register outside the union confirmed, a UK VAT number for one, is outside the union's tax instead of reverse-charged. Invoices already issued are not reassessed.
0.38.0
An implementation of SubscriptionActions declares cancelAt()
Affects you only if you implement the contract. Code that only calls it, the shipped drivers and BillingFake
need nothing. PHP refuses an implementation that lacks the new method:
public function cancelAt(Model $billable, CarbonInterface $endsAt, ?MerchantScope $merchant = null, ?string $type = null): void;
It ends the subscription at $endsAt, a moment inside the period in progress, and refunds nothing itself.
Refuse a moment that has passed and one after the period end with an InvalidArgumentException, and have
resume() refuse a subscription canceled this way: Subscription::endInsideItsPeriod() says whether it was.
A Stripe subscription canceled to a date reads as canceled
Affects you if you read the local subscription row of a Stripe subscription. A subscription with a
cancel_at in Stripe now syncs as grace, and the row's ends_at holds the moment it ends. Before, only
cancel_at_period_end counted as a cancellation, and ends_at was never written for a Stripe subscription at
all. So the account screen now shows the date access ends, and canceledInWindow in
BillingMetricsReporter counts a Stripe cancellation once its end has passed.
A canceled subscription on a driver this package bills itself ends, and its last period is billed
Affects you if you bill through the local engine, Mollie included. Canceling used to clear the schedule that collects a period at its end: the period the customer kept was never billed, and the subscription never ended. It now keeps its last cycle, which bills the period and ends the subscription.
Subscriptions canceled before this release still have no schedule. The next run ends them without billing their last period, and logs each one. To see them before you upgrade, and bill any of them yourself:
SELECT id, owner_type, owner_id, tier_key, current_period_start, ends_at
FROM billing_subscriptions
WHERE provider = 'mollie'
AND scheduled_processing_at IS NULL
AND ends_at <= UTC_TIMESTAMP()
AND status IN ('active', 'grace', 'trialing');
ends_at holds a UTC moment, so the query compares it with the current time in UTC: UTC_TIMESTAMP() on MySQL,
now() AT TIME ZONE 'UTC' on PostgreSQL. CURRENT_TIMESTAMP is the time in the session's time zone.
Onboarding refuses a merchant that was never stored
Affects you only if you onboard a model without a key. createAccount() and onboardingLink() throw an
InvalidArgumentException for it before the provider is asked anything. Such a merchant used to derive the
same idempotency key as every other one without a key, so a second one could be handed the first one's
provider account. Store the merchant first.
0.36.0
Run the migrations
billing_disputes is new: every dispute a provider opens is kept there, once per dispute, and DisputeRates
reads it. It starts empty, so a rate over a window before the upgrade counts no disputes there, while its
payments are counted from the charges already recorded. Read a window that begins after the upgrade.
Let your Stripe endpoint send charge.dispute.created
The package now reads the opening of a dispute. An endpoint that sends only charge.dispute.closed keeps working,
but it never raises DisputeOpened, so no case is recorded and none can be answered in time. Add the event on the
platform endpoint, and on the Connect endpoint where merchants take their own charges.
0.35.0
An implementation of SubscriptionActions declares the contract type on cancel, resume and swap
Affects you only if you implement the contract. Code that only calls it, the shipped drivers and BillingFake
need nothing: the new parameter is optional and comes last. PHP refuses an implementation that does not accept it:
public function cancel(Model $billable, ?CancellationSurvey $survey = null, ?MerchantScope $merchant = null, ?string $type = null): void;
public function resume(Model $billable, ?MerchantScope $merchant = null, ?string $type = null): void;
public function swap(Model $billable, string $tierKey, bool $prorate = true, ?MerchantScope $merchant = null, ?string $type = null): void;
A scheduled downgrade on a driver this package bills itself takes effect now
Affects you only if you bill through the local engine and let customers downgrade at the period end. Such a
downgrade used to be recorded as applied and never applied, so the customer stayed on the higher tier. A
downgrade that came due before you upgrade is not replayed. Compare each billing.scheduled_swap_applied row in
billing_events, whose payload names the tier, with the tier_key of that owner's row in billing_subscriptions,
and swap a row that did not move by hand.
A banner of your own reads the call to action as optional
Affects you only if you render your own banner from a BannerNotice. <x-billing::banner /> needs nothing.
The banner now reads every subscription the owner holds. A notice about a contract the account screens do not
manage, a merchant's subscription or a platform contract of a type other than the default, has no call to action:
$ctaKey and $ctaRoute are null there. $merchant names the merchant such a notice is about, where your
MerchantPartyResolver can name it. Check both before you build a link:
@if ($notice->ctaRoute !== null && $notice->ctaKey !== null && Route::has($notice->ctaRoute))
<a href="{{ route($notice->ctaRoute) }}">{{ __($notice->ctaKey) }}</a>
@endif
The shipped banner puts the merchant's name in front of the message through the new key
billing::account.banner.with_merchant; publish the translations to change how it reads.
0.34.0
Run the migrations
billing_merchant_charges gains payment_reference, the payment behind a subscription cycle, next to the invoice
the cycle is recorded under. A lost dispute over a routed cycle finds its row through it. The column is not
backfilled, so a dispute over a cycle recorded before this release still finds nothing.
An implementation of Checkout, OneTimeCharge or SubscriptionActions declares the new parameters
Affects you only if you implement one of the three contracts. Code that only calls them, the shipped drivers
and BillingFake need nothing: every new parameter is optional and comes last.
Checkout::subscribe() takes the contract type and your own key, OneTimeCharge::purchase() takes your own key,
and SubscriptionActions::cancelNow() takes the contract type. PHP refuses an implementation that does not accept
them:
public function subscribe(Model $billable, string $tierKey, ?string $couponCode = null, ?string $declarationReference = null, ?string $buyerCountry = null, ?bool $collectTaxId = null, ?string $type = null, ?string $callerReference = null): ClientIntent;
public function purchase(Model $billable, string $addonKey, ?string $declarationReference = null, ?string $buyerCountry = null, ?bool $collectTaxId = null, ?string $callerReference = null): ClientIntent;
public function cancelNow(Model $billable, ?MerchantScope $merchant = null, ?string $type = null): void;
Provision a creator's tier again under seller_of_record or platform_intermediary
Affects you only if you sell a creator's tiers under one of those two postures. The price behind such a tier
was minted on the creator's connected account, while the checkout opens its session on the platform account, which
cannot use it. A tier provisioned before this release keeps that price id, so call
MerchantPriceProvisioner::provision() again for each one and store the id it returns.
SellerOfRecordPosture::mintsPriceOnPlatformAccount() is deprecated and answers true for every posture.
A sale that used to open can now refuse
Affects you only if you sell under the intermediary posture, or set
billing.marketplace.tax_status_hold.enforce_from. Both refusals are thrown before the provider is called, so
nothing is charged.
- Goods of a seller established outside the Union are refused as an intermediated sale with
RegimeNotPermitted, on the direct payment and on the hosted one-off purchase. The hosted purchase reads the item's archetype frombilling.addons.<key>.archetype. - After
enforce_from, the hosted subscription checkout, the hosted one-off purchase and the hosted tip refuse a merchant whose taxation is not established, withTaxStandingUnestablished, as the direct payment already did.
0.33.0
Run the migrations
billing_merchant_charges.settlement_invoice_id is filled for charges settled per transaction, matched on the
merchant, the provider and the reference together, so a settlement never claims a charge it does not name. A
collective settlement already wrote the column.
The withheld-fee figure is read from WithheldFeeCounter
Affects you if you call MerchantChargeAnnualEarningsCounter::feesWithheldIn(). The method is gone. The same
call lives on WithheldFeeCounter, constructed like its siblings:
$fees = app(WithheldFeeCounter::class)->feesWithheldIn($seller, 'EUR', $quarter);
The figure is placed by the settlement document now, in the quarter of the consideration it was deducted from, where it was placed by the charge's own settlement date. Figures for past quarters move where a document and its money fell on different sides of a quarter boundary. A filing already submitted is unaffected, but a re-run over an old period can split its quarters differently, with the same annual total.
A reporting run can stop at a fifth plausibility finding
Affects you if you run billing:reporting:run or call ReportingPlausibilityGate::assertClear(). The gate
throws while any finding is open, and settlement_document_does_not_place_this_charge names a charge that no
settlement document places: one whose document was never raised, or a historical one that names no provider. Raise
the missing document and run again, or acknowledge the finding for that period. To see the rows without running the
gate, call WithheldFeeCounter::chargesPlacedByTheirMoneyIn().
A DATEV export refuses a foreign-currency document without a frozen rate
Affects you if you export DATEV batches containing documents in a currency other than the batch's base. The
export throws InvalidDatevBatch, naming the document, its currency and the base currency. Freeze the
document-layer rate on that document, or export a period that does not contain it. A single-currency installation
is unaffected.
A refund on a collectively settled sale can throw SettlementLineNotFound
Affects you if you settle creators collectively. Where no line of the settlement answers for the refunded
charge, because none names it or the one that does was written without the commission rate, the tax rate or the fan
gross, the refund throws SettlementLineNotFound naming the document and the charge. Run the collective settlement
for that month again so its lines carry the figures. A per-transaction settlement is unaffected.
stripe/stripe-php 21 and the 2026-08-26.dahlia API version
Affects you if you use the Stripe driver. stripe/stripe-php 21 is allowed beside 20, and the package pins the
Stripe API version 2026-08-26.dahlia, the one Cashier sends with SDK 21, so both clients address your account in
one generation. Your own code needs no change.
0.32.0
Run the migrations
billing_merchant_charges gains purpose, what the buyer bought: MerchantChargePurpose::Subscription,
AddonPurchase or Tip. It is not backfilled, so a row written before this release keeps null.
Export DATEV periods again that hold local credit notes against reduced-rate or OSS sales
Affects you if the local engine refunded a reduced-rate or OSS sale in a period you exported to DATEV. A credit note the package raised against its own invoice carried none of the invoice's tax characteristics, so the export booked the correction at the standard rate: a correction against a 7% supply reduced 19% of tax, and one against an OSS sale reduced domestic tax instead of the destination country's. Export the affected periods again.
A published config file receives the settings added inside its blocks
Affects you if you published the config files and removed settings from a block. billing.php, account.php
and license.php merge underneath a published copy block by block, so a setting you never saw takes effect with
this release, and a default that is deliberately strict may now be on. Write the value you want into your published
file if you relied on its absence. A list you narrowed stays as you wrote it, and an explicit null stands. With
config:cache nothing is merged: publish the current file, or add the keys you need by hand.
Tips below your pay-what-you-want floor are refused
Affects you if you set billing.marketplace.pwyw.minimum_minor. A tip below that floor throws FanPriceTooLow.
Set billing.marketplace.tips.minimum_minor (BILLING_MARKETPLACE_TIPS_MINIMUM_MINOR) to 0 to take tips without
a floor, or to a floor of your own. On the shipped defaults nothing changes.
Check a dispute webhook you resend by hand
Affects you only if you resend dispute webhooks from a provider dashboard. The chargeback clawback and the
correcting documents are deduplicated per dispute now, so a dispute handled before this release is unknown under its
new key, and resending its webhook runs both again. Check billing_refund_attempts after such a resend.
0.31.0
An implementation of Checkout or OneTimeCharge declares collectTaxId
Affects you only if you implement one of the two contracts. Code that only calls them needs nothing. Add the trailing parameter, which PHP otherwise refuses:
public function subscribe(Model $billable, string $tierKey, ?string $couponCode = null, ?string $declarationReference = null, ?string $buyerCountry = null, ?bool $collectTaxId = null): ClientIntent;
public function purchase(Model $billable, string $addonKey, ?string $declarationReference = null, ?string $buyerCountry = null, ?bool $collectTaxId = null): ClientIntent;
A hosted sale on separate_transfer opens a session
Affects you if your marketplace runs on charge_type separate_transfer, the shipped default. A hosted
subscription, one-off purchase or tip for a merchant opens a checkout session where it threw
MarketplaceUnsupported. The merchant's share moves after the payment is confirmed, so the platform webhook has to
deliver payment_intent.succeeded, as it already does for routed payments that clear later. A flat fee part is
accepted on this lane; the destination lane still refuses one.
0.30.0
Publish the account views again
Affects you only if you published billing-views. Your copies of layouts/account.blade.php and
livewire/account-overview.blade.php keep the stacked phone layout until you publish them again or apply the same
change.
A private individual is declared without a founding year
Affects you if you call CreatorSelfDeclaration::declare() for CreatorTaxStatus::PrivateIndividual. Pass
null as businessFoundedYear; a year given for a private individual is refused. Every business standing still
takes its year.
0.29.0
FanTipPaid::$buyerIsDomestic can be null
Affects you if you listen for FanTipPaid. The property is ?bool. null means the buyer's country was not
established, and false means two countries were compared and differed. A strict comparison against false keeps
working; if (! $event->buyerIsDomestic) also takes the unknown case.
Freeze art and antiques under their own margin basis
Affects you only if you sell works of art or collectors' items under the margin scheme. Freeze such a sale with
TaxationBasis::MarginWorksOfArt or TaxationBasis::MarginCollectorsItems instead of TaxationBasis::Margin, and
the document and the e-invoice name the right goods class. If you override billing::invoice.margin_scheme_note,
add margin_scheme_note_works_of_art and margin_scheme_note_collectors_items beside it.
0.27.0
Run the migrations
billing_merchant_charges gains transfer_failed_at, transfer_failure and seller_posture. The first two
record a merchant share the provider refused to move; the third records who supplied the buyer, which is what
the small-business counter now reads.
A reporting renderer of your own is handed only the reportable sellers
Affects you only if you bind your own RendersReportingRecord. It used to receive every seller the period
examined, with a paragraph asking you to filter on reportable() before transmitting. It now receives the
reportable sellers and their records and nothing else, and seller_count counts those.
If your renderer keeps the record of the due diligence — which has to show every seller it was applied to
— implement RendersDueDiligenceRecord instead, and only for an artifact nobody transmits.
Creators cross the small-business limit earlier under seller_of_record and platform_intermediary
The earnings counter and SmallBusinessThresholdMonitor counted every routed sale at its payout, which is
right only under the commission chain. Where the creator supplies the buyer, the platform's commission is a
service they bought and stays in their own turnover, so those creators were under-counted by the whole
commission. Nothing to configure; expect the threshold warnings to arrive sooner, which is the correction.
A failed merchant transfer no longer throws at your caller
RoutedPayment::charge() used to throw when the share could not be moved after the buyer had paid, leaving
the sale pending for good. It now returns the successful payment, reports the failure to your exception
handler, writes it to the row and dispatches MerchantShareNotMoved. Listen for that event, watch
billing:doctor, and move the shares with billing:marketplace:retry-transfers.
A margin-taxed document you issue yourself is stricter, in both halves
MarginRefundCorrector refuses a document that was not taxed on the margin, and one with no frozen
margin_minor, where it used to correct on a margin of zero. A margin-taxed InvoiceRecord that states a tax
amount is refused when it is created rather than when it is rendered. In XRechnung and ZUGFeRD such a document
is category E with VATEX-EU-F and the prescribed wording, where it used to go out as zero-rated Z; one
that also names an exemption, or whose lines carry a tax rate, is refused. See
Taxes.
A money-credit top-up is not taxed at the till any more
An add-on that grants no usage units credits the owner's balance, and that balance is a voucher: the invoice
it later pays carries the tax. Under a provider tax mode the hosted checkout no longer asks the provider to
tax the top-up itself. Where you sell into one country at one rate, set
BILLING_VOUCHER_INSTRUMENT_TYPE=single_purpose and it is taxed at issue again.
Import the announced monthly averages, or foreign-currency documents will refuse
Affects you only if you issue documents under a monthly-average conversion rule — the German profile does, for domestic turnover.
The reader used to take the arithmetic mean of the central bank's published days for the month and answer the monthly rule with it. That is the aggregation an authority's table is made of, and it is not that table: the two part company as soon as a day is missing locally, an observation is revised, or the rounding differs by a digit, and the difference lands on a document that is checked against the official figure years later.
A month now needs an announced average in the store, and there is a command to put one there:
php artisan billing:exchange-rates:import-file storage/rates/2026-monthly.csv --source=BMF
The file carries from,to,month,rate — see the command reference. Until a month
is imported, a conversion under that rule is refused rather than computed, which is the same refusal a missing
daily rate has always produced.
0.26.0
Run the migrations
billing_tax_id_verifications is new. It keeps what a tax authority's register said about a buyer's tax ID, one
row per answer, and an erasure unlinks it rather than deleting it, like the invoices it supports.
Send the tax ID events to your Stripe endpoint
Stripe reports the register's answer with customer.tax_id.created and customer.tax_id.updated. An endpoint that
receives only the events it was set up with does not see them until both are added. Listen for
TaxIdVerificationFailed to learn about a number the register does not know, together with the invoices issued
with the charge reversed under it.
0.25.0
An implementation of Checkout or OneTimeCharge declares $buyerCountry
Affects you only if you implement one of the two contracts. Code that only calls them, and the shipped drivers, need nothing.
Checkout::subscribe() and OneTimeCharge::purchase() take the buyer's country as a new last parameter, and PHP
refuses an implementation that does not accept it:
public function subscribe(Model $billable, string $tierKey, ?string $couponCode = null, ?string $declarationReference = null, ?string $buyerCountry = null): ClientIntent;
public function purchase(Model $billable, string $addonKey, ?string $declarationReference = null, ?string $buyerCountry = null): ClientIntent;
An implementation of MerchantPriceProvisioner accepts a null interval
Affects you only if you implement the contract. A null interval now asks for a one-time price, so PHP refuses an implementation that still declares a non-nullable interval:
public function provision(Model $merchant, string $tierKey, Money $amount, ?BillingInterval $interval): string;
A sale into a market that is not open is undone
Affects you only if you configure billing.tax_markets and sell through Stripe. When Stripe reports that it
taxed a sale in a country the map does not open, the package ends the subscription, refunds the payment and
dispatches SaleIntoClosedMarketReversed. Listen for it to tell the buyer. Without a market map nothing changes.
An implementation of DiscountResolver accepts the sale's scope
Affects you only if you bound a resolver of your own. Code that only calls resolve(), and the shipped
resolvers, need nothing.
DiscountResolver::resolve() takes the seller of the sale as a new last parameter, and PHP refuses an
implementation that does not accept it:
public function resolve(string $code, ?MerchantScope $merchant = null): ?Discount;
A null scope is the platform. A resolver with no notion of an issuer may ignore it, the way
ConfigDiscountResolver does.
A coupon row resolves without a config entry
Affects you if you keep coupons in billing_coupons. A live row now resolves its code for the seller who
issued it, before billing.coupons is asked, and the Stripe checkout applies its provider_coupon_id with no
config entry at all. An installation that keeps its coupons only in config sees no change.
0.24.0
A driver of your own answers ReadsSubscriptionPayments
Affects you only if you ship a billing driver of your own. The Stripe driver and the local-engine driver bind it themselves.
ConsumerWithdrawal::withdrawSubscription() reads the payment behind a subscription's period in progress through
Contracts\ReadsSubscriptionPayments. The Stripe provider binds its reader unconditionally, so a driver of your
own that leaves the contract alone gets that reader, and it asks Stripe about subscriptions Stripe never saw. Bind
yours next to your SubscriptionActions. A driver that collects a period at its end can bind
LocalSubscriptionPayments, which answers that no payment covers the period in progress.
The migrations add started_at to billing_subscriptions, nullable.
0.23.0
A custom Checkout or StartsSubscriptions takes the declarations key
Affects you only if you implement Contracts\Checkout or Contracts\StartsSubscriptions yourself. Code
that calls them, and the shipped drivers, need nothing.
subscribe() and start() take a fourth, optional parameter: the key a buyer's withdrawal declarations were
recorded under. PHP refuses an implementation that does not accept it, so add it to yours:
public function subscribe(Model $billable, string $tierKey, ?string $couponCode = null, ?string $declarationReference = null): ClientIntent
public function start(Model $billable, string $tierKey, ?string $couponCode = null, ?string $declarationReference = null): SubscriptionStart
Then hand it to wherever your implementation creates the subscription, so the local row ends up carrying it.
An implementation that accepts the key and drops it still runs; WithdrawalConsentLedger::forSubscription()
simply answers null for every subscription it starts.
The migrations add declaration_reference to billing_subscriptions and billing_subscription_intents, both
nullable.
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\Navigation —
visible() 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:
| Class | Required arguments |
|---|---|
ValueObjects\InboundTaxTreatment | 5 → 11 |
ValueObjects\WithdrawalConsent | 4 → 8 |
ValueObjects\RetentionRule | 13 → 15 |
Events\AddonPurchased | 4 → 7 |
Notifications\PaymentSucceededNotification | 2 → 9 |
Marketplace\BuyerProtectionClock | 1 → 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_typeonseparate_transferand route the sale throughPushery\Billing\Marketplace\RoutedPayment, which makes both provider calls and records them. - Use a destination charge honestly. Set
charge_typetodestinationand a posture the table permits for it —platform_intermediary, orseller_of_recordif the Art. 9a rebuttal genuinely holds for you (the package refusesseller_of_recordfor 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.
Later lifted. The hosted subscription checkout now serves separate_transfer itself: the session carries no
routing, the merchant's account and the frozen fee terms ride in the subscription's metadata, and every paid
cycle writes its ledger row and moves the merchant's share. On the shipped defaults it opens a session again,
this time on the lane the table permits for the posture.
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:
| Removed | Use instead |
|---|---|
ValueObjects\CreditNoteSnapshot | ValueObjects\InvoiceCorrectionSnapshot |
Events\InvoiceCredited | Events\InvoiceCorrected, reading $event->correction (was $event->creditNote) |
Webhooks\Effects\PersistCreditNote | Webhooks\Effects\PersistInvoiceCorrection |
InvoiceRecord::isCreditNote() | InvoiceRecord::isCorrection() |
translation key billing::invoice.credit_note | billing::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 raisesTaxModeUnsupportedat 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
VatIdValidatorproves nothing, so the zero-rate is not granted until you bind a real validator. BindViesVatIdValidatorif 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.0 —
SubscriptionActions::cancel()gained?CancellationSurvey $survey = null - 0.6.0 —
PersistCreditNotebecamePersistInvoiceCorrection, andInvoiceCreditedbecameInvoiceCorrected; 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.taxisprovider, 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,BillingDriverand the argument order ofChargeResultare untouched. The routing dimension arrives as an optional trailing?ChargeRouting $routing = nullon 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
RoutesMoneycontract; 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 implementRoutesMoney. - 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 — sobilling.marketplace.tax_status_hold.enforce_fromstarts 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:preflightreports 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.