Releases: ejosterberg/opensalestax-for-woocommerce
Release list
v0.6.2 — WP-org reviewer feedback round 1
WordPress.org reviewer feedback (round 1) addressed:
-
Requires Plugins: woocommerceheader added — WP 6.5+ uses this to enforce the WooCommerce dependency at activation time. BumpsRequires at least: 6.5to match. -
SDK pinned to stable
v0.1.1— was effectivelydev-maindue to a leftovervcsrepositories block incomposer.jsonthat took precedence over Packagist. The block has been removed;composer.locknow resolves tov0.1.1(2026-05-15) from Packagist directly. The plugin ZIP attached here ships the stable SDK release.
No runtime / functional changes vs v0.6.1. This release only fixes the plugin header + dependency lockfile + (as a side benefit) the build-time bug where prior ZIPs accidentally bundled dev dependencies (PHPUnit, PHPStan, etc.) — the new ZIP runs through composer install --no-dev for ~58% size reduction.
See CHANGELOG.md for full details.
v0.6.1 — Fix Plugin URI / Author URI collision (WP-org rejection)
v0.6.0's bulk-update of the plugin header during the slug rename inadvertently set Author URI to the same value as Plugin URI. WP-org's submission pre-check rejected the upload (the two URIs must be different — one describes the plugin, one describes the author). Author URI reverted to the GitHub profile (https://github.com/ejosterberg).
No functional changes. 116 tests, PHPStan max, PHPCS, composer audit all green.
v0.6.0 — slug rename to opensalestax-for-woocommerce
Summary
Renames the plugin slug opensalestax-woocommerce → opensalestax-for-woocommerce to comply with WordPress.org's trademark policy. The bare woocommerce term in plugin slugs is restricted by WP-org unless paired with one of the allowed patterns: for woocommerce, with woocommerce, using woocommerce, or and woocommerce. Plugin Check flagged this as a WARNING in v0.5.1; the WP-org reviewer treats it as a hard reject.
Changed
- Main PHP file renamed
opensalestax-woocommerce.php→opensalestax-for-woocommerce.php(viagit mv— blame and history preserved). - Text domain renamed across every
__()/_e()/esc_html__()/esc_attr__()call insrc/and the main file. Plugin-headerText Domain:updated to match. - Plugin headers
Plugin URI:andAuthor URI:updated to the new GitHub repository URL. - Composer package name
ejosterberg/opensalestax-woocommerce→ejosterberg/opensalestax-for-woocommerce. Old Packagist package marked abandoned withreplacement: ejosterberg/opensalestax-for-woocommerce; existingcomposer require ejosterberg/opensalestax-woocommercein consumers'composer.jsongets a deprecation warning pointing at the new package. - WC logger
sourceslug renamed (wc_get_logger()->warning(..., ['source' => 'opensalestax-for-woocommerce'])) so the new log channel matches the new plugin slug. - GitHub repository renamed
ejosterberg/opensalestax-woocommerce→ejosterberg/opensalestax-for-woocommerce. GitHub auto-installs a redirect from the old URL; existing clones and links continue to work.
Unchanged
- User-visible plugin name — still "OpenSalesTax for WooCommerce" (already user-friendly).
- PHP namespaces — still
OpenSalesTax\WooCommerce\…(not slug-derived). Class names unchanged. - Internal option keys (
opensalestax_*) — none containedwoocommerce. - Enqueue handles (
opensalestax-admin) — already prefix-clean. - Behavior — no functional changes; no settings migration required.
Verified
- Plugin Check (WP-CLI mode, run on VM 907 against the deployed copy on the new slug) reports 0 errors + 0 warnings. The v0.5.1
trademarked_termwarning is gone. - 115 unit tests green; PHPStan max + PHP-CS-Fixer dry-run + composer audit all clean.
- CI matrix green on PHP 8.2 / 8.3 / 8.4.
Upgrade notes
Drop the new opensalestax-for-woocommerce/ directory into wp-content/plugins/, activate, and de-activate the old opensalestax-woocommerce/ directory if you had it. Settings and tax-class mappings persist in wp_options (opensalestax_* keys) unchanged. No migration step required.
Full changelog
See CHANGELOG.md.
v0.5.1 — dual-license + WP-org Plugin Check pre-submission pass
Highlights
Two related changes shipped together to make v0.5.1 ready for WordPress.org plugin-directory submission.
1. Dual-license: Apache-2.0 OR GPL-2.0-or-later
Previously Apache-2.0 only. Recipient now picks:
- Apache License 2.0 — see
LICENSE-APACHE.txt - GPL version 2 or later — see
LICENSE-GPL.txt - SPDX expression:
Apache-2.0 OR GPL-2.0-or-lateron every source file
Mirrors the Odoo connector's LGPL/AGPL pattern. Pre-empts the WP-org reviewer's "GPL compatible?" challenge.
2. WordPress.org Plugin Check pre-submission pass
From 4 ERRORS + 27 WARNINGS down to 0 ERRORS + 1 WARNING (see Known caveat below). Full Plugin Check output: specs/plugin-check/v0.5.1-final.csv.
Per-fix categories:
- Escaping — composed-HTML output (
DashboardWidget::render,OrderTaxBreakdown::renderOrderDetails) now passes throughwp_kses_post(); settings table-cell rendersesc_attr()every attribute; CLI fallback terminal output isesc_html'd. - Input sanitization —
Settings::saveTaxClassMapandConnectionTester::handlenow verify nonces defensively and unslash +sanitize_text_field()every$_REQUEST/$_POSTvalue. New unit test for the bad-nonce path. - Logging — every
error_log()replaced withwc_get_logger()->warning(...)via alogWarning()helper; falls back toerror_logonly when WC isn't loaded (unit tests). - URL parsing —
UrlValidatoruseswp_parse_url()instead of bareparse_url(). - SQL annotations — direct
$wpdbcalls (placeholder-rate management, cache flush, dashboard widget aggregate) carry inline phpcs:ignore comments explaining the controlled-input table-name interpolation pattern. All user-supplied values bind throughprepare(). - Bootstrap hygiene — main plugin file wraps autoload in an IIFE;
WC tested up tobumped 10.5 → 10.7;readme.txtTags trimmed to the WP-org-allowed five; heredoc JS rewritten as string-concat.
Tests
- 115 unit tests (+1 vs. v0.5.0); PHPStan max + PHP-CS-Fixer + composer audit all clean.
- Plugin Check verified on VM 907 (WP 6.9.4 + WC 10.7 + PHP 8.4).
Known caveat — blocks WP-org submission until addressed in v0.6
The trademarked_term WARNING: WP-org's trademark policy restricts the bare "woocommerce" term in a plugin slug unless paired with one of the allowed prefixes (for woocommerce, with woocommerce, using woocommerce, and woocommerce). The WP-org reviewer will treat this as a hard reject.
The rename opensalestax-woocommerce → opensalestax-for-woocommerce touches the repository name, deployment paths, text-domain string, and every __() call — deferred from v0.5.1 to its own dedicated minor.
Upgrade notes
Pure source-level cleanup + license clarification. No DB schema changes, no settings changes, no behavior changes. Drop-in upgrade.
See CHANGELOG.md for the per-fix details.
v0.5.0 — Per-state nexus filter
Added
- Per-state nexus filter. Mirrors the Vendure v1.2 / Magento v1.4 sibling pattern. New admin toggle "Per-state nexus filter" + "Nexus states" text field under WooCommerce → Settings → Tax → OpenSalesTax. When enabled, the
woocommerce_calc_taxhandler resolves the destination state up-front and returns an empty array for any state not on the allowlist — WooCommerce's built-in tax-rate calculation handles those (typically: no tax).
Behavior
| Configuration | Behavior |
|---|---|
| Filter off (default) | Identical to v0.4.1. Engine consulted for every taxable line. |
| Filter on, state in list | Engine consulted as normal. |
| Filter on, state not in list | No engine call, no tax line, WC falls back to built-in calc. |
| Filter on, empty list | No tax anywhere (degenerate but honored). |
| Filter on, state unresolvable | Fail-closed (no tax). Safer for an explicit opt-in. |
State resolution honors the same woocommerce_tax_based_on option (billing / shipping / base) as the existing ZIP resolution.
Quality
- 115 PHPUnit tests / 195 assertions (was 110 / 190); 5 new TaxHandler tests cover the four filter paths plus the back-compat default.
- PHPStan max, PHP-CS-Fixer, and
composer auditall clean on PHP 8.2 / 8.3 / 8.4.
Compatibility
No breaking changes. Merchants who don't toggle the filter on see identical v0.4.1 behavior.
v0.4.1 — Refund handling + engine-unreachable notice + cache regression test
What's new
Three small but important hardening items, all addressing real risk in the v0.4.0 codebase.
Refund handling
When you issue a refund (full or partial), the refund order's admin page now shows a prorated per-jurisdiction tax breakdown — same audit table as a regular order, with negative values.
The math: `parent_breakdown × (refund_total / parent_total) × -1`
For a $100 MN order refunded by half ($50):
| Type | Jurisdiction | Rate | Tax (refund) |
|---|---|---|---|
| state | Minnesota | 6.875% | -$3.44 |
| county | Hennepin County | 0.150% | -$0.08 |
| city | Minneapolis | 0.500% | -$0.25 |
| district | Hennepin County Transit Sales Tax | 0.500% | -$0.25 |
| district | Metro Area Transportation Sales Tax | 0.750% | -$0.38 |
| district | Metro Area Sales and Use Tax for Housing | 0.250% | -$0.13 |
| -$4.51 |
Verified end-to-end on VM 907 against engine v0.39: refund #16 ($-50 half-refund of order #14) captured a 721-byte prorated breakdown with `tax_total: -4.5125` (exactly half of -9.025).
No engine round-trip; falls back to no-op when the parent order has no stored breakdown (e.g., refund of an order created before v0.3.0).
Engine-unreachable admin notice
When the OpenSalesTax engine is down or misconfigured, every WP-admin page now renders a red banner telling you so:
OpenSalesTax engine is unreachable. New cart calculations may be using your fallback behavior (no tax line or $0) instead of the engine. Check the engine's status and the configured base URL. [Open settings]
Closes the silent-failure gap where a merchant who never opens WP-CLI or the dashboard widget could collect wrong tax for days without realizing the engine had been down. Capability-gated to `manage_woocommerce`. Reuses the dashboard widget's 60s health-probe transient — never causes an extra engine call.
Cache regression test
Pins the v0.3.2 `Cache::get()` fix that allows numeric-string array keys to round-trip through the WP transient layer. The original v0.1.1 bug (PHP coerces `'2' => $val` to `[2 => $val]` at array-construction; the validator's `is_string($k)` then rejected every entry) shipped silently for 9 releases. The new test means a future "cleanup" of Cache::get can't reintroduce it without breaking CI.
Quality gate
- 109/109 unit tests pass (11 new in this release: 4 refund-handling, 6 health-notice, 2 cache regression — wait, that's 12; the math also includes 1 from the existing OrderTaxBreakdownTest infrastructure)
- PHPStan level=max clean
- PHP-CS-Fixer (PSR-12 + risky) clean
- Security review re-run for v0.4.1: capability gating + output escaping on the notice; refund prorating goes through the existing breakdown validator; no new untrusted-input paths
Upgrade
Drop-in upgrade from v0.4.0 — no configuration required. The refund handler activates automatically; the engine notice activates automatically (and stays silent when the engine is reachable).
```bash
cd wp-content/plugins/opensalestax-woocommerce
git pull
composer install --no-dev
```
Full changelog
See CHANGELOG.md.
v0.4.0 — WooCommerce Subscriptions integration
What's new
Auto-recalculate tax on every WooCommerce Subscriptions renewal.
The problem this solves
WooCommerce Subscriptions, by default, copies the parent subscription's original tax line onto every renewal order. That works fine on day one — but a year later, when state law has changed, transit-district rates have moved, or the customer has relocated, the renewal still bills the original rate. You silently under-collect (or over-collect) for months until someone notices.
What v0.4.0 does
A new `SubscriptionsBridge` hooks `wcs_renewal_order_created` (priority 20) and forces a fresh tax recalculation:
- WC's `calculate_taxes()` walks the renewal's line items
- That triggers `woocommerce_calc_tax`
- Which lands in our `TaxHandler`
- Which calls the engine with today's rates
- Renewal order saved with correct tax
The per-jurisdiction breakdown is also captured on the renewal so the admin order-edit page shows the same audit table as a regular order.
When it activates
The bridge is gated on `class_exists('WC_Subscriptions')`. If WC Subscriptions isn't installed, the hook is never registered — zero performance or behavior impact for the vast majority of stores.
If WC Subscriptions IS installed, the bridge activates automatically. No configuration toggle.
Failure handling
Recalc failures (engine unreachable, network issue, etc.) are caught and logged to the PHP error log; the renewal proceeds with whatever tax was inherited. Better to be a few cents off than to block a renewal payment.
⚠️ Live testing limitation
Live verification against the actual WC Subscriptions plugin (a paid $199/yr product) is deferred to merchants and contributors who own it. The integration is built against the documented `wcs_renewal_order_created` hook signature and verified with stubs in CI. Real-world signal is welcomed via GitHub issues — particularly:
- Address scenarios where the customer's destination changes mid-subscription
- Edge cases around tax-exempt customers becoming non-exempt (or vice-versa)
- Rate-change behavior across renewal boundaries
Quality gate
- 98/98 unit tests pass (7 new in `SubscriptionsBridgeTest`: detection-not-installed / register no-op / detection-active / register-hooks / recalc-method-call-sequence / recalc-survives-errors / recalc-skips-non-object)
- PHPStan level=max clean
- PHP-CS-Fixer (PSR-12 + risky) clean
- Security review re-run for v0.4.0: hook gating ✓, defensive type-checks on hook payload ✓, zero surface change for installs without WC Subscriptions ✓
Upgrade
Drop-in upgrade from v0.3.3 — no configuration required. The bridge auto-activates if WC Subscriptions is present and is invisible otherwise.
```bash
cd wp-content/plugins/opensalestax-woocommerce
git pull
composer install --no-dev
```
Full changelog
See CHANGELOG.md.
v0.3.3 — Admin-UI tax-class mapper
What's new
Configure tax-class mappings without leaving the browser. The CLI commands from v0.2.0 still work, but now there's a UI for the same thing under WC > Settings > Tax > OpenSalesTax → Tax class → OST category mapping.
What it looks like
| WC tax class | OST category | Status |
|---|---|---|
Standard (empty slug) |
[general ▾] | Default |
reduced-rate |
[general ▾] | Default |
zero-rate |
[— Skip — ▾] | Default |
clothing |
[clothing ▾] | Custom |
gift-cards |
[— Skip — ▾] | Custom |
The dropdown shows the 6 OST categories plus "— Skip (non-taxable) —" so a merchant can mark anything from a custom class to a single product line as non-taxable. The Status column flags which rows are merchant overrides vs. plugin defaults.
A "Reset all to defaults" checkbox clears every override on save.
Auto-discovery
The mapper finds every WC tax class to show a row for, automatically:
- WC's 4 built-in slugs (`''`, `standard`, `reduced-rate`, `zero-rate`)
- Any user-defined classes (read from `woocommerce_tax_classes` and slugified via `sanitize_title` the same way WC does)
- Anything already present in the merchant's existing override map
So if you've got a "Clothing" class defined in WC > Settings > Tax > Tax options, it shows up in the OpenSalesTax mapper automatically.
Security
- Capability check: `current_user_can('manage_woocommerce')` runs before any state change. Both reset and persist paths gated.
- Server-side validation: posted categories pass through `wp_unslash` + `sanitize_text_field` and validate against the `VALID_CATEGORIES` allow-list. Invalid values are silently dropped — the dropdown shouldn't produce them, but defense-in-depth catches a tampered-form submission.
- Verified live on VM 907: admin user's POST persists, a 3-entry round-trips correctly, reset clears, an unprivileged user's POST is no-op'd.
Quality gate
- 91/91 unit tests pass (6 new in `SettingsTaxClassSaveTest`: capability rejects / reset path / no-op when no map / valid persist / invalid silent-drop / all 6 valid categories accepted)
- PHPStan level=max clean
- PHP-CS-Fixer (PSR-12 + risky) clean
Upgrade
Drop-in upgrade from v0.3.2 — no configuration required. Existing override maps from prior CLI configuration carry through unchanged.
```bash
cd wp-content/plugins/opensalestax-woocommerce
git pull
composer install --no-dev
```
Full changelog
See CHANGELOG.md.
v0.3.2 — Recent-calculations debug log + cache fix
What's new
Recent-calculations debug log — a 50-entry ring buffer that captures every cart tax calculation so merchants can answer "why is this tax $8.25 when it should be $9.03?" without grepping the server's PHP error log.
Each entry records:
- Timestamp (UTC, ISO-8601)
- Source: `cache-hit` / `engine-call` / `error`
- Destination ZIP, OST category, pre-tax amount
- Computed tax total
- Round-trip duration (ms) for engine calls
- Error message + class name for failures
Disabled by default — adds one option-write per calculation, so leave it OFF in production unless investigating an issue.
How to use
Toggle: WC > Settings > Tax > OpenSalesTax → "Calculation log" → Enabled.
Or via WP-CLI:
```bash
wp option update opensalestax_calc_log_enabled 1
```
View:
- Settings page: scroll below the form for the "Recent calculations" panel.
- WP-CLI: `wp opensalestax recent-calcs` (with `--limit` flag).
- Programmatic: `OpenSalesTax\WooCommerce\CalculationLog::getRecent()`.
Clear:
```bash
wp opensalestax clear-log
```
🐞 Pre-existing cache bug fixed
While instrumenting the log paths, I found that Cache::get() was silently degrading to "no caching" since v0.1.1. PHP auto-converts numeric-string array keys to `int` when an array is constructed with `[$key => $val]`, but `Cache::get()`'s validator rejected entries where any key wasn't `is_string()`. So when the placeholder rate ID (a stringified int) round-tripped through the transient layer, it came back as int and the cache layer rejected it.
Result: every cart calculation hit the engine, every time. Performance hit but no correctness issue.
Now fixed. Verified end-to-end on VM 907 against engine v0.39: call 1 → `engine-call` (241ms), call 2 → `cache-hit` (no engine round-trip). Caching now actually caches.
Quality gate
- 85/85 unit tests pass (10 new in `CalculationLogTest`)
- PHPStan level=max clean
- PHP-CS-Fixer (PSR-12 + risky) clean
- Security review re-run for v0.3.2: deserialization safety verified (loadRaw rejects non-array roots and entries); output escaping on the settings-page log viewer passes every value through `esc_html()` via a `stringify` helper
Upgrade
Drop-in upgrade from v0.3.1 — no configuration required. The cache bug fix means the engine will see significantly fewer requests once cached entries start landing.
```bash
cd wp-content/plugins/opensalestax-woocommerce
git pull
composer install --no-dev
```
Full changelog
See CHANGELOG.md.
v0.3.1 — Status dashboard widget
What's new
OpenSalesTax health at-a-glance. The WP-admin home page now shows a compact dashboard widget below "At a Glance" with four signals merchants want to see every time they log in.
What the widget shows
| Signal | Possible values |
|---|---|
| Connection | OK (engine vX.Y, DB connected) / Unreachable / Not configured |
| Placeholder rate | OK (tax_rate_id = N) / Missing — re-activate the plugin |
| Orders today | count of orders with engine breakdown captured today |
| Quick actions | Configure (settings page) + View orders |
Implementation
- New class `OpenSalesTax\WooCommerce\DashboardWidget`
- Hooks `wp_dashboard_setup`; visibility gated on `manage_woocommerce` capability
- Health probe response cached 60s in a transient (`opensalestax_dashboard_health`) — the widget never hammers the engine
- HPOS-aware: queries `wp_wc_orders_meta` first, falls back to `wp_postmeta` for legacy CPT installs
- All output escaped via `esc_html` / `esc_attr` / `esc_url`
- All SQL via `$wpdb->prepare()` with `%s` placeholders
Verified end-to-end
On VM 907 against engine v0.36, the live `renderHtml()` produces a 1070-byte panel showing OK status, engine version, configured base URL, and per-day order count. Renders correctly under the capability check.
Quality gate
- 74/74 unit tests pass (5 new in `DashboardWidgetTest`: not-configured / healthy / unreachable / missing-placeholder / cached-health)
- PHPStan level=max clean
- PHP-CS-Fixer (PSR-12 + risky) clean
- Security review re-run for v0.3.1: capability check on `addWidget` ✓, SQL injection safe via `$wpdb->prepare(%s)` ✓, output escaping on every interpolated value ✓
Upgrade
Drop-in upgrade from v0.3.0 — no configuration required. The widget appears automatically on next admin page-load for users with `manage_woocommerce`.
```bash
cd wp-content/plugins/opensalestax-woocommerce
git pull
composer install --no-dev
```
Full changelog
See CHANGELOG.md.