Skip to main content

Troubleshooting

Every exception the package throws, what it means and what to do about it — plus the failures that throw nothing at all, which are the ones that cost money.

The package is deliberately fail-closed. Where a misconfiguration would otherwise degrade into something that looks like it works — usage counted and never billed, VAT computed and never charged, a webhook accepted without a signature — it refuses instead. That is why several of these fire at boot rather than on the request that would have gone wrong.

The app refuses to boot

The guards in the service provider's boot() all sit behind the billing.enabled master switch: turn billing off and none of them can stop your app starting. They check configuration, not data, so what they find is wrong before a single request arrives — which is the point of finding it here.

Two of them do not run in the main provider. WebhookSigningNotConfigured comes from the Stripe driver's own boot(), so it only fires when Stripe is the active driver; and the two checkpoint-registry errors fire while a jurisdiction profile or a go-live checklist is being assembled, which is usually boot but is whenever the registry is first read.

Everything the package refuses at REQUEST time — an ineligible merchant, a price below a floor, a correction outside its window — is under Runtime errors instead. The two used to be mixed, which meant a boot problem had to be found among refusals that describe something else entirely.

MeteringUnsupported

Tier metering is configured (meter '…'), but the active billing driver '…' cannot report usage.

A tier bills for usage on a driver that has no way to report it. The degraded alternative is the worst one available: every unit counted, none reported, and an invoice for the base fee alone — nothing looks broken until the month's revenue comes in short.

Either move to a driver that reports usage, or remove the metered components from the tier.

TaxModeUnsupported

Three shapes, one theme: the invoice would go out with no tax and nothing would surface it until the VAT return did not add up.

  • billing.tax is provider, but the active driver computes no provider tax.
  • billing.tax is a local mode such as eu_oss, but the active driver defers tax to the provider — the VAT would be computed locally and never charged.
  • billing.tax is not a resolvable mode at all: a typo (eu_os), or the key turned into an array by adding a sub-key underneath it.

Set billing.tax to a mode the driver can actually apply, or to none.

InvalidBillingConfig

The configuration contradicts itself in a way that would fail silently at runtime:

MessageCause
billing.owner must be 'user' or 'team'Any other value
billing.zero_tier is '…', but no tier with that key is definedThe fail-safe tier does not exist, so a fallback would land nowhere
billing.untouchable_tiers lists '…', but no tier with that key is definedA protected tier key that does not exist protects nothing
Tier '…' references dimension '…', which is not definedThe usage screen would render a dimension nothing feeds
The price currency '…' is not a valid ISO 4217 codeNot three uppercase letters
Dimension '…' warn_threshold must be between 0 and 1A threshold outside that range never warns, or always does
The dunning ladder's after_days must strictly ascendRungs out of order never escalate

RetentionBelowStatutoryMinimum

billing.retention.erased_financial_days is … days, below the ~10-year statutory floor …

An erased owner's retained invoices would be pruned before the law allows. Keeping data longer is always fine — only shortening it below the floor is refused, and only until someone opts in on purpose for a jurisdiction whose minimum genuinely is shorter. Raise the window, or set billing.retention.allow_below_statutory_minimum deliberately.

CustodyModeNotPermitted

billing.marketplace.custody.platform_held is on, which means the platform itself would hold other people's funds. That is a regulated activity in most jurisdictions, so a configuration flag alone is never enough: bind an implementation of Pushery\Billing\Contracts\PaymentServiceLicenseAttestation to declare in code that you hold the license, or turn the flag off.

MarketplaceUnsupported

The active billing driver [...] does not route money to merchants.

billing.marketplace.enabled is on, but the active driver cannot send a payment anywhere except the platform's own account. A driver announces that it can by implementing Pushery\Billing\Contracts\RoutesMoney; one that does not is refused here rather than at the first sale, because a marketplace that reads as enabled and settles every charge to the platform looks completely healthy until someone reconciles the money. Use a driver that routes, or leave the switch off (the default) and sell as a single seller.

The same exception is thrown at call time when something asks for the marketplace rails anyway — including when billing.enabled is false, where the no-op driver is active and the marketplace path does not exist at all. The two cases carry different messages because their fixes differ.

The payment rails cannot serve a separate-transfer routing on their own.

A third case, and the one most likely to surprise: the charge type is separate_transfer, which is the shipped default. On that shape the platform takes the whole payment and a second provider call moves the merchant their share — and that second call can only be made once the payment has actually succeeded, which is after charge() has returned. The rails alone genuinely cannot serve the lane, so they refuse instead of half-serving it.

The alternative would be worse than an exception. The charge would succeed, the platform would keep everything, the merchant would never be paid, and nothing in the result would say so: no error, and a null transfer reference indistinguishable from one still settling.

The fix is to route the sale through RoutedPayment, which makes both calls, records the sale and settles it with the reference of the transfer it actually made. It is the supported path for this lane, and it also fails in the right direction: if nothing can move a share, it throws before the buyer is charged rather than after.

Or set billing.marketplace.charge_type to destination, where the provider moves the share as part of the payment. Note that the seller-of-record posture has to permit that pairing — see billing.marketplace.charge_type_by_posture — so switching it is a liability decision, not a toggle.

This driver cannot move a merchant's share.

RoutedPayment was asked for a separate-transfer sale and nothing is bound to move the share. Thrown before the buyer is charged, which is the point: discovering it afterwards would leave a completed payment on the platform account with no way to pay the merchant and no signal that anything was wrong.

Bind an implementation of MovesMerchantShare. The Stripe driver binds one; a custom driver has to supply its own or stay on destination charges.

MarketplaceNotReadyForGoLive

billing.marketplace.enabled is true, but the go-live checklist has open blocking points

The marketplace switch is on while php artisan billing:marketplace:preflight still reports something open. The switch is tied to the checklist rather than to your memory of it, because skipping the checklist leaves no trace: the marketplace comes up, sells, and books everything under a configuration nobody signed off.

The message lists every open point by key and reason — it has to, because a refusal at boot has also taken away the command that would have explained it. Switch the marketplace back off, run the preflight, close the points, and switch it on again. A point whose obligation genuinely does not apply to you goes into billing.marketplace.preflight.waived, where it stays visible as a warning instead of disappearing.

UnknownJurisdictionProfile

billing.tax_profile is set to [...], which is not a jurisdiction profile this package ships

The configured profile name is neither shipped (de) nor bound in the container. This is refused rather than ignored: falling back to "no profile" would give you a checklist that quietly omits every obligation you asked it to enforce and still reports green. Use a shipped name, bind your own Pushery\Billing\Contracts\JurisdictionProfile, or set the key to null.

DuplicateGoLiveCheckpoint

Two go-live checkpoints are registered under the key [...]

A checkpoint you registered uses a key that is already taken. Neither resolution would be safe — the later winning would let a passing point silently replace a blocking one, the earlier winning would drop your point — so it is raised where it happens. Rename yours, or, if you meant to switch the existing point off, leave it in place and add its key to billing.marketplace.preflight.waived.

WebhookSigningNotConfigured

Webhook signature verification for the 'stripe' driver is not configured.

Production only. Outside production the guard is silent, so this is a deployment failure, not a local one. Set the driver's signing secret — for the Stripe driver, STRIPE_WEBHOOK_SECRET, which Cashier reads into cashier.webhook.secret. Booting without it would mean accepting unverified webhooks, and a webhook is how money and entitlements move.

Runtime errors

The entries below are refusals of a single operation: the application is running, the request is the thing being turned down. Nineteen of them used to sit under boot failures, which sent anybody debugging a startup problem through pages of errors that cannot occur at startup.

WithdrawalConsentMissing

A digital work was about to be provided without the consent that extinguishes the right to withdraw.

For a work whose withdrawal right ends on delivery — a download, an e-book — the right ends only if the buyer both agreed to immediate provision and acknowledged the forfeiture, before provision, on the record. Providing it without that recorded consent means every refund inside the window is owed rather than granted. Where the platform is the seller of record, that is the platform's own money.

Capture the two declarations at checkout as a WithdrawalConsent and pass it through to provision. They are separate statements about different things and neither alone is enough.

The gate needs two conditions, and the second one is easy to miss. It is live only when billing.consumer_rights.profile is set — with it off there is no gate and this exception cannot be raised. It also needs the work to carry a classified archetype: an add-on with no archetype key (and no SuppliesProductArchetypes answering for it) has no withdrawal type to gate on here, so provision itself raises nothing. That is no longer the silent hole it was — the checkout refuses such a purchase up front with WithdrawalDeclarationsMissing, so an unclassified work under an active profile is now loud rather than quietly provided. billing:doctor reports every work the profile does not actually cover.

WithdrawalWindowClosed

A withdrawal was declared after the buyer's window closed, so it cannot be recorded as a statutory one.

This refuses the classification, not the money. You may still refund — BillingAdmin::refund() with the default kind does exactly that, and it is the right lane for this. What must not happen is the two being booked as one event: RefundKind::StatutoryWithdrawal says the buyer exercised a right, and after the window that is false. It is the platform's decision, one it could have made differently. Same money, different event, and only a category can be counted.

The date it compares against is frozen on the access grant at the moment the work was provided, not recomputed from today's profile — so shortening your window does not shorten a right somebody already holds. The comparison includes the last day.

Two situations pass this check rather than failing it, and neither is an oversight:

  • The sale has no access grant. A subscription is not a content purchase. Turning "there is nothing to compare against" into "you are too late" would refuse the ordinary case.
  • The grant's window is null. That means no honest date exists — no consumer-rights profile is active, the right extinguished on delivery, no right ever attached, or your profile states no windows. Whether such a sale is a statutory withdrawal at all is a question about its WithdrawalType, and it is answered there.

BuyerFeeNotApplicable

Buyer fees are switched on, but this sale is under a regime that has none.

A buyer fee is the platform's own intermediation supply, rendered to the buyer. It exists only where the platform mediates a sale between two other parties. Under a commission chain the platform is itself the seller and sells to the buyer directly — there is no mediation to charge for, and a fee booked there as intermediation revenue would describe a service nobody rendered.

Two ways out, and they are not equivalent:

  • Set billing.marketplace.buyer_fee.enabled to false. Right if you do not charge buyers.
  • Sell under an intermediation regime. Adding intermediation to billing.marketplace.regime.allowed is a liability decision about who contracts with the buyer, not a toggle — see the marketplace guide before changing it.

Why this refuses rather than quietly charging nothing. Of the three things that could happen for "switched on, wrong regime", charging nothing is the worst: the setting reads as on, you believe you are collecting, and the first evidence otherwise is revenue that never arrived.

WithdrawalDeclarationsMissing

A buyer was about to be sent to the payment provider for a purchase whose declarations are not on file.

This is the same rule as WithdrawalConsentMissing, asked at the other end. That one refuses at provision, which is the right refusal and the wrong moment: the buyer has already paid, so what is left is a refund for a sale that could have been declined for free. This one costs nothing when it fires.

Record the two declarations before starting the checkout and pass the key back:

$reference = app(PurchaseDeclarations::class)->declare($buyer, new WithdrawalConsent(
consentedToImmediateProvision: true,
acknowledgedForfeiture: true,
noticeVersion: 'de-2026-08',
givenAt: CarbonImmutable::now(),
));

app(OneTimeCharge::class)->purchase($buyer, 'novel', $reference);

The key is minted by the package rather than taken from you. A declaration has to be recorded before the buyer leaves, and at that moment the purchase has no reference of any kind — and a key you supply cannot carry the uniqueness a proof needs, because a reused order number would let last month's declaration cover today's purchase with nothing going wrong visibly. Your own order reference is welcome beside it.

The key travels to the provider as opaque metadata and comes back on the completion webhook, which is how the record is found again after payment.

Two reasons this is raised, and they read differently. Either the declarations are missing or only one of the two was made — provision beginning early and the right ending are separate statements, and a single combined checkbox is not two declarations. Or nothing classifies the product: with a profile active, an add-on without an archetype key is refused rather than waved through, because "nobody classified this" and "this needs no declarations" look identical to the runtime and one of them is a statutory failure.

The wording of the two notices is yours, for your product and your jurisdiction. The package records which version was shown, and never renders it.

RoutedCycleUnreadable

A routed subscription cycle was paid and the provider could not say what it withheld, so no ledger row was written.

The routed subscription lane READS the commission back rather than computing it from the rate, and the whole justification for that is which way the two fail. A computed commission that drifts from the provider's is a plausible wrong number sitting in a money ledger: nothing goes red, and the figure flows into a clawback cap and a small-business judgement looking exactly like a right one. A failed read produces nothing, and nothing is visible — so this refuses instead of returning.

The effect runs queued, so a transient failure retries and a permanent one surfaces as a failed job. Check, in order: the invoice still exists at the provider; the key may read it; and the subscription still carries transfer_data.

It does not mean the subscription is unrouted. That is decided from the local subscription row before the provider is asked at all, and it returns quietly.

ConformityWaiverNotPermitted

A conformity waiver was asked for and refused, for one of three reasons.

Conformity updates — defect fixes, security fixes, staying compatible — are what a seller owes after the sale, on a different axis from the update policy the creator chose. frozen freezes what a buyer is entitled to; it says nothing about what is still owed, and nothing in the product settings reaches this.

Recording a waiver needs three things, and no configuration value is one of them:

  1. A consumer-rights profile must be active. With none there is no obligation to give up, and writing the flag anyway would leave a row that becomes a real waiver the day a profile is configured.
  2. That profile must permit a waiver at allbilling.consumer_rights.allow_conformity_waiver, off by default. Turning it on does not waive anything: it makes a waiver possible one grant at a time. There is deliberately no install-wide switch that turns conformity updates off, because that is precisely the blanket arrangement the law refuses to recognize.
  3. A reference to the actual agreement, stored on the grant. A flag with nothing behind it cannot be told apart from a defect that set it — which is the position nobody wants to be in when asked to produce the agreement. A blank or whitespace-only reference is refused.

Whether security updates can be waived at all is genuinely disputed. This package does not resolve it; that is a question for your own legal advice, not for a config key.

FanPriceTooLow

A fan chose a pay-what-you-want price below the floor you set.

The minimum lives in billing.marketplace.pwyw.minimum_minor and is enforced on the server, which is the point: a price the buyer picks is the one place the package's stance against price injection would otherwise lapse, so the floor cannot be a client-side check. A chosen amount of zero is refused earlier and separately as no sale at all, whatever the floor — so this exception is specifically "below a non-zero minimum", not "nothing was chosen".

GrossPriceNotSplittable

A buyer-chosen total could not be separated into a net and a tax your regime agrees with.

Tips and pay-what-you-want prices arrive the opposite way round from every other sale: the buyer picks the total, and the net is what is left once tax comes out. Working backwards from a total means inverting the rate, and inverting a rounded figure is not exact — for some totals no whole-cent net reproduces them. That single cent is tolerated, because the amount the buyer agreed to pay is the one figure that may not move.

This exception is the wider case, and it says something specific: charged on the net the split implies, your regime returns a materially different tax. That happens when a rate does not scale with the amount — a tiered or threshold band, or a calculator that decides by size. Then no net reproduces the chosen total, and a split computed anyway would put a net on a document that your return later contradicts.

There is no setting to relax. Price these sales from a net amount instead of a buyer-chosen total, or narrow the rate so it is constant across the range a buyer can choose from. The exception carries the total, the rate it was split at, and both tax figures, so you can see how far apart they were.

ReportingCounterDisabled

A reporting figure was asked for on an installation that has switched the counter off (billing.tax_counters.dac7.enabled).

Refused rather than answered with zero, and the difference is the whole reason the exception exists. Zero is a real reporting answer — this seller received nothing in the window — and it is one that gets filed. A disabled counter that returned it would let a platform produce a return stating that every seller earned nothing, with no error anywhere and every figure internally consistent. Nobody would look, because there would be nothing to look at.

The switch exists so a platform outside the regime stops carrying a counter for a duty it does not have. It is not a way to make the figures go away while still asking for them. Either turn the counter on, or stop asking it for figures.

Note what it does not switch off: the section 19 threshold counter, which measures what a supply was worth to a creator rather than what reached them. That question is about their small-business standing and is asked on installations that have no reporting duty at all, so it keeps running.

SellerModelMissing

A reporting period names a seller whose record cannot be found.

The owner pair came off a settlement document — money was settled against it — and the class it names still exists, because a class that has genuinely gone away is skipped for its own stated reason. What is left is a row pointing at a record that is not there.

Raised rather than skipped, and the difference is the whole point. Skipping removes a seller from a filing without saying so: the return still looks complete, and a seller the duty covers is simply absent from it.

It is not the soft-delete case. Global scopes are taken off before the lookup, so a seller your application has soft-deleted or scoped to another tenant is found and reported normally — a closed account still owes a return for the year it was open. What reaches this exception is a record that no longer exists at all.

Either restore the record, or unlink the documents from it the way an erasure does — billing:erase nulls the owner pair and stamps owner_erased_at, and an unlinked document is skipped rather than refused.

ReportingNotPlausible

A reporting period was asked to produce a filing while findings about it were still open.

Raised before anything is produced, which is the whole design. A check folded into the export runs after numbers have been drawn and files written, so a failure leaves you holding half a run you must not keep — and it names one problem, because the first failure stops the rest. This one names every open finding at once, so a period is worked through in a single pass.

The message lists each finding with its key and what to do about it. Resolve them, or acknowledge each one with a reason:

$gate = app(ReportingPlausibilityGate::class);

foreach ($gate->openFindingsFor(2026, 'EUR') as $finding) {
$gate->acknowledge(2026, 'EUR', $finding, by: '[email protected]', reason: 'Why this is filable anyway.');
}

An acknowledgement covers that period only. The same finding next year is a new finding, because an answer that carried forward would be a switched-off rule with a timestamp in front of it.

If the only finding is no_seller_record_source, nothing was checked at all: bind SuppliesSellerRecords so the package can read your seller records. That is reported rather than skipped on purpose — "we could not look" must not come out looking like "we looked and it was fine".

ReportingFilingRefused

A filing was refused, and every refusal it carries is in the same direction: reporting a period more than once.

Under-reporting is visible — a period nobody filed is a gap an authority names and the filing calendar warns about beforehand. Over-reporting is not: two filings of one year both look like a filing, and the duplicate is usually discovered by the seller whose figures went out twice. So a second first filing is an error rather than a quiet no-op, and the message says which of four things happened:

  • The period was already filed. A filed period does not change by being filed again. Produce it once more, compare, and if the figures moved, file the new record as a correction:

    $register = app(ReportingFilingRegister::class);
    $latest = $register->latestFilingFor(2026, 'EUR');

    $register->fileCorrection($record, $latest, filedBy: '[email protected]');
  • It corrects a different period. A correction restates one period; a record for another year is that year's first filing, not this year's correction.

  • It corrects a filing that has itself been corrected. Corrections are a chain, not a fan — two corrections both answering the original would each claim to be the current state, and the later one would silently drop what the first changed. Name the latest filing, which latestFilingFor() returns.

  • It changes nothing. The record is byte-for-byte what already went out, so there is nothing to correct. If you expected a difference, the period genuinely has not moved.

To find out whether a correction is due at all, ask before you file:

$record = app(ReportingExport::class)->produce(2026, 'EUR');

if (app(ReportingFilingRegister::class)->needsCorrection(2026, 'EUR', $record->contents)) {
// The figures moved after the period was filed — a late refund, a corrected classification,
// amended seller master data. That divergence is the expected outcome of those events.
}

DuplicateReportingRule

Two plausibility rules answer to the same key.

A rule's key is half of an acknowledgement's identity, so two rules sharing one make an answered finding come back on the next run — and that reads as a broken acknowledgement rather than a broken catalog, which is the expensive way to find out. Give the rule you added a key of its own.

CommissionTermsUnknown

A partial refund was asked for on a routed sale whose commission terms were never recorded.

A partial clawback is a difference, not a share: it is what the merchant holds now, less what they would have been paid on what is left of the sale. The second half needs the rate and the fixed amount that sale was priced under, and a charge row written before those columns existed does not carry them.

The package refuses rather than approximating, and the alternative is worth naming because it looks reasonable. Reading today's configuration would produce a figure, the figure would balance, and it would be the amount a different sale owed — off by exactly however much your commission has changed since, in whichever direction, with nothing on either document to show it.

A full refund of the same charge is not refused and needs no setting changed. With nothing left of the sale there is no remainder to price, so every rate returns the same answer: everything the merchant still holds comes back.

If you need partial refunds on charges that predate the frozen terms, the only honest route is to record what those sales were actually priced under. There is no configuration that makes the missing figure appear.

SettlementTransactionOutsidePeriod

A settlement run was given a transaction that counts in a different period.

A transaction's supply date already answers two questions — which standing the creator had, and what service time the line states. Which period it counts in is a third, and it is usually the same date. It stops being the same the moment a term is paid up front: the buyer's side is taxed in the month the money arrived, while the service runs across the year.

Both legs of a chain have to land in the same period. Settling by supply date would put the creator's leg in a month the buyer's leg already taxed elsewhere, and nothing about either document would look wrong — the drift is an input-tax offset across the remaining months, visible only where somebody compares two places no report puts side by side.

Set countsIn on the transaction where it differs from the supply date, and group it into the run for that period. The run refuses rather than moving it for you: reassigning here would make the settlement engine a second place the periodisation is decided, and your own grouping would quietly stop mattering.

MarketNotOpen

A sale was attempted into a country you have not opened.

This one is refused before the payment rather than after it, and the timing is the point: a sale into a country where nobody is registered cannot be repaired by any document written afterwards. The tax has arisen, the registration has not, and the remedies are retroactive registration or a voluntary disclosure.

Set billing.tax_markets to a map of ISO country code to open, planned or blocked. Anything not explicitly open is refused — including a country the evidence could not resolve, because "we could not tell where they are" is the clearest reason not to sell somewhere unknown rather than a reason to guess.

The feature is opt-in: with no map configured there is no gate at all. That is deliberate, because a gate defaulting to closed would stop every existing install at its next sale, which is an outage rather than a guard. The exception carries the country and its state so you can word your own message — a buyer should be told their country is not served yet, not shown a tax registration problem.

InconsistentChargeRoutingForPosture

The money is routed one way and the seller is declared to be somebody else.

The charge type and the seller posture are independent on purpose: how a provider moves money says nothing about who the law treats as the seller, and for electronic services the seller is assigned regardless of the money flow. Independent axes can be set to disagree, and a disagreement raises nothing on its own — it produces a receipt naming one seller and a settlement moving money as though it were another. That is found in an audit, not in a log.

Change one of the two. If the platform really is the seller, the payment has to be taken by the platform and the merchant's share moved separately (separate_transfer). If the merchant is the seller, a destination charge is right and the posture should say so. The permitted pairs are a table in billing.marketplace.charge_type_by_posture, so a different legal reading is a configuration change rather than a code change.

ReceiveEligibilityDenied

The merchant is not eligible to receive money

Money was about to be destined to a merchant the receiving gate refuses, and it is thrown BEFORE any provider call. That ordering is the whole value: once a routed charge is in flight, a merchant who cannot receive does not produce a clean rejection — the money settles wherever the provider can reach, usually the platform, while the local records say it was split, and unwinding that is manual and per transaction.

The usual cause is that the provider has not confirmed all three capabilities for that merchant: they can take charges, they can receive payouts, and they have finished submitting their details. Those are the provider's to grant, arrive asynchronously, and can be withdrawn again — so a merchant who worked yesterday can be refused today. Check the merchant's account row and when it was last refreshed.

It is deliberately not the same exception as EligibilityDenied, which refuses the BUYER. The two are refused for unrelated reasons and fixed by different people.

ExchangeRateFeedUnreadable

The exchange-rate feed did not have the shape its service documents

billing:exchange-rates:import fetched a response it could not read. The whole import for that currency is refused rather than partially applied.

Skipping the rows that would not parse is the tempting alternative, and it is worse. A skipped row leaves a hole in the series, and the reader answers a missing day with the next publication day's rate — a real figure, for the wrong date, on a document. A refused import is visible in your scheduler; a quietly short one is visible nowhere.

The message says which part failed. A missing column names the column, because the change happened at the endpoint rather than in the parser. An unreadable row quotes it. An entirely empty body means the request did not reach the service it was meant for — which is different from a period with no observations, and that one is ordinary: a weekend returns a header and no rows, and imports nothing without complaint.

Other currencies in the same run still import. The command reports the failure per currency and carries on.

TaxStandingUnestablished

Nobody has established how this merchant is taxed

A routed sale was about to be made on behalf of a merchant with no recorded tax standing, and it is thrown before the provider is reached, beside the receiving gate above.

Retrying will not help. Somebody records the merchant's standing — usually the merchant themselves, through your own declaration flow — and the sale goes through. A declaration that simply expired reads the same way here, which is intended: a statement about a year that has ended is not a weaker answer, it is no answer, and billing:tax-holds:announce is what tells the merchant that happened.

There is no default standing to fall back on, and that is a symmetry rather than caution. Assume the merchant charges tax normally and the settlement document states tax a small business does not owe — at which point the recipient owes it merely because a document says so, unless they object in time to a document they never asked for. Assume the opposite and the document understates a real liability and forfeits a deduction. The two errors point in opposite directions, so neither guess is the conservative one.

If this appeared suddenly for many merchants at once, the hold's enforcement date has arrived: billing.marketplace.tax_status_hold.enforce_from is the day it starts refusing, and every creator who had not declared by then is refused from that day. That is what the date is for — pick it far enough out to collect declarations, and billing:marketplace:preflight will tell you while it is still unset.

A settlement document is never produced, and nothing throws

Not an exception — which is why it is here rather than above. A creator whose tax standing nobody has recorded produces a hold: no settlement document, no document number, and no payout, and no error either. SettlementOutcome::isHold is true and everything else on it is null.

There is no safe guess, and that is a symmetry rather than caution. Assume the creator charges tax normally and the document states tax a small business does not owe — at which point the recipient owes it merely because a document says so, unless they object in time to a document they never asked for. Assume the opposite and the document understates a real liability and forfeits a deduction. The two errors point in opposite directions, so neither default is the cautious one. Not producing the document is.

It is a hold rather than a refusal on purpose. A creator who has not yet declared is in a routine, expected state, not an error condition — and the collective engine walks a month of transactions per creator and skips the held ones, so an exception there would abort the whole month's document for everyone in it.

Record a tax standing for the merchant and the hold lifts on its own. It cannot be lifted any other way: there is no override and no key naming a default standing, because either would be the silent default this exists to prevent. billing:tax-holds:announce tells the merchants whose recorded standing has expired.

ProductNotClassified

This product has no archetype

Or: a voluntary payment was resolved without naming what it was paid on. Both refuse for the same reason — there is no safe substitute for the missing answer.

Every consequence of a sale follows from what kind of thing was sold: where it is taxed, at which band, whether it is reportable, what the buyer may undo. A missing classification is therefore not one unknown but five guesses, and guesses that happen to be right most of the time are the hardest defects to find — nothing fails, the numbers look ordinary, and only the minority of sales where the guess was wrong are wrong.

Classify the product before it becomes sellable. For a voluntary payment, name what it was paid on: it takes its treatment from there, and defaulting would under-report a tip on reportable work — or, guessing the other way, report one that is not reportable, which is its own offense rather than a cautious error.

RegimeNotPermitted

The supply regime [...] is not one this platform has opted into

Or: it contradicts the seller posture. Both mean the same thing — a sale was about to be classified in a shape you have not signed up for.

A regime decides which documents a sale produces and whose turnover it is, so falling into one is never acceptable. Add it to billing.marketplace.regime.allowed only when you mean it, and set billing.marketplace.regime.default to a value that exists — an unreadable one is refused rather than defaulted, because silently choosing here would pick which documents every sale produces on the strength of a typo.

The contradiction case is stricter and worth understanding. The regime and the seller posture are one decision seen twice: the regime is how the books read, the posture is who the receipt names. A pair that disagrees would issue a receipt and a settlement document describing different transactions — each internally consistent, and comparable only by somebody who thought to compare them. So reselling in your own name pairs with the deemed-supplier posture, arranging somebody else's sale pairs with the intermediary posture, and naming the merchant as the seller has no regime at all in the shipped profile: it is neither of the two shapes, so no document chain follows from it. A jurisdiction where it does binds its own profile.

TaxDisclosureNotPermitted

A settlement document to a creator whose standing is [...] may not state tax

A self-billed document was about to state tax for a creator whose standing does not permit it. Only a validated, standard-rated domestic creator may be shown tax on a document the platform writes for them — the German profile's whitelist. For every other standing (a small business, a business abroad, a private individual, one still awaiting registry validation, or one never established) the document is issued with no tax statement, and a document that already states no tax always passes.

This is the strictest lock in the settlement chain, and for a concrete reason: a self-billed document that wrongly states tax makes the RECIPIENT owe that tax (§ 14c Abs. 2 UStG), so a classification slip would hand a creator a tax bill for a document they never wrote. The standing is read at the supply date, not when the document is generated, so a retroactive correction cannot rewrite the tax on a past supply. Reach it by letting the tax matrix decide the variant rather than setting a tax amount yourself; the whitelist lives in the jurisdiction profile, so a consumer elsewhere admits their own permitted standings and the guard is unchanged.

SelfBillingAgreementMissing

No active self-billing agreement authorizes a document for creator [...] on a supply dated [...]

A self-billed document was about to be issued for a creator who has no agreement authorizing it. A self-billed document is an invoice only if both sides agreed to the arrangement before the supply — one issued without that agreement carries no input-tax effect and cannot be healed, so the write is refused rather than left to produce a worthless document.

The check is strictly ex ante: an agreement accepted after the supply does not reach back to cover it, and a revocation dated after the supply leaves that supply covered because the arrangement was live when it happened. Record the creator's agreement — its accepted_at, the terms version, and a proof protocol — before settling them, and re-issue any document that was refused once the agreement exists. A jurisdiction that does not require a prior agreement sets billing.marketplace.self_billing.require_agreement to false, but never rely on that implicitly: the default, and a missing or non-boolean value, keep the requirement.

SellerContradictsPosture

The seller-of-record posture [...] makes the platform the seller, but the document names a different party

The seller a document snapshots does not agree with its frozen seller-of-record posture. The posture is the role, the seller is the party that role resolves to, and they are one decision seen twice: under the deemed-supplier posture the platform is the seller toward the buyer, and when the platform only arranges the sale (or the merchant sells in their own name) the merchant is.

Naming the merchant as seller under a deemed-supplier posture would put a creator in front of the buyer as the seller — the exact outcome the deemed-supplier rule exists to prevent — so the write is refused at creation, for either direction of the mismatch. Snapshot the party the posture calls for: the platform company for the deemed supplier, the merchant otherwise. A document that snapshots no seller at all is a single-seller one and never reaches this check.

DocumentRoleViolatesRegime

A commission invoice cannot exist in the commission chain (regime K)

The role a document plays does not belong to the sale's frozen supply regime. Each regime produces its own document roles: the commission chain receipts the buyer, self-bills the creator, or settles a private party, and the platform's margin there is the difference between two fictional supplies' tax bases — not a supply of its own, so a commission invoice in that regime would bill something that does not exist for VAT and leave a § 14c liability. Genuine intermediation is the mirror: it issues a commission invoice for the platform's own fee and settles no creator. A correction inherits the permission of whatever it corrects.

The role is frozen onto the document and checked against the frozen regime at creation, so a role from the wrong regime is refused before any row exists, for every caller. Issue the role the regime calls for — a self-billed invoice or settlement note under the commission chain, a commission invoice under intermediation — or, if the document is already issued, cancel and re-issue in the correct role rather than editing it. A row with no regime and no role is an ordinary single-seller invoice and never reaches this check.

InvalidDatevBatch

The document reference [...] is [...] characters; the field carries 36 The document reference [...] contains a character the field cannot carry A batch covers one posting period; [...] spans more than one

Three refusals from the booking export, all with the same shape: the import that reads the file is not going to argue, so anything it would accept-and-mangle has to be caught before it is written.

A reference too long or carrying a character outside letters, digits and $ & % * + - / is shortened or mangled on the way in, and the booking then points at a document nobody can find. Configure a shorter document-number prefix, or one built from permitted characters.

A batch spanning more than one posting period is posted whole into the period its header states, which lands part of it in the wrong month. Export each period on its own — one run per month, not one per quarter or year.

None of these surface as an error anywhere downstream. They surface as a reconciliation that does not close, months later, with nothing to point at — which is why they are refusals rather than warnings.

CorrectionOutsideWindow

A correction to [...] cannot be declared in [...]: the window is [...] year(s) from the date the original return was due, and it has passed

A refund or cancellation reaches back to a period whose return can no longer be corrected. The window runs from the date the ORIGINAL return was due — a month after that period ended, not the period's own last day — so a correction can be out of time a month earlier than the arithmetic suggests.

It is refused rather than dropped or moved into the current period, because both of those are worse. A correction that vanishes is indistinguishable from one that was never owed: nothing errors, nothing is short, and the return simply omits money that moved. One folded into the current period declares a country's tax in a period it does not belong to, which is a misdeclaration rather than an omission.

There is no configuration that makes it go through. A correction this old is settled with the authority directly, not through a periodic return — so exclude the document from the export and take it up there.

CollectiveSettlementSpansTaxCategories

The collective settlement for period [...] spans more than one VAT category

A monthly collective settlement document gathered transactions that resolve to more than one VAT category — some exempt, some taxed. It happens when a creator crosses the small-business threshold mid-month: the supplies before the flip are exempt (§ 19, category E) and those after carry tax (category S). A collective document breaks VAT down per rate, but the exemption category is a document-level property, so one document cannot state E for some lines and S for others without misstating half of them.

The month is refused rather than issued wrong. Settle those transactions once per-line category rendering exists, or split the flip month so each side is a single category. Every other month — all exempt, all standard, or several rates of one category — builds normally, so this only appears for the one month a creator's standing changes.

CollectiveSettlementSpansCurrencies

The collective settlement for period [...] spans more than one currency

A monthly collective settlement gathered transactions in more than one currency. A collective document states a single currency, and the totals behind it are accumulated in minor units — so mixing them would produce an amount in no currency at all: 100 cents plus 100 cents is 200 of neither.

The month is refused rather than issued with a meaningless total. Settle each currency in its own document. Money refuses currency mixing everywhere else in the package, and this restores that guarantee at the one place the accumulation works on raw minor units for speed.

SettlementTransactionChargeIncomplete

A settlement transaction named half of a charge identity

A SettlementTransaction was given chargeReference without chargeProvider, or the other way around. The pair is what names the routed charge a collective settlement covers, and it is optional — a transaction that names neither settles exactly as it always did.

Half is refused because a charge reference is unique only per provider; the charge table says so with a composite unique key. A bare reference could therefore be matched against a charge belonging to a different driver, and the collective document would record that it settled a sale it never mentioned. That failure is silent in the direction that matters: the settlement arithmetic is untouched, the totals still equal the payout run, and the only thing wrong is which row now claims to have been settled by that document.

Pass both halves, or neither. The check runs at construction rather than during the run so the error points at the line that wrote the transaction, instead of reporting a whole month as unsettleable.

NotASelfBilledDocument

Only a self-billed document (a Gutschrift or settlement note) can be objected to

An objection was raised against a document that is not self-billed. The right to object exists because the platform wrote the creator's invoice for them; an ordinary fan invoice carries no such right, so there is nothing to object to. The objection is otherwise unconditional — no reason, no deadline, and it works even against an arithmetically correct document — but it must point at a self-billed document.

Point the objection at the settlement document (the Gutschrift or settlement note), not the fan's receipt. A document with no settlement document type is an ordinary invoice and never reaches this path.

SelfBillingDisabled

The self-billing engine was reached while billing.marketplace.self_billing.enabled is off

The self-billing engine was called to settle a creator, but self-billing is switched off. A platform that does not self-bill routes its creators to the fallback lane — the creator submits their own invoice — and never settles them through the engine, so reaching it with the switch off is a caller mistake, not a document to produce.

Check billing.marketplace.self_billing.enabled before settling and route to the fallback lane when it is off, or turn it on if the platform does self-bill. The engine refuses loudly here rather than issue a document a disabled platform never meant to.

MerchantRelationshipEnded

This merchant's relationship with the platform has ended, so onboarding cannot simply continue with it

The merchant disconnected their provider account — deliberately, or while tidying up connected apps. They are terminated here and the provider no longer releases funds through that account, so onboarding them again would hand you a link to an account that cannot receive money.

Reopen the relationship first:

php artisan billing:merchant:reopen "App\Models\Creator" 42

That is a deliberate act rather than something a retry does quietly, and it is deliberately not reachable from a webhook: a provider goes on reporting healthy capabilities for an account long after its owner disconnected it, so letting a report resume the relationship would resume routing into one that no longer exists.

They still cannot receive money immediately after reopening, and that is correct. The three capability flags are reset, because the old ones were gathered before the disconnection. Let the provider report again, or run billing:merchant:refresh, and check with billing:merchant:status.

MerchantPartyUnavailable

No merchant party resolver is bound, so the identity of a [...] merchant cannot be read for a self-billed document

A self-billed document was about to be issued, but nothing knows how to read the merchant's invoice identity — their legal name and registered address. A self-billed document names the merchant as the seller, so it cannot be issued without that, and a merchant's details live in your application's own schema, not in this package. The shipped resolver therefore fails closed rather than issue a document with no seller.

Bind a MerchantPartyResolver that reads your merchants before self-billing — it maps one of your merchant models to an invoice Party. A single-seller install never reaches this: it does not self-bill.

BillingDisabled

Billing is disabled; cannot charge.

billing.enabled is false, so the manager resolved the NullDriver — and something asked it to move money anyway. Reading is fine while billing is off; charging, refunding, tokenizing a payment method and creating a mandate are not. Guard the call site, or turn billing on.

UnsupportedDriver

billing.default names a driver nothing registered. Check the spelling, and that the driver's service provider is installed.

EligibilityDenied

The owner is not eligible to transact; money movement was refused.

The CanTransactMoney gate denied the owner before a charge, subscribe, checkout or add-on purchase. The gate is fail-closed: it denies unless the owner is positively eligible, so an unbound or not-yet-answering implementation denies rather than lets money move on an unanswered question. Bind your own implementation if you have age or identity requirements; if you do not, make sure the one you bound returns true for an ordinary owner.

QuotaExceeded

Quota exceeded on meter '…': N requested, M remaining in the allowance.

A metered request would take the owner past a blocking allowance. A degrading or fair-use meter never raises this — those keep serving and are only flagged. The exception carries meterKey, policy and remaining, so catch it and render "you have M left" rather than a bare status code. The billing.quota:<meter> middleware turns it into billing.quota.status (429 by default) for you.

SeatDowngradeBelowOccupied

The seat quantity would be set below the number of seats actually occupied, which would bill for fewer seats than are in use. Remove members first, or bill the higher number.

CouponUnavailable

Four reasons, all recoverable and all worth showing the customer verbatim: the coupon is not active, it has expired, it has reached its redemption limit, or this account has already redeemed it. Catch it at the redemption call site and surface the message.

CurrencyMismatch

Cannot operate on Money of different currencies: … vs … .

Two Money values in different currencies met in an arithmetic operation. This is a programming error rather than a configuration one: the value object refuses instead of producing a number whose currency is a guess. It also fires when a tier's catalog price is in a different currency from the subscription line it would price.

CycleAmountUnresolvable

A subscription line cannot be priced for its cycle. Four causes:

  • a fixed line carrying no amount — only a metered line may be stored unpriced
  • a metered line with no resolver named, on the line's preprocessor column or bound as a default
  • a metered line whose meter has no matching component in the tier catalog
  • a metered component with no unit price, on a driver that rates usage locally

It throws rather than returning zero, because a metered line with no usage legitimately costs nothing: a zero for "I could not work this out" produces an invoice that looks settled while billing nothing.

PostureNotPermitted

Either the resolved seller-of-record posture is not in billing.marketplace.seller_of_record.allowed_postures, or a seller_of_record posture was resolved for an electronically supplied service without the rebuttal being asserted. A platform that sets its own terms, authorizes billing or approves the supply cannot truthfully assert the rebuttal.

ContradictoryExemption

This document is frozen as a service supplied outside the union … but carries a band taxed at …

Two statements on one document that cannot both be true, refused before rendering rather than after.

The first case: a document frozen as supplied outside the union that names a member state as its destination. Whichever of the two is wrong, one of them is, and only an auditor comparing the two fields would notice.

The second: a service outside the scope of VAT (EN 16931 category O) on the same document as a taxed supply. O is exclusive — BR-O-11 forbids any other category alongside it, and the BR-O-* rules forbid such a document stating a tax rate or amount at all. This is not a category to soften but a document that cannot exist: a conformant validator rejects it outright, and an invalid invoice is worse than an imprecise one because it cannot be filed.

Split it — the out-of-scope service on its own document, the taxed supply on another. The category is not downgraded to Z for you, because that would file a supply frozen as outside the scope of tax as though the tax had reached it.

FeeRefundPolicyNotPermitted

The fee refund policy 'retain' is configured, but the supply regime is 'commission_chain'.

Each half is a reasonable setting on its own, which is why nothing downstream catches the pair — every document the combination produces is well-formed. Together they describe a platform keeping money for a service it never billed.

Retaining a commission presupposes a document the platform issued the merchant for a service, which survives the sale being unwound. A commission chain has none: the platform buys and resells, its turnover is the margin between two supplies, and unwinding the sale unwinds both. What is kept afterwards sits on no supply at all — turnover on a tax return with nothing behind it, and a merchant short by an amount no invoice explains.

Set billing.marketplace.fee.refund_policy to refund, or move to the intermediation regime if the platform really does bill the merchant a commission separately.

The same exception is raised for a policy value the package cannot read. That is refused rather than defaulted, because a fallback would answer a question about money with whatever the package prefers, on every refund, for as long as the typo survives.

ExchangeRateUnavailable

A conversion was asked for and no published rate is held for that currency, that day and that rule — or no exchange-rate source is bound at all.

This package ships no rates. Which rate is correct is jurisdiction knowledge, and the rules contradict each other on the same turnover: German domestic turnover takes the ministry's monthly average, while OSS expressly excludes monthly averages and takes the central bank's rate at period end. A shipped default would be wrong for somebody by law rather than by oversight, so the seam is bound to a refusal instead.

If you are single-currency, you should never see this. Such an install never converts, so it never asks. Seeing it means something is asking for a conversion you did not expect — worth finding out what, before supplying a rate to silence it.

If you do convert: bind an ExchangeRateSource and import the rates for the periods you bill in. The message names the currency pair, the day and the rule, so a refusal usually points at one period nobody imported rather than at your wiring.

The refusal is deliberate and the two alternatives are both worse. A zero makes the converted amount vanish into a plausible-looking total; the nearest available rate states a figure the publisher never issued for that day, off by whatever the currency moved, and indistinguishable from a correct one until somebody holds the document against the official series. Neither announces itself.

ReportingPeriodNotClosed

billing:exchange-rates:freeze-reporting was run for a period that has not ended yet.

The reporting rate converts at the last day of the period, so before that day has passed there is no rate to freeze. Run the command after the period closes and before you file.

Why this refuses instead of doing its best, which is the part worth knowing: a missing day resolves forward to the next publication day. An early run therefore would not fail — it would take the first rate published after you asked, stamp it with that day's date, and write it onto every document in the period. The result is a real rate, correctly recorded, for a day the return is not filed on. A wrong figure that passes every check is more expensive than an error, so this one is an error.

Running on the final day of the period is also early: the central bank publishes that day's rate at the end of it.

Tax and invoicing

UnknownTaxCountry

The tax country code '…' is not an assigned ISO 3166-1 alpha-2 country code.

Pass a two-letter code (DE, US). It is refused rather than zero-rated because a zero here is indistinguishable from a legitimate supply outside the EU VAT area, and would under-declare VAT silently.

Worth knowing what this cannot catch: a typo that lands on another assigned country (DE mistyped as DK) is indistinguishable from a deliberate supply to that country.

TaxRateSnapshotTampered

The tax rate snapshot at … has been edited since it was recorded.

The shipped rate file carries a digest over its own numbers, and they no longer agree. Pricing stops rather than falling back to whatever is in the file.

What this is really guarding against is not a corrupted download — Composer already covers that — but the edit nobody sees: a digit changed inside vendor/, which appears in no diff because vendor/ is in no diff, and which would silently reprice every invoice to that country with the money as the only trace.

If the change was deliberate, re-record the snapshot through the importer so its header says who accepted it. An edited table with a stale digest is a table nobody has vouched for.

The same exception covers a snapshot that is missing or malformed, for the same reason: the file is the source every invoice is priced from, so there is no sensible fallback.

UnknownTaxRateAt

No tax rate is known for '…' at '…'.

The rate table holds dated intervals, and this moment falls outside all of them. Usually it means a supply older than the earliest rate you have loaded.

The intervals come from billing.tax_matrix.history, and they are what makes a rate belong to the tax point rather than to the moment of lookup. Without a history configured this exception cannot arise, because every sale is priced from the single current table — which is the right answer for today's sale and the wrong one for a document written now about a supply taxed under a rate that has since changed.

Refused rather than answered with the oldest rate you hold. That fallback would always produce a number, which is precisely what makes it dangerous — the number would carry a date it was never valid for, and an invention with a date on it cannot be told apart from a fact. Load the interval that applied, or correct the tax point if it is wrong.

RateIntervalConflict

A … rate for … already covers … to …; the incoming interval starts … and overlaps it.

Rates are append-only. The incoming data disagrees with an interval already held, and that disagreement is information — silently keeping one of the two would destroy it.

Overwriting is the operation this exists to prevent: afterwards nobody can reconstruct what an invoice said, not the auditor, not you, not a court. A rate change closes the previous interval and appends the new one; it never edits in place. If the existing interval is genuinely wrong, correct its end date first, then append.

ContradictoryExemption

This document is frozen as supplied outside the union, but states '…' as its destination, which is a member of it.

The document asserts two things that cannot both be true: an exemption that depends on the supply leaving the union, and a destination inside it. Whichever field is wrong, one of them is — and a reader has no way to tell which, so neither the exemption claim nor the destination can be trusted.

Refused at render time rather than written out, because a rendered document buries the conflict until an audit while a refusal names it while somebody can still fix its cause. Correct the record: either the exemption reason is wrong for this sale, or destination_country is.

InvalidInvoiceCorrection

  • An amendment must reference the invoice it corrects. A correction with no origin reference is only valid as a cancellation.
  • A correction carries positive magnitudes. The document's nature inverts the meaning, not the sign, so pass the absolute amount being corrected.

Both are thrown when the snapshot is constructed, so a malformed correction never reaches persistence or an e-invoice writer.

VoucherNotPermitted

Three cases, each one of the properties that keeps a voucher outside regulated money.

Vouchers are switched off. Turn on billing.marketplace.vouchers.enabled deliberately — the default is off because a balance customers pay into is a supervised question.

More was asked of a voucher than is left on it. The difference is not credit: allowing it would let a voucher pay for more than was ever paid into it, and the shortfall would sit in the books as revenue nobody received.

An expired voucher was spent. Its remaining value has already been taken to income; spending it now would take that back with no document saying so.

BuyerProtectionMisconfigured

Buyer protection is configured in a way it cannot actually deliver. Two causes.

A decision deadline the provider will not wait for. Your payment provider delays a payout only so long; past that point the money goes out whatever your settings say, and the protection you promised the buyer is gone without anything failing. Bring decide_after_days inside provider_limit_days with margin_days to spare.

A connected-account type with no payout control. Some account types pay out on the provider's own schedule, which leaves nothing to hold back. Use one that does — express (the default) or custom.

Both refuse up front rather than at the first sale, because both fail weeks later, to money that has already been taken from a buyer.

A third case is raised later rather than at boot: deciding a hold that is already finished. Releasing or refunding twice sends the same money twice, and the second instruction is indistinguishable from the first once it has gone.

DatevTransactionUnresolvable

No DATEV account is configured for the '…' transaction.

Configure the account under billing.datev.accounts for the active chart. The export aborts rather than booking to a default account, because a posting on the wrong account imports cleanly and surfaces only when an auditor reads it.

One transaction ships deliberately unmapped, and it is the one you are most likely to hit: creator_input_de_reduced — the input account for a domestic creator whose supply carries the reduced rate (books, e-books, cultural supplies). The package has no account it can confirm for this, so it refuses instead of guessing.

Agree an account with your accountant and configure it:

'accounts' => [
'skr03' => [
// …
'creator_input_de_reduced' => ['account' => '3110', 'automatic' => true],
],
],

Until you do, a settlement to a reduced-rate creator stops the export. That is the intended outcome, and it is better than what it replaced: the branch used to fall through to creator_input_de_standard, so 7% input VAT booked to the 19% account. Nothing errored, the file imported, and the amount disagreed with the account it sat on — on the advance return, on the wrong line.

PdfRendererUnavailable and MissingPdfEmbedder

The package renders invoice HTML itself and leaves the PDF step to you, because a PDF toolchain is a heavy, opinionated dependency a lean package should not force on every install.

  • PdfRendererUnavailable — a PDF was requested and no PdfRenderer is bound. Bind one, or use the HTML.
  • MissingPdfEmbedder — a hybrid ZUGFeRD PDF/A-3 was requested without the optional embedding toolchain. Install it, or use the CII XML directly.

MandateNeedsRedirect

A mandate was asked for through a seam that promises one synchronously, from a driver that cannot keep that promise.

PaymentRails::createMandate() returns a non-nullable mandate reference, which is a promise: after this call, a mandate exists. Stripe keeps it. Mollie cannot — a mandate there is born when the customer completes a first payment on Mollie's own checkout, and depending on what they do it may never be born at all. The same exception covers tokenize(), which Mollie has no equivalent of: payment details are captured on the provider's checkout, so there is no raw data for a server to exchange for a token.

Refusing is the only available answer that is not a defect. Returning the payment id instead would be stored as a mandate, charged against on the next cycle, refused, and read as the subscriber's payment failing — a wrong answer that looks like a right one for as long as it takes somebody to investigate a dunning ladder nobody earned.

What to do: start the redirect flow, send the customer to the checkout it returns, and store the mandate when the webhook reports the first payment paid.

OrderItemPreprocessorFailed

A step configured in billing.order_item_preprocessors threw while pricing a billing cycle. The message names the step's class, because a chain is configured rather than written: you are looking at a list of class names in a config file and need to know which one to open. The original failure is attached as the previous exception.

Nothing was charged, and the cycle was not claimed. That is deliberate, and it is the recoverable outcome. The alternative — continuing with the lines the chain had reached — would charge the subscriber a total no configuration reproduces, and because a claimed cycle is skipped by the next run, the amount would never be revisited. A cycle that failed loudly is retried on the next billing:run; a cycle that was half-priced is simply billed.

One subscriber's failing step does not stop the sweep. tick() logs and moves on, so the subscribers ordered after this one are still billed.

Fix the step, or remove it from billing.order_item_preprocessors.

MollieNotConfigured

The Mollie driver was selected and its client cannot be built. Three different problems share the name, and each says which one it is, because they need different fixes:

  • No key. billing.mollie.api_key (BILLING_MOLLIE_API_KEY) is unset — or set to a blank value, which counts the same. An empty variable is what a half-finished deployment leaves behind, and it reads as configured to anybody looking at the file.
  • A key Mollie will not accept. The value is set but is not a key: a test key begins test_, a live one live_. This is a typo or a truncated secret. The SDK's own rejection is caught and restated so the message names the setting rather than arriving as a stack trace from inside a third-party library.
  • The package is missing. mollie/mollie-api-php is a suggestion rather than a dependency, so an install that never selects this driver carries no HTTP client it will not call. Run composer require mollie/mollie-api-php.

It is raised where the client is asked for rather than at boot, and that is worth knowing: an install that selects the driver without a key does not break on deploy. It breaks at the first charge, inside a scheduled run nobody is watching, against a real subscriber.

Nothing threw, and something is still wrong

These are the expensive ones, because the app looks healthy.

A paying customer still sees the free tier. billing.customer.model is not set, so no subscription webhook can resolve its owner and the local row is never written. Set it, then run billing:sync to backfill what the webhooks could not apply.

Usage is recorded and never billed. The meter does not exist at the provider, or was archived there. Usage reported into a meter that does not exist fails silently and surfaces, if ever, as an under-charged invoice a month later. Run billing:meters:check, which exits non-zero on a missing meter and fits a deploy check. Then billing:usage:reconcile --redrive returns the rollups that gave up to pending.

Usage stopped flowing after an outage. The flusher exits successfully during a provider outage on purpose — a growing backlog is not a crash. Watch for the UsageBacklogStalled event, which fires once the oldest pending rollup is older than billing.metering.stall_hours, and for UsageReconciliationDrift, which fires when the local ledger and the provider's meter disagree.

Webhooks arrive but nothing happens. Check the delivery rows: an effect that failed all its retries is marked failed and stays re-driveable. billing:webhooks:replay --failed runs it again long after the provider stopped redelivering. Idempotency is per effect, so a replay re-runs only the effect that failed.

The account hub 404s. Either billing.enabled is off — the routes are not registered at all — or Livewire is not installed. The hub is optional and Livewire is a suggested dependency, not a hard one; the billing core does not need it.

The admin console denies everyone. billing.admin.ability names a Gate ability your app defines. Until it is defined the Gate denies, which is deliberate: the console is never open by accident.

A cancellation at the provider did not stop billing here, or the other way round. Dispatch BillableAccountDeleting before you delete a billable model. The listener runs while the owner still exists, so the live subscription can be canceled at the provider before the row is gone.

SubscriptionNotPermitted when somebody tries to subscribe. Four refusals share this exception and each names its own reason in the message. A tier the catalog does not carry is refused rather than resolved to anything, because the key arrives from a browser and a default would let whoever sent it decide what the subscription costs. A tier listed in billing.untouchable_tiers is granted by hand and deliberately kept out of the billing flow, so selling it through checkout would let the next provider event overwrite the grant. An owner who already has a live subscription is refused because a second one bills alongside the first and only one of them is the row every screen reads — swap the tier instead. And a provider that has to redirect the customer refuses when nothing says where they come back to: set billing.subscribe_return_url or billing.checkout.success_url, because a guessed return lands somebody on an error page after a real payment.

CustomerBelongsToAnotherProvider when starting a subscription. The billable already carries a customer reference the active provider did not issue — an install that changed provider meeting a customer from before. It refuses rather than replacing the reference: replacing it would detach that owner from the live subscription, the mandates and the invoices the other provider still holds, and the first sign of it would be a webhook from there that resolves to nobody. Moving an existing customer between providers is a migration somebody performs deliberately.

Onboarding a merchant logs a warning about Accounts v2. Stripe returns a stripe-notice header recommending its newer Accounts API, and from SDK 20.3 the client raises it as an E_USER_WARNING on every accounts->create(). Your payment is unaffected: the v1 Connect endpoint this package uses is supported and carries no sunset date. The package deliberately does not filter the notice away — deciding your log policy for you would also take away the advance warning the header exists to give.

If your application converts warnings into exceptions, that handler turns the notice into a failure after Stripe has already created the account. Calling again is safe: account creation carries an idempotency key derived from the merchant, so the retry returns the account that already exists instead of making a second one. Exclude the notice from your handler, or let the retry run.

Everything is fine locally and broken in production. The webhook-secret guard is production-only, and tax and metering guards depend on the driver that is actually active. Run with the production driver and APP_ENV=production once before deploying.


← Back to the documentation index