Account Tax ↔ Gateway Alignment — Design
| Design status | AGREED — this document is the agreed design; satisfies AC #1 of #1415 |
| Implementation status | NOT YET SCHEDULED — the gateway-sdk-core tax surface is already available; the open work spans merchant-api (calc enrichment, read projection, preview), Android checkout wiring (prerequisite §3), an invoicing-workflow tax scope decision covering both merchant-api and management-api (prerequisite §4), and one Gateway sync-contract agreement |
| Last reviewed | 2026-06-20 |
| Issue | #1415 (P3, future) |
Prerequisites (unblocks implementation)
Correction (2026-06-20): an earlier draft listed "Gateway tax config + calc API exposed via
gateway-sdk-core" as a blocker. That surface already exists and is wired intomerchant-api, so it is not a prerequisite — see the verification note below. The genuine open item is the projection sync contract.
- Account tax-projection sync contract agreed with Gateway. Converting the
local
tax_ratestable to a Gateway-synced read model requires a defined sync/invalidation contract (event/webhook or poll interval) from the Gateway team, which does not yet exist. This is the only external dependency. - Product-side work in
merchant-api(mostly unblocked). Surface the already-returned GatewayjurisdictionBreakdownthrough the response DTOs and build the org-level read projection (union over stores) — neither requires the SDK to change. Thetx-bundler-orchestrated projection-sync job is blocked on prerequisite §1 (the sync/invalidation contract with Gateway does not yet exist); the calc enrichment and read-projection work can proceed without it. - Android checkout tax path (in scope; requires coordinated change). Live
POS checkout on Android currently bypasses Gateway:
CartItem(libs/android/core/src/main/kotlin/com/myriad/pinpointpos/cart/CartItem.kt:32) computeslineTax = (lineSubtotal - lineDiscount) * taxRatefrom a local per-producttaxRate, andMainActivitysubmitscartViewModel.taxTotalastaxAppliedat six call sites: standard card (line 1462) and cash (line 2068), split-confirm (line 2099), split-retry card (line 2123), and keyed-card sale (line 2207). Two helpers senditem.lineTaxper line item:buildCartItemsPayload(used by standard card checkout) andList<CartItem>.toLineItemRequests()(CheckoutPaymentPayloads.kt:11–20), which is used by the cash and keyed-card sale paths viacartItems.toLineItemRequests()— both must be updated when wiring Gateway tax.TaxCalculationService.calculateTaxinmerchant-apihas no production caller in the Android checkout path today. Until Android checkout calls the Gateway calc path, surfacingjurisdictionBreakdowninmerchant-apiDTOs will not affect live POS tax amounts or produce explainable tax on checkout receipts. Aligning Android checkout with the Gateway calc path is in scope for the explainable-tax goal and must be tracked alongside themerchant-apiwork. - Invoicing workflow tax path — both services (explicit scope decision required).
Invoice tax is stored locally in both services independently, with no Gateway
call in either path today:
merchant-api—AccountInvoicingWorkflowControllerdrivesAccountInvoiceWorkflowService, which delegates tax calc tobuildAccountInvoiceLine()inAccountInvoiceWorkflowCalculations.kt(computestaxAmount = preTax * taxRatefrom caller-suppliedtaxRate);AccountInvoiceWorkflowRepositorypersiststaxAmount/taxRateat lines 354–356 (insert) and 387–389 (update). Also on this surface:CreditNoteControllerat/api/v1/orgs/{orgId}/invoicing/invoices/{invoiceId}/credit-notescopies caller-suppliedtaxRate(line 51), andCreditNoteServicecomputestaxAmount = preTax * taxRatelocally (lines 59–71) — the same gap applies to credit notes and must be included in any invoicing scope decision.management-api—AccountInvoiceWorkflowService(packagecom.myriad.management_api.invoicing) computes the same localtaxAmount = preTax * taxRateformula at lines 505–506 (estimate add-line) and 625–627 (invoice add-line);AccountInvoiceLinePersistencepersiststaxAmount.amount/taxRateat lines 45–47 (insert) and 78–80 (update). ATaxConfigApibean is wired inGatewayClientConfig.kt:47but is not called in any invoice workflow path. Decision required before implementation begins: either (a) include both invoicing paths in the Gateway alignment rollout (call Gateway calc in bothmerchant-apiandmanagement-apiinvoice workflows and surface the jurisdiction breakdown), or (b) explicitly defer one or both to a separate track. Without an explicit decision, invoices in both services will continue storing caller-supplied local tax amounts with no Gateway breakdown.
Verification — the gateway-sdk-core tax surface is already available:
MODULE.bazelwiresgateway-sdk-core-kmp(line ~608, atGATEWAY_SDK_VERSION).merchant-apiinjectscom.myriad.gateway.api.TaxConfigApiin bothTaxConfigService.ktandTaxCalculationService.kt.- Config CRUD is live:
TaxConfigService.ktcallscreateForLocation/listByLocation/updateForLocation/deleteForLocation. - Calculation SDK path wired (no production caller yet):
TaxCalculationService.ktimplementscalculateTax(...)and returns aTaxCalculationResultcarrying per-linelineItemTaxesand a transaction-leveljurisdictionBreakdown(exercised inTaxCalculationServiceTest.kt), but no controller or production service injects or calls it — POS checkout, CNP, and invoicing paths all persist local or caller-supplied tax today. - The breakdown is available but currently dropped:
TaxCalculationServicemaps linetaxes = emptyList()and does not threadjurisdictionBreakdowninto DTOs. Surfacing it is implementation work inmerchant-api, not a Gateway ask. A per-line jurisdiction attribution field would be the only genuine future Gateway ask.
Where Gateway jurisdiction/source breakdown is currently dropped
These are the entry points for the implementing engineer:
apps/microservices/merchant-api/src/main/kotlin/com/myriad/merchant_api/service/TaxCalculationService.kt— per-line result mapstaxes = emptyList(), and the transaction-leveljurisdictionBreakdownfrom Gateway is not threaded through to response DTOs. (Gateway returns per-line amounts keyed bylineItemIdinlineItemTaxes; jurisdiction/source detail is only in the top-leveljurisdictionBreakdown— there is no per-line jurisdiction breakdown to discard.) This is the primary calc flatten point.apps/microservices/merchant-api/src/main/java/com/myriad/merchant_api/service/TaxConfigService.kt— Gateway-backed store-scoped config CRUD; the write path is correct but the org-level read-projection (union over stores) is not yet built.apps/microservices/merchant-api/src/main/java/com/myriad/merchant_api/controller/AccountTaxController.kt— account-ownedtax_rates/tax_exemptionsCRUD at/api/v1/orgs/{orgId}/tax(permissionTAX_CONFIGURATION_READ/WRITE). Currently reads/writes local SQLDelight schema. This is the surface to convert to a Gateway-synced read projection once upstream is ready. Note: this controller lives inmerchant-api, notmanagement-api.apps/websites/portals/retail/src/pages/AccountTaxPage.tsx— retail page rendering account tax rates and exemptions; jurisdiction/source breakdown display is not implemented.
Status: Design only — agreed design; implementation not yet scheduled.
Issue #1415 (P3, future). Date: 2026-06-07. Owner: Platform/Tax. This document is
the "agreed design" acceptance criterion. (The gateway-sdk-core tax surface is
already available — see the status header above; the only external dependency is
the projection sync contract.)
Why
Tax responsibility is currently split across two services with no reconciliation:
merchant-api(Gateway-backed, store-scoped).TaxConfigController→TaxConfigService.ktproxies the Gateway tax-config API (com.myriad.gateway.api.TaxConfigApi) with no local persistence. Routes:GET/POST/PUT/DELETE /api/v1/stores/{storeId}/tax-configs, permissionstore.tax. It already carries the rich Gateway model —jurisdictionType/Code/Name,rateType,rateBps|rateAmountMinor,taxBasis,appliesToCategory,priority,effectiveFrom|effectiveTo,active,label— and resolves the Gateway location viaGatewayStoreAuthRepository. Gateway calculation returns total tax, per-line amounts, and jurisdiction/source breakdowns.merchant-api(account-owned, org-scoped).AccountTaxController(with inlineAccountTaxService) owns localtax_ratesandtax_exemptions(SQLDelight), a simpler model: code, name, jurisdiction, scope (GOODS|SERVICES|BOTH), rate, effectiveFrom, active; exemptions with reasonCode (RESALE|GOV|NONPROFIT|EXPORT|OTHER), certificate, expiry. Routes/api/v1/orgs/{orgId}/tax/tax-ratesand/tax-exemptions, permissionTAX_CONFIGURATION_READ/WRITE. Surfaced by retailAccountTaxPage.tsx(rates / jurisdiction summary / exemptions).
The two never reconcile: a merchant can configure store-level Gateway tax configs and account-level rates that disagree, and the account model drops the Gateway jurisdiction/source detail needed for explainable tax. former PeakPro tax behaviour (historically local Peak/tenant tables) has folded into the account model but with no defined relationship to the Gateway source of truth.
Intended outcome: one decided ownership boundary, explainable tax that preserves Gateway jurisdiction/source detail, and a tax-preview contract — without introducing a second tax engine.
Decision: Gateway is the calculation engine; account tax is a typed overlay
We do not build a second tax engine and we do not make the account the authority for rates that drive calculation. Instead:
- Calculation: Gateway-authored. All tax computation uses Gateway. The
per-transaction calc path stays a synchronous in-request Gateway SDK call from
the owning service (POS checkout in
merchant-api; account/invoice contexts call the same Gateway calc). No local rate table is consulted to compute tax. - Rate configuration: Gateway-authored, account-readable (Gateway-synced read
model). Tax rates/jurisdictions are authored in Gateway (the existing
merchant-apiproxy is the write path). The account/org view becomes a read-through projection of Gateway configs, not an independent writable table. The currentmerchant-apitax_ratestable (owned byAccountTaxController/AccountTaxService) is demoted to a cache/projection (or removed) rather than a parallel authority. The projection is store/location-scoped: Gateway tax configs are addressed per store (/api/v1/stores/{storeId}/tax-configs), so each projected row must carry its originatingstoreId(and the resolved Gateway location id). An org-level view is a union over stores, never a merge — two stores with different rules or duplicate jurisdiction codes stay distinct, and edits/readbacks route to the correct Gateway location. - Exemptions & certificates: account-owned. Customer tax-exemption
certificates (reason code, certificate number, jurisdiction, expiry) are a
customer/account concept, not a Gateway rate concept. These stay authored in
merchant-api(tax_exemptions, owned byAccountTaxController/AccountTaxService) and are passed into Gateway calculation as inputs (exempt flag / certificate reference), not stored in Gateway.
Rationale: rates and jurisdiction logic are exactly what Gateway exists to own and keep compliant; duplicating them invites drift and a second engine. Exemptions are customer master-data that Gateway treats as a calculation input, so the account is the natural owner.
Ownership boundary (summary)
| Concern | Authority | Surface | Notes |
|---|---|---|---|
| Tax calculation | Gateway | merchant-api (sync SDK) | per-line tax amounts + top-level jurisdiction/source breakdown returned |
| Tax rates / jurisdictions | Gateway | merchant-api proxy (write); merchant-api read projection | account view is read-through, store-scoped, not a parallel table |
| Exemption certificates | account (merchant-api) | merchant-api tax_exemptions, retail AccountTaxPage | passed as a calc input to Gateway |
| Tax rule category codes | Gateway | merchant-api proxy | only the Gateway rule's appliesToCategory codes; see note below |
| Product categories | POS (unchanged) | POS catalog | product_categories stay POS-owned; tagged through to Gateway metadata |
| Tax preview | composed | new read endpoint | wraps Gateway calc in preview mode (see below) |
| Android checkout tax | Gateway (target) | Android CartItem/MainActivity → merchant-api calc | currently local (CartItem.lineTax); must be wired to Gateway SDK call |
| Invoice tax (merchant-api) | decision pending | AccountInvoiceLinePersistence (local today) | explicit scope decision required; see prerequisites §4 |
Migration catalog discrepancy
Action required before implementation begins. The executable
PeakRearchitectureCatalog(tools/peak-rearchitecture/src/main/java/com/myriad/peak_rearchitecture/migration/PeakRearchitectureCatalog.kt:204) setstargetOwner = TargetOwner.MANAGEMENT_API_ACCOUNT_BOUNDARYfor theaccount-tax-configurationdomain slice (issue 1500) and recordsdestinationBoundary = "management-api /api/v1/accounts/{orgId}/tax owns account tax behavior". ThePeakRearchitectureCatalogTestasserts these strings at lines 314–317.This contradicts the agreed design above, which places
AccountTaxController(andAccountTaxService) inmerchant-apiat/api/v1/orgs/{orgId}/tax. The migration tooling will direct implementation to the wrong service until the catalog is updated. Before cutting implementation code, update the catalog entry: changetargetOwnertoMERCHANT_API(or the correct boundary enum value), updatedestinationBoundaryto referencemerchant-api /api/v1/orgs/{orgId}/tax, and fix the corresponding test assertions. Catalog issue reference: 1500.
Explainable tax
Preserve and surface the Gateway breakdown that the account model currently drops.
Note the shape of today's Gateway calc result: per-line entries carry the line
tax amount keyed by lineItemId, while jurisdiction/source detail is a
transaction-level jurisdictionBreakdown (the current TaxCalculationService
maps line taxes = emptyList()). So "explainable tax" must surface the breakdown
at the level Gateway actually provides — do not invent per-line jurisdiction
matches locally.
- Plumb Gateway calculation's per-line tax and the transaction-level
jurisdiction/source breakdown through to the response DTOs in both the POS and
account/invoice calc paths (today
merchant-apihas the data; the account surfaces flatten it away). - Retail
AccountTaxPage(and invoice/receipt detail) render "why this tax applied" from the transaction-level breakdown (jurisdiction name/type, rate, source, matched rule), with per-line amounts shown alongside. - If per-line jurisdiction attribution is genuinely required, that is a Gateway ask (add a per-line breakdown field) — not something the account layer reconstructs.
- The account read projection of rates shows the Gateway-authored jurisdiction, rate type, basis, priority, and effective dates verbatim (no re-derivation).
Tax-preview contract
A read-only preview that answers "given this customer, location, and line items, what tax applies and why" by delegating to Gateway calc in a non-committing mode — not a re-implementation:
The shape mirrors what Gateway calc returns — per-line amounts, plus a
transaction-level jurisdiction breakdown (not per-line). Keys are quoted to
read as a concrete contract; ? denotes optional fields, described after.
{
"totalTaxMinor": 0,
"lines": [{ "lineRef": "", "taxMinor": 0 }],
"jurisdictionBreakdown": [
{ "jurisdictionType": "", "jurisdictionName": "", "rateType": "", "rate": 0, "source": "", "ruleLabel": "" }
],
"exemptionsApplied": [{ "customerId": "", "reasonCode": "", "certificateRef": "" }]
}
Request: { "storeId": "", "customerId?": "", "lines": [{ "category": "", "amountMinor": 0, "qty": 1 }], "destination?": {} }
(customerId/destination optional; customerId drives exemption resolution).
Implementation note: the preview endpoint calls the same Gateway calculation the
real transaction uses, with exemption certificates resolved from the account model
and passed as inputs. It returns the Gateway breakdown unmodified — including the
transaction-level jurisdictionBreakdown. If a future per-line attribution is
needed it must come from a Gateway per-line breakdown field, not local synthesis.
tx-bundler boundary
Per the repo architecture rule, any background Gateway tax sync/backfill runs in
tx-bundler, not the canonical services — but it must respect the Gateway
integration charter (Rule C: only merchant-api, management-api, and
terminal-api may import gateway-sdk-core; tx-bundler may not):
- Synchronous in-request Gateway tax calc and config CRUD stay in
merchant-api(owns the Gateway SDK) — these are not "integrations." - A scheduled Gateway-config → account-projection sync/backfill job is
orchestrated from
tx-bundler, but it must not call the Gateway SDK directly. It calls an authorized internal endpoint onmerchant-api(which owns the Gateway SDK and thetax_ratesprojection) to read the Gateway configs and write thetax_ratesprojection.tx-bundlerprovides scheduling and batching only. Note:tax_exemptionsare account-owned and are not written by this sync job — they are passed as inputs to Gateway calc, not sourced from it. - Any accounting export of tax totals (QuickBooks/Xero) already routes through
tx-bundlerand is unaffected.
Migration sketch (when scheduled)
- Add the preview endpoint + thread Gateway jurisdiction/source breakdown through
the live calc responses (no schema change for live previews; pure read
enrichment). Persisted history is separate: the committed transaction
schema stores only totals (
transactions.tax_applied,transaction_items.tax_applied) with nowhere to save the breakdown. So explainable tax on historical receipts/refunds requires either (a) a new persisted breakdown column/table written at checkout, or (b) an explicit live-requery keyed to the immutable Gateway result id. Pick one before promising post-hoc explanation on saved transactions — do not assume "no schema change" covers history. - Convert the account rate view to read-through Gateway configs; keep
tax_ratesas a dual-read cache during rollout; stop accepting account-side rate writes (writes go to the Gateway proxy). - Add the
tx-bundlerprojection-sync job once read-through is proven. - Retire the local
tax_ratesauthority after dual-read confidence; keeptax_exemptions(account-owned). - Remove residual former PeakPro tax naming as the canonical path lands.
Acceptance (maps to #1415)
- Done (this doc): agreed design for Gateway vs account-owned tax responsibilities.
- Implementation: calc uses Gateway output without dropping jurisdiction/ source detail.
- Implementation: former PeakPro tax workflows have a canonical owner with no PeakPro portal dependency.
- Implementation: preview/explanation possible without a second tax engine (via the preview contract above).
Items 2–4 are not blocked on the Gateway SDK (the tax config CRUD and
calculation surface is already available — see the status header). The only
external dependency is the projection sync contract for item 2's read-through
rollout. Items 2 (calc enrichment) and 4 (preview) are merchant-api work; item
3 (PeakPro canonical ownership) is merchant-api/naming work with no upstream
blocker. Android checkout wiring (prerequisite §3) and the merchant-api invoicing-workflow
tax scope decision (prerequisite §4) are tracked as prerequisites rather than
standalone acceptance criteria.