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.
No driver in this package implements RoutesMoney yet. The contract, the switch and the gates that
check for it are in place; the connected-account driver behind them is not. Turning the flag on today
therefore refuses to boot, which is the designed answer to "routing is configured but nothing can route" —
it is simply the only answer available so far. A custom driver opts in by implementing the interface, and
that path works today; the shipped Stripe driver remains single-seller until its Connect implementation
lands.
MarketplaceRails itself is the seam onto the connected-account operations:
interface MarketplaceRails
{
public function onboarding(): MerchantOnboarding;
public function accounts(): MerchantAccountDirectory;
}
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.
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, and its tiers are rows like any creator'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.
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 asks how much is owed and hands the amount to the correction path a refund
already takes, so both links of the chain get their correcting document.
Each of those documents is booked in the period the refund happened, never back-dated into the original's. 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.
[$correction, $creatorDocument, $buyerDocument] = app(PrepaidTermCancellation::class)->cancel(
charge: $merchantCharge,
merchant: $creator,
term: Money::fromDecimal('119.00', 'EUR'),
periodsUsed: 4,
periodsInTerm: 12,
);
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 — a cancellation at period end simply runs out. Canceling at the very end of a term returns nulls and issues nothing, so no number leaves the document series for a term that owes 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.
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.
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.
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.
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.
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: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.
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.
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.
The sales lock, and the date it starts on
From the day you set it, RoutedPayment refuses a sale on behalf of a merchant whose taxation nobody has
established — TaxStandingUnestablished, thrown before the provider is reached, beside the receiving gate.
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
'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.
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 the period's settlement documents name, splits each one's year into lines, and reads
the three per-quarter figures from the counters that own them.
$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
$q->feesWithheld; // what the platform kept, net of refunds
}
}
The roster comes from the documents, 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 documents also makes the roster and the figures come from one source, 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 value and the fee are placed on different clocks, and a quarterly return states them side by side.
The gross inflow counts a transaction by its settlement document (issued_at); the withheld fee counts
it by the money — the charge's settlement, and a refund by the confirmation that moved it. For an
ordinary sale the two dates are the same day and the distinction never shows.
They come apart when a document and its money fall on opposite sides of a quarter boundary. A sale settled on 31 March whose settlement document is issued on 1 April puts the fee in Q1 and the value in Q2:
Q1 Q2
gross inflow 0 107.10 ← placed by the document
withheld fee 10.00 0 ← placed by the money
Both counters are right about their own source. What is wrong is a return that takes the two lines from different populations without saying so. Reconcile the boundary before you file, or accept the offset knowingly — the plausibility gate below does not catch this, because neither figure is implausible on its own.
This is a known limitation rather than a defect being hidden, and part of it has since been closed. A correcting document now names the reversal it documents, so on a refund the reported fee can be read off the confirmation that actually moved it rather than off a figure computed separately.
That link does not close the case shown above, and the reason is worth being explicit about: there is no refund in it. One charge, one settlement document, and the two figures already part company — nothing that binds a correction to a reversal reaches a sale that was never corrected. Placing the fee by the settlement document as well would close it, and that is a reporting decision with a tax consequence rather than a correction: it is not well defined under collective self-billing, where one month-end document carries a whole month of transactions. So the offset above is still stated here rather than discovered during a reconciliation.
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);
Read this before you write one. render() receives every seller the period examined, not only the
ones that must be reported. Each SellerPeriodReport answers reportable(), and the shipped renderer
writes that 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 their figures are right there
A renderer that iterates the reports and writes them all will therefore transmit sellers the duty does not
cover, together with their quarterly income. Filter on reportable(), or decide deliberately not to:
foreach (array_filter($reports, fn ($report) => $report->reportable()) as $report) {
// …
}
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.
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
Four rules ship, and all four 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 |
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 judgement changed.
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.
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.
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.
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 |
LedgerBalanceReader::heldFor($party, $currency) | settled but still behind a buyer-protection hold |
ListsEarningCurrencies::currenciesFor($party) | which currencies this creator has actually earned in |
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.
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.
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.