Sales Tax at the Speed of Commerce
The TaxCloud sales tax extension for Magento Open Source and Adobe Commerce 2.4.x.
π fedtax.github.io/Magento2: installing, configuring, and how every feature behaves. Start there if you run a store.
- Installing the extension: supported versions, Composer, upgrading, uninstalling
- Settings reference: every admin setting and its configuration path
- Common problems and sending diagnostics to support
- Extending the extension: events, log location, settings with no admin field
The rest of this README is for developers working on the extension itself.
- Fork the repository and create a feature branch
- Make your changes and test them in a Magento installation
- Submit a pull request
Note: Tests run automatically on pull requests via GitHub Actions.
Unit tests run with the PHPUnit and Magento classes of the Magento
installation this module lives inside β the module has no vendor/ of its
own. With the module checked out at app/code/Taxcloud/Magento2/:
make test-unit # or: make test
make test-unit MAGENTO_ROOT=/path/to/magento # explicit install rootIn CI, unit tests run on every push/PR against a matrix of Magento
versions (see .github/workflows/test.yml).
Integration tests live in their own pipeline β they boot the full Magento
application against a real database and run via PHPUnit. They are expensive
and run on demand (or on release tags), not on every push. See
Integration tests for the 5-minute
local setup and the matrix of supported editions/versions. The suite
includes live v3 REST API contract checks (Test/Integration/Rest/), which
is why TAXCLOUD_API_V3_KEY (a real key from Developer β API) is required
alongside the V1 sandbox pair; the E2E suite additionally re-runs every
checkout journey with the store switched to V3 REST (see
E2E tests).
This module is linted against the Magento2 ruleset from
magento/magento-coding-standard,
pinned to ^40. On top of the PSR-1/PSR-2 basics it adds the Magento-specific
checks that a generic PHP standard has no opinion on: short array syntax,
constant visibility, discouraged functions, proxies/interceptors requested in
constructors, legacy Mage entities, and Magento's PHPDoc conventions.
The ruleset, the scanned paths and the deferrals all live in
phpcs.xml.dist, which both CI and make lint run with no
arguments, so the two cannot drift:
make lint # run the standard (uses the Magento install's phpcs)
make lint-fix # auto-fix what phpcbf can (short arrays, whitespace, imports)Two things are worth knowing before changing this setup:
- The
Magento2standard reports findings as warnings, not errors. Nothing it reports on this module is an error, so the gate deliberately fails on any finding, viaphpcs's exit code. Grepping the output for"ERROR"could never fail against this ruleset. Magento2is not a superset of PSR-12. It cherry-picks PSR-1/PSR-2 sniffs and exactly one PSR-12 sniff (PSR12.Properties.ConstantVisibility). It is used on its own here for the Magento-specific coverage; that choice trades away PSR-12's stricter whitespace, import and control-structure sniffs. Adding<rule ref="PSR12"/>alongside it is viable but not free β the two standards configure some shared Squiz sniffs with conflicting properties (ignoreBlankLines,equalsSpacing), so whichever is included last silently wins.
Some sniffs are deferred rather than satisfied, in the same tactical spirit as
phpstan-baseline.neon, to burn down during the REST
migration. Each carries its rationale inline in phpcs.xml.dist; the largest is
Magento2.Annotation (PHPDoc formatting, redundant with the native PHP 8.2 types
already on the signatures). Deferrals are scoped to a specific file wherever the
findings are confined to one, so new code is still held to the rule.
Static analysis runs separately, via PHPStan at level 5 β see
phpstan.neon and make phpstan.
The documentation site is built from docs/ with MkDocs (Material theme) by
.github/workflows/docs.yml: pull requests get a mkdocs build --strict check,
and pushes to main publish to GitHub Pages. Navigation lives in mkdocs.yml.
make docs # live-reloading preview at http://127.0.0.1:8000
make docs-build # one-off build into site/The site is written for store owners and admins, not developers β read Writing documentation before editing it. Merchant-facing behavior (settings, install steps, features) is documented there only; this README covers developer material.
Magento's tax total is winner-take-all: exactly one class occupies the tax entry of the quote total collectors, and this module claims it with a preference on Magento\Tax\Model\Sales\Total\Quote\Tax in etc/di.xml. If another tax extension wins that slot β via its own preference, its own sales.xml entry, or an around plugin on collect() that skips $proceed β Taxcloud\Magento2\Model\Tax::collect() never runs. There is no exception and no error: tax is simply not calculated and orders are not filed, while the extension still reports as installed, enabled, and connected.
bin/magento taxcloud:diagnose
reports, per store where TaxCloud is enabled, which class occupies the tax collector slot, any around plugins wrapping collect(), and any non-core collector ordered after tax that could overwrite the result. It exits non-zero when TaxCloud is not the active collector, so it can be used as a monitoring check. Its output is unaffected by an admin dismissing the notification described below.
Three surfaces report the same verdict:
- CLI β the command above.
- Admin β a critical system message naming the class that won, with a link that acknowledges it. The acknowledgement stores a fingerprint of the conflict rather than a permanent flag, so a different responsible class, or the same conflict reaching another store view, re-raises the message on its own.
- Log β
Taxcloud\Magento2\Model\Tax::collect()marks the quote when it runs, and an observer onsales_model_service_quote_submit_beforewrites a warning (rate limited to one per store per hour) when an order is placed on an enabled store without that mark. This is the only surface that fires on the storefront path, and it deliberately computes no verdict there.
A clean verdict means only that this module's collector runs β it says nothing about credentials or calculation correctness. Resolving a conflict, including the <sequence> recipe for coexisting with a specific named module, is covered in Another extension is calculating tax.
A single ZIP with everything needed to diagnose a store without access to it, designed to be attached to a support ticket and read by an engineer or an AI assistant. Merchant-facing documentation: Sending diagnostics to support.
bin/magento taxcloud:diagnostics:export [--order=INCREMENT_ID] [--redact] [--output=PATH]
[--store=ID | --website=ID] [--no-probe]
[--log-window=standard|extended|maximum]
Writes var/taxcloud-diagnostics-{scope-code}-{YYYYMMDD-HHMMSS}.zip (UTC timestamp) unless --output names a file or directory, and prints the path. Without --order the bundle covers every store (or the --store/--website given); with it, the bundle is narrowed to that order at its store scope. Increment IDs are sequenced per store, so when the same one exists in several stores the command stops and asks for --store to pick one. --redact masks customer names, street lines, emails and phones; credentials are redacted unconditionally. Exit code is non-zero only when no bundle could be written β a bundle whose sections partly failed is still a success, and lists the failures.
The same bundle comes from Download Diagnostics in the TaxCloud settings group (scoped to the config scope being edited) and TaxCloud Diagnostics on the admin order view. Both POST to taxcloud/diagnostics/export, guarded by the Taxcloud_Magento2::diagnostics ACL resource.
| File | Contents |
|---|---|
summary.md |
Flattened report: blockers first, then environment, effective settings, collector verdict, probe, order |
manifest.json |
Schema version (BundleGenerator::SCHEMA_VERSION), module version, generated-at (UTC and store TZ), generating user, redaction mode, files with byte counts, failed sections |
settings.json |
Every tax/taxcloud_settings/* value at default/website/store scope, inherited vs explicit, source (database, config.xml, env.php, config.php, environment variable) and lock state, plus the effective value per store |
magento-tax.json |
Native tax/* config, tax rules, rates (capped at 2,000), tax classes, taxcloud cache type state |
modules.json |
Every module with composer version, setup_version and enabled state |
environment.json |
Magento/PHP/extension versions, PHP ini limits, deploy mode, cache and session backend names, cron health, indexer states, time zones |
collector-diagnostics.json |
TaxCollectorDiagnostics verdict per store |
probe.json |
Live Lookup + VerifyAddress per distinct configuration, with DNS and TLS measured separately from the API result; plus the Canada access check (canada_access) for configurations with Canadian tax on |
order.json |
Per-order only: totals, items with TIC and TIC source, addresses, TaxCloud order columns, RDF state, invoices/credit memos/shipments |
logs/ |
taxcloud.log (and rotations) within the window, or the order's correlated records; TaxCloud-related records of system.log and exception.log |
Implementation notes:
- Collection lives in
Model/Diagnostics/Bundle/, independent of HTTP. Sections implementSection\SectionInterfaceand are registered, in order, onBundleGeneratorinetc/di.xml; a section that throws is recorded in the manifest and the rest still run. - Every byte written passes through
BundleContext::scrub():LogRedactor::redactText()(credential patterns in XML, JSON, print_r, headers, Bearer tokens) plus every credential value configured at any scope, locked in a deployment file, or cached as a Bearer token (Redaction\CredentialInventory).app/etc/env.phpis read only throughConfigSourceReader, which keeps thesystemsubtree and a hardcoded key whitelist. - The log path is read from the
Taxcloud\Magento2\Logger\Handlerthe object manager builds, so a relocated log (see Changing the log file location) is still found. Logs are tailed with seeks and a timestamp binary search, never loaded whole; the ZIP is written tovar/tmp/taxcloud-diagnostics/and streamed from disk, then deleted. - Generating a bundle is audited to
system.log, and on Adobe Commerce to the Admin Actions Log (etc/logging.xml).
Model\Logging\GatewayLogger::beginOperation($operation, $quoteId, $orderIncrementId) binds a correlation context at each operation entry point (the gateways' lookup, verify-address, capture, refund, cancel and order-details methods, and the capture/refund/cancel observers), alongside the setStore() binding. Every record forwarded while it is bound carries correlation_id, operation, quote_id and order_increment_id in its context array, which Monolog's line formatter renders as JSON at the end of the line. The line format, as operators read it, is described in Reading the log.
Beginning an operation replaces the previous context, so a long-running process never attributes one order's lines to another; beginning one for the same order (an observer, then the gateway call it makes) keeps the correlation id. Calls made with no context bound carry none of these keys, and keys a call site passes explicitly win over the bound ones.
Every record also carries the request that wrote it in Monolog's extra (the second JSON group): request, a random id fixed per logger instance (one HTTP request, CLI run, or cron/consumer process), and pid when the host allows getmypid(). Logger\Processor\RequestIdentityProcessor adds it, registered on the channel in etc/di.xml; on hosts with getmypid in disable_functions it omits pid rather than failing. Messages are one line each β trailing line breaks are trimmed, SOAP params and responses are written as JSON, and SOAP wire traces are collapsed with | β so a line-oriented search finds every record of an order. SOAP operations are labelled (v1 SOAP) and REST operations (v3 REST).
Installing the module adds four EAV attributes via data patches:
taxcloud_ticon products (the TaxCloud TIC), fromInstallTaxcloudData;taxcloud_ticon categories (the inherited TIC), fromAddCategoryTicAttribute;taxcloud_certificate_idon customers (the attached exemption certificate), fromAddCertificateAttachmentAttribute;taxcloud_customer_idon customers (the TaxCloud identity certificates are filed under), fromAddTaxcloudCustomerIdAttribute.
(The legacy taxcloud_cert attribute is created and immediately removed
again during a fresh install, so the migration patch always has a source
attribute to read values from.)
These patches are revertable (they implement PatchRevertableInterface), so
bin/magento module:uninstall Taxcloud_Magento2 β which applies to
Composer-installed modules β runs each patch's revert() before removing the
module's files.
Reverting drops all four attributes (and, because EAV value storage cascades on
the attribute, any TIC/certificate values stored against products, categories
and customers). Re-installing and running bin/magento setup:upgrade re-applies
the patches and recreates the attribute definitions β but not the previously
stored values β so revert is a destructive, one-way cleanup. It only runs on
uninstall. The merchant-facing warning is in
Installing the extension.
This extension includes an automated deployment pipeline for sandbox environments via GitHub Actions. The pipeline runs itself end to end β tests, file transfer, and Magento setup β but you start it by hand; see Deployment below.
-
Configure GitHub Secrets in your repository settings:
SFTP_HOST: Your sandbox server IPSFTP_USERNAME: SSH username (e.g.,rootordeploy)SFTP_PORT: SSH port (default:22)MAGENTO_ROOT_PATH: Magento root directory (e.g.,/var/www/html)WEB_USER: Web server user (e.g.,www-data)WEB_GROUP: Web server group (e.g.,www-data)SSH_PRIVATE_KEY: Private SSH key for server access
-
Generate SSH Key (if needed):
./scripts/setup-ssh-deployment.sh
Sandbox deployment is manual only: go to Actions β Deploy to Sandbox β Run workflow. No branch triggers a deployment on push.
The deployment process will:
- Run all integration tests
- Deploy module files via SFTP
- Execute Magento setup commands
- Verify deployment success
New versions of the extension are distributed through the Adobe Commerce Marketplace.
Each GitHub release automatically produces a Marketplace-ready zip named taxcloud_magento2-<version>.zip via the Build Marketplace Release Package workflow. This zip respects .gitattributes export-ignore rules, so dev/CI files (Test/, Makefile, scripts/, .github/, phpunit.xml.dist, etc.) are excluded.
To cut a new release:
- Make sure
composer.jsonversionmatches the version you're about to tag (e.g.1.2.0). If it doesn't, bump it onmainfirst β the Marketplace's EQP validation will reject a submission whosecomposer.jsonversion doesn't match the tag. - Bump
etc/module.xmlsetup_versionto the same value. Magento 2.3+ no longer usessetup_versionfor schema migration (data patches drive that now), so behavior does not change either way β but keeping it in sync withcomposer.jsonis the canary that catches a forgotten version bump. The two should never drift. - Create a tag on
main:git tag -a v1.2.0 -m "v1.2.0" git push origin v1.2.0 - Create a GitHub release from the tag at Releases β Draft a new release. Auto-generate release notes or write them manually.
- The
Build Marketplace Release Packageworkflow runs automatically on publish and attachestaxcloud_magento2-<version>.zipto the release. (If it ever fails, you can re-run it manually from Actions β Build Marketplace Release Package β Run workflow and pass the tag.) - Go to the Marketplace extension page and start a new version submission.
- Attach the zip from the GitHub release under Attach package, and paste the release notes from the GitHub release body into the submission form.
- Submit for review. Once Adobe approves the submission, the new version is published to the Marketplace automatically.
This project is distributed under the Open Software License ("OSL") v. 3.0 (see the LICENSE file in the project root).
