Pattern · Tax
The Apex tax engine adapter
When the shipped engine returns nothing, what a working adapter actually has to implement — and the eight failures that stand between you and a taxed invoice.
The problem
The shipped standard tax engine computed nothing on the org under test: it accepted the request and returned zero. A custom adapter was the only way to produce a taxed invoice.
Context
Rates already held in a legacy table, several legal entities with different rules, and no external tax provider in scope. The adapter is therefore purely internal — it reads rates from the org and returns them.
Recommended architecture
An Apex class implementing the tax adapter interface, registered through a provider and an engine. The engine type that works requires a named credential, a seller code and engine address fields even though the adapter never leaves the org. A dummy named credential satisfies it.
Do not write the adapter from scratch. Start from one that already runs. The engine will accept a structurally valid response and still read zero if the amount details, the taxable flag, the response addresses or the header totals are missing — which makes a from-scratch adapter a long sequence of silent failures.
Implementation
The order that works: permissions first, then engine registration, then the class, then the data, then a real transaction. Each of these can fail in a way that looks like the previous one.
Trade-offs
An adapter is Apex, so it needs a test class and org coverage before it can reach production. On one project the request-processing entry point proved untestable — Salesforce exposes no constructor for its request classes — which capped coverage and became the real blocker to deployment.
If the rule can be expressed as rate rows plus a treatment on the line, prefer configuration. See country-driven tax code, which solved a comparable problem with no Apex at all.
Watch out
- Engine type. The wrong one fails with “Commerce Tax Service isn't accessible”. The working type demands a named credential and a seller code regardless.
- API version of the class.
ZipCodeandProductCodeon the rate object only exist from API 66.0. An older class returns “No such column” even in dynamic SOQL — and you will blame the query, not the class version. - Apex access. The user who triggers the calculation needs access to the adapter class, or the calculation returns nothing.
- Addresses. Read from the line, never the header — and on one org they arrived null entirely, forcing a fallback to the transaction's account.
- Tax name. The required name comes from the jurisdiction, not from the response object, which has no setter for it.
- Do not filter rates by product. The line's tax code is a fiscal code, not a product type. Filtering on product breaks rates that are identical across products.
- Standard permission sets were refused inside a permission set group when deployed as metadata; adding them by API worked, and each addition re-locked the group, so the insert needs a retry.
- An active treatment can no longer change engine. Deactivate first.