@ejosterberg/vendure-plugin-opensalestax
v1.4.0
Published
Vendure plugin for destination-based US sales tax via the self-hosted OpenSalesTax engine.
Maintainers
Readme
@ejosterberg/vendure-plugin-opensalestax
Vendure plugin for destination-based US sales tax via the self-hosted OpenSalesTax engine. No SaaS, no per-transaction fees, no third-party API keys.
- Implements Vendure's
TaxLineCalculationStrategy— Vendure invokes us perOrderLine; we return per-jurisdiction tax lines (state, county, city, transit district, etc.) - USD-only / US-only. Non-USD or non-US orders pass through
to Vendure's built-in
TaxRatepipeline unchanged. - Fail-soft default. If the OpenSalesTax engine is unreachable, the plugin returns zero tax + logs a warning; Vendure's default tax rates take over so checkouts don't block. Opt into fail-hard via env var.
- No inbound HTTP surface. Pure outbound HTTP to the configured engine URL. The plugin runs in-process inside your Vendure server.
Compatibility
| Plugin | Vendure | OST engine | Node | |--------|---------|-----------|------| | 0.1.x | 3.x | 0.22+ (v1 API) | 20+ |
Quickstart (≤10 minutes)
1. Install
npm install @ejosterberg/vendure-plugin-opensalestax2. Wire it into vendure-config.ts
import { OpenSalesTaxPlugin } from '@ejosterberg/vendure-plugin-opensalestax';
import { VendureConfig } from '@vendure/core';
export const config: VendureConfig = {
// ...your existing config...
plugins: [
// ...your existing plugins...
OpenSalesTaxPlugin.init({
apiUrl: process.env.OSTAX_API_URL!,
// failHard: true, // optional; default fail-soft
// apiToken: '...', // optional; X-API-Key header
// timeoutMs: 5000, // optional; per-request timeout
}),
],
};3. Set the environment variable
# Point at your OpenSalesTax engine instance (HTTP or HTTPS)
export OSTAX_API_URL="https://ost.your-domain.com"
# Optional:
# export OSTAX_API_TOKEN="..."
# export OSTAX_FAIL_HARD=1
# export OSTAX_TIMEOUT_MS=50004. (Recommended) Create a US Zone in Vendure Admin
As of v1.1.0 this step is optional — the plugin's
OstaxTaxZoneStrategy auto-resolves the active zone for US
shipping addresses, so checkout works even with a fresh
Vendure install. Still, having a real Zone is best practice.
In the Admin UI:
- Settings → Zones → Add a Zone with United States as a member country (name it whatever you like).
- Settings → Tax Categories → Confirm "Standard" exists (or create it).
- Settings → Tax Rates → Create a placeholder rate
"US Standard" of
0%in your US zone, category Standard. (OST provides the real rate via the strategy; this placeholder exists so Vendure's normal pipeline always has something to fall back to.)
If you skip this step, the plugin will log a one-time WARN at startup recommending you create a US Zone, and checkout will fall through to your channel's default Zone — taxes still flow from the OST engine either way.
5. Restart Vendure
On boot you'll see a log line like:
[OpenSalesTaxPlugin] OpenSalesTax engine reachable: status=ok version=0.55.4 db=trueIf the engine is unreachable, you'll see a WARN (default fail-soft mode) — the plugin still loads and Vendure's default tax pipeline takes over until the engine comes back.
6. Place a test US order
Use the Shop GraphQL API to add an item, set a US shipping
address, and inspect taxLines:
mutation {
setOrderShippingAddress(input: {
streetLine1: "100 N 6th St"
city: "Minneapolis"
province: "MN"
postalCode: "55403"
countryCode: "US"
}) {
... on Order {
lines {
taxLines { description rate }
}
totalWithTax
}
}
}You should see one TaxLine per OST jurisdiction (e.g.
"Minnesota (State)", "Minneapolis (City)", "Hennepin Transit
(Transit)") with the correct percentage rates. Vendure
multiplies these against the line price to compute the actual
tax amount.
Configuration reference
| Option | Env var | Default | Description |
|---------------|-----------------------|---------|-------------|
| apiUrl | OSTAX_API_URL | — | Required. Base URL of your OST engine. Must be http: or https:. Validated at plugin init. |
| apiToken | OSTAX_API_TOKEN | — | Optional. Sent as the X-API-Key header. Most self-hosted deployments don't need this. |
| failHard | OSTAX_FAIL_HARD=1 | false | When true, engine errors throw (Vendure surfaces as order error). Default false returns [] + warns. |
| timeoutMs | OSTAX_TIMEOUT_MS | 5000 | Per-request timeout in milliseconds. |
| categoryByTaxCategoryName | — | {} | Map Vendure TaxCategory.name → OST category. See "Tax category mapping" below. |
| defaultCategory | — | 'general' | OST category for lines whose TaxCategory name doesn't appear in the map. Set to '' (empty string) to make unmapped lines non-taxable. |
| enabledStates | — | — | Allowlist of US state codes (uppercase 2-letter). When set, the plugin computes only for orders shipping to one of these. See "Per-state nexus filter" below. |
| disabledStates | — | — | Denylist of US state codes. Mutually exclusive with enabledStates. |
Options passed to init() take priority over env vars.
Per-state nexus filter (v1.2.0+)
Sales-tax nexus is the legal threshold (physical presence, economic activity) that obligates a merchant to collect and remit tax in a state. No US merchant has nexus in all 50 states, and the OpenSalesTax engine doesn't decide nexus for you — that's your call (or your accountant's). Use one of these options to tell the plugin where you collect:
// Pattern 1 — allowlist: most small merchants
OpenSalesTaxPlugin.init({
apiUrl: process.env.OSTAX_API_URL!,
enabledStates: ['MN', 'WI', 'IA'], // collect only in these 3
}),
// Pattern 2 — denylist: larger merchants with broad footprints
OpenSalesTaxPlugin.init({
apiUrl: process.env.OSTAX_API_URL!,
disabledStates: ['MT', 'WY'], // skip these; collect everywhere else
}),When the order's shippingAddress.province doesn't match the
configured filter, the strategy returns [] and Vendure's
default TaxRate pipeline takes over (typically zero tax for
a US-only merchant).
Validation: state codes must be uppercase 2-letter ISO
3166-2 subdivision codes (the same format Vendure's stock
checkout form populates). Invalid entries (lowercase, full
names, non-letter) throw at plugin init with a list of bad
codes. Setting both enabledStates and disabledStates
throws.
Empty array (enabledStates: []) is treated as no filter
(footgun mitigation). To disable the plugin entirely, remove
it from your plugins array.
Migration from v1.1: zero config required. v1.1 computed for every US ZIP (no nexus filtering); v1.2's defaults preserve that. Add the filter when you're ready.
Tax category mapping (v1.1.0+)
The OST engine accepts a category per line item and applies
category-specific rules — clothing exemptions in some states,
groceries usually exempt, prescription drugs always exempt, etc.
Map each of your Vendure TaxCategory records to one of OST's
six categories so the engine can apply the right rules.
OST categories (the values in the map):
| Value | Use for |
|-------|---------|
| 'general' | General merchandise (default) |
| 'clothing' | Apparel (PA, MN, NJ exempt; rules vary) |
| 'groceries' | Unprepared food (most states exempt or reduced) |
| 'prescription_drugs' | Rx drugs (universally exempt) |
| 'prepared_food' | Restaurant / hot food (often higher rate) |
| 'digital_goods' | Digital downloads (varies by state) |
| '' (empty string) | Skip the line entirely — non-taxable |
Worked example — a mixed retailer:
In the Vendure Admin UI, create a TaxCategory for each
product class you sell ("Clothing", "Food", "Digital",
"Gift Cards", and the existing "Standard"). Assign each
ProductVariant to the right category as you create it.
Then in vendure-config.ts:
OpenSalesTaxPlugin.init({
apiUrl: process.env.OSTAX_API_URL!,
categoryByTaxCategoryName: {
'Clothing': 'clothing',
'Food': 'groceries',
'Digital': 'digital_goods',
'Gift Cards': '', // non-taxable; engine never called
// 'Standard' isn't listed → falls back to defaultCategory
},
defaultCategory: 'general',
}),A merchant whose entire catalog is clothing can skip the
mapping and just set defaultCategory: 'clothing'.
Validation: invalid values throw at plugin init — you'll see the error at server boot, not at first checkout. Valid values are listed in the table above.
Migration from v1.0: zero config required. v1.0 hardcoded
category: 'general' for every line; v1.1's defaults reproduce
that behavior exactly. Add the mapping when you're ready.
How it works
┌─────────────────────────────────────────────────┐
│ Merchant's Vendure server │
│ ┌─────────────────────────────────────────┐ │
│ │ Order checkout flow │ │
│ │ ↓ │ │
│ │ TaxZoneStrategy.determineTaxZone() │ │
│ │ ↓ (US ship-to → US Zone, else default) │ │
│ │ TaxLineCalculationStrategy.calculate() │ │
│ │ ↓ │ │
│ │ OstaxTaxLineStrategy │ │
│ │ ├─ Gate: USD? US? valid ZIP? │ │
│ │ └─ POST /v1/calculate ─────────────────┼────┼──→ OpenSalesTax engine
│ └─────────────────────────────────────────┘ │ (merchant-self-hosted)
└─────────────────────────────────────────────────┘The plugin registers two strategies:
OstaxTaxZoneStrategy(added in v1.1.0): inspects the order's shipping country code; if it'sUS, finds a Zone whose members include the United States and returns it; if no US Zone exists, returns the channel's default Zone (with a one-time WARN suggesting you create one). For non-US orders, returns the channel default unchanged.OstaxTaxLineStrategy: per-line tax calculation against the OST engine, gated on USD currency + US shipping country- valid ZIP regex.
For each order line, the plugin:
- Gates on
currencyCode === "USD",shippingAddress.countryCode === "US", and ZIP regex^\d{5}(-\d{4})?$. If any fail → return[]and Vendure's built-inTaxRatepipeline takes over. - Sends a single-line
POST /v1/calculateto the engine with the line'sproratedUnitPrice(in dollars) and category"general". - Maps each jurisdiction in the response to a Vendure
TaxLine(description+taxRate). Vendure handles the multiplication.
Troubleshooting
"OpenSalesTax engine health check failed at startup"
The plugin couldn't reach apiUrl at boot. The plugin still
loads (fail-soft); calculations will return zero tax until the
engine is reachable. Check:
- Is
OSTAX_API_URLset and reachable from the Vendure host? - Is the OST engine container running and healthy?
- Can you
curl $OSTAX_API_URL/v1/healthfrom the Vendure server?
"Returns zero tax even for US orders"
Possible causes:
- The engine is unreachable — check Vendure boot logs for the health-check warning.
- The order's
shippingAddress.countryCodeis not exactly"US"(case-sensitive). - The order's
postalCodedoesn't match^\d{5}(-\d{4})?$. - The order's
currencyCodeis not"USD". - The OST engine has no rate data for the destination ZIP (rare; check engine logs).
"I see a one-time WARN about no US Zone"
Expected if you haven't created a Zone with United States as
a member. Checkout still works — the plugin falls through to
your channel's default Zone. To silence the WARN, create a
US Zone in Settings → Zones. The WARN is rate-limited to
once per process so it won't flood your logs.
"I want errors to surface, not silently fall back"
Set OSTAX_FAIL_HARD=1 (or init({ failHard: true })).
Engine errors then throw, which Vendure surfaces as an order
error in the Admin UI.
"Plugin doesn't appear to register"
Verify in the running Vendure server:
import { ConfigService } from '@vendure/core';
// In a NestJS-aware context (e.g. an admin-API resolver):
const cfg = injector.get(ConfigService);
console.log(cfg.taxOptions.taxLineCalculationStrategy);
// Expect: OstaxTaxLineStrategyCommon causes: forgot to call OpenSalesTaxPlugin.init({...})
(the init() is required to capture options); plugin
registered after a different plugin that also overrides
taxLineCalculationStrategy.
What this plugin does NOT do
By design (constitution §6 + §10):
- Filing or remittance. It computes tax. You file.
- Address validation. Bring your own / use Vendure's.
- Non-USD currencies. Returns
[]so Vendure's default pipeline applies. - Non-US jurisdictions. Same — returns
[]. - Tax-exempt customer certificate validation.
- Marketplace facilitator (NJ / CA seller-of-record edge cases).
- Inbound webhooks / standalone server. Pure outbound HTTP to your engine.
For any of these, run a separate process or use a dedicated service.
Security
The plugin exposes no inbound HTTP routes, no GraphQL resolvers, no webhook receivers. The trust boundary is your Vendure host; whatever code loaded the plugin is already trusted.
apiUrl is validated at plugin init: URL parse + scheme
allowlist (http:, https:). Customer addresses, line item
descriptions, product names, and customer email are never
logged.
Reporting vulnerabilities: see SECURITY.md.
Contributing
DCO sign-off mandatory on every commit
(git commit -s). See CONTRIBUTING.md.
Dual-licensed under your choice of Apache-2.0 OR GPL-2.0-or-later.
See LICENSE.
Related projects
| Connector | Stack | Repo | |-----------|-------|------| | OpenSalesTax engine | Python | opensalestax | | Medusa v2 | TypeScript | medusa-plugin-opensalestax | | WooCommerce | PHP | woocommerce-opensalestax | | Odoo | Python | opensalestax-odoo-src | | Vendure | TypeScript | this repo |
