Skip to main content

Account Tax ↔ Gateway Alignment — Design

Design statusAGREED — this document is the agreed design; satisfies AC #1 of #1415
Implementation statusNOT 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 reviewed2026-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 into merchant-api, so it is not a prerequisite — see the verification note below. The genuine open item is the projection sync contract.

  1. Account tax-projection sync contract agreed with Gateway. Converting the local tax_rates table 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.
  2. Product-side work in merchant-api (mostly unblocked). Surface the already-returned Gateway jurisdictionBreakdown through the response DTOs and build the org-level read projection (union over stores) — neither requires the SDK to change. The tx-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.
  3. 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) computes lineTax = (lineSubtotal - lineDiscount) * taxRate from a local per-product taxRate, and MainActivity submits cartViewModel.taxTotal as taxApplied at 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 send item.lineTax per line item: buildCartItemsPayload (used by standard card checkout) and List<CartItem>.toLineItemRequests() (CheckoutPaymentPayloads.kt:11–20), which is used by the cash and keyed-card sale paths via cartItems.toLineItemRequests() — both must be updated when wiring Gateway tax. TaxCalculationService.calculateTax in merchant-api has no production caller in the Android checkout path today. Until Android checkout calls the Gateway calc path, surfacing jurisdictionBreakdown in merchant-api DTOs 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 the merchant-api work.
  4. 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-apiAccountInvoicingWorkflowController drives AccountInvoiceWorkflowService, which delegates tax calc to buildAccountInvoiceLine() in AccountInvoiceWorkflowCalculations.kt (computes taxAmount = preTax * taxRate from caller-supplied taxRate); AccountInvoiceWorkflowRepository persists taxAmount/taxRate at lines 354–356 (insert) and 387–389 (update). Also on this surface: CreditNoteController at /api/v1/orgs/{orgId}/invoicing/invoices/{invoiceId}/credit-notes copies caller-supplied taxRate (line 51), and CreditNoteService computes taxAmount = preTax * taxRate locally (lines 59–71) — the same gap applies to credit notes and must be included in any invoicing scope decision.
    • management-apiAccountInvoiceWorkflowService (package com.myriad.management_api.invoicing) computes the same local taxAmount = preTax * taxRate formula at lines 505–506 (estimate add-line) and 625–627 (invoice add-line); AccountInvoiceLinePersistence persists taxAmount.amount/taxRate at lines 45–47 (insert) and 78–80 (update). A TaxConfigApi bean is wired in GatewayClientConfig.kt:47 but 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 both merchant-api and management-api invoice 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.bazel wires gateway-sdk-core-kmp (line ~608, at GATEWAY_SDK_VERSION).
  • merchant-api injects com.myriad.gateway.api.TaxConfigApi in both TaxConfigService.kt and TaxCalculationService.kt.
  • Config CRUD is live: TaxConfigService.kt calls createForLocation / listByLocation / updateForLocation / deleteForLocation.
  • Calculation SDK path wired (no production caller yet): TaxCalculationService.kt implements calculateTax(...) and returns a TaxCalculationResult carrying per-line lineItemTaxes and a transaction-level jurisdictionBreakdown (exercised in TaxCalculationServiceTest.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: TaxCalculationService maps line taxes = emptyList() and does not thread jurisdictionBreakdown into DTOs. Surfacing it is implementation work in merchant-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 maps taxes = emptyList(), and the transaction-level jurisdictionBreakdown from Gateway is not threaded through to response DTOs. (Gateway returns per-line amounts keyed by lineItemId in lineItemTaxes; jurisdiction/source detail is only in the top-level jurisdictionBreakdown — 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-owned tax_rates / tax_exemptions CRUD at /api/v1/orgs/{orgId}/tax (permission TAX_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 in merchant-api, not management-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). TaxConfigControllerTaxConfigService.kt proxies 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, permission store.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 via GatewayStoreAuthRepository. Gateway calculation returns total tax, per-line amounts, and jurisdiction/source breakdowns.
  • merchant-api (account-owned, org-scoped). AccountTaxController (with inline AccountTaxService) owns local tax_rates and tax_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-rates and /tax-exemptions, permission TAX_CONFIGURATION_READ/WRITE. Surfaced by retail AccountTaxPage.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:

  1. 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.
  2. Rate configuration: Gateway-authored, account-readable (Gateway-synced read model). Tax rates/jurisdictions are authored in Gateway (the existing merchant-api proxy is the write path). The account/org view becomes a read-through projection of Gateway configs, not an independent writable table. The current merchant-api tax_rates table (owned by AccountTaxController/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 originating storeId (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.
  3. 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 by AccountTaxController/ 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)

ConcernAuthoritySurfaceNotes
Tax calculationGatewaymerchant-api (sync SDK)per-line tax amounts + top-level jurisdiction/source breakdown returned
Tax rates / jurisdictionsGatewaymerchant-api proxy (write); merchant-api read projectionaccount view is read-through, store-scoped, not a parallel table
Exemption certificatesaccount (merchant-api)merchant-api tax_exemptions, retail AccountTaxPagepassed as a calc input to Gateway
Tax rule category codesGatewaymerchant-api proxyonly the Gateway rule's appliesToCategory codes; see note below
Product categoriesPOS (unchanged)POS catalogproduct_categories stay POS-owned; tagged through to Gateway metadata
Tax previewcomposednew read endpointwraps Gateway calc in preview mode (see below)
Android checkout taxGateway (target)Android CartItem/MainActivity → merchant-api calccurrently local (CartItem.lineTax); must be wired to Gateway SDK call
Invoice tax (merchant-api)decision pendingAccountInvoiceLinePersistence (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) sets targetOwner = TargetOwner.MANAGEMENT_API_ACCOUNT_BOUNDARY for the account-tax-configuration domain slice (issue 1500) and records destinationBoundary = "management-api /api/v1/accounts/{orgId}/tax owns account tax behavior". The PeakRearchitectureCatalogTest asserts these strings at lines 314–317.

This contradicts the agreed design above, which places AccountTaxController (and AccountTaxService) in merchant-api at /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: change targetOwner to MERCHANT_API (or the correct boundary enum value), update destinationBoundary to reference merchant-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-api has 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 on merchant-api (which owns the Gateway SDK and the tax_rates projection) to read the Gateway configs and write the tax_rates projection. tx-bundler provides scheduling and batching only. Note: tax_exemptions are 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-bundler and is unaffected.

Migration sketch (when scheduled)

  1. 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.
  2. Convert the account rate view to read-through Gateway configs; keep tax_rates as a dual-read cache during rollout; stop accepting account-side rate writes (writes go to the Gateway proxy).
  3. Add the tx-bundler projection-sync job once read-through is proven.
  4. Retire the local tax_rates authority after dual-read confidence; keep tax_exemptions (account-owned).
  5. Remove residual former PeakPro tax naming as the canonical path lands.

Acceptance (maps to #1415)

  1. Done (this doc): agreed design for Gateway vs account-owned tax responsibilities.
  2. Implementation: calc uses Gateway output without dropping jurisdiction/ source detail.
  3. Implementation: former PeakPro tax workflows have a canonical owner with no PeakPro portal dependency.
  4. 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.