Skip to main content

Upgrading

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

Before 1.0, a minor may break​

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

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

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

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

0.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_fallback on billing_payment_mandates, false on every existing mandate, and fallback_attempt and fallback_mandate_reference on billing_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 until billing.usage.spend_cap.enabled is set.
  • New columns: erased_owner_key on billing_invoices and erased_merchant_key on billing_merchant_creditor_accounts, empty on existing rows, and trial_days on billing_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.read to list the refunds of a payment, and payments.read for what is left to refund
  • payments.write to cancel a payment the customer has not completed
  • onboarding.read for MollieConnect::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_declarations and billing_seller_declaration_contradictions.
  • New columns: ioss, deemed_supply and goods_located_in on billing_invoices, and deemed_supply, goods_consignment and destination_country on billing_merchant_charges. ioss is false on every existing document, and the others stay empty on existing rows.
  • One migration rewrites the webhook ledger: it removes the client_secret of a Stripe PaymentIntent from the payloads billing_webhook_events already 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_timing and paused_at on billing_subscriptions, debt on billing_prepaid_units, grace on billing_usage_reservations, reinstated_at on billing_access_grants, clawed_back_units on billing_addon_purchases and reinstates_invoice_id on billing_invoices.
  • One migration moves data: the key of a credit note for a refund and of a proration correction moves from provider_id into correction_key on billing_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_id becomes an integer where an application's Schema::morphUsingUuids() or morphUsingUlids() 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 DATETIME on MySQL, where a TIMESTAMP ends 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 DATETIME on MySQL, so that it no longer passes through the session time zone. On a connection that is not in UTC a TIMESTAMP stored 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 the DATETIME in place.
  • On PostgreSQL, the columns that reference your models become the native uuid type when your models are keyed by UUID (billing.schema.host_key_type is uuid), the type Laravel's own uuidMorphs() declares. They were char(36), and PostgreSQL compares a uuid with no character column, so a whereHas() from one of your models into a package table failed with operator 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 into char(36).
  • The credit movements of an erased owner are kept. billing_credit_ledger_entries gains owner_erased_at and erased_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, and billing:prune removes 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_verifications gains follows, 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 CreditSync into 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.addons entry with neither grants nor archetype is money credit, as before, and so is one with the voucher archetype. 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 the voucher archetype through SuppliesProductArchetypes. 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 = true on the listener if the job should be deleted instead.
  • BillableAccountDeleting hands 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.enabled
  • billing.mandate_verification_minor
  • billing.checkout.promotion_codes and billing.checkout.tax_id_collection
  • billing.erasure.forget_customer
  • billing.realtime.enabled and billing.realtime.render_toast_region
  • billing.trial.requires_payment_method
  • billing.tax_counters.us_state_gmv.enabled
  • billing.consumer_rights.conformity_update_period_days and billing.consumer_rights.allow_conformity_waiver
  • billing.tax_evidence.collect_subdivision
  • billing.content_ownership.bundle_additive_default, revoke_on_refund and revoke_on_chargeback
  • billing.marketplace.tips.commission_bps and billing.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 from billing.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, with TaxStandingUnestablished, 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:

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

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

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

ConsumerWithdrawal takes two collaborators fewer​

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

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

0.13.0​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2. The realtime toast names its severity differently​

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

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

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

3. A routed subscription on the shipped defaults now refuses​

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

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

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

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

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

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:

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

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

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

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

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

0.5.0​

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

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

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

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

0.4.0 and 0.4.1​

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

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

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

0.3.0​

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

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

0.2.0​

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

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

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

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

0.1.1​

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

use Pushery\Billing\Events\BillableAccountDeleting;

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

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

If you ship your own driver​

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

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

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

See the contract reference for what each seam guarantees.

The marketplace surface is opt-in​

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

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

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

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

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

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


← Back to the documentation index