Skip to content

Dates

Dates

Tax is dated law. Rates change, categories move in and out of tax, exemption certificates expire. An engine that only ever answers "as of now" cannot reissue an invoice, cannot restate a period, and cannot be audited — and the failure is quiet, because the answer still looks like a number.

Two dates, because they do two jobs

new TaxQuery(
    // ...
    suppliedAt: new DateTimeImmutable('2026-12-30'),   // when the supply happened
    reportedOn: new DateTimeImmutable('2027-01-03'),   // which return it lands in
);

suppliedAt is the tax point. It decides which law applies: which rate was in force, whether the category was taxable, whether the buyer's exemption was valid that day. Null means today, which is what an ordinary live sale wants.

reportedOn decides which return period the supply is filed in. It defaults to the tax point, and is only worth setting when the two genuinely differ — goods supplied on 30 December and invoiced on 3 January are rated at December's rate while national rules may put them in either period. One date cannot do both jobs, and collapsing them means either mispricing the supply or misfiling it.

Both are echoed on the assessment as taxPoint and reportedOn, so an audit can see what the engine actually resolved against rather than inferring it.

One date, everywhere in the assessment

The tax point is threaded through every dated lookup for that supply:

Resolved on the tax point Where the dated data lives
the rate TaxRateSource::rateFor($jurisdiction, $category, $at)
product taxability ProductTaxability::isTaxable($jurisdiction, $category, $at)
the buyer's exemption validity TaxExemption validity window
nexus thresholds dated windows in the dataset

This is a correctness property, not a convenience. An assessment priced with one year's rate and another year's taxability is internally inconsistent, and a state that started taxing a category last year would otherwise have the engine charge tax on a supply made before the law existed.

// A state that exempted groceries until 2025-12-31 and taxes them from 2026-01-01.
$grocery = fn (string $date): TaxQuery => new TaxQuery(
    amount: Money::of('100.00', 'USD'),
    pricing: Pricing::Exclusive,
    place: $kansas,
    customer: CustomerType::Consumer,
    seller: $registrations,
    category: TaxCategory::Grocery,
    suppliedAt: new DateTimeImmutable($date),
);

$calculator->assess($grocery('2025-06-15'))->treatment;  // TaxTreatment::Exempt
$calculator->assess($grocery('2026-06-15'))->treatment;  // TaxTreatment::Standard

($query->on() reads the resolved tax point back — it is the getter the engine itself uses, not a builder.)

Window boundaries are inclusive on both sides: 31 December is the last day of the old rule and 1 January the first day of the new one. An off-by-one here is a whole day of invoices priced wrong.

What a source does with a date it cannot honour

A source that has no dated data answers the same for every date — and should say so rather than imply otherwise. StaticProductTaxability accepts the date and ignores it, because it is a hand-maintained snapshot that only knows one answer; UsTaxDatasetTaxability reads the dataset's dated windows and honours it.

A source that cannot answer for a past date at all should return null rather than quietly serve today's figure. ArcGisRateSource does exactly this: the state polygon services publish only the current boundaries, so it declines a historical question instead of answering a different one.