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 firessales.tax.calculate.beforeandsales.tax.calculate.after.catalogPricingService(packages/core/src/modules/catalog/services/catalogPricingService.ts) picks the best price row for a product/variant/offer. It wrapsresolveCatalogPrice()frompackages/core/src/modules/catalog/lib/pricing.ts, which emitscatalog.pricing.resolve.before/catalog.pricing.resolve.afterand walks resolvers registered viaregisterCatalogPricingResolver.salesCalculationService(packages/core/src/modules/sales/services/salesCalculationService.ts) computes line + document totals for orders, quotes, and invoices. It reuses the shared registry inpackages/core/src/modules/sales/lib/calculations.ts, emittingsales.line.calculate.*andsales.document.calculate.*plus hook registriesregisterSalesLineCalculator/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. setInputlets you swap tax classes before the default math;setResultcan 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
undefinedto defer to the next resolver or the built-inselectBestPrice. - The event bus still wraps
resolveCatalogPrice, so you can also tweakrows/contextviacatalog.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
registerSalesLineCalculatorto change per-line math (e.g., bundle discounts) andregisterSalesTotalsCalculatorfor cart-level adjustments. - Events
sales.line.calculate.before/afterandsales.document.calculate.before/aftersurround 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/afterreceive{ line, context, result, setResult }; mutate the wholeresultto keep math consistent.sales.document.calculate.before/afterreceive{ lines, adjustments, context, result, setResult }; you can adjust any totals or swap lines entirely.- A post-recalc hook
sales.document.totals.calculatedfires 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.*(orNEXT_PUBLIC_OM_PROFILEin 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:prepareso generated registrars stay in sync with your module wiring.