Rate sources
Rate sources
The engine owns the calculation and sources only the rate data, behind one contract:
interface TaxRateSource
{
public function rateFor(Jurisdiction $jurisdiction, TaxClass $category, ?DateTimeImmutable $at = null): ?TaxRate;
}
Out of the box it is bound to RegisterRateSource, which reads the compiled register
off local disk — see The register for how that
gets there. It is the only rate source this package ships.
Having no rate, and being unable to answer
These are different, and the difference decides what a caller does next.
null— "I have no rate for this jurisdiction." A normal answer from a source with limited scope. A chain moves on to the next source.RateSourceUnavailable— "my endpoint timed out." Not an answer at all. Returned asnullit was indistinguishable from a source that simply does not cover the jurisdiction, and the chain moved on and billed from whatever was behind it.DatasetNotInstalled— nothing has been synced, so there is no data to read. The message names the command.
A jurisdiction in a regime the store was not compiled with refuses rather than
returning null. A store built --region=eu knows nothing about Japan, and answering
"no tax in Japan" from an absence would be a fabrication — that refusal is what makes
per-region and per-state opt-in safe to offer at all.
What a rate carries
TaxRate is a percentage plus everything needed to defend it:
kind— standard, reduced or zero.confidence—Authoritativewhen the answer is exact,Derivedwhen it is the best available.limitedBy— aRateLimitsaying why it is not exact and what would close the gap. Null when nothing is missing, which is the common case and stays cheap.components— the authorities the rate is made of, where the source can decompose it. An empty list means "this source cannot decompose this rate", not "one authority takes it all".provenance— the release, the window the answer stood on, and the source's own snapshot hash. The window matters more than the version: a version alone puts every invoice in the blast radius of every republish, while the window's start is what a correction actually names.
Commodity codes
CommodityRateSource extends the contract with a code:
$source->rateForCommodity($place, TaxClass::Groceries, 'cn:0401 10');
Three rules, and each one was a real defect before it was a rule:
- The scheme is part of the key.
32is a CPA division and a CN chapter, and they are about different things. A bare code is read as CN. - A code refines the question, never moves it. The search stays inside the category that was asked about, so a customs code scoped to foodstuffs cannot answer a question about hotel accommodation.
- The longest match wins, and a shortened one is an inference. It comes back
DerivedwithRateLimit::ClassificationInferred, because the register has 95 live cases where a chapter and a subheading beneath it disagree.
The answer names the code that decided it: source reads cbox-tax:cn:040110.
Local authorities
For an address below the state line, the rate source consults
LocalAuthorityResolver — see Local authorities. It is bound
to the register's own boundary resolver, and every authority that applies is summed
or none of them: a rate short by one authority's share is an under-charge stamped
authoritative, which is the one outcome this package works hardest to prevent.
Putting your own source in front
Rebind the contract. ChainTaxRateSource tries sources in order and takes the first
that answers:
$this->app->singleton(TaxRateSource::class, fn ($app) => new ChainTaxRateSource([
new MyCommercialAdapter(...),
$app->make(RegisterRateSource::class),
]));
CachingTaxRateSource wraps any source that costs a request per lookup. The register
source needs neither — it reads local files, and there is nothing to expire because the
store holds one pinned release.