Skip to main content

The marketplace surface

Most of this package is for an app that sells its own product. This section is for the other shape: a platform that collects a fan's payment and routes it to a connected merchant, keeping a commission.

If you are a single seller, you never need to read past this paragraph. With the marketplace switch off — the shipped default — none of what follows exists: no route, no table, no event, no config key you have to set. Your install is byte-for-byte what it would be if this section had never been written. That promise is stated once, here, and every marketplace feature is built to keep it.

The switch, and what makes it real​

billing.marketplace.enabled turns the surface on. But a config flag alone does not make money routable, and that is deliberate. The marketplace path hangs off an optional contract, RoutesMoney, that a driver must implement:

interface RoutesMoney
{
public function marketplaceRails(): MarketplaceRails;
}

A driver that does not implement RoutesMoney has no marketplaceRails() method, so no amount of configuration can produce a rails object to route through. The switch and the capability are two locks, not one: turning the flag on without a driver that routes money refuses to boot rather than silently doing nothing.

The marketplace branch requires the Stripe driver. StripeDriver implements RoutesMoney and answers marketplaceRails(); MollieDriver deliberately does not. The marketplace is offered on Stripe only and no Mollie marketplace is planned, so no rails are built onto Mollie Connect, and claiming the interface would turn a clear refusal at the seam into a failure somewhere further in. The null driver does not route either, by definition.

So turning the flag on with Mollie selected refuses to boot. That is the designed answer to "routing is configured but nothing can route", and it is the right one: the state it prevents is a marketplace that reads as enabled and behaves like a single seller, where every payment settles to the platform and nobody finds out until the money has moved.

A custom driver opts in by implementing the interface, and that path works today.

MarketplaceRails itself is the seam onto the connected-account operations:

interface MarketplaceRails
{
public function onboarding(): MerchantOnboarding;
public function accounts(): MerchantAccountDirectory;
}

Which events the connected-account endpoint has to receive. Stripe sends the events of connected accounts to billing.marketplace.webhook.path, an endpoint of its own beside the platform's. Subscribe it to all events, or at least to every type below. billing:doctor names every type it does not receive, as it does for the platform's endpoint, and fails while one is missing.

EndpointEvent types
billing.marketplace.webhook.path, events from connected accountsaccount.application.deauthorized, account.updated, charge.dispute.closed, charge.dispute.created, customer.subscription.created, customer.subscription.deleted, customer.subscription.updated, payout.failed

The list is StripeMarketplaceWebhookEventMapper::EVENT_TYPES, for a deployment script that creates the endpoint.

And one more type on the platform's own endpoint. A routed subscription's renewals are invoiced on the platform account, and each renewal's commission is set on the net of its draft invoice before Stripe charges it, so the platform's endpoint has to receive invoice.created once the marketplace is enabled (StripeWebhookEventMapper::MARKETPLACE_EVENT_TYPES). billing:doctor names it while it is missing. Without it every renewal keeps the percentage of the invoice total, the buyer's tax included.

Two gates, two questions​

The single-seller path knows one eligibility question: may this owner move money out? The marketplace adds its mirror, CanReceiveMoney, a separate fail-closed gate answering a different question about a different person — may money be routed to this merchant?

interface CanReceiveMoney
{
public function check(Model $merchant): bool;
}

It is fail-closed because the capabilities behind it — a merchant's identity checks, their payout capability — arrive asynchronously from the provider. "We have not heard yet" has to mean no, or a merchant the provider has not cleared would be paid.

Routing a payment​

A routed payment carries a ChargeRouting value: the destination account, the platform fee, and a ChargeType that decides who the provider treats as the merchant of record. That choice is a liability decision, not a technical one — see who carries the liability below — so it is made per payment and never inferred.

Refunds claw back the merchant's share only on a destination charge, where the transfer is part of the payment and the provider can unwind both in one call. On a separate transfer the money moved in its own call, and refunding the payment does not touch it — so the reversal is yours to issue, and RefundResult::$reversedTransferReference comes back null to say that nothing was reversed here.

Read that as the instruction it is. A refund that returns the buyer's money without clawing back the merchant's leaves the platform paying out of its own pocket, and a lost chargeback is not a call the package can decline. Note also that reversing the proportional share is the wrong figure whenever your fee has a fixed component: a 100.00 sale at 10% plus 1.00 flat pays out 89.00, and half of it refunded leaves a 50.00 sale that would have paid out 44.00 — so 45.00 comes back, not 44.50. ClawbackCalculator computes the figure that is owed; both numbers look reasonable, which is why the calculator exists.

A reversal reports its amount, and null there means "not reported" rather than zero. The reference answers whether a reversal happened; RefundResult::$transferReversed answers for how much, read off the reversal the provider made rather than worked back from the refund total — which is the same trap as the paragraph above, since a proportional reconstruction is short on any fee with a fixed part. RefundResult::$applicationFeeRefunded is its own dimension because the two amounts move between different pairs of parties: the reversal takes money back from the merchant, the fee refund gives up your own margin, and a single netted figure cannot say which side gave. The shipped rails leave the fee amount null — the provider reports it only as a cumulative total across every refund of that charge, which stops being this refund's share the moment a second partial refund exists.

The fee comes back in the figure the ledger books. Stripe, asked to give the platform's fee back with a refund, gives back a share of the fee as it was first withheld: 5.15 of a 10.30 fee on a half refund of 100.00 at 10% plus 0.30, where the ledger books 5.00 because the fixed part is not halved. So a refund through BillingAdmin on a destination charge goes out without the fee, and the RefundPlatformFee job gives back the amount the ledger booked afterwards, under the refund's key, and retries on its own until it is through. Under billing.marketplace.fee.refund_policy = retain nothing of the fee comes back, and the merchant carries the whole refund.

A reversal that comes back short is announced, and charging it is your call. When the provider reports less than the reversal asked for, MerchantReversalCameBackShort carries the difference, once per refund attempt. It is not dispatched where the provider was not asked to reverse, as on a separate transfer. The package books no debt for it: whether the merchant owes the difference is a matter of your terms with them. Where your terms say it does, charge it to the merchant's balance, and let your payout run pay it down:

use Pushery\Billing\Events\MerchantReversalCameBackShort;
use Pushery\Billing\Marketplace\MerchantSubLedger;

Event::listen(function (MerchantReversalCameBackShort $event): void {
app(MerchantSubLedger::class)->chargeShortfall($event->merchant, $event->shortfall);
});

// In your payout run, before paying a merchant:
[$payable, $balance] = app(MerchantSubLedger::class)->applySettlement($merchant, $settlementGross);

With billing.marketplace.negative_balance.offset_against_payouts off, applySettlement() passes the settlement whole and the debt stands as a claim, which claimable() lists once it is older than claim_after_days.

Charge through RoutedPayment, not through the rails​

The rails can route a payment. They do not write down that one was routed, and nothing downstream can recover that fact afterwards. RoutedPayment is the path that does both as one operation, and it is the one to use:

$result = app(RoutedPayment::class)->charge(
merchant: $creator, // who the money is destined for -- the recipient, never the payer
buyerOwner: $fan, // who pays, and who the receipt belongs to
gross: Money::of(10_000, 'EUR'),
fee: new PlatformFee(bps: 1_000, flatMinor: 100),
taxBps: 1_900, // the BUYER's rate -- the commission is taken on the net, not the total
buyerIsDomestic: true, // whether your own country's small-value rules apply to this buyer
token: $paymentMethodToken,
routing: $routing,
archetype: TaxArchetype::Download, // what is being sold; there is no sale without one
idempotencyKey: "order_{$order->id}",
);

Where $routing comes from​

Assemble it with ChargeRoutingResolver rather than by hand:

$routing = app(ChargeRoutingResolver::class)->resolveFor(
merchant: $creator,
gross: Money::of(10_000, 'EUR'),
taxBps: 1_900,
suppliesAreElectronic: true, // what is being sold, which is what the seller posture turns on
);

ChargeRouting's constructor is public, so building one by hand compiles — and gets two things wrong quietly. The resolver takes the commission on the net, which is what a configured rate has always meant; on the gross it would include the buyer's tax, and that figure is what the provider receives as the application fee, so the difference is money the merchant is not paid. And it checks the charge type against the resolved seller posture before anything is assembled, so an incompatible pairing is refused before a charge is made rather than after, when only the transfer is left to fail.

It resolves the posture rather than accepting one, which is the point: a caller cannot hand in the posture that would make its own pairing legal.

taxBps and archetype are both required and neither has a default, which is deliberate. A defaulted rate would take the commission on the buyer's total for every caller that had not been updated, and that is money. A defaulted archetype would make "unclassified" the quiet normal case within a release or two.

buyerOwner and buyerIsDomestic are required for the same kind of reason. This method takes the money and issues the buyer's receipt, and it cannot do the second without knowing who the buyer is and which document tier they earn. Made optional, the receipt would go on being skipped for every call site not yet updated — silently, and for exactly the sales that already work. buyerIsDomestic is supplied rather than derived because the package has no second opinion about where your buyer is; the subscription lane already takes it the same way.

Why this is not a bookkeeping nicety. Three things are computed from the row it writes:

  • the cap a later reversal is allowed to claw back,
  • the merchant's earnings total,
  • the small-business threshold verdict that decides whether a creator charges tax at all.

A payment that skipped the row is not merely unlogged. It is invisible to every rule that money is supposed to obey afterwards — and the readers do not fail, they answer zero, which is a number that looks like an answer. One caller forgetting once is a merchant who can be refunded past what they were paid.

It is also where the receiving gate runs. Before a single provider call, RoutedPayment asks CanReceiveMoney whether this merchant may be paid at all, and refuses with ReceiveEligibilityDenied if not. The shipped default admits everybody, so this changes nothing until you compose a gate — but once you have, this is the path that asks it.

The ordering is the point. A merchant who cannot receive does not produce a clean rejection: the money settles wherever the provider can reach, usually the platform, while the row says a merchant was paid. Nothing errors, the two records disagree, and somebody finds out while reconciling.

Composing the gate​

The package ships the shape of both gates and none of the policy, because only you know who you are willing to pay and who is old enough to buy. Two classes are the shape:

use Pushery\Billing\Contracts\CanReceiveMoney;
use Pushery\Billing\Eligibility\ComposedReceiveGate;
use Pushery\Billing\Eligibility\ProviderCapabilityCheck;

$this->app->bind(CanReceiveMoney::class, fn ($app) => new ComposedReceiveGate(
$app->make(ProviderCapabilityCheck::class), // the provider confirmed charges, payouts and details
new YourOwnSanctionsCheck, // whatever else must be true before money moves
));

ComposedReceiveGate is fail-closed: with no checks registered it admits nobody. That is deliberate and it is the direction a new marketplace wants — the alternative is a gate that is most permissive on the day nobody has been verified yet. ProviderCapabilityCheck is the one check the package can supply itself, and it never throws: an absent or stale account is a "no", not an error page for a merchant who simply has not finished onboarding.

ComposedEligibilityGate is the same shape on the buying side, bound to CanTransactMoney, for the age and identity rules a jurisdiction puts in front of a purchase. The package binds AlwaysEligible by default, because eligibility is entirely yours.

billing:marketplace:preflight asks whether you did this. With the marketplace switched on and CanReceiveMoney still resolving to the shipped AlwaysReceivable, the configuration.receiving_gate point fails: every merchant is admitted, and the first symptom would be at a buyer's checkout, because a payment provider refuses a transfer to an account that cannot receive.

The point is waivable — the package cannot judge a gate it did not write, and refusing to be waived would block every legitimate custom implementation. Waiving it demotes the line to a warning that still says the point is not satisfied; it does not make it disappear. On a single-seller install the point passes, because nothing routes money to a merchant and there is nothing to gate.

Per-merchant catalogs​

The shipped MerchantCatalog resolves every scope to the platform's own tiers, which is what a single-seller install wants. A marketplace where each creator sets their own prices binds DatabaseMerchantCatalog instead:

use Pushery\Billing\Contracts\MerchantCatalog;
use Pushery\Billing\Marketplace\DatabaseMerchantCatalog;

$this->app->bind(MerchantCatalog::class, DatabaseMerchantCatalog::class);

It builds a fresh catalog per scope, so two merchants never share one, and a null scope resolves to the platform's own — a marketplace still has a platform.

The platform's own tiers come from billing.tiers first. A sale with no merchant is priced from the bound tier and plan catalogs, which read billing.tiers, so the platform scope answers from the same catalogs: the webhook resolves the price the checkout charged to its tier, a swap finds the platform tier's price, and the revenue report counts the subscription. Platform rows in your repository still answer for every key the configuration does not declare, ranked after the configured tiers. A key both declare is priced from the configuration, because that is the price the checkout charges. A merchant's scope reads that merchant's rows only, and a merchant's price never resolves in the platform scope.

The provider price behind a creator's tier lives on the platform account, under every seller posture. Both charge types this package offers are platform charges, so every hosted checkout opens its session on the platform account — and a session can only use a price that account knows. Stripe says as much for a destination charge: the price has to be defined on the platform. The posture decides who the buyer transacts with and which documents follow; it does not move a Stripe object to another account. The one exception is an application that runs direct charges on the creator's account itself: it binds StripeMerchantPriceProvisioner to MerchantPriceProvisioner, and its own checkout has to run on that account too.

BillingMetricsReporter prices each subscription from the catalog of the seller it belongs to. So compute(merchant: MerchantScope::forMerchant($creator)) reports a creator's MRR from their own tiers, and compute() without a scope adds every creator's tiers to the platform's.

PaymentRails::charge() stays available and unchanged; a consumer that wants the raw call still has it. Know what it does not do, and it is now five things: it writes no row, it asks no receiving gate, it asks no tax-standing gate, it checks no charge-type/posture pairing, and a payment that clears later never settles the row it did not write.

So RoutedPayment is the recommended path and not an enforced one, and that is worth saying plainly rather than leaving to be discovered. Nothing inside this package calls charge() or offSessionCharge() — the payment verbs are yours to call, which is the right shape (only your application knows when a sale happens) and it means the package cannot make the safe path the only path. An application that reaches for the rails directly gets a payment that moves money and leaves no trace any downstream rule can read.

If you have your own reason to use the rails — a flow this package does not model — then the five things above become yours: record the routed charge, ask all three gates before the provider, and settle the row when the payment finally clears.

The pairing gate is the least obvious of the three, and the most expensive to skip. A charge type has to agree with the seller-of-record posture; the combination decides who the provider treats as merchant of record, and therefore who carries a chargeback. An incompatible pairing does not fail — the provider accepts it. Nothing surfaces until a dispute lands on the wrong party, once, months later.

Per-merchant coupon codes​

A coupon row carries the merchant who issued it, and its code resolves on that merchant's sales only. Two creators can each run a SUMMER25: a fan who enters one creator's code on another creator's checkout gets no discount, and CouponRedeemer refuses to spend it there as well.

use Pushery\Billing\Models\Coupon;
use Pushery\Billing\ValueObjects\MerchantScope;

Coupon::query()->create([
'code' => 'SUMMER25',
'merchant_uid' => MerchantScope::forMerchant($creator)->uid(),
'type' => 'percent',
'value' => 25,
'duration' => 'once',
'provider_coupon_id' => $stripeCouponId, // what the hosted checkout applies
]);

The provider coupon behind a code is minted on the platform account for the same reason as the tier price above: the session that applies it runs there.

The hosted checkout asks about the sale it is about to open, which is the sale to the merchant it routes to. A code in billing.coupons names no issuer and applies on every sale, a routed one included, so a discount only one creator should give belongs in a row that creator issued. Where a live row and a config entry share a code for the same seller, the row answers.

Canceling a prepaid term: PrepaidTermCancellation​

A year paid up front and canceled after four months owes eight of them back. PrepaidTermCancellation is the entry point for that: it works out how much is owed and refunds it, through BillingAdmin::refund() as RefundKind::UnusedPrepaidPeriod. That is the package's one refund verb, so the money moves on the same rails, under the same audit record and with the same routing as every other refund, and on a routed sale the same call corrects both links of the chain once the provider confirms.

$result = app(PrepaidTermCancellation::class)->cancel(
owner: $buyer, // who the term was sold to, whom the refund names
charge: $merchantCharge,
term: Money::fromDecimal('119.00', 'EUR'),
periodsUsed: 4,
periodsInTerm: 12,
reason: 'Canceled after four months',
);

$result?->successful; // what the provider answered; null when nothing was owed

Do not refund the unused part yourself as well. The cancellation is the refund. Refunding through BillingAdmin::refund() and then calling cancel() moves the money twice.

Each correcting document is booked on the day the refund happened, never back-dated into the original's period. The original stays exactly as issued and the correction stands beside it: back-dating would reopen a period that has usually been declared already, and would leave two documents describing one month differently. A refund the provider refuses moves nothing and corrects nothing.

You supply the term and the periods, and that is deliberate. The package does not store that a subscription was sold as a twelve-month prepaid term with four of them used. The obvious substitute, deriving it from a start date and today, is wrong the moment a cycle was shifted, paused or swapped, and wrong silently. You know what you sold; this refuses to guess.

It is a service you call, not something that fires on a cancellation. A refund is a money movement, not a side effect of a status change: cancellations arrive from a webhook, an admin action and your own UI, and many owe nothing back, since a cancellation at period end simply runs out. Canceling at the very end of a term returns null and moves nothing.

The cent is decided on purpose: 119.00 × 8/12 is 79.3333…, and the indivisible unit stays with the portion that was kept, so 79.33 goes back.

A term is canceled once. The charge is stamped as canceled before the provider is asked, and a second call for the same charge throws CancellationUnavailable without moving anything. Two calls arriving together, from a double click or a retried job, wait for each other on the charge's row, so the second one finds the stamp. A refund the provider refuses takes the stamp back, so the cancellation can be asked for again.

Tips and pay-what-you-want: FanPayment​

A tip is not a donation and it is not a side path. It is consideration for the creator's supply — the fan sought the channel out — so it carries the same regime, the same commission and the same document chain as any other sale. What makes it different is only that the buyer picks the amount, and picks it gross: the fan chooses what they will pay, and the net is whatever is left once tax comes out.

FanPayment is the entry point for that shape. RoutedPayment::charge() is still where the money moves; this is what works out the rate first.

$result = app(FanPayment::class)->tip(
merchant: $creator,
buyerOwner: $fan, // who pays, and who the receipt belongs to
chosen: Money::of(1_190, 'EUR'), // what the fan chose to give, tax included
normalFee: new PlatformFee(bps: 1_000),
buyer: new TaxContext(countryCode: 'DE'),
buyerIsDomestic: true,
token: $paymentMethodToken,
routing: $routing,
soldAlongside: TaxArchetype::Download, // what the tip was paid ON -- required
);

buyerOwner and buyer are two different things, and both are needed. buyer is a TaxContext — where the fan is, for the purpose of taxing the supply. buyerOwner is the model the receipt belongs to. The tax context cannot stand in for it: a country code is not a person a document can be addressed to. The rate band is not the context's to give either: it follows from what was sold, so a band on buyer does not decide, and hasAudioVisualComponent is the one fact about the product the context still brings.

A tip has no tax treatment of its own. Its regime, its place of supply, its rate band and whether it is reportable all come from the thing it was paid alongside. A tip on commissioned work is taxed where the seller is; a tip on a download where the buyer is. The same 11.90 therefore belongs in two different countries' returns, and nothing about the tip itself could tell them apart — which is why soldAlongside is asked for and why omitting it raises ProductNotClassified rather than picking a default.

A tip on nothing in particular names TaxArchetype::Support. A tip given on a profile, or on something offered for free, accompanies no sale. It is consideration for the creator's work as a whole: supplied electronically, taxed at the standard rate where the buyer is, not reportable, and with no right to withdraw, since nothing is left to provide once it is paid. Passing a product's archetype in its place would put a sale on the receipt and in the report that never happened.

What the fan chose is what the parts add up to. 11.90 at 19% is a net of 10.00 and tax of 1.90; the tax is taken as the remainder rather than computed a second time, so the receipt always sums back to the amount the fan agreed to pay. Where a total has no exact whole-cent inverse the split still holds the total, and the rate is checked against it — see GrossPriceNotSplittable in troubleshooting.

Nothing chosen is not a sale. tip() returns null for a zero amount without touching the provider, which is the ordinary outcome when a fan leaves the box empty. A zero would otherwise be a provider call, a charge row an earnings counter reads, a document stating nothing, and a line in a tax return — all describing a sale nobody made.

payWhatYouWant() is the same shape for a product whose price the buyer sets. It takes the product's own archetype rather than a reference, and it enforces billing.marketplace.pwyw.minimum_minor on the server — a price the buyer picks is the one place this package's stance against price injection would otherwise lapse, so a floor checked in the browser is not a floor. Below it, FanPriceTooLow is raised before the provider is reached.

Tipping is off until billing.marketplace.tips.enabled is true, and billing.marketplace.tips.commission_bps overrides the ordinary commission rate for tips only; left null, tips carry the same rate as everything else.

Subscriptions: one cycle, one document​

A billing period is a part-supply — separately agreed, separately settled — so each cycle gets its own document stating the stretch it covers. That is not bookkeeping tidiness; two things follow from it that a reader can check.

$schedule = SubscriptionPeriodSchedule::monthly(
term: Money::of(11_900, 'EUR'), // the whole term
start: CarbonImmutable::parse('2026-01-01'),
periods: 12,
);

$documents = app(SubscriptionCycleBilling::class)->issueSchedule(
buyer: $fan,
schedule: $schedule,
taxRateBps: 1_900,
isDomestic: true,
);

The receipt tier follows each period's own gross, never the contract's. A monthly subscription stays far under the small-value threshold and may be issued as a simplified receipt carrying no buyer identity — which is the anonymity a fan is usually promised. Billed as one annual document, the same contract crosses that threshold and the promise is gone. SubscriptionCycleBilling therefore does not accept a tier from you; it asks each period.

The periods sum back to the term exactly, and they touch. 119.00 over twelve is 9.9166…, a figure no currency has. Rounding each period on its own gives twelve times 9.92 — four cents nobody was charged, on a document set that reconciles with itself everywhere except the total. And an end stated as the next period's start makes every receipt internally right while any two of them claim the same day. SubscriptionPeriodSchedule::isContiguous() is there to be asserted, including against a schedule you built some other way.

A term that begins on a 31st keeps its 31st. Every boundary is measured from the start date you pass, not accumulated from the period before, so a term beginning 31 January runs 31 January to 27 February, then 28 February to 30 March, then 31 March — the anchor day comes back the moment a month has one, instead of walking backwards a day at a time from the first short month. Consecutive periods still touch exactly, and twelve periods from a 31st still start in twelve different months.

Billing a cycle twice returns the first document. Payment events are redelivered and runs get retried; the period's own key is what makes the repeat recognizable, so a half-finished schedule resumes without duplicating what it already wrote.

A term paid up front is one document, not twelve. Where your jurisdiction taxes on receipt, the tax arises when the money does — for the whole term, including the months not yet supplied. Twelve monthly documents would spread one liability across a year it does not belong to, and every one of them would look ordinary:

app(SubscriptionCycleBilling::class)->issuePrepaid(
buyer: $fan,
term: new ServicePeriod($start, $end, Money::of(11_900, 'EUR')),
taxRateBps: 1_900,
isDomestic: true,
paidOn: $paymentDate, // the month the tax belongs to
);

That single document is measured against the term's gross, so a large enough term crosses the small-value threshold and must name its buyer. This is the same rule as above read the other way round, and it is not something to route around: a small-value receipt for an amount above the limit would be a document making a claim it is not entitled to make. Which of the two shapes applies is yours to choose, because whether a term was prepaid is a fact about the payment — the package is not told about payments.

A one-off purchase covers no stretch of time, states none, and is completely unaffected — the EN 16931 period terms (BG-14, BT-73, BT-74) appear only when a period was actually supplied.

The shell banner reads the merchants' subscriptions too​

<x-billing::banner /> reads every subscription the owner holds, a merchant's as well as the platform's own, so a failed payment on a creator's subscription reaches the owner before the dunning ladder withdraws the access it pays for. At equal rank the platform's subscription wins. A notice about a merchant's subscription names the merchant by the name your buyers know them by, through MerchantDisplayName. The banner never asks your MerchantPartyResolver, because on a creator marketplace that answers with the creator's legal name, which the creator gave you for the documents and not to their fans. The shipped binding names nobody, and the notice then shows without a name:

use Illuminate\Database\Eloquent\Model;
use Pushery\Billing\Contracts\MerchantDisplayName;

$this->app->bind(MerchantDisplayName::class, fn () => new class implements MerchantDisplayName
{
public function nameFor(Model $merchant): ?string
{
return $merchant->display_name;
}
});

A failed or unconfirmed payment links to the payment-recovery screen whichever subscription it is on. That screen reads every subscription the owner holds as well. Under Stripe, the card its hosted page collects becomes the default for the customer and for each subscription that names a card of its own, and a merchant's subscription lives on the platform's customer on both routing lanes, so the same card repairs it. A merchant's grace period, pause or ending trial has no link, because the subscription and plan screens act on the platform's own subscription alone.

A payment that clears later settles later​

Most routed payments settle in the same breath they are made: on a destination charge the transfer exists the moment the charge does. Two do not — a card that demands 3-D Secure, and a bank debit that clears in days. Both return successfully and immediately having moved no money, so the merchant's row is written pending rather than pretending otherwise.

The row leaves that state when the provider says what became of the payment. payment_intent.succeeded dispatches RoutedChargeConfirmed and the shipped effect settles the matching row; payment_failed and canceled dispatch RoutedChargeAbandoned and fail it. Only ever from pending: a charge that settled and later goes wrong is a refund or a dispute, never a failure, and calling it one would erase the fact that the money was, for a while, really there.

This matters more than a status column suggests. The three readers above count settled rows only — so until the confirmation arrives, a merchant paid by bank debit reads as having earned nothing. That includes the small-business turnover threshold, where reading zero is not a missing figure but a wrong one.

You need the platform webhook delivering for this, which you already do for dunning. An event naming a payment this package did not route matches nothing and does nothing, so nothing else changes.

On a separate transfer this is also what pays the merchant. A destination charge carries the transfer inside the payment, so the provider moves the share. A separate transfer does not: the platform takes the whole payment, and the merchant's share moves in a second call, made here once the payment has actually succeeded. The transfer names the charge that funds it (source_transaction), so it waits for that specific payment to settle rather than drawing on the platform's balance — which would fail whenever the balance is short, succeed out of somebody else's payment whenever it is not, and either way lose the link that reconciliation and reversal both need.

If your driver cannot move a share at all, a separate-transfer sale throws before the buyer is charged, rather than charging them and then discovering there is no way to pay the merchant.

The commission terms are frozen onto the row, not read back from configuration. A platform that raises its rate would otherwise claw old sales back at the new one, and both figures would look entirely plausible.

Watching the dispute rate: DisputeRates​

The card networks judge a seller by the share of its payments that are disputed, and past a line a seller pays for every case and can lose card acceptance. DisputeRates reads that share for one seller or for the whole platform over a window you choose:

use Carbon\CarbonImmutable;
use Pushery\Billing\Marketplace\DisputeRates;

$rates = app(DisputeRates::class);
$from = CarbonImmutable::parse('2026-09-01');

$rate = $rates->forMerchant($creator, $from, $from->addMonth());

$rate->disputes; // disputes opened in September, whatever their outcome
$rate->payments; // routed payments that settled in September
$rate->ratio(); // null where there was no payment
$rates->reachesWarningThreshold($rate); // true from billing.marketplace.dispute_rate.warn_at on

Read a calendar month to compare with the networks, which measure by the month. The rate counts payments rather than money, so one disputed payment in a hundred is the same rate whatever the prices were.

The warning line is a share of payments, and it defaults to 0.01, one dispute per hundred payments. That is Mastercard's line for monitoring a seller. Both networks call a seller excessive from 1.5%: Mastercard with at least 100 chargebacks, in each of two consecutive months, and Visa with at least 1,500 disputes and fraud reports together in the month, in the US, Canada, the EU and Asia-Pacific since 1 April 2026. The package counts disputes and no fraud reports, so the rate it reads is lower than the one Visa measures, and the default leaves room for that. Set billing.marketplace.dispute_rate.warn_at to draw the line elsewhere.

What "reached" means is decided once, in DisputeRate::reaches(). The line itself counts as reached. A window with disputes and no payment has reached every line, because nothing in it dilutes the disputes; a window with neither has reached none. The networks' minimum counts are left out on purpose: they keep a small seller out of the programs, and a platform still wants to hear about a small seller whose buyers dispute every tenth sale.

A threshold that is not a share above zero and at most one is refused with an exception rather than never reached. Written as a percentage, 1.5 would ask for three disputes for every two payments.

The package raises no warning of its own. Whether a seller who reaches the line gets a message, a review or a pause is your decision, and the rate is the figure to make it on.

Recording what a creator says about their own taxation​

A creator's tax standing decides whether they charge tax at all, whether a payout may be made net, and what a settlement document is allowed to say. The package cannot work it out: it only ever sees what was sold here, and the threshold that matters is about everything the creator sold anywhere.

So the platform asks, and the package writes it down:

app(CreatorSelfDeclaration::class)->declare(
merchant: $creator,
status: CreatorTaxStatus::DomesticSmallBusiness,
businessFoundedYear: 2024,
evidenceRef: 'tax-declaration-v3:2026-07-27T09:12:04Z',
);

Four things about that call are load-bearing.

It expires, and that is the feature. The commonest standing in this area is a statement about a year that has not finished yet, so a declaration made in March says nothing about the following January. Every declaration is therefore stamped with an expiry at the next year boundary plus billing.tax_small_business.reattestation.grace_days. The grace is added to the boundary rather than replacing it: the obligation arrives on the first day of the year, and the grace is how long somebody has to answer it — not a license to treat last year's answer as this year's. billing:tax-holds:remind tells a creator when the renewal falls due and again reattestation.remind_days before the declaration runs out, with the date the hold begins; billing:tax-holds:announce tells the merchants whose declarations have run out.

The founding year is collected, never derived. When a business started and when it signed up here are different facts that differ routinely, and the threshold reads them as different regimes rather than as a blurred number — so a derived year would be wrong invisibly. It is validated on the way in: a future year and anything before 1800 are both refused rather than rounded off. A private individual, who is not in business at all, declares without one: businessFoundedYear is null for CreatorTaxStatus::PrivateIndividual and required for every other standing, and a year given for a private individual is refused just like a business standing without its year.

evidenceRef is not a label. It is what was accepted and when — the wording, its version, the moment. Without it, a declaration is an assertion nobody can go back to, which is exactly the thing a tax authority asks about years later.

Nothing overwrites anything. A standing is a series of dated intervals, so a document issued in January is still explained by what was true in January after the creator's standing changes in March. Ask CreatorTaxStatusResolver for the standing on a date, never for the current one.

The turnover a small-business limit is measured against follows whom the creator supplied. Under the commission chain (platform_deemed_supplier) the creator supplies the platform, and what that supply was worth is the payout. As a seller of record or through a disclosed intermediary the creator supplies the buyer, and their turnover is what the buyer paid less their own tax, with the platform's commission left in: a commission pays for a service the platform performed for the creator, and a sum a third party keeps back for its own service stays in the seller's taxable amount. On one 119.00 sale at 19% with a 10% commission that is 90.00 in the first case and 100.00 in the other two, and a refund comes off on the same basis.

Each sale records the posture it was made under, so changing the posture later does not recount old sales. A sale recorded before the posture was stored is counted under the posture your configuration names today.

A creator nobody has declared for is Unclarified, which is a real state and not a missing value. It is the one standing that blocks selling, and it is what a merchant stays until somebody records an answer. That is deliberate: a friendlier default would put a tax treatment on a document that nothing supports.

A standing corrected into the past issues the settlements again​

A standing is often recorded with a start date in the past: a creator reports in April that they stopped being a small business in January, or a registry confirms a standing that was pending. The settlements issued since then say something the corrected standing does not support, and a self-billed document binds the party it names whether it is right or not.

So a change like that queues every settlement of the creator from its start date on, and billing:settlements:restate (scheduled daily) issues them again. It cancels each one and issues a replacement under the standing now in force at the supply date. Both documents are dated the day they are written: the cancellation names the settlement it takes back, and the replacement carries the original supply date as its delivery date and states the payout and tax the corrected standing gives it. A settlement that already says what the corrected standing requires is left alone, and running the command again changes nothing.

Receipts on the buyer's side are never touched, even the ones a creator holds as a buyer. The creator's standing is a fact about the creator's own supply to the platform, and a receipt states the platform's supply at its full tax.

The cancellation and the replacement are written together or not at all. When a replacement cannot be issued, the settlement stays as it is and the order records why:

reasonwhat it means
collective_settlementthe settlement covers a whole month, and one document carries one treatment; correct it by hand
standing_unclarifiedthe standing now in force is unknown, and an unknown standing issues no document
supply_rate_unknownthe new standing states tax, and the settlement names no rate: it was issued without tax before settlements recorded the rate of their supply
no_self_billing_agreementthe replacement would be a self-billed invoice, and no agreement covers the supply date
tax_not_disclosablethe replacement would state tax the standing does not permit a document to show
product_not_classifiedthe replacement would be reverse-charged, and the settlement never recorded what was sold
original_unresolvablethe settlement or its creator can no longer be found

Blocked orders are tried again on every run, so an agreement recorded later or another correction of the standing unblocks them. The orders are the rows of billing_settlement_restatements.

The package moves no money here. A replacement usually pays a different amount from the settlement it replaces: a creator who turns out to be standard-rated is owed the tax on top of the payout, and one who turns out to be a small business was paid tax they must not charge. Listen for SettlementRestated and settle the difference in your payout process. difference() is positive when the creator is owed more.

Asking the register about an EU small-business exemption​

A creator who uses the union's cross-border small-business scheme has an exemption number ending in -EX, and the union keeps a register, SME-on-the-web, that says whether the exemption holds in a given member state. SmallBusinessRegistrationCheck asks it and records the answer as a standing, with CreatorTaxStatusSource::RegistryCheck as its source:

app(SmallBusinessRegistrationCheck::class)->check(
merchant: $creator,
registrationId: 'PL1111111111-EX',
checkReference: 'onboarding-42',
memberState: 'DE',
);

Nothing is asked until you bind the register. By default the package contacts no external service, the check answers Unavailable, and the creator stays Unclarified. Bind the register in a service provider of yours:

use Pushery\Billing\Contracts\SmallBusinessExemptionValidator;
use Pushery\Billing\Tax\SmeOnTheWebExemptionValidator;

$this->app->bind(SmallBusinessExemptionValidator::class, SmeOnTheWebExemptionValidator::class);

The member state is where the supply is taxed, never where the creator is established. The register answers for one member state at a time, and it refuses a question about the member state of establishment, where the scheme does not apply. A refused question is thrown as an InvalidArgumentException in the register's words, because it says nothing about the creator. Greece is EL to the register, and GR is sent as EL.

Only a confirmation reaches the exempt standing. The register confirms an exemption when the number is in force and the exemption is granted in the member state you asked about. That becomes CreatorTaxStatus::UnionSmallBusinessExempt for billing.tax_small_business.eu_revalidate_after_days. A number the register rejects and a register that cannot be reached both leave the creator Unclarified, and the record says which of the two it was and which member state was asked about.

A check without a member state asks SmallBusinessIdValidator instead, as it did before. An implementation of yours bound there also answers the member-state question until you bind the register, because the default of SmallBusinessExemptionValidator asks it and leaves the member state out.

The sales lock, and the date it starts on​

From the day you set it, a sale on behalf of a merchant whose taxation nobody has established is refused with TaxStandingUnestablished, before the provider is reached and beside the receiving gate. Every lane that sells for a merchant asks it: RoutedPayment, the hosted subscription checkout, the hosted one-off purchase and the hosted tip. A hosted session is refused before it is opened, so the buyer never reaches a payment page.

It comes with a date because its default refuses everybody. A merchant nobody has declared for is Unclarified, and Unclarified is exactly the standing that blocks. So switching this on with today's date stops every creator who has not yet declared, all at once, for something they were never asked for.

// config/billing.php
'marketplace' => [
'tax_status_hold' => [
'blocks_sales' => true, // WHAT is held
'blocks_payouts' => true,
'enforce_from' => '2026-10-01', // FROM WHEN — null until you choose a day
],
],

Pick a date far enough out to collect declarations, tell the merchants who are missing one, and let the date arrive. Before it, nothing is refused; from it, a merchant without a standing cannot be sold for until somebody records one.

billing:marketplace:preflight reports an unset date as outstanding, not as configured. That is the point of the separation: two switches reading true with no date looks like two active enforcements and is none, and an operator reading the config file has every reason to believe otherwise. Waive the point if your merchants have no standing to establish — a single-brand marketplace has nothing to collect.

A date nobody can read is refused outright, naming the key. Both alternatives are worse than stopping: reading it as "now" would refuse every routed sale on a typo, and reading it as unset would switch a tax control off silently.

A jurisdiction profile that requires the hold overrides all three settings. There it is a legal condition rather than a rollout, and a date cannot postpone it.

The payout half is not wired, because this package has no payout path yet. blocksPayout() answers correctly and nothing asks it. That is stated here rather than left to be discovered — holding sales while paying out regardless is not the safe half of the pair.

When a seller does not supply their data​

A reporting duty needs data from the seller, and some sellers do not supply it. billing:seller-data:escalate, scheduled daily with the marketplace on, walks every seller with a merchant account and moves each one whose record is incomplete one step along a fixed order: a first reminder, a second one, then a measure. Each step is a stored state on billing_seller_data_escalations, one row per episode, so what the platform did and when is on the record rather than worked out from dates later.

A reminder goes to every seller whose record lacks something the profile collects. A measure follows only where a missing field is required of that seller now, which under the German profile means the seller falls under the reporting duty this year. A seller the duty does not cover is asked twice and never held.

StepDueWhat happens
First reminder7 days after the record was found incompleteSellerDataReminderNotification, naming the missing details
Second reminder30 days, and at least 23 after the firstthe same notice, with the date the measure begins where one can follow
Measure60 days, and at least 30 after the secondwithhold_payout or suspend_sales, from billing.marketplace.seller_data_escalation.measure

A reminder that went out late moves the next step back by the same amount, so a seller always gets the time a reminder promised. When the record is complete again, the measure ends on the next run and the episode closes. A measure also ends when its basis goes away, for example in a new year in which the seller has not yet sold anything reportable.

A withholding holds the money, and nothing is forfeited. A withheld share stays pending on its sale, with transfer_withheld_at and the reason, and moves on the run after the reason ends, together with every share that waited. No withholding outlasts the money rail: money older than billing.marketplace.seller_data_escalation.payout_deadline_days moves whatever held it. When a withholding has run as long as it may, on_withholding_exhausted decides what follows: suspend_sales, the default, turns it into a suspension, and release ends it with the money. On a destination charge the provider moves the share with the payment, so a configured withholding applies as a suspension there.

A suspension holds until the measure ends. A provider report that the account's capabilities are healthy again lifts only a suspension a report made. One made by the measure stays, and so does one an operator made with MerchantLifecycle::suspend(). Where the account was already suspended over a withheld capability when the measure began, the healthy report passes that suspension to the measure, and the measure lifts it when the record is complete.

The same gate carries the tax-standing hold. A creator whose standing is unestablished while the hold is in force is not paid out either, on a share released from buyer protection or moved by billing:marketplace:retry-transfers. The two reasons are independent, and ending one does not lift the other.

The escalation needs two things from your application. Bind SuppliesSellerRecords, because the record lives with you; without it the command says so and assesses nobody. And name the route of the screen where a seller completes their record in billing.marketplace.seller_record_route, so the notice carries a button to it. A suspension made here carries seller_data_outstanding as its reason and is lifted when the measure ends; a suspension made for any other reason is left alone. SellerDataReminderDue and SellerDataMeasureChanged carry each step for a host that routes its notices its own way.

Whether a platform escalates at all is the reporting profile's call. A profile that does not implement EscalatesMissingSellerData gets no reminders, no measures and no rows.

When a seller of goods trades regularly​

Selling regularly makes somebody a business whether or not they earn anything by it, and a good deal follows from that: the buyer's rights, the documents of a sale. So a seller who sells goods through the platform as an intermediary is asked to declare their standing once they reach an activity threshold, and their next sale of goods waits for the answer.

The threshold is reached on either figure, counted over the calendar year: 30 sales, or EUR 2,000.00 of proceeds, both configurable under billing.marketplace.seller_activity, together with how each is compared and the window. SellerActivityCounter reads the recorded sales rather than a running total, so the count can be made again for any window, and it names the sale that reached the threshold. Goods only, sold under intermediation, in the platform's currency; a sale whose payment failed does not count, and a later refund does not move the proceeds.

After that sale, RoutedPayment and the hosted one-time checkout refuse the next sale of goods with TradingStandingUndeclared until the seller declares, through your declaration flow and CreatorSelfDeclaration. A business standing answers the question whenever it was declared; a private one answers it only if it was declared after the threshold was reached.

The same two paths refuse a sale of goods by a private individual whose record lacks what the profile requires of them, with SellerRecordIncomplete. The German profile asks for name, address and date of birth, from the same catalog and the same SuppliesSellerRecords as the reporting record, so the seller fills in one form. Without a bound record source there is nothing to read, and nothing is refused on this ground.

What a buyer has to be told at the checkout comes from SellerStandingDisclosure::for($seller): whether the seller sells as a business, whether the buyer has consumer rights against them, and whether the seller issues an invoice. All three come from one answer of the bound DescribesSellerStanding, so a seller who declares a new standing changes all three on their next sale. The reporting verdict does not move with the standing: the reporting exemption asks about sales and compensation, not about who the seller is.

Assembling a reporting period: SellerReportingPeriod​

A reporting duty asks about a whole period, seller by seller. SellerReportingPeriod is the seam for that: it names every seller who sold in the period, splits each one's year into lines, and reads the three per-quarter figures from the counters that own them. A seller sells in one of two ways: under a commission chain, where a settlement document records the sale, or through the platform acting as an intermediary, where no settlement document exists and the sale itself is the record. Both are read.

$reports = app(SellerReportingPeriod::class)->reportsFor(2026, 'EUR');

foreach ($reports as $report) {
$report->seller; // your own model
$report->reportable(); // any line obliging a report -- throws on an unclassified one
$report->lines; // one per kind of thing sold, unclassified last

foreach ($report->quarters as $q) { // always all four, keyed 1..4
$q->grossInflow; // what reached the seller
$q->transactions; // how many settlements and intermediated sales
$q->feesWithheld; // what the seller was charged, net of refunds
}
}

The roster comes from the sales, not from your merchant registry. The registry lists everyone who ever onboarded, so a run built from it would produce a row of zeros for each merchant who sold nothing — and a zero is a reportable answer. It states that a seller received nothing, which is a claim about their year rather than the absence of one. Reading the sales also makes the roster and the figures come from the same records, so they cannot disagree.

All four quarters are always present, including empty ones. A report carrying only the quarters with movement leaves a reader to decide whether a missing quarter is a zero or an omission, and those are different statements to an authority.

What it does not assemble is your seller's own record — their name, address, and the identifiers a statute names. The package holds the field catalog (ReportingProfile::fieldsFor()) and the completeness rule; the values live in your application. Join your own record to this.

The three figures are never derived from each other. In particular the fee is not the gross inflow minus a payout: that is right for one unmixed sale at one rate and wrong for a basket that mixes rates, for a fee with a flat component, and for any quarter holding both — wrong quietly, because both inputs are correct.

The fee field holds what the seller was charged, which is not what the platform kept. Under a commission chain the platform buys and resells, and its margin is the difference between two supplies: it is never billed to the seller, so nothing from the chain appears in this field. Under intermediation the platform charges the seller for arranging each sale, and that fee, less whatever of it was refunded, is the figure.

An intermediated sale is dated by the sale. Its value and its fee come from the same row, so they share a quarter. A payment that failed is not counted, and what was later taken back from the seller comes off the value. A sale recorded before its kind was comes back as an unclassified line, and one recorded before its posture was counts under the posture your installation sells under.

What the platform kept: WithheldFeeCounter​

WithheldFeeCounter::feesWithheldIn() answers the other question, what the platform kept out of a seller's settlements.

The value and the fee are placed on the same clock: the settlement document. A withheld fee is not an event of its own — it is a deduction from a particular consideration, and the reporting field says as much: fees withheld or charged out of what is paid or credited to the seller. So its quarter is the quarter of the consideration it reduces, and the consideration is dated by the document that credits it (issued_at).

Placed by the money instead — the charge's own settlement date — the fee would part from its value. For an ordinary sale the two dates are the same day. When they come apart, a sale settled on 31 March and documented on 1 April would put the fee in Q1 and the value in Q2:

Q1 Q2 Q1 Q2
gross inflow 0 107.10 0 107.10
withheld fee 10.00 0 → 0 10.00
↑ by the money ↑ by the document

That Q1 pair would state a fee withheld from a consideration the quarter does not contain. Each counter would be right about its own source, and neither figure would be implausible on its own — which is why the plausibility gate below could never catch it, and why the clock is a decision rather than a fix. Releases before 0.33.0 placed the fee by the money, so a re-run over a period they counted can split its quarters differently, with the same annual total.

A refund is placed by ReversalAttribution on either clock, which decides where a reversal sits relative to its own sale and reaches nothing about document-versus-money.

One limit survives, and it is worth knowing before you reconcile: a charge that no settlement document claims keeps the money clock. There is no consideration whose date could place it. That happens when the money moves before any document is raised, and on historical rows whose document names no provider and therefore cannot be matched to a charge at all.

Such a charge is never dropped — a fee nobody reports is as wrong as one reported twice, and it is the quieter of the two. Ask for those rows instead of assuming there are none:

$fallbacks = app(WithheldFeeCounter::class)->chargesPlacedByTheirMoneyIn($seller, 'EUR', $quarter);

Under collective self-billing the document clock holds as well, which was the open question: a month-end document carries a whole month of transactions as lines, and each charge reaches it through its own link, so every one of them is placed on that document's date.

Writing your own record: RendersReportingRecord​

The shipped renderer produces a delimited file that is complete, deterministic and archivable. It is not any authority's wire format, and it does not pretend to be — bind your own implementation when you have a schema to meet, and neither the run nor the plausibility check changes:

app()->bind(RendersReportingRecord::class, YourWireFormat::class);

Your renderer is handed the sellers the duty covers, and only those. A plain RendersReportingRecord receives the reportable sellers and their records, so a wire format can't transmit a seller the duty does not reach, whatever it does with what it's given. Over-reporting is not a lesser mistake than under-reporting: under a regime like DAC7 it is a not correct return in its own right and a data-protection breach at the same time, and unlike a missing seller nobody downstream notices it.

A record of the due diligence asks for everybody. A regime like DAC7 also has you keep, for every seller the due diligence was applied to, what was processed, when, and with which result. The shipped renderer is that record: it implements RendersDueDiligenceRecord, is handed every seller the period examined, and writes each verdict as a column rather than dropping the row:

year;currency;seller;reportable;q1_gross_minor;…
2026;EUR;app#41;0;50000;1;0;… ← examined, NOT reportable, and kept as the evidence of that

Implement that contract for an artifact of that kind, and never for what you transmit:

use Pushery\Billing\Contracts\RendersDueDiligenceRecord;

final class YourDueDiligenceRecord implements RendersDueDiligenceRecord
{
// format(), version() and render() as for any renderer. render() receives every examined seller.
}

seller_count on the archived record counts the sellers the bytes cover: every examined seller for a due-diligence record, the reportable ones for anything else.

Keeping what you reported: ReportingExportArchive​

A file is a thing on a disk. It can be moved, regenerated, overwritten or edited between production and filing, and none of that leaves a trace — so a year later which figures did we actually report has no answer, only a file somebody may have touched. The archive keeps the bytes, with the moment they were produced and a fingerprint over them.

$record = app(ReportingExportArchive::class)->store(
year: 2026,
currency: 'EUR',
format: 'dac7',
formatVersion: '2024-11',
contents: $rendered, // already rendered — the archive does not know your format
sellerCount: count($reports),
);

app(ReportingExportArchive::class)->matchesStored($record, $renderedAgain); // by fingerprint
app(ReportingExportArchive::class)->runsFor(2026, 'EUR', 'dac7'); // oldest first

A second run is a second row, never an overwrite. Producing a period twice is normal — figures move as late corrections land — and the interesting fact is that it happened and whether the two agree. An archive that replaced the earlier row would destroy the only evidence that anything changed. A written row cannot be edited either: the record exists to say what was reported, and an answer that can be rewritten answers nothing.

It does not know your format, on purpose: rendered content goes in, and the record notes which format and which version, so a reader years later needs neither the renderer nor the release that produced the bytes. And it files nothing — the package holds no portal credentials and transmits nothing. Set billing.marketplace.reporting.export_disk if you want a copy on disk; leaving it null keeps the record and writes no file, which is a supported answer rather than a missing setting.

Checking a period before you file it: ReportingPlausibilityGate​

The duty is to check before reporting, and that word decides what a failure costs you. A check folded into the export runs after numbers have been drawn and files written, so it leaves you holding half a run you must not keep — and it tells you about one problem at a time, because the first failure stops the rest.

This step produces nothing. It runs every rule, names every finding, and refuses once.

$gate = app(ReportingPlausibilityGate::class);

$gate->assertClear(2026, 'EUR'); // throws ReportingNotPlausible while anything is open
$gate->openFindingsFor(2026, 'EUR'); // what is still in the way
$gate->findingsFor(2026, 'EUR'); // everything, including what has been answered

All three take the period's reports as an optional third argument, for a caller that already holds them from SellerReportingPeriod::reportsFor(). ReportingExport passes them: it reads the period once and checks exactly the reports it goes on to render.

Five rules ship, and all five are structural rather than German:

FindingWhat it means
unclassified_activityThe reporting duty for that seller is undecided, not "probably no"
seller_record_incompleteA seller who must be reported has a record that cannot be filed
quarters_do_not_sum_to_the_yearSomething falls inside the year and outside every quarter
seller_reported_twiceTwo rows for one seller — both correct, and the filing doubles their income
settlement_document_does_not_place_this_chargeA chain sale no settlement document names, by the quarter it was paid out in. The period counts a chain seller's figures from settlement documents alone, so the sale is in them only if a collective document covers it without naming it. Confirm that one does, or raise the missing document and re-run. A sale arranged as an intermediary is never named: intermediation issues no settlement document

An unclassified group is the mildest-looking entry and the one that decides whether the filing is lawful. Reporting a seller the duty does not cover discloses their income with no ground; leaving out one it does cover is the omission the duty exists to prevent. There is no safe default, so the package does not pick one.

Answering a finding​

$open = $gate->openFindingsFor(2026, 'EUR');

$gate->acknowledge(2026, 'EUR', $open[0], by: '[email protected]', reason: 'Sold before archetypes existed.');

An acknowledgement clears one finding for one period. The same finding next year is a new finding and somebody answers it again — an answer that carried forward would be a switched-off rule with a timestamp in front of it, and the report would keep listing it as passing. Acknowledgements cannot be edited afterwards; withdraw one and answer again if the judgment changed:

$gate->withdraw(2026, 'EUR', $finding, by: '[email protected]', reason: 'The seller sells downloads after all.');

The withdrawal removes the answer, opens the finding again and writes a reporting.acknowledgement_withdrawn audit entry with the answer that was given, who withdrew it and why. It returns false when there was no answer to withdraw.

Where your seller records come from: SuppliesSellerRecords​

The completeness rule needs the values the package deliberately does not store. Bind this and it can read them:

$this->app->bind(SuppliesSellerRecords::class, YourSellerDirectory::class);

interface SuppliesSellerRecords
{
public function valuesFor(Model $seller): array; // keyed by the profile's field names
public function isLegalEntity(Model $seller): bool;
}

Leaving it unbound is reported as a finding, not treated as a pass. "We could not look" and "we looked and it was fine" must never produce the same answer.

Rules of your own​

ReportingPlausibilityRules::add() takes them, and what varies by jurisdiction already comes from the bound ReportingProfile — so a different reporting duty means binding a different profile and adding your rules, never switching a shipped one off. A rule's key is half of an acknowledgement's identity, so two rules sharing a key is refused outright rather than tolerated.

Once it has gone out: ReportingFilingRegister​

A period has two states with completely different rules, and the moment you file separates them. Before, produce it as often as you like — every run has to come back byte-identical, and a run that does not is the signal that something moved. After, the figures that went out are settled. A seller correcting their master data, a classification pulled straight, a refund landing against last year: none of them reach back. They produce a new record, which goes out as a correction naming the filing it supersedes.

The package transmits nothing, so it cannot know a period went out — only you do. Recording it is what draws the line:

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

$first = $register->file($record, filedBy: '[email protected]');

$record->wasFiled(); // the run carries its state
$register->filingsFor(2026, 'EUR'); // the period's history, first filing first

Filing a period twice is refused, and it is refused rather than quietly absorbed. Under-reporting is visible — a period nobody filed is a gap an authority names, and the filing calendar warns you beforehand. Over-reporting is not: two filings of one year both look like a filing, and the duplicate tends to be discovered by the seller whose figures went out twice.

When something moves afterwards, ask before you produce a correction:

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

if ($register->needsCorrection(2026, 'EUR', $today->contents)) {
$register->fileCorrection($today, $first, filedBy: '[email protected]');
}

The divergence itself is the expected outcome of a late refund or an amended record, not a defect in the run that produced it — what matters is seeing it before the deadline rather than hearing about it from an authority. A correction that names nothing cannot be recorded at all: a run restating a period without saying which filing it restates is a second first report, whatever word is on it.

Nothing here can be edited. The export row is immutable, the filing row is immutable, and a period's history is therefore a list of facts in the order they happened rather than a current state somebody has been maintaining. There is no withdrawal path either — a filing that went out cannot be un-sent, and a mistake in what was filed is answered by a correction.

Deciding when to act on a reading: UsRegimeActivationPolicy​

A counter tells you a number. Whether that number means "register now" is a different question, and this answers it:

$verdict = app(UsRegimeActivationPolicy::class)->assess($grossByState, $transactionCount);

It takes readings as arguments and counts nothing itself. That is deliberate — a second counter over the same money disagrees with the first eventually, and the one that disagrees quietly is the one the alarm is wired to.

Waiting for a limit to be crossed is waiting too long. Registration takes weeks, the obligation starts at the crossing, and the gap between them is a period of selling into a region unregistered. So the policy acts on a configurable share of the limit rather than on the limit.

Recording a seller's declaration: UsTaxFormRegistry​

app(UsTaxFormRegistry::class)->record($creator, $declaration);

Collect early, act late. A declaration is given at onboarding, or it is chased a year later from sellers who have moved or gone quiet, under a filing deadline — and that chase ends in withholding money from people who did nothing wrong. So this records regardless of whether the regime is on; activeRegime() is what any consequence has to ask first. An install with no US exposure carries a few unused rows.

Before reconciling two figures: ReportingBaseComparability​

if (app(ReportingBaseComparability::class)->comparable($basis)) {
// safe to compare what was reported against what was declared
}

A platform reports what a seller received; a tax return declares what the seller is taxed on. For most sales those differ only by fees, and reconciling them is a good check. For a margin-taxed resale they are constructed differently on purpose — on a 500 sale of goods bought for 400, that is 450 against 100.

Both numbers are correct, and a reconciler comparing them reports a discrepancy on every such transaction. The cost of that is not a wasted afternoon: a books-do-not-reconcile finding is what turns an ordinary audit into a thorough one, over a difference that was never an error. So the answer is not a tolerance or a footnote — a check that cannot be right must not run.

Watching a per-state threshold: SubdivisionGrossSalesCounter​

Some obligations are reached per subdivision rather than nationally — a US sales-tax nexus is the common one. A platform watching a country total learns it crossed a state threshold only when the total says something about every state at once, which is the year after the one that mattered.

config(['billing.tax_counters.us_state_gmv.enabled' => true]);

$gross = app(SubdivisionGrossSalesCounter::class)->countedIn('US', 'USD', CountingPeriod::year(2026));
// ['CA' => Money, 'NY' => Money, 'unknown' => Money]

$counts = app(SubdivisionGrossSalesCounter::class)->transactionsIn('US', 'USD', CountingPeriod::year(2026));

This is what the buyer paid, and it is a third figure. The reporting counter measures what reached a seller and the small-business monitor measures what a supply was worth to a creator; on one 119.00 sale those see 90.00 where this sees 119.00. Do not reconstruct one from another — gross = settled / 0.9 * 1.19 holds for exactly one unmixed basket at one rate and is quietly wrong for every other sale.

unknown is a bucket, never a guess. A sale whose subdivision was never settled is counted there rather than attributed to a plausible state. A guessed state is not a smaller error than a missing one: it raises a threshold in a place you never sold into.

Write the subdivision onto the receipt at the moment of sale, from what the place evidence settled on:

app(RoutedPayment::class)->charge(
// …
destinationCountry: 'US',
destinationSubdivision: app(PlaceEvidenceStore::class)->subdivisionFor($reference),
);

It runs while the market is closed, deliberately, and its switch is independent of the other counters in both directions. Asking it while billing.tax_counters.us_state_gmv.enabled is off refuses rather than answering zero — zero reads as we sold nothing into that state, which is the sentence being watched for.

One seller, one line at a time: SellerReportingRun​

A platform reporting duty asks about a seller's period, and it asks per kind of thing they sold. There is no small-scale relief for commissioned work — three commissions worth a year's rent are reportable — and a thousand standardized downloads are not, however much they came to. One total for a seller's quarter cannot answer a rule that branches like that; whichever kind the caller happened to name would decide the whole figure.

SellerReportingRun splits the period and asks the bound rule once per line.

$lines = app(SellerReportingRun::class)->linesFor(
seller: $creator,
currency: 'EUR',
period: CountingPeriod::quarter(2026, 1),
);

foreach ($lines as $line) {
$line->activity->archetype; // what was sold, or null
$line->activity->compensation; // gross inflow for that kind, corrections subtracted
$line->activity->salesCount; // settlements in the window
$line->verdict?->reason; // null when the documents could not say
}

The figures come from the settlement documents, not from the charge rows. What reached a creator is their supply plus their own tax, and that sum was decided by the standing they had at the supply — frozen on the settlement. Counting the frozen figure is also what makes a re-run reproduce the original: a creator who registers for VAT in March does not retroactively change what reached them in February.

A correction subtracts from the line it corrects. A correcting settlement states a positive magnitude because a negative invoice is not a thing; its meaning lives in the document it credits. Summed over the document type alone it would add a clawback to the figure it reduces — and over-reporting here is not the safe error.

What the shipped rule decides, and the statute each branch reads​

The profile bound by default in a German install is GermanReportingProfile, and it is one profile among several — a consumer under another regime binds another one. It branches in three places, and every branch has a norm behind it rather than a preference:

the line isverdictread from
individually commissionedreportablea personal service under § 5 Abs. 1 Nr. 2 PStTG
standardized — a prerecorded download, a library subscription, a one-to-many streamnot reportable§ 6 Abs. 3 Satz 2 PStTG
consumer goodsreportable unless both de-minimis edges hold§ 4 Abs. 5 PStTG

The goods exemption has two edges and they are cumulative. A seller is left out only with fewer than 30 sales and compensation under EUR 2,000.00 in the calendar year. One edge alone exempts nobody: 29 sales at EUR 2,000.00 is reported, and so is 30 sales at EUR 100.

The upper edge is strict in the German statute, and the directive words it differently. § 4 Abs. 5 PStTG exempts a seller who received weniger als 2 000 Euro, less than the figure. DAC7 Annex V Section I lit. B words the same edge as did not exceed EUR 2 000, which would also exempt the seller sitting exactly on it. A German platform files under the German statute, so 29 sales at EUR 1,999.99 is the last case the exemption covers, and a seller sitting on exactly EUR 2,000.00 is reported. The shipped comparison is <. Both figures and both operators are configuration under billing.reporting.goods_de_minimis.* rather than constants, because the operator is where the two texts part: a consumer under a statute that follows the directive's wording sets BILLING_REPORTING_COMPENSATION_OPERATOR to <=.

There is no de-minimis outside the goods branch. Three commissions worth a year's rent are reportable; a thousand standardized downloads are not, however much they came to. Reaching for the thresholds outside the goods branch would exempt exactly the sellers the duty exists for.

Acting as a commissionaire does not exempt you, and this is the reading most often reached for. A platform that buys and resells rather than arranging — the commission chain this package settles through SupplyRegime::CommissionChain — is still an operator with a reporting duty: § 3 Abs. 1 Satz 2 PStTG covers the intermediate acquisition expressly. The VAT rules that make you the seller (§ 3 Abs. 11 and 11a UStG) decide the VAT treatment and say nothing about this duty. We are the seller, so we report nothing is a conclusion drawn from the wrong statute.

Both directions of error are punished, which is why neither is the cautious one. Omitting a seller the duty covers is the offense the duty exists to prevent. Filing one the statute leaves out is a defective return under § 25 PStTG — an administrative fine of up to EUR 30,000 — and a data-protection breach at the same time, because personal data reaches an authority with no basis for it. When in doubt we file is not the safe direction; it is the second error, and unlike a missing seller nobody downstream notices it.

And the whole table above is an interpretation, not a quotation. The norms are the statute's; where your own catalog falls between § 6 Abs. 3 Satz 2 and § 5 Abs. 1 Nr. 2 is a judgment about your products, and it decides the reportability of most of what you sell. The section below is about having that judgment confirmed rather than merely argued well.

The unclassified line is the one to look at​

A line whose settlements name no archetype comes back with no verdict, and reportable() on it throws rather than answering. That is deliberate, and it is the part worth reading twice.

Two real things land there. A settlement issued before your catalog could record the classification, and a collective settlement — one document covering a month of transactions, which has no single archetype to carry. Asking the rule about them anyway returns standardized, not because the sales were standardized but because the question never reached them, and that answer is indistinguishable from one the documents supported.

Both directions of guessing are violations: filing a seller the statute leaves out hands an authority personal data with no basis, and omitting one it covers is the offense the duty exists to prevent. So the line is handed back unjudged for you to resolve from your own catalog, and it is placed last so it is the one you end on.

The classification is yours, and this is where that becomes concrete. The package enforces it as a gate where money is taken and freezes it on the document it issues — it does not keep a catalog. A line with no archetype is the package saying so out loud rather than filling the gap with a plausible answer.

And a classification you decided is an interpretation, which most regimes let you have confirmed. Where the line runs between a standardized thing and a commissioned one is a judgment about your own catalog, and it decides the reportability of most of what you sell. Being able to argue it well is not the same as having it settled: under DAC7 as enacted in Germany, a platform operator can apply to the federal tax office for a binding ruling on their own product portfolio (§ 10 PStTG). It costs a fee, it binds the authority towards you, and it binds nobody else — your ruling says nothing about another operator's catalog, and theirs says nothing about yours.

It is worth the fee precisely because the error is punished in both directions. Reporting a seller the statute leaves out is a defective return and a data-protection breach at once, and unlike a missing seller nobody downstream notices it. A ruling takes the edge case out of the risk instead of leaving a defensible reading defensible forever. Check your own regime for the equivalent instrument and who issues it — most have one, and the office that receives your returns is rarely the one that answers questions about them.

Settling a whole month at once: CollectiveSelfBillingEngine​

A per-transaction settlement writes one document per sale. The collective run writes one document for a creator's whole month, dated to the last day of it, with a line per transaction — the monthly credit note behind a monthly payout.

use Carbon\CarbonImmutable;
use Pushery\Billing\Enums\SupplyRegime;
use Pushery\Billing\Marketplace\CollectiveSelfBillingEngine;
use Pushery\Billing\ValueObjects\Money;
use Pushery\Billing\ValueObjects\PlatformFee;
use Pushery\Billing\ValueObjects\SettlementTransaction;

$document = app(CollectiveSelfBillingEngine::class)->settleMonth(
$creator,
SupplyRegime::CommissionChain,
2026,
6,
[
new SettlementTransaction(
net: Money::of(10_000, 'EUR'),
commission: new PlatformFee(1_000),
supplyRateBps: 1_900,
supplyDate: CarbonImmutable::parse('2026-06-14'),
),
],
);

The single date is the point. A per-transaction document scatters a creator's supplies across the month; dating the collective on the month end puts the platform's expense, its input tax and the matching output turnover in the same period — which is what "supply received and invoice held" needs. The six-month issuance limit is then met structurally rather than by watching a clock.

The document total equals the month's payout run to the cent, so reconciliation is a property of how it was built rather than a search.

It does not re-decide tax​

Every transaction is planned through the same path a single settlement uses — the same status-at-supply-date resolution, the same matrix, the same guards, minus the number. A transaction on hold carries no treatment, so it falls out of the document entirely; one number is drawn for the whole document.

settleMonth() returns null when every transaction was a hold, or when there were none: no document, and nothing paid.

Three refusals, and each is a misstatement avoided​

RefusalWhen
SettlementTransactionOutsidePerioda transaction counts in a different month than the one being settled
CollectiveSettlementSpansTaxCategoriesthe month mixes exempt and taxed supplies — a creator crossing the small-business threshold mid-month
CollectiveSettlementSpansCurrenciesthe month mixes currencies

Different rates in one document are fine — VAT is broken down per rate. A mixed category is not, because the category is a document-level property here, and a mixed currency is not, because the totals are accumulated as raw minor units. Each of these is refused rather than issued wrong.

Running it twice is safe​

One document per creator and period. A second run for the same month returns the first document instead of drawing a second number out of a gapless series.

Naming the charge a transaction settles​

A transaction may name the routed charge it settles. When it does, the run records which document settled that charge, so the question can be answered later from the charge's side:

new SettlementTransaction(
net: Money::of(10_000, 'EUR'),
commission: new PlatformFee(1_000),
supplyRateBps: 1_900,
supplyDate: CarbonImmutable::parse('2026-06-14'),
chargeProvider: 'stripe',
chargeReference: 'pi_1234567890',
);

$charge->settlementDocument; // the collective document that settled it, or null

Both halves or neither, or the transaction is refused with SettlementTransactionChargeIncomplete. A charge reference is unique only per provider, so a bare reference could be matched against a charge belonging to a different driver — and the document would then record that it settled a sale it never mentioned. Naming a charge that does not exist is not an error: the link is simply absent, which is the same answer as naming none.

Null on the charge means no collective run has claimed it. It does not mean the charge is unsettled — settlement_state answers that, and the two are separate facts.

One limit to know​

A collectively settled transaction is not corrected by a refund today. A correcting document copies the frozen tax characteristics of the document it corrects, and a collective header deliberately carries none — a month has no single archetype, and writing whichever came first would make the document state something false about the rest. So a refund on such a sale moves the money and issues no correcting document. Settle per transaction where a sale needs to remain correctable.

Charging a commission when you only arranged the sale​

Under intermediation the platform does not sell the item — it arranges somebody else's sale — and it makes two supplies of its own: a fee to the buyer, and a commission to the seller. They are separate supplies, separately taxed, and each gets its own invoice.

$commission = app(SellerFeeCalculator::class)->feeFor(
$saleGross, // the mediated sale, which the commission comes out of
'DE', // where the MEDIATED SALE happens — not where the seller is
1_900, // that country's rate for this supply, in basis points
SupplyRegime::Intermediation, // refused in any other regime
);

app(FanReceiptIssuer::class)->issueSellerCommission(
$seller, $commission->gross, 1_900, CarbonImmutable::now(), $chargeReference,
);

Three things about it are worth knowing before you switch it on:

  • It is off by default (billing.marketplace.seller_fee.enabled). With it off, a mediated sale produces exactly the one document it produces today.
  • A fixed commission is capped by the sale. It comes out of the payout, so a fee larger than the sale would owe the seller a negative amount. The buyer fee is charged on top of the price and is not capped — that asymmetry is deliberate.
  • The taxable base is the commission, never the sale. The mediated sale is the merchant's turnover; it appears nowhere on the platform's invoice.

Pass the charge reference and a redelivery returns the document already issued rather than drawing a second number from a gapless series.

Where the seller is a private person, ask for the place. The commission's place of supply is where the mediated sale happens, and a sale between private people is not a taxable supply, so there is nothing to anchor to. IntermediationFeePlace::place() falls back to where the goods are, or where their transport began, and keeps the fee out of the cross-border consumer scheme:

$placement = app(IntermediationFeePlace::class)->place(null, 'DE'); // no taxable sale, goods shipped from DE

if (! $placement->resolved()) {
// Neither country is known: issue nothing rather than a document on a guess.
}

Pass the country of the mediated sale as the first argument where that sale is taxable; it wins.

Goods the platform is treated as supplying are never intermediated​

A platform that facilitates a sale of goods through its interface is, in two cases, treated as having received and supplied those goods itself (Art. 14a of the VAT Directive; in Germany, § 3 Abs. 3a UStG):

  • an imported consignment of an intrinsic value up to 150 euros, whoever sells it (paragraph 1), and
  • goods already in the Union, sold by a seller established outside it to somebody who is not a business (paragraph 2).

Neither is an intermediation, whatever the posture says: the platform's turnover is the whole supply, not a fee. Which one applies turns on facts the archetype does not carry, so a sale of goods states them in a GoodsConsignment:

use Pushery\Billing\Enums\TaxArchetype;
use Pushery\Billing\ValueObjects\GoodsConsignment;
use Pushery\Billing\ValueObjects\Money;

$consignment = new GoodsConsignment(
imported: true, // the transport begins outside the Union's VAT territory
intrinsicValue: Money::of(4_900, 'EUR'), // required when imported, stated in euros
);

$payment->charge($merchant, $buyer, $gross, $fee, $taxBps, $buyerIsDomestic, $token, $routing,
TaxArchetype::ConsumerGoods, consignment: $consignment);

imported is the legal fact rather than a country: goods leaving the Canary Islands for Madrid are imported, and goods held in a customs warehouse inside the Union are not. The intrinsic value is the price of the goods themselves, without transport and insurance costs stated separately and without the taxes customs can ascertain. A value in another currency is refused, because it converts at the rate customs applies. Three flags mark goods that distance sales do not cover or that the import rules leave out: newMeansOfTransport, installedOrAssembled and subjectToExcise. basis carries a declared taxation basis, and a margin-taxed one is refused for a deemed supply, because the scheme is closed to a platform that is the supplier. goodsLocatedIn names the member state goods already in the Union are in when the transport begins, as an ISO 3166 code, and both documents of the sale read it from there. description and quantity name the goods, as the buyer's document names them.

On the hosted one-off purchase the add-on catalog states the consignment, by implementing SuppliesGoodsConsignments beside SuppliesProductArchetypes. The shipped config catalog reads it from billing.addons.<key>.consignment. A hosted tip and a hosted subscription are their own archetypes and never goods, so the question does not arise there.

Under a tax profile that holds what is sold to a regime, the posture has to carry that regime too. The German profile holds ConsumerGoods, goods sold between private people, to be arranged rather than resold, so a routed sale of them under platform_deemed_supplier is refused with RegimeNotPermitted before the provider is called. They sell under platform_intermediary. A deemed supply is no sale between private people, and the rule does not reach it.

A sale of ConsumerGoods is classified before the provider is called:

  • A buyer counts as somebody who is not a business unless a verified VAT number stands for them, or your IdentifiesBusinessBuyers binding names them one (Art. 5d of Implementing Regulation (EU) No 282/2011). A seller counts as a business unless their standing is PrivateIndividual.
  • The goods of a seller outside the Union that are no deemed supply remain that seller's own supply in the Union, which would need their registration there. They are refused with the reason: a buyer who is a business, a new means of transport, goods supplied after installation or assembly, imported goods subject to excise duty, or an imported consignment above 150 euros.
  • The goods of a seller in the Union that are no deemed supply pass as an intermediation, as before.
  • A deemed supply under either paragraph is the platform's own sale where the tax profile documents one, which a profile says by implementing DocumentsDeemedSupplyOfGoods; the German profile does. Under any other profile it is refused with RegimeNotPermitted, and the message names the paragraph.

Without a consignment nothing can tell these cases apart. The goods of a seller outside the Union are then refused, and the goods of a seller in the Union pass, both as before, although an imported consignment of theirs worth up to 150 euros is a deemed supply too. Pass the consignment wherever you know it. A service from a seller outside the Union is not asked about here, since an electronic service answers to its own deemed-supplier rule.

Selling a deemed supply​

Where the profile documents it, the sale goes ahead whatever posture you configured for goods. It is booked under platform_deemed_supplier and the commission chain, and its row in billing_merchant_charges keeps the paragraph in deemed_supply and the consignment in goods_consignment. The sale is classified once and never moved between the paragraphs afterwards. Two statements of yours come first, and the sale is refused without them:

  • an imported consignment needs billing.marketplace.deemed_supply_of_goods.ioss_number, the import scheme number, IM followed by ten digits;
  • goods already in the Union need billing.marketplace.deemed_supply_of_goods.union_scheme set to true, your statement that the platform declares those supplies in the Union scheme. The receipt the package issues is enough only there (Art. 219a VAT Directive).

The consignment has to describe the goods as well, because the platform's records and the buyer's document name them; a sale without a description is refused with RegimeNotPermitted.

The charge type has to carry the deemed-supplier posture. charge_type_by_posture permits it on a separate transfer and not on a destination charge, where the money is the merchant's.

On RoutedPayment::charge() the tax of the platform's supply is decided before the provider is called, by DeemedSupplyOfGoodsTaxDecision::decide(), and the sale has to be charged at that rate. A $taxBps that differs is refused with DeemedSupplyOfGoodsRefused, because the commission is taken on the net at that rate and the buyer's document states it; ask the decision for the rate before the sale. Pass destinationCountry for these sales, since the supply is taxed where the transport ends. Once the payment settles, the buyer's document is the one FanReceiptIssuer::issueDeemedSupplyOfGoods() writes: a payment record or the full invoice the buyer asks for, and the small-value tier only for a sale within one member state.

On the hosted one-off purchase the document is written when the provider reports the payment, with the tax decided from the consignment the row kept and the address the buyer entered. Three things only the payment reveals contradict the classification, and then no document is issued and DeemedSupplyReceiptWithheld says why: a tax ID the buyer entered on the provider's page, a destination outside the Union, and a tax the provider computed at another rate than the one the supply is taxed at. The money has moved by then, so listen for the event and have somebody refund the sale or document by hand what really happened. A buyer fee switched on is refused on such a sale with BuyerFeeNotApplicable: the platform sells the goods itself, and there is no intermediation to charge the buyer for.

The seller's half is documented by SelfBillingEngine::issue() when the sale's SupplyTaxCharacteristics name the classification as deemedSupply. For goods already in the Union the seller's supply to the platform is exempt (Art. 136a VAT Directive) and takes place where the goods were when the transport began, which you pass as goodsLocatedIn. Its document is a self-billed invoice in the series of the others and under the same self-billing agreement: it states no tax, names the exemption, is drawn up over what the seller gets for the goods, is dated to the moment the payment was accepted (Art. 66a), and names the seller's VAT registration in that member state, which SuppliesSellerVatRegistrations answers. For an imported consignment that supply takes place outside the Union, and a settlement note documents the payout. Settle each such sale on its own: CollectiveSelfBillingEngine::settleMonth() refuses a month that holds one with CollectiveSettlementHoldsADeemedSupply. Both documents carry deemed_supply and goods_located_in from the same classification.

A refund of such a sale corrects both documents in the same proportion, on the same paths as every routed sale: BillingAdmin::refund() and the correction a lost chargeback owes. The paragraph is read from the sale's row, the buyer's document is corrected at the rate it stated and against the price the buyer paid, and the seller's as the supply to a deemed supplier it was. The correction of the buyer's document carries the one-stop-shop or the import scheme's flag of the sale, so the return of the period it is issued in declares it as a negative line naming the sale's period. A lost chargeback corrects the buyer's document and leaves the seller's supply standing, as it does for every sale in the commission chain.

DeemedSupplyOfGoodsTaxDecision::decide() places the platform's supply of the goods to the buyer and taxes it where the transport ends: for goods sent from a warehouse in another member state, for goods sold within the member state they are in, and for an imported consignment of up to 150 euros. Below the distance-sale threshold, goods sent from the member state the platform is established in are taxed there instead. The rate is that member state's standard rate unless you pass a reduced band, read at the moment the payment was accepted from the tables the calculator prices from. A reduced band that only the shipped table would answer is refused, because that table carries standard rates only; configure the member state's bands in billing.tax_matrix. An imported consignment above 150 euros or of excise goods, a buyer outside the Union and the other facts no deemed supply has are refused with DeemedSupplyOfGoodsRefused. A supply of goods already in the Union that is taxed at the destination carries the one-stop-shop flag, so billing:tax-return:export declares it under its member state, the supply within one member state included (Art. 369b(b) VAT Directive). Below the threshold it is the platform's own supply at home and stays out of that return. An imported consignment carries the import scheme's flag instead, and billing:import-scheme-return:export declares it in that scheme's monthly return.

The records of a deemed supply​

The platform keeps records of every supply it declares in the Union scheme or the import scheme and makes them available electronically on request (Art. 369k and 369x VAT Directive), with the content Art. 63c of Implementing Regulation 282/2011 lists. billing:deemed-supply-records:export --from= --to= writes them, one row per buyer's document of such a supply and per correction of one, read from those documents and from the consignment the sale's row froze, so nothing is kept twice. The commands reference lists the columns. Where the platform takes part in delivering the goods, the records of an imported consignment carry the number it shipped under: bind SuppliesConsignmentNumbers to answer it for a payment reference, and the column stays empty without it.

The records of the sales the platform arranges​

A platform keeps records of the supplies it facilitates without being treated as their supplier too (Art. 242a VAT Directive), with the content Art. 54c(2) of Implementing Regulation 282/2011 lists. billing:platform-records:export --from= --to= writes them, one row per routed sale under platform_intermediary or seller_of_record: the supplier with its address, VAT number and account, what was sold, its value and what was given back, the day, where the goods were sent or the buyer was, the payment reference, and the consignment number where your SuppliesConsignmentNumbers binding answers one. The platform issues no document of a sale it only arranges, so each row reads the sale's own row, where the lane kept the consignment and the destination, and your MerchantPartyResolver for who the supplier is. A column it cannot answer stays empty and is yours to supply. The supplier's account is the IBAN that party names or, without one, the account the merchant holds at the payment provider. A hosted purchase gets its destination from the address the buyer entered on the provider's page.

What the seller declared​

The platform relies on what the seller tells it: where it is established, its tax numbers, the goods it sells and where they are dispatched from. It is not liable beyond the tax it declared and paid where it relied on those facts and neither knew nor could have known they were wrong, and it has to show that (Art. 5c Implementing Regulation 282/2011). Record each declaration from your seller screens with SellerDeclarations::record(), with the statement the seller confirmed and the moment it confirmed it. The package keeps it unchanged, with a SHA-256 hash of the statement, in billing_seller_declarations, and a seller who declares again writes a new one. When something you hold contradicts a declaration, such as a delivery address of the seller inside the Union, report it with SellerDeclarations::contradict(). It is kept with the moment it was observed, and SellerDeclarationContradicted is dispatched. The sale gate asks SellerDeclarations::assertDeclared() before every sale of goods the platform is treated as supplying, after the checks on your configuration: a seller who never declared, or one with a signal newer than its latest declaration, is refused with SellerDeclarationMissing before any money moves, until the seller has declared again. Both are kept, unlinked, through an erasure of the seller, for ten years from the end of the year of that erasure, because they support the records of the supplies made under them.

Around a payout: what the package answers and your payout run asks​

The package pays nobody out, so the payout run is yours. Three answers are built for it, and nothing inside the package asks them for you:

  • FallbackLane::holdsPayout($creator) before paying a creator. A creator who objected to self-billing, or never agreed to it, sends an invoice of their own. FallbackLane::reconcile() checks it against what they earned in the period, as Money in the currency they earned it in, and while a submission is open, failed or in manual review, holdsPayout() answers true. A payout run that does not ask pays what the creator wrote rather than what they earned.
  • SelfBillingObjection::record($document, $receivedAt, $channel) when a creator objects to a self-billed document, from your own screen or inbox. The objection needs no reason and no form. From its taxation period on the document is no longer an invoice, and the DATEV export and the payables sub-ledger leave it out.
  • MerchantSubLedger::applySettlement($merchant, $gross) for each settlement, which pays down what the merchant owes before anything is paid out, where you charge shortfalls to it as the reversal paragraph above shows.

Reading a creator's balance​

The package keeps the earnings journal and answers questions about it; it never holds or moves the money. Four readers cover the whole surface, and each takes the currency as a required parameter:

AskAnswers
LedgerBalanceReader::availableFor($party, $currency)what this creator can be paid right now
LedgerBalanceReader::pendingFor($party, $currency)earned, not yet settled, and not behind a hold
LedgerBalanceReader::heldFor($party, $currency)still behind a buyer-protection hold
ListsEarningCurrencies::currenciesFor($party)which currencies this creator has actually earned in

The three balances never count the same money twice. A sale under buyer protection stays pending until its hold releases, because the release is what pays the creator and settles the sale, and while it waits its share is reported as held and left out of pending.

The currency is required rather than optional because a currency is a bucket and is never converted: there is no single total, and a reader that produced one would be inventing an exchange rate the package does not hold. That is also why the last row exists — without it, "show me my balances per currency" has no starting point, and the only route left is querying billing_merchant_charges directly, which couples your application to a schema this package owns and changes.

Do not loop over the configured exchange-rate currencies

billing.tax_exchange_rates.currencies looks like the list of currencies you settle in and is not. It lists the currencies rates are imported for — two separate decisions, as the configuration comment beside it says. A creator earning in a currency nobody imports rates for would simply be shown one balance fewer, with nothing anywhere reporting a problem. Ask currenciesFor() instead.

ListsEarningCurrencies is a separate contract rather than a fourth method on LedgerBalanceReader, and deliberately so: that interface is one you may implement yourself, and a method added to it would stop your class from satisfying it on the next update. Binding the new one is opt-in and changes nothing if you do not.

Vouchers: the code is the instrument​

VoucherLedger::issue() sells a voucher under a code you pass, and whoever presents that code can spend what is left on it. The package neither generates the code nor checks its shape, so three things are yours:

  • Make the code unguessable. Generate it, for example with Str::random(24), and never derive it from an order number, a date or a counter: a code a buyer can guess is a balance anybody can spend.
  • Look a voucher up for its holder. findFor($code, $holder) returns the voucher only when it was sold to that holder or to nobody in particular, and null for a code nobody issued and for somebody else's voucher. Look vouchers up this way rather than through the model, and hand the result to redeem().
  • Throttle the lookup. A form that answers whether a code exists is a way to try codes, so put it behind a rate limiter keyed on the account.

The code is stored as you passed it, because every movement carries it as the reference your accounting export books against. Treat the billing_vouchers table as a table of open balances: whoever can read it can read every code that still has value.

Who carries the liability​

The charge type moves the merchant of record between the platform and the connected account, and the document side follows it: who the buyer's receipt names, and which posture your platform may declare. That decision matrix is a jurisdiction question and is not repeated here; what matters at this level is that the money flow and the declared seller are checked against each other, and a pair that disagrees is refused before any money moves.

The money risk is a different question, and it is decided somewhere else. Who pays the provider's processing fee and who absorbs a chargeback follows the connected account's type, not the charge type and not on_behalf_of. Both are stated on the account itself, so you can read them before a payment exists:

marketplace.onboarding.account_typeProcessing feeChargeback
express (default)The platform pays (controller.fees.payer = application_express)The platform absorbs it (controller.losses.payments = application)
standardThe merchant pays (account)Not debited from the platform balance (stripe)

Measured against the live API on 2026-08-06, pinned version 2025-08-27.basil, identical for DE and US accounts. A smoke test re-reads it against the real API so a version bump cannot flip it quietly.

So on the default, your commission is a gross take. marketplace.fee states what the platform keeps before the provider's own percentage and per-transaction amount, and the package does not subtract them — it does not know your pricing, and a guessed deduction would put every payout figure out by the size of the guess. On a small sale that difference is the whole margin, and the chargebacks are yours.

When the provider computed the tax​

On a hosted checkout with automatic tax switched on, the provider works the tax out from the address the buyer types in, and the completion event it sends you carries amounts and no rate. A tax document has to name the applicable rate, so for a long time that lane issued nothing at all — the honest answer, because a rate nobody supplied is a number no decision stands behind, and a stated tax is owed by whoever stated it.

The rate is not missing, though: it is on the sale's line items, together with the exact base and the exact tax. The package now asks for it, through the ReadsProviderComputedTax seam, and writes what it gets back. Nothing is derived and nothing is chosen — the document states what was applied.

The amounts are written as they arrived, not recomputed from the rate. The two can land a cent apart whenever the provider taxed the net and the rounding fell the other way, and a document that quietly corrects the provider by a cent disagrees with the payment your buyer is holding it against.

Four shapes get no document, and that is deliberate:

The sale…Why nothing is issued
carries several ratesa document states one rate against one base; averaging two would describe neither
carries a flat-amount taxthere is no percentage to state — the provider says so itself
carries a rate finer than a basis point8.875% does not fit the stored rate, and rounding it would stop it reproducing the amounts beside it
could not be read backa failed read is loud; a fallback computation is a plausible figure nobody questions

One more check sits on the caller's side: the base and the tax have to add up to what the buyer actually paid. A reading that lost a line or took the wrong one still produces a well-formed split, and only the sum catches it.

If you implement the seam yourself, for a provider this package has no driver for, the one thing to get right is which rate you report: the one the provider actually applied, not the jurisdiction's headline rate. On an exempt, zero-rated or reverse-charge supply those differ, and the headline figure would put a rate on the document that was never charged.

A merchant's data: export and erasure​

The same account can buy and sell, and billing:export and billing:erase answer for both sides of it.

billing:export answers for the person as a buyer and as a merchant. The export also holds what the package keeps about the account as a merchant: the provider account, payouts, balances, tax standing, the documents about it and the coupons it issued. It leaves out the subscriptions and access its fans hold with it. Those rows belong to the fans and reach them in their own export.

A person who sells is erased as a merchant too. The merchant rows carry no owner columns, so billing:erase walks both sides: the buyer side, and the merchant side, where the provider account and the tax-hold reminders go and the financial record (routed charges, balances, tax standings, disputes and the rest) stays, unlinked and stamped with merchant_erased_at. The receipt counts both, per table, and ErasureReport::$axes holds the counts per side.

One erasure key ties the two sides together. The settlement documents a merchant was issued are kept on the buyer side's columns, and the creditor account their payables book to on the merchant side's. An erasure writes one random key for the person into both, erased_owner_key on the documents and erased_merchant_key on the account, so a later DATEV export still books such a document to the account the merchant had, and the payables sub-ledger keeps the merchant's movements together under that key. The booking text names the creator as CR-geloescht. A merchant erased before the key was kept left neither side with one: with an account per merchant, the export refuses such a document with MerchantCreditorAccountUnknown instead of booking it to another account, and with one collective account it books the document as before.

The coupons the merchant issued go as well, with every redemption of them. A coupon names its issuer in merchant_uid rather than in owner columns, and loosened from its merchant it would read as the platform's own; the documents of a sale keep what a customer was charged.

The subscriptions fans hold with that merchant are among the rows that go, so they end at the provider first, in the same listener that ends the account's own on BillableAccountDeleting. billing.erasure.merchant_subscriptions decides how: now (the default) ends each immediately, period_end lets each run to the end of the period the fan paid for and renew no more. Either way the rows go with the merchant, so the package no longer sees a subscription that runs on. A fan subscription the provider would not end is logged and reported as DeletedAccountStillSubscribed, naming the fan, and the erasure goes on.

What stays a single-seller concern​

The jurisdiction-specific tax rules — the regimes, the statutes, the creator tax-status matrix — belong to a jurisdiction profile, not here. This overview is the mechanics of routing money; a consumer on another profile reads it without meeting a single paragraph of one country's tax law.

A creator who resells second-hand goods on the margin accounts for that scheme themselves. The package issues no goods document in a creator's name under any posture, so nothing it issues is taxed on the margin, and where the platform is the deemed supplier the scheme cannot apply at all. The parts a reseller uses to issue such a document are described under taxes.