How Hard Can US Sales Tax Be
I was building a billing engine, so I had to handle tax. The EU was hard but knowable. Then I looked at the US and said the sentence that sits right at the peak of the Dunning-Kruger curve. What came out the other side was not tax logic, it was an ETL pipeline against forty government websites.
I have been building a billing engine. Not the storefront kind, the kind that has to produce an invoice line a company can put in front of an auditor. Once you are producing invoice lines, tax stops being somebody else's problem.
I started with the EU, because that is where I am and where my customers are.
The EU is hard in a way you can read about
EU VAT is not simple, but it is knowable. One legal framework across 27 member states. Place of supply is Articles 44, 45 and 58. Intra-EU B2B is reverse charge, once you have validated the buyer's VAT number against VIES. There is a 10,000 euro micro-business threshold in Article 59c that decides whether a small seller charges origin or destination VAT. The rates are published, and there is an official database to read them from.
You can sit down with the directive and work out what the right answer is. It takes a while. It is finite. I built it, tested it against every case I could find, and open sourced it as Tax for Laravel, because that is what I do with the parts that are not my product. The engine it feeds is Billing Engine for Laravel, and jurisdictions come from Geo for Laravel, so a country is a stable ISO code rather than a string somebody typed.
Then I looked at the United States
How hard can it be. Fifty states, fifty rates, a lookup table and a multiplication.
That sentence is the peak of the Dunning-Kruger curve, and I said it out loud.
It is not fifty rates
The first thing to go is the word rate. US sales tax is sub-federal, so the state is only the first layer. Counties add to it. Cities add to it. Transit authorities, stadium districts and library districts add to it. There are thousands of taxing jurisdictions, and which one applies depends on where the buyer is, sometimes down to the street.
The second thing to go is the assumption that you owe anything at all. After South Dakota v. Wayfair in 2018, a seller with no physical presence in a state can still owe sales tax there, but only once its cumulative sales into that state cross a threshold. So before you can ask what rate, you have to ask whether you are registered, and before that, whether you should be.
And a threshold is not a number. It has at least four dimensions, and only one of them is the figure everybody quotes.
the figure a dollar amount, a transaction count, and whether
the test is sales OR transactions, or sales AND
measuring period previous calendar year / current calendar year /
previous or current / rolling twelve months
sales basis gross sales / retail sales / taxable sales
marketplace sales whether sales made through a marketplace count
toward your own threshold
Get the measuring period wrong and you register a year late. Get the sales basis wrong and you register on the wrong number entirely.
The third thing to go is taxability. Is SaaS taxable? It depends on the state, and for an uncomfortable number of states the published compilations disagree with each other.
The fourth is sourcing. Some states tax an intrastate sale where it ships from, some where it ships to, and a couple split the rule by jurisdiction layer or by seller type.
The fifth is arithmetic, and this one is a trap you fall into silently. Some states publish local rates as components you add to the state rate. Others publish one combined record that already contains the state share. Sum a combined record and you have just charged the state rate twice.
component state combined state
state 6.25% local record 9.50% (already all-in)
county 1.00%
city 0.75% state + record = 15.75% wrong
-----
8.00% record = 9.50% right
Then there is the part nobody writes blog posts about: the identifiers. A local jurisdiction is not named the same way twice. The Streamlined states use county FIPS codes. Texas and Alabama use comptroller authority codes. California uses a state prefix and a place name. Illinois uses its own location ids. There is no single key, so there is no single join.
And some places cannot be rated at a jurisdiction level at all. Illinois flags 218 locations where the rate varies inside the location, so no single figure is right for the whole of it. Louisiana flags three. Colorado and Alabama have home-rule localities that administer their own tax and answer to nobody's central file.
The valley
Somewhere in there I understood that I had been solving the wrong problem. The code was never going to be the hard part. Every correct implementation I could write would be exactly as good as the table behind it, and no better.
There are two normal responses to that. One is to hardcode a table, ship it, and let it rot, because rates change and you will not notice. The other is to buy a tax SaaS and let it do the calculation, which solves the data problem by giving away the part I actually wanted to own.
I did not want either. So I stopped writing tax logic for a while and built a pipeline.
Forty government websites
Most states do publish their rate files. Not nicely, and not in the same format as each other, but they publish them. So for those, the pipeline fetches the official file, hashes it, and records the URL, the file name, the sha256 and the retrieval time next to every figure derived from it. That is the path I want every state on.
A handful have no machine-readable file worth trusting, so their local rates are curated by hand from the department of revenue's own charts, with a dated citation per edition. Nexus thresholds and SaaS taxability are curated too, because those live in guidance documents rather than in files.
Curated data rots quietly, so it is not left alone. Wherever an official file gives ground truth, the curated baseline has to agree with it or the build aborts. And a monthly check fails if any curated citation has aged past 120 days without a human looking at it again. That last rule is the one that makes the whole thing maintainable rather than a snapshot I will be embarrassed by in a year.
What comes out is one versioned JSON artifact, published at cboxdk/us-tax-dataset on every real change.
19,761 rate records
41 states with local rate records
5 states where the state rate is the whole rate
1 state with local taxes the dataset does not carry (Idaho)
4 states with no general sales tax
---
51 jurisdictions, all accounted for
47 jurisdictions with nexus thresholds
40 states with a SaaS taxability determination
The rule I care about most is that a gap is a stated fact, never a silence. Idaho has local taxes the dataset does not carry, so Idaho is marked partial and says so, instead of returning a state rate that looks complete. Where two sources disagree on whether something is taxable, the determination is left out rather than picked. An engine is allowed to refuse to answer. It is not allowed to guess and sound confident.
Records carry effective windows, so a rate change that takes effect next quarter is already in the dataset, waiting for its date. The artifact is semver tagged and carries its own schema version, and additive fields are not breaking, so a consumer written a year ago keeps working.
Two engines, not one
With the data underneath, the logic got small, and it split in two.
Tax for Laravel answers a per-invoice question. This supply, this buyer, this seller: is it taxable, and at what rate. It asserts nexus from an explicit seller registration and refuses to infer it, because a single invoice cannot tell you anything about a cumulative threshold.
Economic Nexus for Laravel answers the other question. Given everything the seller has sold into a state so far, should there be a registration at all. Each state resolves to one of five outcomes, in precedence: already registered, triggered by physical presence, triggered economically, approaching within a configurable band, or below. Only triggered means you have to act. Approaching is the one you actually want, because you would rather hear about it before you cross.
It does not count your sales for you either. You bind a ledger from your own invoices, because the package cannot know your totals and should not pretend to.
Where I actually am
I think I am out the other side. I have thought that before on this project, so hold it loosely.
The honest limit today is precision, not coverage. All 51 jurisdictions resolve and the rate records are there. What is missing is the last hop: turning a street address into the right local record.
Addresses are resolved by Geocodio, which is the part of this stack I never had to fight. Give it a US or Canadian address and you get back a country, a state, and with census fields on, a county FIPS. It is fast, it is priced like a tool rather than a platform, and it does not try to be the whole answer. That last part matters here: Geocodio has its own tax append, and I deliberately do not use it, because the entire point of this exercise was to own the calculation. I take the geography and nothing else, which is exactly the shape a good building block should have.
The licensing is the part that would have killed this project somewhere else, and it is worth being specific about. An invoice has to keep the jurisdiction it was taxed in for as long as the invoice matters, which for tax purposes is years, not weeks. Google's Maps Platform terms let you cache a latitude and longitude for at most 30 consecutive calendar days and then require you to delete it. Geocodio's terms say the customer may store, transmit, transform, sell and otherwise use the results as they see fit, during and beyond the term of the agreement, as long as the underlying data sources allow it. One of those two lets you build an auditable tax engine. The other does not, whatever it costs.
The free tier is 2,500 lookups a day, use it or lose it, no rollover. That is enough to develop the whole US path against without putting a card down, which is how I ended up building on it in the first place.
A county is as far as that gets me, and a county cannot single out a city or a special district. So rooftop stacking is opt-in and county-level, and where no local record matches, the engine falls back to the state rate and labels that answer derived rather than authoritative. It tells you what it did not know, which is the least it can do.
Closing that gap is a point-to-jurisdiction crosswalk: a boundary set precise enough to say which taxing districts contain a given rooftop. That is a geocoding problem wearing a tax hat, and it is the piece I am most interested in next. Another data problem, which after the last few months feels about right.
Disclosure, since it would be worse to leave it out: I work at Geocodio. I picked it here for the reasons above and I would have written the same paragraph before I joined, but you should know, and you should discount it accordingly.
The dataset is open at cboxdk/us-tax-dataset. The pipeline that builds it is not, because it is mostly janitorial work against forty government websites and nobody should have to maintain that twice. The engines on top, laravel-tax and laravel-nexus, are MIT, like the rest.
If you are about to say how hard can it be about US sales tax: it is fifty separate tax systems wearing one name, and the code is the easy part.