DEV Community

InvoiceTariff
InvoiceTariff

Posted on

I Packaged Our Tariff Engine for npm — Versioned Data, Zero Dependencies, and Trusted Publishing

Shipping data inside a code package is supposed to be an anti-pattern. Data goes in databases, or S3 buckets, or API responses; packages ship logic. We spent October deliberately violating that rule: our npm package us-tariff-lookup bundles a 452KB minified ruleset — every US import tariff layer we track, with legal citations — next to the TypeScript that queries it.

This is the story of why the violation is the feature, how the data gets into the bundle without leaking our internal docs, and what publishing through npm's staging-and-Trusted-Publishing pipeline actually felt like from the outside. (The engine itself is a hundred-odd KB of TypeScript across nine files — the data outweighs the logic by 4:1, which is the point.)

The case for shipping the data

US tariff math has a reproducibility problem. The rules changed roughly five times a month through 2026 — new Section 301 tiers, sector measures, fee schedules — and every change retroactively makes your old quotes look wrong. If your pricing page computes duty by calling a rate API, you can never answer the question that arrives three months later: "what rules were you on when you quoted this?"

So we inverted the design. The package ships one versioned seed — rulesetVersion and dataAsOf right in the JSON — and the engine is a set of pure functions over it. Same input, same output, forever, until you consciously upgrade. No API key, no network calls, no per-quote billing, and dataAsOf travels with every result so an auditor can replay the number.

The cost of that choice is real: updates are a release, not a deploy. We think that's the correct trade for something people use to sign quotes. Your CI pins the version; your quotes stay explainable.

Pure functions over one seed

The engine surface is deliberately boring:

import { rulesetSeed, searchHts, calculateLandedCost } from 'us-tariff-lookup';

const hits = searchHts(rulesetSeed, 't-shirt', 10);   // EN + 中文 keyword search
const r = calculateLandedCost(rulesetSeed, {
  hts: '61091000', value: 20000, originCountry: 'CN',
  entryDate: '2026-10-21', mode: 'sea', units: 5000, locale: 'en',
});
Enter fullscreen mode Exit fullscreen mode

calculateLandedCost returns layers, not a number:

{
  "totalDuty": 7394.28,
  "effectiveTaxRate": 36.97,
  "layers": [
    { "kind": "mfn",       "rateLabel": "16.5%",  "amount": 3300, "legalBasis": "HTS Column 1 general rate" },
    { "kind": "china301",  "rateLabel": "7.5%",   "amount": 1500, "chapter99": "9903.88" },
    { "kind": "global301", "rateLabel": "12.5%",  "amount": 2500, "status": "litigation" }
  ],
  "fees": [ /* MPF, HMF with statutory citations */ ],
  "warnings": [ /* live caveats, e.g. fee table pending verification */ ]
}
Enter fullscreen mode Exit fullscreen mode

Every layer carries its legalBasis, a source URL (USITC / USTR / Federal Register), and a status. That's not documentation generosity — it's the data model. When one layer is in litigation and the others hold, a flattened percentage can't tell you which part of your number is shaking.

Bitemporal by necessity

The awkward lesson of 2026 trade policy: when a shipment entered decides what it pays. The same HTS code answers differently on either side of a layer's effective date. So entryDate is a calculation input, not metadata — the engine is bitemporal (rules-date × entry-date), and it refuses dates outside dataAsOf + 90 days with the exact ceiling in the error message:

Entry date cannot be later than 2027-01-05 (today + 90 days)
Enter fullscreen mode Exit fullscreen mode

Monthly package updates slide that window forward. The 90-day horizon isn't a technical limit — it's honesty about how far ahead the rules actually exist.

Getting 452KB of data into the bundle without leaking

The data pipeline has one hard rule: the published artifact is built only from public, cleaned data. Our working copies of the ruleset carry internal document references in meta.baseline — research notes, strategy doc paths. Private documentation is fine; a public npm tarball containing internal docs paths is not.

The build is a thin esbuild script:

await build({
  entryPoints: [join(here, '..', '..', 'engine', 'src', 'index.ts')],
  bundle: true, format: 'esm', platform: 'neutral', target: 'node18',
  minify: true, outfile: join(dist, 'index.mjs'),
});
Enter fullscreen mode Exit fullscreen mode

platform: 'neutral' keeps Node built-ins out of the bundle graph, minified ruleset JSON inlines into the output, and a hand-written CLI (bin: us-tariff) gets copied in as a separate file. Tarball: 96KB. Dependencies: zero.

The incident that wrote the rule: our first build bundled the working-copy JSON, and a pre-publish grep caught internal paths sitting in the seed's metadata. We rebuilt from the public clean source (the us-tariff-rates-2026 repo), re-verified the three reference calculations digit-for-digit, and made "build only from the public snapshot" a script-level invariant instead of a memory.

Publishing: staging, tokens, and an OIDC cliff

npm's flow for a first-time publisher turned out to be sensibly paranoid: publish to staging, a human approves, then it goes live. Our 0.1.0 sat in staging until a manual approval clicked it out on October 10.

The token zoo is where we lost an afternoon. Granular publish tokens now start with npm_ — our incident notes from an older automation assumed a different prefix and simply never matched. And npm's new Trusted Publishing (OIDC: tag a release, CI publishes without a long-lived token) needs your GitHub side configured too — our PAT turns out to lack the workflow scope, so the release workflow file couldn't be pushed by automation and the OIDC loop stays unverified for now. 0.1.1 will be the first tag-driven release; if the OIDC path works, the long-lived token gets revoked the same day. (Manual 2FA steps are exactly the parts we refuse to script around.)

What we'd tell ourselves in September

  • Ship the version, not just the data. rulesetVersion + dataAsOf in every response has already paid for itself in support threads.
  • Grep the artifact like it's untrusted. The tarball is a public surface; treat internal strings as a leak class, not a style issue.
  • Bitemporal isn't gold-plating. It's the minimum model for rules that change under you — one date field turned every "why did the rate change" question into a five-minute answer.
  • Known limits, stated plainly: the bundled search covers 484 curated high-frequency codes (the full 5,689-line HTS ships separately), most rate lines carry explicit sample/unverified flags, and multi-word search queries don't tokenize yet — that fix rides the next release.

If you want the consumer side — npm install to a working landed-cost endpoint in Express or Next — that's the integration tutorial. And the raw data, free without any package, is the datasets post.

Numbers in this post come from us-tariff-lookup@0.1.0 (ruleset v0.3.4, as of 2026-10-07). Estimates for planning only — not legal advice; your customs broker signs the entry.

npm install us-tariff-lookup — zero dependencies, versioned data bundled, MIT licensed. Source on GitHub; the web calculator lives at invoicetariff.com.

Top comments (0)