Skip to main content

Pricing and tax overrides

The catalog and sales modules expose overridable services so you can plug in regional tax engines, bespoke price selection, or special document totals without forking core code.

Pipelines you can extend

  • taxCalculationService (packages/core/src/modules/sales/services/taxCalculationService.ts) derives net/gross/tax amounts for price commands and admin forms. It fires sales.tax.calculate.before and sales.tax.calculate.after.
  • catalogPricingService (packages/core/src/modules/catalog/services/catalogPricingService.ts) picks the best price row for a product/variant/offer. It wraps resolveCatalogPrice() from packages/core/src/modules/catalog/lib/pricing.ts, which emits catalog.pricing.resolve.before / catalog.pricing.resolve.after and walks resolvers registered via registerCatalogPricingResolver.
  • salesCalculationService (packages/core/src/modules/sales/services/salesCalculationService.ts) computes line + document totals for orders, quotes, and invoices. It reuses the shared registry in packages/core/src/modules/sales/lib/calculations.ts, emitting sales.line.calculate.* and sales.document.calculate.* plus hook registries registerSalesLineCalculator / registerSalesTotalsCalculator.

Favor events or resolver hooks when you only need to alter inputs or selection. Swap the DI service when the whole pipeline must be replaced (e.g., delegating to an external engine).

Hooking into tax calculation events

src/modules/taxes/subscribers/tax-hooks.ts
export const metadata = { event: 'bootstrap', persistent: false }

export default async function register(_, ctx) {
const eventBus = ctx.resolve('eventBus')

// Route Brazilian orgs to a default class before the built-in math runs
eventBus?.on('sales.tax.calculate.before', ({ input, setInput }) => {
if (input.organizationId === 'org-brazil' && !input.taxRateId) {
setInput({ taxRateId: 'default-brazil' })
}
})

// Snap to whole cents after the default calculation
eventBus?.on('sales.tax.calculate.after', ({ result, setResult }) => {
if (!result) return
const taxAmount = Math.ceil(result.taxAmount * 100) / 100
setResult({ ...result, taxAmount })
})
}
  • Keep handlers idempotent and scoped to the caller's organizationId/tenantId.
  • setInput lets you swap tax classes before the default math; setResult can short-circuit or post-process the result.

Adding a custom catalog pricing resolver

src/modules/pricing/di.ts
import { registerCatalogPricingResolver } from '@open-mercato/core/src/modules/catalog/lib/pricing'
import type { AppContainer } from '@/lib/di/container'

export function register(_: AppContainer) {
registerCatalogPricingResolver((rows, ctx) => {
// Prefer a member-only price when a loyalty id is present
if (!ctx.customerGroupId) return undefined
const memberPrice = rows.find(
(row) => row.priceKind?.code === 'member' && row.customerGroupId === ctx.customerGroupId
)
if (memberPrice) return memberPrice
return undefined // fall back to next resolver or selectBestPrice
}, { priority: 10 })
}
  • Resolvers run in priority order (higher first); return undefined to defer to the next resolver or the built-in selectBestPrice.
  • The event bus still wraps resolveCatalogPrice, so you can also tweak rows/context via catalog.pricing.resolve.before.

Adjusting order/quote totals

src/modules/surcharges/di.ts
import {
registerSalesTotalsCalculator,
type SalesTotalsCalculationHook,
} from '@open-mercato/core/src/modules/sales/lib/calculations'
import type { AppContainer } from '@/lib/di/container'

const addEnvironmentalFee: SalesTotalsCalculationHook = async ({ current, context }) => {
if (context.countryCode !== 'SE') return current
const fee = 2.5
return {
...current,
totals: {
...current.totals,
surchargeTotalAmount: current.totals.surchargeTotalAmount + fee,
grandTotalNetAmount: current.totals.grandTotalNetAmount + fee,
grandTotalGrossAmount: current.totals.grandTotalGrossAmount + fee,
},
}
}

export function register(_: AppContainer) {
registerSalesTotalsCalculator(addEnvironmentalFee, { prepend: true })
}
  • Use registerSalesLineCalculator to change per-line math (e.g., bundle discounts) and registerSalesTotalsCalculator for cart-level adjustments.
  • Events sales.line.calculate.before/after and sales.document.calculate.before/after surround the registry so you can also observe results without mutating them.

Override via events (no code changes to core)

When you only need to patch a few fields, listen to the calculation events and call setResult to override values per line or for the full document:

src/modules/discounts/subscribers/sales-calculation.ts
export const metadata = { event: 'bootstrap' }

export default async function register(_, ctx) {
const eventBus = ctx.resolve('eventBus')

// Apply a free-gift line: zero out price on a specific SKU
eventBus?.on('sales.line.calculate.after', ({ line, result, setResult }) => {
if (line.productId === 'gift-product-id') {
setResult({
...result,
netAmount: 0,
grossAmount: 0,
taxAmount: 0,
discountAmount: result.netAmount, // capture the waived value
})
}
})

// Enforce a minimum order total at the document level
eventBus?.on('sales.document.calculate.after', ({ result, setResult }) => {
const minTotal = 25
if (result.totals.grandTotalGrossAmount < minTotal) {
const bump = minTotal - result.totals.grandTotalGrossAmount
setResult({
...result,
totals: {
...result.totals,
surchargeTotalAmount: (result.totals.surchargeTotalAmount ?? 0) + bump,
grandTotalGrossAmount: minTotal,
grandTotalNetAmount: result.totals.grandTotalNetAmount + bump,
},
})
}
})
}
  • sales.line.calculate.before/after receive { line, context, result, setResult }; mutate the whole result to keep math consistent.
  • sales.document.calculate.before/after receive { lines, adjustments, context, result, setResult }; you can adjust any totals or swap lines entirely.
  • A post-recalc hook sales.document.totals.calculated fires whenever documents are persisted (create/update/delete line), useful for auditing or side effects without changing math.

Swapping DI services entirely

When an external engine must own the calculation, replace the DI token in your module di.ts.

src/modules/taxes/di.ts
import { asFunction } from 'awilix'
import type { AppContainer } from '@/lib/di/container'
import type { TaxCalculationService, CalculateTaxInput, TaxCalculationResult } from '@open-mercato/core/src/modules/sales/services/taxCalculationService'

class ExternalTaxService implements TaxCalculationService {
constructor(private readonly eventBus) {}

async fetchExternalQuote(
input: CalculateTaxInput
): Promise<{ net: number; gross: number; tax: number; rate: number | null }> {
// Call your provider here (Avalara, TaxJar, etc.) and map the response
return { net: input.amount, gross: input.amount, tax: 0, rate: null }
}

async calculateUnitAmounts(input: CalculateTaxInput): Promise<TaxCalculationResult> {
const external = await this.fetchExternalQuote(input)
const result: TaxCalculationResult = {
netAmount: external.net,
grossAmount: external.gross,
taxAmount: external.tax,
taxRate: external.rate,
}

await this.eventBus?.emitEvent('sales.tax.calculate.after', {
input,
result,
setResult(next) {
if (next) Object.assign(result, next)
},
})
return result
}
}

export function register(container: AppContainer) {
container.register({
taxCalculationService: asFunction(
({ eventBus }) => new ExternalTaxService(eventBus)
).scoped(),
})
}
  • Mirror the existing interface so callers stay type-safe.
  • You can replace other tokens the same way (e.g., catalogPricingService, salesCalculationService) if you need full control instead of event-driven tweaks.
  • Keep modules isolated: avoid hard dependencies on other modules' entities; fetch extra data through APIs or events instead.

Debugging and validation tips

  • Emit profiling by setting OM_PROFILE=customers.* (or NEXT_PUBLIC_OM_PROFILE in the browser) to capture pricing/tax spans during slow paths.
  • Cover overrides with integration tests that assert the correct events fire (e.g., sales.tax.calculate.*) and that totals remain tenant-scoped.
  • When swapping DI services, rerun npm run modules:prepare so generated registrars stay in sync with your module wiring.