Skip to main content

Command reference

Scheduled commands

The service provider registers these for you:

CommandCadenceWhat it does
billing:usage:flushevery minuteReports recorded usage to the provider that bills it
billing:runhourlyAdvances the recurring cycle (a no-op under Stripe, which drives its own)
billing:dunning:advancedailyWalks the dunning ladder: escalating warnings + late fees
billing:dunning:reminddailyReminds marketplace subscriptions in arrears that are still inside the cure window, once a day each
billing:dunning:expiredaily, after the reminderExpires marketplace subscriptions whose cure window ran out — cancels only those, and sends the one final notice
billing:cards:warndailyWarns owners whose card is about to expire
billing:trials:warndailyWarns owners whose generic trial is about to end (the subscription trial is announced by the provider)
billing:usage:reconciledailyReads the provider's usage totals back and alarms on drift or recorded-but-unbilled usage
billing:prunedailyAges out stored webhook payloads and expired financial records

On-demand commands

php artisan billing:sync # reconcile subscriptions from the provider onto the local rows
php artisan billing:install # publish the config + generate the owner-columns migration
php artisan billing:webhooks:replay --failed # re-drive webhook effects that failed
php artisan billing:erase {owner} # erase an owner's billing data (see Data protection)
php artisan billing:export {owner} # everything the package holds about one owner, as JSON
php artisan billing:doctor # webhook endpoints, rate-table age, union-membership age, distance-sale threshold age, exchange-rate series age, inherited profile readings, uncovered works, provider key vs environment
php artisan billing:release-claim {order} # hand a stranded cycle back to the biller after checking the provider (see below)
php artisan billing:vouchers:volume # announce voucher volume that has reached a supervisory threshold (scheduled only where vouchers are on)
php artisan billing:meters:check # verify every configured usage meter exists and is active at the provider
php artisan billing:usage:reconcile --redrive # (also scheduled daily) retry the rollups a flush gave up on
php artisan billing:tier:grant {owner} {tier} # comp an owner onto a tier out of band, recorded on the audit trail
php artisan billing:subscription:cancel {owner} # end a subscription immediately, recorded on the audit trail
php artisan billing:datev:export # a period of invoices as a DATEV EXTF booking batch (defaults to last month)
php artisan billing:protection:advance # release or escalate buyer-protection holds whose deadlines have passed
php artisan billing:tax-return:export # a quarter's sales as tax-return lines, corrections included (defaults to the quarter just ended)
php artisan billing:markets:record # write down which markets changed standing, and by whom
php artisan billing:marketplace:preflight # the multi-merchant go-live checklist: what still has to hold
php artisan billing:merchant:onboard {type} {id} # create a merchant's connected account and print the onboarding link
php artisan billing:merchant:status # what each merchant account may do, and what the provider is still waiting for
php artisan billing:merchants:reconcile # compare the merchant journal against what the provider says it moved; non-zero on any disagreement (--driver must name the ACTIVE driver — an install reads back through one provider)
php artisan billing:reporting:run {year} # produce a reporting period's official record, refusing while it is implausible
php artisan billing:reporting:file {year} # record that a produced record was actually submitted
php artisan billing:merchant:refresh # ask the provider for merchant capabilities, after a missed webhook
php artisan billing:merchant:reopen {type} {id} # begin again with a merchant whose relationship had ended — an operator's act, never a webhook's
php artisan billing:rates:probe # ask the source whether the shipped VAT rates still match (off unless enabled)
php artisan billing:tax-rates:check # ask the source what the rates should be and write a proposal (off unless enabled)
php artisan billing:tax-holds:announce # (scheduled daily) tell the merchants whose tax attestation just expired
php artisan billing:tax-holds:warn # (scheduled daily) tell the merchants who never declared that the deadline is coming
php artisan billing:filings:announce # (scheduled daily) warn about filing obligations falling due soon
php artisan billing:tax-status:reconcile # (scheduled daily) flip creators whose turnover here has broken a small-business limit
php artisan billing:exchange-rates:import # (scheduled daily) fetch published exchange rates into the local store
php artisan billing:exchange-rates:freeze-reporting --year=2026 --quarter=1 # freeze the reporting rate onto a closed period's documents

Why the tax-hold announcement is scheduled and not triggered

billing:tax-holds:warn is the other half of the same problem, and it runs before the deadline rather than after it. The configuration beside enforce_from says to "tell the merchants who are missing one, and let the date arrive" — the date arrives on its own, and without this the first a merchant hears of it is a refused sale, at the till, having been asked for nothing. It warns anyone who has taken money and would be held on the day, warn_days_before ahead of it, once per deadline. It is silent while no date is set, and it warns again if you move the date, because a merchant told about March has not been told about June.

It cannot reach everyone, and the gap is worth knowing before you rely on it. The package sees a merchant only through a routed charge or a recorded tax standing, so somebody who has neither — signed up, never sold, never declared — is invisible to it and is not warned. That is deliberate rather than missed: warning somebody about a deadline for an activity they have not started is noise. But it means this sweep is not a complete notice to your sellers, and if you need one, it has to come from your own side, where the full list lives. An announcement read as complete when it is not is worse than none.

billing:tax-holds:announce is the only way a merchant learns that their tax hold has begun. Every other hold starts because somebody recorded a blocking standing — a write, which can be watched and reacted to. An attestation running out writes nothing at all: the hold begins because a date passed. Without this daily sweep the merchant finds out by trying to sell.

It marks each merchant it has told, so a merchant is notified once rather than every night, and a standing that a later declaration already covers is skipped rather than announced.

The reconciliation DECIDES something, which no other scheduled command here does

billing:tax-status:reconcile exists for the same reason — an event nothing can observe. A creator crossing a turnover limit writes no row: enough sales accumulate and a threshold is simply past, so the moment the flip should have happened is a moment nothing dispatched.

The difference is what it does about it. Every other command in this list reports, exports or announces. This one writes a new tax standing: a creator whose turnover here has broken a small-business limit stops being exempt, from the moment the breaking sale happened. Read both failure directions before scheduling it — though not running it is the worse one, because a creator who has outgrown the relief keeps issuing tax-free documents, and that is knowingly wrong from the breaking sale onward.

It never flips anybody back. The count it works from sees only what was sold through your platform, so it is a lower bound: over the limit here means over the limit for certain, while under the limit here proves nothing. A return to a relief is a self-declaration with its own effective date.

It can succeed while telling you something is missing. A business in its founding year is measured against a much lower limit, and that year is recorded with the declaration and may be absent. For those creators the run applies the ordinary limit and names them in a warning rather than substituting a year — inventing when somebody's business started would be a fact nobody stated, and the threshold reads an early year and a late one as different regimes rather than as a blurred number. The exit code stays zero on purpose: a scheduler that treats this as a broken job alerts on it nightly, which is how a real warning gets muted.

The reporting rate is frozen later than the document rate, because it exists later

billing:exchange-rates:freeze-reporting gives a closed period's documents the rate their return will be filed on — a different figure from the one on the document itself, for the same sale.

That divergence is lawful rather than a defect. Germany's document rule is the ministry's monthly average; the one-stop-shop rule is the central bank's rate on the last day of the tax period, and it expressly displaces monthly averages. A filer who used one rule for both would be wrong on one of them.

It is a separate command, rather than part of issuing a document, because of when the rate exists. The rule converts at the last day of the period — and on the day a sale is booked, that day has not happened:

php artisan billing:exchange-rates:freeze-reporting --year=2026 --quarter=1

With no options it takes the previous quarter, since the current one cannot be closed.

Run early, it refuses rather than approximating. That refusal matters more than it looks: a missing day resolves forward to the next publication day, so an early run would not fail — it would freeze the first rate published after you asked and stamp it with that day, on every document in the period at once. See ReportingPeriodNotClosed in the troubleshooting guide.

Re-running is safe. A document that already carries a reporting rate keeps it, so a second pass only picks up stragglers such as a late correction.

An installation whose jurisdiction profile names no filing rule freezes nothing, exactly as it does for documents — "the central bank at quarter end" is one jurisdiction's answer, not a neutral default.

Notes on exit behavior

billing:marketplace:preflight prints the go-live checklist that gates billing.marketplace.enabled — every point, in order, with what it found and why. It exits non-zero while a blocking point is open, so it fits a deploy check. Two things about it are worth knowing before you read a report.

Order is enforced, not suggested. A stage with an open blocking point makes every point after it UNREACHABLE rather than evaluating it, because publishing terms after the first sale is a different act from publishing them before it. An unreachable point is not a failure and emphatically not a pass: it is a point nobody ran.

A stage can be empty, and the report says so in words. Where nothing is registered you get "no checks are registered for this stage" instead of a green line — a stage that passes because it holds no checks would read exactly like a stage that passed.

The command is available whether or not the marketplace is switched on. It has to be: flipping the switch is the last step of the checklist, and with the switch on and a point open the application refuses to boot at all. That refusal names the open points itself, so the information is never out of reach.

billing:meters:check catches a metered tier whose provider_meter was never created, or was archived, at the provider — usage reported into a meter that does not exist fails silently, and the miss surfaces (if ever) as an under-charged invoice a month later. It exits non-zero when a meter is missing, so it fits a deploy check. billing:usage:reconcile answers "is there any recorded-but-unbilled usage right now?" — after fixing the cause (often a meter billing:meters:check found), --redrive returns the failed rollups to pending so the next flush retries them.

billing:sync is the bulk version of the post-checkout reconcile — use it to backfill after a webhook outage. It applies each subscription through the same plan-sync effect the webhook uses, so it can never overwrite a newer webhook state; it only moves a stale local row forward. Scope it with --owner, preview with --dry-run.

billing:tier:grant is the terminal form of a support comp (the same BillingAdmin::comp an admin panel calls): it writes the tier column directly and records the grant on the audit trail. It refuses a tier key no billing.tiers entry declares, and warns when the tier is not in billing.untouchable_tiers — because the next provider webhook is otherwise free to overwrite the grant.

billing:subscription:cancel is the terminal form of a support cancellation (the same BillingAdmin::cancel the admin console's own action calls). Pass --reason= and it is recorded on the audit trail beside the actor. An unknown owner FAILS rather than exiting zero over a no-op — the outcome worse than an error here is a support agent moving on believing a subscription is ended while it bills again next cycle.


← Back to the documentation index

When a merchant is stuck on "cannot receive"

billing:merchant:refresh asks the provider what a merchant's capability flags are right now, and writes the answer through the same seam the webhook writes through.

It exists because those flags could only ever be told to this package, never asked for. A lost account.updated — endpoint down, wrong secret, retry window closed — leaves a merchant at "cannot receive" while their account is fully enabled at the provider, and no money is routed to them. billing:sync has done this for subscriptions since it was built; this is the same repair on the receiving side.

billing:merchant:status now carries a Heard column, and the pair is the diagnosis:

FlagsHeardWhat it means
three falsenevernobody has ever told us anything — run the refresh
three falsea timestampwe asked, and the provider said no — the merchant has paperwork to do

It is a maintenance command, not a schedule. One provider call per merchant, so it belongs where billing:sync belongs and nowhere near a checkout. It is deliberately not on a timer: calling the provider for every merchant on a cadence is a cost and rate-limit decision an operator makes, not one a package makes for them.

A refresh never makes a deauthorized merchant receivable again. The provider does not know the merchant disconnected from your platform and will report every flag true; that disconnection is your platform's own position, and it stands.

Checking the shipped tax rates

billing:rates:probe compares the VAT rates this package ships against what the source publishes. It is off unless BILLING_RATE_PROBE=1 — a package should not contact a public service because it happened to be installed.

Its exit code distinguishes three outcomes, and that distinction is the point:

CodeMeaning
0the shipped rates match the source
1a difference was found, or the source gave an answer that cannot be compared
2the source could not be asked — nothing was learned either way

Collapsing 2 into 1 is how a check becomes noise. A DNS failure reported as "your rates are wrong" teaches you to dismiss the one signal that matters, and then a real difference arrives looking exactly like the noise you have learned to ignore. An answer for a different date than the one you asked about is also a 2: the source replies 200 for today when asked about next year, which is evidence about neither.

Run it nightly, never in a push gate. A network-dependent check in the gate goes red the first time DNS hiccups, gets marked flaky, and is disabled — and then the check is gone. The gate carries a different assertion for this: one that compares two dates and cannot flake.

Turning a difference into something you can act on

billing:tax-rates:check is the other half. Where the probe reports that the shipped table differs, this asks what the rates should be and writes a proposal you can read, diff and decide on. It shares the probe's switch and its three exit codes, with 1 meaning a proposal was written.

Three things happen between the answer and the file, and each of them is why the proposal is worth reading rather than a dump of the response:

  • Plausibility. A source that gives one country two different standard rates has contradicted itself, and no part of that answer is used to change that country. Using the rest of a self-contradicting response is hope, not caution.
  • The window. An answer for a date you did not ask about is not "no change" — it is no answer, and it exits 2. Booked as nothing-to-do, that is how a year of stale rates hides behind a log full of successful runs.
  • Triage. Each change is classified against the countries you have actually billed into, read from your issued documents rather than from configuration. A rate move in a country you have never sold to is recorded; one in a country you invoice, or a rate that fell, is held for a person to look at. The point is that the prompts which remain are worth stopping for.

It proposes. It never applies. The proposal is written beside the snapshot — proposal-<date>.json, next to eu-<date>.json — and the snapshot is not touched. A rate is a legal statement about what a member state charges, and a package that edited its own priced-from file on the strength of one HTTP response would be making that statement on your behalf. Applying a dated change is yours: ScheduledRateChange is the shape to hold one in, and nothing in this package executes it.

Point billing.tax_rate_probe.proposal_path somewhere writable if your deployment treats the installed package as read-only, or would lose the file on the next release.

The exchange-rate import contacts nobody unless asked twice

billing:exchange-rates:import fetches the central bank's daily reference rates into billing_exchange_rates. This package ships no rates and will not — which rate is correct is jurisdiction knowledge and the rules contradict each other, so a shipped figure would be wrong for somebody by law. The numbers on your documents are the ones you imported, from a publisher you can name.

It needs two switches, not one: billing.tax_exchange_rates.enabled on, and billing.tax_exchange_rates.currencies listing something. Those are two decisions — whether to hold rates locally at all, and which currencies you actually settle in — and a package that inferred the second would dial out for currencies nobody sells in. With either missing the command says which one stopped it and sends no request.

The window overlaps on purpose. It re-fetches lookback_days back rather than yesterday alone, because a publisher revises, a run is missed, a machine sleeps through a night. Importing is idempotent, so an overlap costs a few rows rewritten with the same figures — while a one-day window turns any missed run into a permanent hole, and the reader answers a hole with the next publication day's rate: a real figure for the wrong date. --from and --to override it for a backfill.

One observation is stored under two rules. The bank publishes a rate, not a rule: its daily reference rate is the correct one both at a tax point and at a period end, and what differs is which day each rule tells you to ask for. Storing both keeps every answer traceable to a row somebody imported under that rule, instead of a lookup silently falling back to a rule the caller did not ask for.

A currency the bank will not answer for is reported, and the others still import. One outage must not discard what was already fetched — and it is never silent, because a currency that quietly stopped importing is a series that grows a hole.

Rates are stored in the direction the bank publishes them, euro to each listed currency, and are never turned around. Source: European Central Bank.

charges_enabled is not a switch, which is what the two merchant commands are for

The most common question about a marketplace install is where to turn charges_enabled on. There is no such place. The flag lives on the merchant's connected account at the provider, and the provider raises it itself once its own identity and bank review passes — no API call and no dashboard control sets it, by design, because the flag is the statement this account may take money and only the provider is in a position to make it.

So the path is always the same three steps, and the first two are what billing:merchant:onboard does:

  1. A connected account is created for the merchant. Idempotent through the local row — a second run creates no second account, because the provider has no notion of your merchants and could not answer the question if asked.
  2. An account link is handed back, and the merchant opens it and works through the provider's hosted flow. Those links are single-use and short-lived by design, so re-running the command for a fresh one is the normal thing to do, not a workaround.
  3. The provider reviews, raises the flags itself, and announces it with an account event.

billing:merchant:status answers the question that replaces the original one — what is it still waiting for. The three capability flags come from the local row, because that snapshot is what every routing decision reads and a live copy here would show a different answer from the one the money paths use. The outstanding requirements come from the provider, because they change every time the merchant touches the form and nothing routes on them.

It exits zero even while accounts are blocked. A merchant part-way through onboarding is the normal state of a funnel rather than a fault in the installation, and a non-zero exit there would make the command unusable in the deploy check where you would first reach for it.

A merchant is stored polymorphically, so onboard takes a type and a key rather than a bare id. The type may be the morph alias where you have a morph map, or the class name where you do not.

Reading requirements is optional for a driver — it is a separate ReportsOnboardingRequirements contract, because MerchantOnboarding is public surface you may implement and a new method on it would break your implementation on upgrade. A driver that does not answer still gets the whole flag table.

Producing a reporting record is not filing it, and they are two commands

billing:reporting:run builds the official record for a period. It runs the plausibility check first, and that check blocks: a period with open findings produces no bytes at all. That is deliberate — an export is evidence of what was reported, so producing one and then saying "do not send this" leaves a file indistinguishable from a filed record.

Rebuilding is free and expected. Nothing is fixed until billing:reporting:file records that a produced record actually went out. The package transmits nothing — it holds no portal credentials and submits on nobody's behalf. What it records is that an operator submitted these exact bytes on this day, which is why filing takes the produced record rather than a year: the bytes are the claim.

Filing is therefore its own verb rather than a flag. A flag would put an irreversible act one character away from every rebuild — and it is irreversible for a reason: once a period is filed, later changes are a correction that names its predecessor, never a rewrite of what went out.

  • A second first filing is refused, not silently accepted. Accepting it would swallow the call that meant --correction and forgot to say so, and nothing afterwards could tell which of the two it had been.
  • --correction on a period that was never filed is refused for the mirror reason: it would claim to amend something nobody ever sent.
  • --by is recorded verbatim, defaulting to the shell user. A filing is somebody's act, and a package-invented label would name nobody at exactly the moment somebody has to be named.

Both commands need billing.marketplace.enabled. Without it there are no sellers to report and no period to file.

A stranded cycle needs a person, not a sweep

billing:doctor reports orders that were claimed and never charged. It has always ended that report by telling you to inspect them before deciding, because "a retry is only safe where the provider was never called". billing:release-claim is what carries out that decision once you have looked.

The local engine writes its order before calling the provider, so the claim survives the call and a second run cannot bill the same cycle twice. When a process dies inside that window the order is left processing with no payment behind it, and three things line up: the cycle looks taken so no run reopens it, no payment was created so no webhook can arrive, and the credit the attempt spent is sitting inside a row nobody queries. The subscriber is simply never billed again, and the only symptom is that nothing happens.

Why this is not automatic. The absence of a payment reference is the closest thing to proof that the provider was never called, and it is not proof: a process killed mid-call leaves no reference behind and may still have created a payment. An idempotency key collapses a prompt retry onto that payment, but a claim has to be hours old before it can be told apart from ordinary in-flight work, and by then the key has expired. A sweep would be choosing between acting too early, where it races a live charge, and acting too late, where it takes the money a second time. You can look at the provider, which is the one place that knows.

It charges nothing. It returns the credit the abandoned attempt spent and puts the cycle in the state an ordinary refusal leaves it in. The next scheduled run reprices it and collects it — under the same order, so the charge carries the idempotency key the abandoned attempt used rather than a fresh one.

It refuses everything you have not established: an order that is not a claim in progress, one that reached the provider (that one is held, not stranded — its webhook settles it), and one still inside the six-hour window where a charge is probably still in flight. In production it asks before acting; --force skips that for an operator cleaning up several.