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.
| Endpoint | Event types |
|---|---|
billing.marketplace.webhook.path, events from connected accounts | account.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:
| reason | what it means |
|---|---|
collective_settlement | the settlement covers a whole month, and one document carries one treatment; correct it by hand |
standing_unclarified | the standing now in force is unknown, and an unknown standing issues no document |
supply_rate_unknown | the 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_agreement | the replacement would be a self-billed invoice, and no agreement covers the supply date |
tax_not_disclosable | the replacement would state tax the standing does not permit a document to show |
product_not_classified | the replacement would be reverse-charged, and the settlement never recorded what was sold |
original_unresolvable | the 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.
| Step | Due | What happens |
|---|---|---|
| First reminder | 7 days after the record was found incomplete | SellerDataReminderNotification, naming the missing details |
| Second reminder | 30 days, and at least 23 after the first | the same notice, with the date the measure begins where one can follow |
| Measure | 60 days, and at least 30 after the second | withhold_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:
| Finding | What it means |
|---|---|
unclassified_activity | The reporting duty for that seller is undecided, not "probably no" |
seller_record_incomplete | A seller who must be reported has a record that cannot be filed |
quarters_do_not_sum_to_the_year | Something falls inside the year and outside every quarter |
seller_reported_twice | Two rows for one seller — both correct, and the filing doubles their income |
settlement_document_does_not_place_this_charge | A 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);
$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)) {
}
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 is | verdict | read from |
|---|---|---|
| individually commissioned | reportable | a personal service under § 5 Abs. 1 Nr. 2 PStTG |
| standardized — a prerecorded download, a library subscription, a one-to-many stream | not reportable | § 6 Abs. 3 Satz 2 PStTG |
| consumer goods | reportable 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
| Refusal | When |
|---|---|
SettlementTransactionOutsidePeriod | a transaction counts in a different month than the one being settled |
CollectiveSettlementSpansTaxCategories | the month mixes exempt and taxed supplies — a creator crossing the small-business threshold mid-month |
CollectiveSettlementSpansCurrencies | the 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
IdentifiesBusinessBuyersbinding names them one (Art. 5d of Implementing Regulation (EU) No 282/2011). A seller counts as a business unless their standing isPrivateIndividual. - 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 withRegimeNotPermitted, 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,IMfollowed by ten digits; - goods already in the Union need
billing.marketplace.deemed_supply_of_goods.union_schemeset totrue, 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, asMoneyin 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:
| Ask | Answers |
|---|---|
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.
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, andnullfor a code nobody issued and for somebody else's voucher. Look vouchers up this way rather than through the model, and hand the result toredeem(). - 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_type | Processing fee | Chargeback |
|---|---|---|
express (default) | The platform pays (controller.fees.payer = application_express) | The platform absorbs it (controller.losses.payments = application) |
standard | The 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 rates | a document states one rate against one base; averaging two would describe neither |
| carries a flat-amount tax | there is no percentage to state — the provider says so itself |
| carries a rate finer than a basis point | 8.875% does not fit the stored rate, and rounding it would stop it reproducing the amounts beside it |
| could not be read back | a 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.