Add a tax provider
Goal: take an external tax provider from zero to a working TaxEngine
implementation, wired as a per-invoicing-entity Tax connector, without
guessing API shapes or nexus/validation semantics. The abstraction is already
pluggable (one engine impl behind one extension point, plus a connector enum
threaded through the stack); the risk is integrating against imagined endpoints.
So: research → document → implement, in that order. Do not write the engine
before the research doc exists.
The tax layer lives in two places:
modules/meteroid/crates/meteroid-tax/ — the TaxEngine trait and the two
built-in engines (MeteroidTaxEngine = world-tax/VIES, ManualTaxEngine).
modules/meteroid/crates/meteroid-store/src/services/invoice_lines/invoice_lines.rs
— build_tax_engine, the single extension point that chooses the engine
for an invoicing entity.
Key source files to reread each run (they drift — don't trust this skill's line
numbers, re-grep):
.../meteroid-tax/src/lib.rs — the TaxEngine trait (validate_vat_number,
calculate_line_items_tax, calculate_customer_tax) + MeteroidTaxEngine /
ManualTaxEngine templates.
.../meteroid-tax/src/model.rs — Address, CustomerForTax (carries
billing_address and an optional shipping_address ship-to override),
LineItemForTax, CustomerTax, CalculationResult, TaxBreakdownItem,
VatNumberExternalValidationResult — the exact input/output types you must
produce and consume.
.../meteroid-store/src/services/invoice_lines/invoice_lines.rs —
build_tax_engine (where you register the provider).
.../meteroid-store/src/domain/connectors.rs — ProviderData /
ProviderSensitiveData enums + per-provider config structs.
.../meteroid-store/src/domain/enums.rs + crates/diesel-models/src/enums.rs
— the ConnectorProviderEnum and ConnectorTypeEnum (Tax).
.../domain/invoicing_entities.rs — tax_provider_id (the connector the
entity points at) and default_tax_category_id.
How a tax provider hangs together: an invoicing entity may point
tax_provider_id at a Tax-typed connector row (encrypted credentials, like
any payment/CRM connector). build_tax_engine sees that id, loads the connector,
matches on its provider, and returns your TaxEngine. When tax_provider_id
is NULL the built-in resolver (tax_resolver) is used instead. Tax categories
(tax_category, product.tax_category_id, invoicing_entity.default_tax_category_id)
are provider-agnostic classifications carried on each LineItemForTax; an
external provider maps them to its own product-tax codes.
Phase 0 — Scope (before any research)
An external tax provider replaces our calculation (and possibly validation)
with theirs. Decide up front:
- Which
TaxEngine methods the provider actually backs. Most back
calculate_line_items_tax (line-level rates by jurisdiction + product tax
code). Some also back validate_vat_number / registration lookups; if not,
delegate that method to the existing VIES path rather than stubbing it.
- Nexus / registration model. Does the provider decide taxability itself
(you send addresses + amounts + product codes, it returns rates), or do you
configure nexus out-of-band in their dashboard? This decides how much config
lives in
<Provider>PublicData.
- Addresses you'll actually have. Ship-from is the invoicing entity's
address (passed in full). Ship-to is
CustomerForTax.shipping_address when the
customer set a distinct one, else billing_address — resolve
shipping_address then fall back to billing_address. Both are our Address
(country, region ISO-3166-2, city, postal, line1) — no line2. If the provider
needs rooftop accuracy the customer hasn't supplied, that's a data gap to
surface, not something to invent.
- What the trait does NOT carry today — decide whether the provider needs
any of these, and raise it before implementing rather than stubbing:
- a stable customer code (providers that store exemption certificates
server-side, e.g. Avalara CertCapture, key on it) — not passed;
- an exemption certificate / entity-use code — only a
tax_exempt bool
reaches the engine, not the reason/certificate;
- a commit / void document lifecycle.
TaxEngine is calculate-only and
passes no invoice-level document id (only a per-line line_id). A filing
provider (Kintsugi, Avalara) must record the committed transaction on
finalize and void it on void so its returns match the invoices actually
issued — that needs new trait method(s) + a stable document id threaded from
the invoice, which is an architectural change to agree with the user first.
- Category mapping. How our
tax_category keys map to the provider's product
tax codes. Note it now; it becomes a lookup table in the engine.
- Sync vs async. Tax calc is request/response for every provider worth
integrating; if a provider only does async/batch, stop and raise it — the
invoice-line flow calls
calculate_line_items_tax synchronously.
State which built-in engine you're templating on (MeteroidTaxEngine is the
closest — it does a real external call shape) and why.
Phase 1 — Research (the important part)
Use WebSearch + WebFetch. Prefer official sources, in this priority
order, and record the URL of every source you rely on:
- Official OpenAPI / API reference — the source of truth for
request/response shapes. Search
"<provider> api reference",
"<provider> tax calculation api", site:docs.<provider>.com. If they
publish OpenAPI/Swagger JSON, fetch it — exact field names, types, and
required/optional for the calculation, address-validation, and
registration/nexus endpoints.
- Official client library — search
"<provider> official rust client",
then "<provider> official <lang> sdk" (node/python/java are fine as shape
references). An official Rust crate may be usable directly; an official
non-Rust SDK is still gold for deriving the request/response structs to
hand-roll in a <provider>-client crate. Community/unofficial crates:
reference only, never depend on without flagging it to the user.
- Tax calculation semantics — read the actual calculation docs. Capture:
how a line item is sent (amount, currency, product tax code, ship-from /
ship-to addresses, customer tax id / exemption), whether tax is inclusive
or exclusive, how multiple jurisdictions (state + county + city, or
compound EU rates) come back, rounding rules, and how exemptions and
reverse charge are signalled. This maps onto
CalculationResult /
TaxBreakdownItem and CustomerTax.
- VAT / registration validation (only if the provider backs it) — the
endpoint, what it returns, and how it distinguishes valid / invalid /
service-unavailable. Maps onto
VatNumberExternalValidationResult.
For each provider concept, resolve it to our contract before writing code:
| Provider concept |
Maps to |
| ship-from / origin address |
invoicing_entity_address (full Address) |
| ship-to / destination address |
CustomerForTax.shipping_address ?? billing_address |
| calculate tax for a set of lines |
TaxEngine::calculate_line_items_tax → CalculationResult |
| per-line rate + jurisdiction breakdown |
TaxBreakdownItem (one per rate/jurisdiction) |
| single-amount / customer-level rate |
TaxEngine::calculate_customer_tax → CustomerTax |
| exemption / reverse charge / no-nexus |
CustomerTax::{Exempt, NoTax} |
| validate a VAT / tax registration id |
TaxEngine::validate_vat_number → VatNumberExternalValidationResult |
our tax_category key → provider tax code |
a lookup in the engine (document the table) |
| API key / account id |
<Provider>SensitiveData / <Provider>PublicData |
Precision rule (project-wide): never assume 2-decimal currency. Amounts in
LineItemForTax are integer minor units for the line's currency; convert with
the currency's precision, never a hardcoded /100. Cite how the provider
expects amounts (minor units vs decimal string) in the doc.
If a provider capability has no mapping in our contract, stop and raise it
with the user before inventing one — it may need a new CustomerTax variant, a
new field on TaxBreakdownItem, or a change to LineItemForTax.
Phase 2 — Document (the deliverable that de-risks implementation)
Write .../meteroid-tax/research/<provider>.md containing:
- Sources — every official URL used (API ref, OpenAPI, SDK repo, calc docs,
validation docs), so a reviewer can verify against the same docs.
- Auth & environments — credential types (API key / OAuth), sandbox vs live
base URLs, what goes in
<Provider>PublicData vs <Provider>SensitiveData.
- Calculation request map — the concrete endpoint(s), method, exact request
fields for a multi-line calculation (addresses, amounts + currency handling,
product tax codes, customer tax id/exemption), and the success/error response
shape (cite the OpenAPI/SDK).
- Category map — table of our built-in
tax_category keys → the provider's
product tax codes, plus the fallback for the entity's default_tax_category_id.
- Response → contract map — how the response rows become
TaxBreakdownItems
and the per-line CustomerTax, incl. inclusive/exclusive handling, multiple
jurisdictions, rounding, and exemption/reverse-charge signalling.
- Validation map (if backed) — the endpoint and its result → each
VatNumberExternalValidationResult variant; otherwise state "delegated to VIES".
- Open questions / gaps — anything the docs didn't answer, unofficial-source
caveats, or capabilities with no contract mapping.
Checkpoint: summarize the research doc to the user and confirm the category
map + which TaxEngine methods the provider backs before implementing. This is
the cheapest place to catch a wrong assumption.
Phase 3 — Implement
Grounded in the research doc (no invented endpoints or payloads). Order that
compiles incrementally — the enum is threaded through several layers, and the
compiler's non-exhaustive-match errors are your live checklist:
- Provider enum variant, all layers (the
Tax connector type already
exists — do not re-add it):
- DB migration
modules/meteroid/migrations/diesel/<date>_<name>/up.sql:
ALTER TYPE "ConnectorProviderEnum" ADD VALUE IF NOT EXISTS 'KINTSUGI';
(Postgres can't drop enum values — say so in down.sql).
crates/diesel-models/src/enums.rs (variant + as_meta_key() arm).
crates/meteroid-store/src/domain/enums.rs (o2o-mapped variant — names must match).
proto/api/connectors/v1/models.proto ConnectorProviderEnum (append the
next number, never renumber) → regenerate Rust + TS
(pnpm --prefix modules/web --filter @md/web generate:proto).
src/api/connectors/mapping.rs (domain → server arm) and
src/api/customers/mapping.rs (a tax provider is not a customer
connection: return None, like Mock). In adapters/payment/factory.rs a
tax provider is not a payment provider — add it to the None /
ConnectorError::Unsupported arms.
ProviderData / ProviderSensitiveData variants + config structs in
domain/connectors.rs (public vs encrypted split per the research doc: API
key is sensitive; account id / environment toggle is public; sandbox-default
any live toggle).
<provider>-client crate if hand-rolling (mirror gocardless-client shape),
or wire the official crate. Hold the HTTP client in a OnceLock.
- The engine: implement
meteroid_tax::TaxEngine for a <Provider>Engine
in meteroid-tax (new module .../meteroid-tax/src/<provider>.rs). Start
from a stub returning TaxEngineError everywhere; fill method by method
against the research doc's request map. Delegate validate_vat_number to the
existing vies path if the provider doesn't back it. Convert amounts with the
line currency's precision — never /100.
- Register it in the extension point. In
build_tax_engine
(invoice_lines.rs), replace the "no external tax engine is registered" error
branch: when invoicing_entity.tax_provider_id is set, load that Tax
connector (via the store — this makes build_tax_engine async; thread the
PgConn and update its callers), decrypt its config, match on
connector.provider, and return Box::new(<Provider>Engine::new(cfg)?).
- Connector configuration surface — a
Connect<Provider> gRPC (message in
models.proto, rpc in connectors.proto, handler in
api/connectors/service.rs) so a tenant can store the credentials, and the
ability to set invoicing_entity.tax_provider_id to that connector.
- Frontend (
web-app/): a tax-provider integration card in
settings/tabs/IntegrationsTab.tsx + a config modal under
settings/integrations/, and a control to select the provider for an
invoicing entity. Reuse existing components before building new UI — ask
before building custom tables/comparison UIs.
Rules the abstraction depends on (repeated because they're easy to violate):
- Unsupported/failed calc → return a
TaxEngineError, never panic! and
never silently return zero tax.
- No provider-specific type leaks past the engine — the outside world only sees
CalculationResult / CustomerTax / VatNumberExternalValidationResult.
- Currency precision from the currency, never hardcoded decimals.
- Sandbox-default any live/sandbox toggle so a malformed config never routes a
real tax calculation to the wrong environment.
Phase 4 — Verify
- Unit-test the engine against recorded provider responses (fixtures from the
research doc), asserting the
TaxBreakdownItem totals and rounding.
- Test the category map: every built-in
tax_category key resolves to a provider
tax code (or the documented fallback).
- Exercise
build_tax_engine with an entity whose tax_provider_id is set,
proving the connector loads and the right engine is returned.
cargo build and re-grep the existing providers (grep -rin gocardless,
grep -rin stancer) — every compiler-flagged non-exhaustive match arm is a
site you must handle.
- Run the billing-reviewer agent over the tax-calculation path before finishing.
Guardrails
- Research before code. If asked to "just add fast", still produce
the research doc first — it is the fast path; guessing endpoints and rounding
costs more later.
- Official sources win. Cite them. Flag any reliance on unofficial clients.
- Never assume 2-decimal currency. Convert with the currency's precision.
- Fail loud, not to zero tax. A calc error must surface as an error, not a
zero-rate invoice line.
- Sandbox-default any live/sandbox toggle.
1---2name: add-tax-provider3description: Add a new external tax provider (Kintsugi, Avalara, TaxJar, Stripe Tax, …) to the meteroid tax layer. Research-first — finds the official client/OpenAPI to derive the calculation + nexus + validation request shapes, maps them onto the TaxEngine trait, writes a research doc, and only then implements the engine and threads a Tax-typed connector through the stack. Use whenever asked to add/integrate/support an external tax calculation provider.4---56# Add a tax provider78Goal: take an external tax provider from zero to a working `TaxEngine`9implementation, wired as a per-invoicing-entity `Tax` connector, **without10guessing API shapes or nexus/validation semantics**. The abstraction is already11pluggable (one engine impl behind one extension point, plus a connector enum12threaded through the stack); the risk is integrating against imagined endpoints.13So: **research → document → implement**, in that order. Do not write the engine14before the research doc exists.1516The tax layer lives in two places:17- `modules/meteroid/crates/meteroid-tax/` — the `TaxEngine` trait and the two18 built-in engines (`MeteroidTaxEngine` = world-tax/VIES, `ManualTaxEngine`).19- `modules/meteroid/crates/meteroid-store/src/services/invoice_lines/invoice_lines.rs`20 — `build_tax_engine`, **the single extension point** that chooses the engine21 for an invoicing entity.2223Key source files to reread each run (they drift — don't trust this skill's line24numbers, re-grep):25- `.../meteroid-tax/src/lib.rs` — the `TaxEngine` trait (`validate_vat_number`,26 `calculate_line_items_tax`, `calculate_customer_tax`) + `MeteroidTaxEngine` /27 `ManualTaxEngine` templates.28- `.../meteroid-tax/src/model.rs` — `Address`, `CustomerForTax` (carries29 `billing_address` and an optional `shipping_address` ship-to override),30 `LineItemForTax`, `CustomerTax`, `CalculationResult`, `TaxBreakdownItem`,31 `VatNumberExternalValidationResult` — the exact input/output types you must32 produce and consume.33- `.../meteroid-store/src/services/invoice_lines/invoice_lines.rs` —34 `build_tax_engine` (where you register the provider).35- `.../meteroid-store/src/domain/connectors.rs` — `ProviderData` /36 `ProviderSensitiveData` enums + per-provider config structs.37- `.../meteroid-store/src/domain/enums.rs` + `crates/diesel-models/src/enums.rs`38 — the `ConnectorProviderEnum` and `ConnectorTypeEnum` (`Tax`).39- `.../domain/invoicing_entities.rs` — `tax_provider_id` (the connector the40 entity points at) and `default_tax_category_id`.4142How a tax provider hangs together: an invoicing entity may point43`tax_provider_id` at a `Tax`-typed `connector` row (encrypted credentials, like44any payment/CRM connector). `build_tax_engine` sees that id, loads the connector,45matches on its `provider`, and returns your `TaxEngine`. When `tax_provider_id`46is NULL the built-in resolver (`tax_resolver`) is used instead. Tax categories47(`tax_category`, `product.tax_category_id`, `invoicing_entity.default_tax_category_id`)48are provider-agnostic classifications carried on each `LineItemForTax`; an49external provider maps them to its own product-tax codes.5051---5253## Phase 0 — Scope (before any research)5455An external tax provider replaces our *calculation* (and possibly *validation*)56with theirs. Decide up front:57- **Which `TaxEngine` methods the provider actually backs.** Most back58 `calculate_line_items_tax` (line-level rates by jurisdiction + product tax59 code). Some also back `validate_vat_number` / registration lookups; if not,60 delegate that method to the existing VIES path rather than stubbing it.61- **Nexus / registration model.** Does the provider decide taxability itself62 (you send addresses + amounts + product codes, it returns rates), or do you63 configure nexus out-of-band in their dashboard? This decides how much config64 lives in `<Provider>PublicData`.65- **Addresses you'll actually have.** Ship-from is the invoicing entity's66 address (passed in full). Ship-to is `CustomerForTax.shipping_address` when the67 customer set a distinct one, else `billing_address` — resolve68 `shipping_address` then fall back to `billing_address`. Both are our `Address`69 (country, region ISO-3166-2, city, postal, line1) — no line2. If the provider70 needs rooftop accuracy the customer hasn't supplied, that's a data gap to71 surface, not something to invent.72- **What the trait does NOT carry today** — decide whether the provider needs73 any of these, and raise it before implementing rather than stubbing:74 - a stable **customer code** (providers that store exemption certificates75 server-side, e.g. Avalara CertCapture, key on it) — not passed;76 - an **exemption certificate / entity-use code** — only a `tax_exempt` bool77 reaches the engine, not the reason/certificate;78 - a **commit / void document lifecycle**. `TaxEngine` is calculate-only and79 passes no invoice-level document id (only a per-line `line_id`). A *filing*80 provider (Kintsugi, Avalara) must record the committed transaction on81 finalize and void it on void so its returns match the invoices actually82 issued — that needs new trait method(s) + a stable document id threaded from83 the invoice, which is an architectural change to agree with the user first.84- **Category mapping.** How our `tax_category` keys map to the provider's product85 tax codes. Note it now; it becomes a lookup table in the engine.86- **Sync vs async.** Tax calc is request/response for every provider worth87 integrating; if a provider only does async/batch, stop and raise it — the88 invoice-line flow calls `calculate_line_items_tax` synchronously.8990State which built-in engine you're templating on (`MeteroidTaxEngine` is the91closest — it does a real external call shape) and why.9293---9495## Phase 1 — Research (the important part)9697Use `WebSearch` + `WebFetch`. **Prefer official sources**, in this priority98order, and record the URL of every source you rely on:991001. **Official OpenAPI / API reference** — the source of truth for101 request/response shapes. Search `"<provider> api reference"`,102 `"<provider> tax calculation api"`, `site:docs.<provider>.com`. If they103 publish OpenAPI/Swagger JSON, fetch it — exact field names, types, and104 required/optional for the calculation, address-validation, and105 registration/nexus endpoints.1062. **Official client library** — search `"<provider> official rust client"`,107 then `"<provider> official <lang> sdk"` (node/python/java are fine as shape108 references). An official Rust crate may be usable directly; an official109 non-Rust SDK is still gold for deriving the request/response structs to110 hand-roll in a `<provider>-client` crate. **Community/unofficial crates:111 reference only, never depend on without flagging it to the user.**1123. **Tax calculation semantics** — read the actual calculation docs. Capture:113 how a line item is sent (amount, currency, product tax code, ship-from /114 ship-to addresses, customer tax id / exemption), whether tax is **inclusive115 or exclusive**, how **multiple jurisdictions** (state + county + city, or116 compound EU rates) come back, rounding rules, and how **exemptions** and117 **reverse charge** are signalled. This maps onto `CalculationResult` /118 `TaxBreakdownItem` and `CustomerTax`.1194. **VAT / registration validation** (only if the provider backs it) — the120 endpoint, what it returns, and how it distinguishes valid / invalid /121 service-unavailable. Maps onto `VatNumberExternalValidationResult`.122123For each provider concept, resolve it to our contract before writing code:124125| Provider concept | Maps to |126|---|---|127| ship-from / origin address | `invoicing_entity_address` (full `Address`) |128| ship-to / destination address | `CustomerForTax.shipping_address` ?? `billing_address` |129| calculate tax for a set of lines | `TaxEngine::calculate_line_items_tax` → `CalculationResult` |130| per-line rate + jurisdiction breakdown | `TaxBreakdownItem` (one per rate/jurisdiction) |131| single-amount / customer-level rate | `TaxEngine::calculate_customer_tax` → `CustomerTax` |132| exemption / reverse charge / no-nexus | `CustomerTax::{Exempt, NoTax}` |133| validate a VAT / tax registration id | `TaxEngine::validate_vat_number` → `VatNumberExternalValidationResult` |134| our `tax_category` key → provider tax code | a lookup in the engine (document the table) |135| API key / account id | `<Provider>SensitiveData` / `<Provider>PublicData` |136137Precision rule (project-wide): **never assume 2-decimal currency.** Amounts in138`LineItemForTax` are integer minor units for the line's currency; convert with139the currency's precision, never a hardcoded `/100`. Cite how the provider140expects amounts (minor units vs decimal string) in the doc.141142If a provider capability has **no** mapping in our contract, stop and raise it143with the user before inventing one — it may need a new `CustomerTax` variant, a144new field on `TaxBreakdownItem`, or a change to `LineItemForTax`.145146---147148## Phase 2 — Document (the deliverable that de-risks implementation)149150Write `.../meteroid-tax/research/<provider>.md` containing:1511521. **Sources** — every official URL used (API ref, OpenAPI, SDK repo, calc docs,153 validation docs), so a reviewer can verify against the same docs.1542. **Auth & environments** — credential types (API key / OAuth), sandbox vs live155 base URLs, what goes in `<Provider>PublicData` vs `<Provider>SensitiveData`.1563. **Calculation request map** — the concrete endpoint(s), method, exact request157 fields for a multi-line calculation (addresses, amounts + currency handling,158 product tax codes, customer tax id/exemption), and the success/error response159 shape (cite the OpenAPI/SDK).1604. **Category map** — table of our built-in `tax_category` keys → the provider's161 product tax codes, plus the fallback for the entity's `default_tax_category_id`.1625. **Response → contract map** — how the response rows become `TaxBreakdownItem`s163 and the per-line `CustomerTax`, incl. inclusive/exclusive handling, multiple164 jurisdictions, rounding, and exemption/reverse-charge signalling.1656. **Validation map** (if backed) — the endpoint and its result → each166 `VatNumberExternalValidationResult` variant; otherwise state "delegated to VIES".1677. **Open questions / gaps** — anything the docs didn't answer, unofficial-source168 caveats, or capabilities with no contract mapping.169170**Checkpoint:** summarize the research doc to the user and confirm the category171map + which `TaxEngine` methods the provider backs before implementing. This is172the cheapest place to catch a wrong assumption.173174---175176## Phase 3 — Implement177178Grounded in the research doc (no invented endpoints or payloads). Order that179compiles incrementally — the enum is threaded through several layers, and the180compiler's non-exhaustive-match errors are your live checklist:1811821. **Provider enum variant, all layers** (the `Tax` connector type already183 exists — do **not** re-add it):184 - DB migration `modules/meteroid/migrations/diesel/<date>_<name>/up.sql`:185 `ALTER TYPE "ConnectorProviderEnum" ADD VALUE IF NOT EXISTS 'KINTSUGI';`186 (Postgres can't drop enum values — say so in `down.sql`).187 - `crates/diesel-models/src/enums.rs` (variant + `as_meta_key()` arm).188 - `crates/meteroid-store/src/domain/enums.rs` (o2o-mapped variant — names must match).189 - `proto/api/connectors/v1/models.proto` `ConnectorProviderEnum` (append the190 next number, never renumber) → regenerate Rust + TS191 (`pnpm --prefix modules/web --filter @md/web generate:proto`).192 - `src/api/connectors/mapping.rs` (`domain → server` arm) and193 `src/api/customers/mapping.rs` (a tax provider is not a customer194 connection: return `None`, like Mock). In `adapters/payment/factory.rs` a195 tax provider is not a payment provider — add it to the `None` /196 `ConnectorError::Unsupported` arms.1972. `ProviderData` / `ProviderSensitiveData` variants + config structs in198 `domain/connectors.rs` (public vs encrypted split per the research doc: API199 key is sensitive; account id / environment toggle is public; sandbox-default200 any live toggle).2013. `<provider>-client` crate if hand-rolling (mirror `gocardless-client` shape),202 or wire the official crate. Hold the HTTP client in a `OnceLock`.2034. **The engine**: implement `meteroid_tax::TaxEngine` for a `<Provider>Engine`204 in `meteroid-tax` (new module `.../meteroid-tax/src/<provider>.rs`). Start205 from a stub returning `TaxEngineError` everywhere; fill method by method206 against the research doc's request map. Delegate `validate_vat_number` to the207 existing `vies` path if the provider doesn't back it. Convert amounts with the208 line currency's precision — never `/100`.2095. **Register it in the extension point.** In `build_tax_engine`210 (`invoice_lines.rs`), replace the "no external tax engine is registered" error211 branch: when `invoicing_entity.tax_provider_id` is set, load that `Tax`212 connector (via the store — this makes `build_tax_engine` async; thread the213 `PgConn` and update its callers), decrypt its config, `match` on214 `connector.provider`, and return `Box::new(<Provider>Engine::new(cfg)?)`.2156. **Connector configuration surface** — a `Connect<Provider>` gRPC (message in216 `models.proto`, rpc in `connectors.proto`, handler in217 `api/connectors/service.rs`) so a tenant can store the credentials, and the218 ability to set `invoicing_entity.tax_provider_id` to that connector.2197. **Frontend** (`web-app/`): a tax-provider integration card in220 `settings/tabs/IntegrationsTab.tsx` + a config modal under221 `settings/integrations/`, and a control to select the provider for an222 invoicing entity. Reuse existing components before building new UI — ask223 before building custom tables/comparison UIs.224225Rules the abstraction depends on (repeated because they're easy to violate):226- Unsupported/failed calc → return a `TaxEngineError`, **never `panic!`** and227 never silently return zero tax.228- No provider-specific type leaks past the engine — the outside world only sees229 `CalculationResult` / `CustomerTax` / `VatNumberExternalValidationResult`.230- Currency precision from the currency, never hardcoded decimals.231- Sandbox-default any live/sandbox toggle so a malformed config never routes a232 real tax calculation to the wrong environment.233234---235236## Phase 4 — Verify237238- Unit-test the engine against recorded provider responses (fixtures from the239 research doc), asserting the `TaxBreakdownItem` totals and rounding.240- Test the category map: every built-in `tax_category` key resolves to a provider241 tax code (or the documented fallback).242- Exercise `build_tax_engine` with an entity whose `tax_provider_id` is set,243 proving the connector loads and the right engine is returned.244- `cargo build` and re-grep the existing providers (`grep -rin gocardless`,245 `grep -rin stancer`) — every compiler-flagged non-exhaustive match arm is a246 site you must handle.247- Run the billing-reviewer agent over the tax-calculation path before finishing.248249---250251## Guardrails252253- **Research before code.** If asked to "just add <provider> fast", still produce254 the research doc first — it *is* the fast path; guessing endpoints and rounding255 costs more later.256- **Official sources win.** Cite them. Flag any reliance on unofficial clients.257- **Never assume 2-decimal currency.** Convert with the currency's precision.258- **Fail loud, not to zero tax.** A calc error must surface as an error, not a259 zero-rate invoice line.260- Sandbox-default any live/sandbox toggle.