From cb9871c9d12ee2c7d3b59d14eef121b084e68c15 Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 13:34:38 +0100 Subject: [PATCH 1/9] feat(core): Add configurable OrderTaxSummaryCalculationStrategy Introduces a strategy pattern for how order-level tax totals and tax summaries are calculated, allowing different rounding approaches. The default strategy (DefaultOrderTaxSummaryCalculationStrategy) rounds tax per line then sums, preserving existing Vendure behavior. The new OrderLevelTaxSummaryCalculationStrategy groups net subtotals by tax rate and rounds once per group, eliminating per-line rounding accumulation. This is required by certain jurisdictions and ERP systems that expect tax calculated on the subtotal per rate. Configurable via taxOptions.orderTaxSummaryCalculationStrategy. Relates to #4375 --- packages/core/src/config/config.module.ts | 4 +- packages/core/src/config/default-config.ts | 2 + packages/core/src/config/index.ts | 3 + ...-order-tax-summary-calculation-strategy.ts | 100 +++++ ...-level-tax-summary-calculation-strategy.ts | 137 +++++++ ...r-tax-summary-calculation-strategy.spec.ts | 346 ++++++++++++++++++ .../order-tax-summary-calculation-strategy.ts | 67 ++++ packages/core/src/config/vendure-config.ts | 18 +- .../core/src/entity/order/order.entity.ts | 55 +-- .../order-calculator/order-calculator.spec.ts | 114 ++++++ .../order-calculator/order-calculator.ts | 30 +- 11 files changed, 798 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts create mode 100644 packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts create mode 100644 packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts create mode 100644 packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts diff --git a/packages/core/src/config/config.module.ts b/packages/core/src/config/config.module.ts index 18518e39b5..91d0a61d9d 100644 --- a/packages/core/src/config/config.module.ts +++ b/packages/core/src/config/config.module.ts @@ -86,7 +86,8 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo adminApiKeyStrategy, shopApiKeyStrategy, } = this.configService.authOptions; - const { taxZoneStrategy, taxLineCalculationStrategy } = this.configService.taxOptions; + const { taxZoneStrategy, taxLineCalculationStrategy, orderTaxSummaryCalculationStrategy } = + this.configService.taxOptions; const { jobQueueStrategy, jobBufferStorageStrategy } = this.configService.jobQueueOptions; const { schedulerStrategy } = this.configService.schedulerOptions; const { @@ -128,6 +129,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo assetStorageStrategy, taxZoneStrategy, taxLineCalculationStrategy, + orderTaxSummaryCalculationStrategy, jobQueueStrategy, jobBufferStorageStrategy, mergeStrategy, diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 2275608257..d1dbec39e8 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -58,6 +58,7 @@ import { defaultShippingEligibilityChecker } from './shipping-method/default-shi import { DefaultShippingLineAssignmentStrategy } from './shipping-method/default-shipping-line-assignment-strategy'; import { InMemoryCacheStrategy } from './system/in-memory-cache-strategy'; import { NoopInstrumentationStrategy } from './system/noop-instrumentation-strategy'; +import { DefaultOrderTaxSummaryCalculationStrategy } from './tax/default-order-tax-summary-calculation-strategy'; import { DefaultTaxLineCalculationStrategy } from './tax/default-tax-line-calculation-strategy'; import { DefaultTaxZoneStrategy } from './tax/default-tax-zone-strategy'; import { RuntimeVendureConfig } from './vendure-config'; @@ -194,6 +195,7 @@ export const defaultConfig: RuntimeVendureConfig = { taxOptions: { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), + orderTaxSummaryCalculationStrategy: new DefaultOrderTaxSummaryCalculationStrategy(), }, importExportOptions: { importAssetsDir: __dirname, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index dcf4b8f4df..5cdfe5d57c 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -98,8 +98,11 @@ export * from './system/error-handler-strategy'; export * from './system/health-check-strategy'; export * from './system/instrumentation-strategy'; export * from './tax/address-based-tax-zone-strategy'; +export * from './tax/default-order-tax-summary-calculation-strategy'; export * from './tax/default-tax-line-calculation-strategy'; export * from './tax/default-tax-zone-strategy'; +export * from './tax/order-level-tax-summary-calculation-strategy'; +export * from './tax/order-tax-summary-calculation-strategy'; export * from './tax/tax-line-calculation-strategy'; export * from './tax/tax-zone-strategy'; export * from './vendure-config'; diff --git a/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts new file mode 100644 index 0000000000..7174c679b1 --- /dev/null +++ b/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts @@ -0,0 +1,100 @@ +import { OrderTaxSummary, TaxLine } from '@vendure/common/lib/generated-types'; +import { summate } from '@vendure/common/lib/shared-utils'; + +import { OrderLine } from '../../entity/order-line/order-line.entity'; +import { Order } from '../../entity/order/order.entity'; +import { Surcharge } from '../../entity/surcharge/surcharge.entity'; + +import { + OrderTaxSummaryCalculationStrategy, + OrderTotalsResult, +} from './order-tax-summary-calculation-strategy'; + +/** + * @description + * The default {@link OrderTaxSummaryCalculationStrategy}. Tax is rounded at the + * individual line level and then summed. This matches the standard Vendure behaviour + * prior to the introduction of this strategy. + * + * @docsCategory tax + * @docsPage OrderTaxSummaryCalculationStrategy + * @since 3.6.0 + */ +export class DefaultOrderTaxSummaryCalculationStrategy implements OrderTaxSummaryCalculationStrategy { + calculateOrderTotals(order: Order): OrderTotalsResult { + let subTotal = 0; + let subTotalWithTax = 0; + for (const line of order.lines) { + subTotal += line.proratedLinePrice; + subTotalWithTax += line.proratedLinePriceWithTax; + } + for (const surcharge of order.surcharges) { + subTotal += surcharge.price; + subTotalWithTax += surcharge.priceWithTax; + } + + let shipping = 0; + let shippingWithTax = 0; + for (const shippingLine of order.shippingLines ?? []) { + shipping += shippingLine.discountedPrice; + shippingWithTax += shippingLine.discountedPriceWithTax; + } + + return { subTotal, subTotalWithTax, shipping, shippingWithTax }; + } + + calculateTaxSummary(order: Order): OrderTaxSummary[] { + const taxRateMap = new Map< + string, + { rate: number; base: number; tax: number; description: string } + >(); + const taxId = (taxLine: TaxLine): string => `${taxLine.description}:${taxLine.taxRate}`; + const taxableLines = [ + ...(order.lines ?? []), + ...(order.shippingLines ?? []), + ...(order.surcharges ?? []), + ]; + for (const line of taxableLines) { + if (!line.taxLines?.length) { + continue; + } + const taxRateTotal = summate(line.taxLines, 'taxRate'); + for (const taxLine of line.taxLines) { + const id = taxId(taxLine); + const row = taxRateMap.get(id); + const proportionOfTotalRate = 0 < taxLine.taxRate ? taxLine.taxRate / taxRateTotal : 0; + + const lineBase = + line instanceof OrderLine + ? line.proratedLinePrice + : line instanceof Surcharge + ? line.price + : line.discountedPrice; + const lineWithTax = + line instanceof OrderLine + ? line.proratedLinePriceWithTax + : line instanceof Surcharge + ? line.priceWithTax + : line.discountedPriceWithTax; + const amount = Math.round((lineWithTax - lineBase) * proportionOfTotalRate); + if (row) { + row.tax += amount; + row.base += lineBase; + } else { + taxRateMap.set(id, { + tax: amount, + base: lineBase, + description: taxLine.description, + rate: taxLine.taxRate, + }); + } + } + } + return Array.from(taxRateMap.entries()).map(([, row]) => ({ + taxRate: row.rate, + description: row.description, + taxBase: row.base, + taxTotal: row.tax, + })); + } +} diff --git a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts new file mode 100644 index 0000000000..6416fdbd8e --- /dev/null +++ b/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts @@ -0,0 +1,137 @@ +import { OrderTaxSummary, TaxLine } from '@vendure/common/lib/generated-types'; + +import { taxPayableOn } from '../../common/tax-utils'; +import { Order } from '../../entity/order/order.entity'; + +import { + OrderTaxSummaryCalculationStrategy, + OrderTotalsResult, +} from './order-tax-summary-calculation-strategy'; + +interface TaxGroup { + rate: number; + description: string; + netBase: number; +} + +/** + * @description + * An {@link OrderTaxSummaryCalculationStrategy} that groups net subtotals by tax rate + * and rounds once per group. This eliminates per-line rounding accumulation errors + * present in the {@link DefaultOrderTaxSummaryCalculationStrategy}. + * + * This approach is required by certain jurisdictions and ERP systems that expect + * tax to be calculated on the subtotal per tax rate rather than per line. + * + * Note that when using this strategy, the `taxTotal` in the tax summary may differ + * by ±1 minor unit from the sum of individual line-level `proratedLineTax` values. + * This is expected and is the intended behaviour. + * + * @example + * ```ts + * import { OrderLevelTaxSummaryCalculationStrategy, VendureConfig } from '\@vendure/core'; + * + * export const config: VendureConfig = { + * taxOptions: { + * orderTaxSummaryCalculationStrategy: new OrderLevelTaxSummaryCalculationStrategy(), + * }, + * }; + * ``` + * + * @docsCategory tax + * @docsPage OrderTaxSummaryCalculationStrategy + * @since 3.6.0 + */ +export class OrderLevelTaxSummaryCalculationStrategy implements OrderTaxSummaryCalculationStrategy { + calculateOrderTotals(order: Order): OrderTotalsResult { + const { subTotal, subTotalGroups, shipping, shippingGroups } = this.groupOrder(order); + + let subTotalTax = 0; + for (const [, group] of subTotalGroups) { + subTotalTax += Math.round(taxPayableOn(group.netBase, group.rate)); + } + + let shippingTax = 0; + for (const [, group] of shippingGroups) { + shippingTax += Math.round(taxPayableOn(group.netBase, group.rate)); + } + + return { + subTotal, + subTotalWithTax: subTotal + subTotalTax, + shipping, + shippingWithTax: shipping + shippingTax, + }; + } + + calculateTaxSummary(order: Order): OrderTaxSummary[] { + const { subTotalGroups, shippingGroups } = this.groupOrder(order); + + const merged = new Map(); + for (const groups of [subTotalGroups, shippingGroups]) { + for (const [key, group] of groups) { + const existing = merged.get(key); + if (existing) { + existing.netBase += group.netBase; + } else { + merged.set(key, { ...group }); + } + } + } + const taxSummary: OrderTaxSummary[] = []; + for (const [, group] of merged) { + taxSummary.push({ + taxRate: group.rate, + description: group.description, + taxBase: group.netBase, + taxTotal: Math.round(taxPayableOn(group.netBase, group.rate)), + }); + } + return taxSummary; + } + + private groupOrder(order: Order) { + let subTotal = 0; + let shipping = 0; + const subTotalGroups = new Map(); + const shippingGroups = new Map(); + + for (const line of order.lines) { + subTotal += line.proratedLinePrice; + this.accumulateIntoGroups(subTotalGroups, line.taxLines, line.proratedLinePrice); + } + for (const surcharge of order.surcharges) { + subTotal += surcharge.price; + this.accumulateIntoGroups(subTotalGroups, surcharge.taxLines, surcharge.price); + } + for (const shippingLine of order.shippingLines ?? []) { + shipping += shippingLine.discountedPrice; + this.accumulateIntoGroups(shippingGroups, shippingLine.taxLines, shippingLine.discountedPrice); + } + + return { subTotal, subTotalGroups, shipping, shippingGroups }; + } + + private accumulateIntoGroups( + groups: Map, + taxLines: TaxLine[] | undefined, + lineNetBase: number, + ) { + if (!taxLines?.length) { + return; + } + for (const taxLine of taxLines) { + const key = `${taxLine.description}:${taxLine.taxRate}`; + const existing = groups.get(key); + if (existing) { + existing.netBase += lineNetBase; + } else { + groups.set(key, { + rate: taxLine.taxRate, + description: taxLine.description, + netBase: lineNetBase, + }); + } + } + } +} diff --git a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts new file mode 100644 index 0000000000..3b48f6938c --- /dev/null +++ b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts @@ -0,0 +1,346 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity'; +import { Surcharge } from '../../entity/surcharge/surcharge.entity'; +import { createOrder, createRequestContext, taxCategoryStandard } from '../../testing/order-test-utils'; +import { ensureConfigLoaded } from '../config-helpers'; + +import { DefaultOrderTaxSummaryCalculationStrategy } from './default-order-tax-summary-calculation-strategy'; +import { OrderLevelTaxSummaryCalculationStrategy } from './order-level-tax-summary-calculation-strategy'; + +describe('OrderTaxSummaryCalculationStrategy', () => { + beforeAll(async () => { + await ensureConfigLoaded(); + }); + + describe('DefaultOrderTaxSummaryCalculationStrategy', () => { + const strategy = new DefaultOrderTaxSummaryCalculationStrategy(); + + it('sums per-line totals for a single tax rate', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 300, taxCategory: taxCategoryStandard, quantity: 2 }, + { listPrice: 1000, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + order.lines.forEach(l => (l.taxLines = [{ taxRate: 5, description: 'tax a' }])); + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(1600); + expect(totals.subTotalWithTax).toBe(1680); + expect(taxSummary).toEqual([{ description: 'tax a', taxRate: 5, taxBase: 1600, taxTotal: 80 }]); + }); + + it('includes surcharges with multiple tax lines in totals and summary', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 300, taxCategory: taxCategoryStandard, quantity: 2 }], + surcharges: [ + new Surcharge({ + description: 'Extra', + listPrice: 400, + listPriceIncludesTax: false, + taxLines: [ + { description: 'tax 50', taxRate: 50 }, + { description: 'tax 20', taxRate: 20 }, + ], + sku: 'extra', + }), + ], + }); + order.lines[0].taxLines = [{ taxRate: 5, description: 'tax a' }]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(600 + 400); + expect(taxSummary).toEqual([ + { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, + { description: 'tax 50', taxRate: 50, taxBase: 400, taxTotal: 200 }, + { description: 'tax 20', taxRate: 20, taxBase: 400, taxTotal: 80 }, + ]); + }); + + it('handles shipping lines', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 300, taxCategory: taxCategoryStandard, quantity: 2 }], + }); + order.lines[0].taxLines = [{ taxRate: 5, description: 'tax a' }]; + order.shippingLines = [ + new ShippingLine({ + listPrice: 500, + listPriceIncludesTax: false, + adjustments: [], + taxLines: [{ taxRate: 20, description: 'shipping tax' }], + }), + ]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(600); + expect(totals.shipping).toBe(500); + expect(totals.shippingWithTax).toBe(600); + expect(taxSummary).toEqual( + expect.arrayContaining([ + { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, + { description: 'shipping tax', taxRate: 20, taxBase: 500, taxTotal: 100 }, + ]), + ); + }); + + it('handles lines with no taxLines gracefully', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }], + }); + order.lines[0].taxLines = []; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(100); + expect(totals.subTotalWithTax).toBe(100); + expect(taxSummary).toEqual([]); + }); + }); + + describe('OrderLevelTaxSummaryCalculationStrategy', () => { + const strategy = new OrderLevelTaxSummaryCalculationStrategy(); + + it('rounds tax on grouped net subtotal rather than per line', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 102, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 215, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + order.lines[0].taxLines = [{ taxRate: 21, description: 'VAT' }]; + order.lines[1].taxLines = [{ taxRate: 21, description: 'VAT' }]; + + const defaultStrategy = new DefaultOrderTaxSummaryCalculationStrategy(); + const defaultTotals = defaultStrategy.calculateOrderTotals(order); + const defaultTaxSummary = defaultStrategy.calculateTaxSummary(order); + const orderLevelTotals = strategy.calculateOrderTotals(order); + const orderLevelTaxSummary = strategy.calculateTaxSummary(order); + + // Both strategies agree on subTotal (net) + expect(defaultTotals.subTotal).toBe(317); + expect(orderLevelTotals.subTotal).toBe(317); + + // But subTotalWithTax differs due to rounding + expect(defaultTotals.subTotalWithTax).toBe(383); // 123 + 260 + expect(orderLevelTotals.subTotalWithTax).toBe(384); // 317 + 67 + + // Tax summary reflects the difference + expect(defaultTaxSummary).toEqual([ + { description: 'VAT', taxRate: 21, taxBase: 317, taxTotal: 66 }, + ]); + expect(orderLevelTaxSummary).toEqual([ + { description: 'VAT', taxRate: 21, taxBase: 317, taxTotal: 67 }, + ]); + }); + + it('handles multiple tax rates', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 102, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 215, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 500, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + order.lines[0].taxLines = [{ taxRate: 21, description: 'Standard VAT' }]; + order.lines[1].taxLines = [{ taxRate: 21, description: 'Standard VAT' }]; + order.lines[2].taxLines = [{ taxRate: 9, description: 'Reduced VAT' }]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(817); + expect(taxSummary).toEqual([ + { + description: 'Standard VAT', + taxRate: 21, + taxBase: 317, + taxTotal: Math.round(317 * 0.21), // 67 + }, + { + description: 'Reduced VAT', + taxRate: 9, + taxBase: 500, + taxTotal: Math.round(500 * 0.09), // 45 + }, + ]); + expect(totals.subTotalWithTax).toBe(817 + 67 + 45); + }); + + it('handles zero-rate items', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 300, taxCategory: taxCategoryStandard, quantity: 2 }], + }); + order.lines[0].taxLines = [{ taxRate: 0, description: 'zero-rate' }]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(600); + expect(totals.subTotalWithTax).toBe(600); + expect(taxSummary).toEqual([{ description: 'zero-rate', taxRate: 0, taxBase: 600, taxTotal: 0 }]); + }); + + it('handles surcharges with multiple tax lines', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 300, taxCategory: taxCategoryStandard, quantity: 2 }], + surcharges: [ + new Surcharge({ + description: 'Special', + listPrice: 400, + listPriceIncludesTax: false, + taxLines: [ + { description: 'tax 50', taxRate: 50 }, + { description: 'tax 20', taxRate: 20 }, + ], + sku: 'special', + }), + ], + }); + order.lines[0].taxLines = [{ taxRate: 5, description: 'tax a' }]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(1000); + expect(taxSummary).toEqual( + expect.arrayContaining([ + { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, + { description: 'tax 50', taxRate: 50, taxBase: 400, taxTotal: 200 }, + { description: 'tax 20', taxRate: 20, taxBase: 400, taxTotal: 80 }, + ]), + ); + // Total tax = 30 + 200 + 80 = 310 + expect(totals.subTotalWithTax).toBe(1000 + 310); + }); + + it('handles shipping lines', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 300, taxCategory: taxCategoryStandard, quantity: 2 }], + }); + order.lines[0].taxLines = [{ taxRate: 5, description: 'tax a' }]; + order.shippingLines = [ + new ShippingLine({ + listPrice: 500, + listPriceIncludesTax: false, + adjustments: [], + taxLines: [{ taxRate: 20, description: 'shipping tax' }], + }), + ]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(600); + expect(totals.shipping).toBe(500); + expect(totals.shippingWithTax).toBe(600); + expect(taxSummary).toEqual( + expect.arrayContaining([ + { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, + { description: 'shipping tax', taxRate: 20, taxBase: 500, taxTotal: 100 }, + ]), + ); + }); + + it('groups multiple shipping lines by rate', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }], + }); + order.lines[0].taxLines = [{ taxRate: 21, description: 'VAT' }]; + order.shippingLines = [ + new ShippingLine({ + listPrice: 102, + listPriceIncludesTax: false, + adjustments: [], + taxLines: [{ taxRate: 21, description: 'VAT' }], + }), + new ShippingLine({ + listPrice: 215, + listPriceIncludesTax: false, + adjustments: [], + taxLines: [{ taxRate: 21, description: 'VAT' }], + }), + ]; + + const defaultStrategy = new DefaultOrderTaxSummaryCalculationStrategy(); + const defaultTotals = defaultStrategy.calculateOrderTotals(order); + const orderLevelTotals = strategy.calculateOrderTotals(order); + + // Shipping net totals are the same + expect(defaultTotals.shipping).toBe(317); + expect(orderLevelTotals.shipping).toBe(317); + + // But shipping tax may differ due to rounding + // Default: round(102*0.21) + round(215*0.21) = round(21.42) + round(45.15) = 21 + 45 = 66 + // Order-level: round(317 * 0.21) = round(66.57) = 67 + expect(defaultTotals.shippingWithTax).toBe(317 + 21 + 45); // 383 + expect(orderLevelTotals.shippingWithTax).toBe(317 + 67); // 384 + }); + + it('handles prices-include-tax mode', () => { + const ctx = createRequestContext({ pricesIncludeTax: true }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 260, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + order.lines[0].taxLines = [{ taxRate: 21, description: 'VAT' }]; + order.lines[1].taxLines = [{ taxRate: 21, description: 'VAT' }]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + expect(totals.subTotal).toBe(317); + expect(totals.subTotalWithTax).toBe(384); + expect(taxSummary).toEqual([{ description: 'VAT', taxRate: 21, taxBase: 317, taxTotal: 67 }]); + }); + + it('handles lines with no taxLines gracefully', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }], + }); + order.lines[0].taxLines = []; + order.shippingLines = [ + new ShippingLine({ + shippingMethodId: '1', + }), + ]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + expect(totals.subTotal).toBe(100); + expect(taxSummary).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts new file mode 100644 index 0000000000..1cfdaf439b --- /dev/null +++ b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts @@ -0,0 +1,67 @@ +import { OrderTaxSummary } from '@vendure/common/lib/generated-types'; + +import { InjectableStrategy } from '../../common/types/injectable-strategy'; +import { Order } from '../../entity/order/order.entity'; + +/** + * @description + * The result of an {@link OrderTaxSummaryCalculationStrategy}'s `calculateOrderTotals` method. + * + * @docsCategory tax + * @docsPage OrderTaxSummaryCalculationStrategy + * @since 3.6.0 + */ +export interface OrderTotalsResult { + subTotal: number; + subTotalWithTax: number; + shipping: number; + shippingWithTax: number; +} + +/** + * @description + * Defines how order-level tax totals and the tax summary are calculated. + * + * The default implementation ({@link DefaultOrderTaxSummaryCalculationStrategy}) rounds + * tax at the individual line level and then sums. This is the standard Vendure behaviour. + * + * An alternative implementation ({@link OrderLevelTaxSummaryCalculationStrategy}) groups + * net subtotals by tax rate and rounds once per group, which eliminates per-line rounding + * accumulation errors. This approach is required by certain jurisdictions and ERP systems. + * + * :::info + * + * This is configured via the `taxOptions.orderTaxSummaryCalculationStrategy` property of + * your VendureConfig. + * + * ::: + * + * @docsCategory tax + * @docsPage OrderTaxSummaryCalculationStrategy + * @docsWeight 0 + * @since 3.6.0 + */ +export interface OrderTaxSummaryCalculationStrategy extends InjectableStrategy { + /** + * @description + * Calculates the order totals (subTotal, subTotalWithTax, shipping, shippingWithTax) + * for the given Order. This is called frequently during promotion application, so + * it should be as cheap as possible - avoid building tax summary data here. + * + * The Order's `lines` and `surcharges` relations must be loaded. + * `shippingLines` may be empty/unloaded, in which case shipping is treated as zero. + */ + calculateOrderTotals(order: Order): OrderTotalsResult; + + /** + * @description + * Calculates the full tax summary for the given Order. This is called once + * when the `taxSummary` getter is accessed on the Order entity. + * + * This method must be synchronous, as it is called from the `taxSummary` getter + * on the Order entity. + * + * The Order's `lines`, `surcharges`, and `shippingLines` relations must be loaded. + */ + calculateTaxSummary(order: Order): OrderTaxSummary[]; +} diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index f1c6f46854..ac46448489 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/packages/core/src/config/vendure-config.ts @@ -64,6 +64,7 @@ import { CacheStrategy } from './system/cache-strategy'; import { ErrorHandlerStrategy } from './system/error-handler-strategy'; import { HealthCheckStrategy } from './system/health-check-strategy'; import { InstrumentationStrategy } from './system/instrumentation-strategy'; +import { OrderTaxSummaryCalculationStrategy } from './tax/order-tax-summary-calculation-strategy'; import { TaxLineCalculationStrategy } from './tax/tax-line-calculation-strategy'; import { TaxZoneStrategy } from './tax/tax-zone-strategy'; @@ -949,6 +950,19 @@ export interface TaxOptions { * @default DefaultTaxLineCalculationStrategy */ taxLineCalculationStrategy?: TaxLineCalculationStrategy; + /** + * @description + * Defines how order-level tax totals and the tax summary are calculated. + * + * The default strategy rounds tax at the individual line level and then sums + * (per-line rounding). The {@link OrderLevelTaxSummaryCalculationStrategy} alternative + * groups net subtotals by tax rate and rounds once per group (per-total rounding), + * which is required by certain jurisdictions and ERP systems. + * + * @default DefaultOrderTaxSummaryCalculationStrategy + * @since 3.6.0 + */ + orderTaxSummaryCalculationStrategy?: OrderTaxSummaryCalculationStrategy; } /** @@ -1017,7 +1031,7 @@ export interface JobQueueOptions { * @description * Options related to scheduled tasks.. * - * @since 3.3.0 + * @since 3.6.0 * @docsCategory scheduled-tasks */ export interface SchedulerOptions { @@ -1321,7 +1335,7 @@ export interface VendureConfig { * @description * Configures the scheduler mechanism and tasks. * - * @since 3.3.0 + * @since 3.6.0 */ schedulerOptions?: SchedulerOptions; /** diff --git a/packages/core/src/entity/order/order.entity.ts b/packages/core/src/entity/order/order.entity.ts index 571c5d39f6..bdaae34171 100644 --- a/packages/core/src/entity/order/order.entity.ts +++ b/packages/core/src/entity/order/order.entity.ts @@ -4,7 +4,6 @@ import { OrderAddress, OrderTaxSummary, OrderType, - TaxLine, } from '@vendure/common/lib/generated-types'; import { DeepPartial, ID } from '@vendure/common/lib/shared-types'; import { summate } from '@vendure/common/lib/shared-utils'; @@ -13,6 +12,7 @@ import { Column, Entity, Index, JoinTable, ManyToMany, ManyToOne, OneToMany } fr import { Calculated } from '../../common/calculated-decorator'; import { InternalServerError } from '../../common/error/errors'; import { ChannelAware } from '../../common/types/common-types'; +import { getConfig } from '../../config/config-helpers'; import { HasCustomFields } from '../../config/custom-field/custom-field-types'; import { OrderState } from '../../service/helpers/order-state-machine/order-state'; import { VendureEntity } from '../base/base.entity'; @@ -292,59 +292,12 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField * @description * A summary of the taxes being applied to this Order. */ - @Calculated({ relations: ['lines', 'surcharges'] }) + @Calculated({ relations: ['lines', 'surcharges', 'shippingLines'] }) get taxSummary(): OrderTaxSummary[] { this.throwIfLinesNotJoined('taxSummary'); this.throwIfSurchargesNotJoined('taxSummary'); - const taxRateMap = new Map< - string, - { rate: number; base: number; tax: number; description: string } - >(); - const taxId = (taxLine: TaxLine): string => `${taxLine.description}:${taxLine.taxRate}`; - const taxableLines = [ - ...(this.lines ?? []), - ...(this.shippingLines ?? []), - ...(this.surcharges ?? []), - ]; - for (const line of taxableLines) { - const taxRateTotal = summate(line.taxLines, 'taxRate'); - for (const taxLine of line.taxLines) { - const id = taxId(taxLine); - const row = taxRateMap.get(id); - const proportionOfTotalRate = 0 < taxLine.taxRate ? taxLine.taxRate / taxRateTotal : 0; - - const lineBase = - line instanceof OrderLine - ? line.proratedLinePrice - : line instanceof Surcharge - ? line.price - : line.discountedPrice; - const lineWithTax = - line instanceof OrderLine - ? line.proratedLinePriceWithTax - : line instanceof Surcharge - ? line.priceWithTax - : line.discountedPriceWithTax; - const amount = Math.round((lineWithTax - lineBase) * proportionOfTotalRate); - if (row) { - row.tax += amount; - row.base += lineBase; - } else { - taxRateMap.set(id, { - tax: amount, - base: lineBase, - description: taxLine.description, - rate: taxLine.taxRate, - }); - } - } - } - return Array.from(taxRateMap.entries()).map(([taxRate, row]) => ({ - taxRate: row.rate, - description: row.description, - taxBase: row.base, - taxTotal: row.tax, - })); + const { orderTaxSummaryCalculationStrategy } = getConfig().taxOptions; + return orderTaxSummaryCalculationStrategy.calculateTaxSummary(this); } private throwIfLinesNotJoined(propertyName: keyof Order) { diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index 650fc44c1f..0e009c4df8 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts @@ -10,8 +10,10 @@ import { ensureConfigLoaded } from '../../../config/config-helpers'; import { ConfigService } from '../../../config/config.service'; import { MockConfigService } from '../../../config/config.service.mock'; import { PromotionCondition } from '../../../config/promotion/promotion-condition'; +import { DefaultOrderTaxSummaryCalculationStrategy } from '../../../config/tax/default-order-tax-summary-calculation-strategy'; import { DefaultTaxLineCalculationStrategy } from '../../../config/tax/default-tax-line-calculation-strategy'; import { DefaultTaxZoneStrategy } from '../../../config/tax/default-tax-zone-strategy'; +import { OrderLevelTaxSummaryCalculationStrategy } from '../../../config/tax/order-level-tax-summary-calculation-strategy'; import { CalculateTaxLinesArgs, TaxLineCalculationStrategy, @@ -50,6 +52,7 @@ describe('OrderCalculator', () => { mockConfigService.taxOptions = { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), + orderTaxSummaryCalculationStrategy: new DefaultOrderTaxSummaryCalculationStrategy(), }; }); @@ -1537,6 +1540,7 @@ describe('OrderCalculator with custom TaxLineCalculationStrategy', () => { mockConfigService.taxOptions = { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new CustomTaxLineCalculationStrategy(), + orderTaxSummaryCalculationStrategy: new DefaultOrderTaxSummaryCalculationStrategy(), }; }); @@ -1650,6 +1654,116 @@ describe('OrderCalculator with custom TaxLineCalculationStrategy', () => { }); }); +describe('OrderCalculator with OrderLevelTaxSummaryCalculationStrategy', () => { + let orderCalculator: OrderCalculator; + + beforeAll(async () => { + const module = await createTestModule(); + orderCalculator = module.get(OrderCalculator); + const mockConfigService = module.get(ConfigService); + mockConfigService.taxOptions = { + taxZoneStrategy: new DefaultTaxZoneStrategy(), + taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), + orderTaxSummaryCalculationStrategy: new OrderLevelTaxSummaryCalculationStrategy(), + }; + }); + + it('calculates order-level totals using grouped rounding', async () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 102, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 215, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + await orderCalculator.applyPriceAdjustments(ctx, order, []); + + // Both lines have 20% standard tax rate. + // Order-level: round((102+215)*0.20) = round(63.4) = 63 + expect(order.subTotal).toBe(317); + expect(order.subTotalWithTax).toBe(317 + Math.round(317 * 0.2)); + + // taxSummary should be consistent with the totals + const taxTotal = order.taxSummary.reduce((sum, s) => sum + s.taxTotal, 0); + expect(order.subTotalWithTax - order.subTotal).toBe( + taxTotal - (order.shippingWithTax - order.shipping), + ); + assertOrderTotalsAddUp(order); + }); + + it('handles order with shipping', async () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [{ listPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }], + }); + order.shippingLines = [ + new ShippingLine({ + shippingMethodId: mockShippingMethodId, + }), + ]; + await orderCalculator.applyPriceAdjustments(ctx, order, []); + + expect(order.subTotal).toBe(100); + expect(order.shipping).toBe(500); + expect(order.shippingWithTax).toBe(600); + expect(order.total).toBe(order.subTotal + 500); + expect(order.totalWithTax).toBe(order.subTotalWithTax + 600); + assertOrderTotalsAddUp(order); + }); + + it('applies percentage order promotion correctly with order-level rounding', async () => { + const percentageOrderAction = new PromotionOrderAction({ + code: 'percentage_order_action', + description: [{ languageCode: LanguageCode.en, value: '' }], + args: { discount: { type: 'int' } }, + execute(_ctx, _order, args) { + const orderTotal = _ctx.channel.pricesIncludeTax ? _order.subTotalWithTax : _order.subTotal; + return -orderTotal * (args.discount / 100); + }, + }); + const alwaysTrueCondition = new PromotionCondition({ + args: {}, + code: 'always_true_condition', + description: [{ languageCode: LanguageCode.en, value: '' }], + check() { + return true; + }, + }); + const promotion = new Promotion({ + id: 1, + name: '50% off order', + conditions: [{ code: alwaysTrueCondition.code, args: [] }], + promotionConditions: [alwaysTrueCondition], + actions: [ + { + code: percentageOrderAction.code, + args: [{ name: 'discount', value: '50' }], + }, + ], + promotionActions: [percentageOrderAction], + }); + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 102, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 215, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]); + + // subTotal before promo = 317, 50% off = -158, so subTotal = 159 + expect(order.subTotal).toBe(159); + expect(order.discounts.length).toBe(1); + expect(order.discounts[0].description).toBe('50% off order'); + // Order-level rounding: tax = round(159 * 0.20) = round(31.8) = 32 + expect(order.subTotalWithTax).toBe(159 + Math.round(159 * 0.2)); + assertOrderTotalsAddUp(order); + }); +}); + function createTestModule() { return Test.createTestingModule({ providers: [ diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.ts index aa2b858779..976743af8f 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.ts @@ -363,30 +363,12 @@ export class OrderCalculator { * totals. */ public calculateOrderTotals(order: Order) { - let totalPrice = 0; - let totalPriceWithTax = 0; - - for (const line of order.lines) { - totalPrice += line.proratedLinePrice; - totalPriceWithTax += line.proratedLinePriceWithTax; - } - for (const surcharge of order.surcharges) { - totalPrice += surcharge.price; - totalPriceWithTax += surcharge.priceWithTax; - } - - order.subTotal = totalPrice; - order.subTotalWithTax = totalPriceWithTax; - - let shippingPrice = 0; - let shippingPriceWithTax = 0; - for (const shippingLine of order.shippingLines) { - shippingPrice += shippingLine.discountedPrice; - shippingPriceWithTax += shippingLine.discountedPriceWithTax; - } - - order.shipping = shippingPrice; - order.shippingWithTax = shippingPriceWithTax; + const { orderTaxSummaryCalculationStrategy } = this.configService.taxOptions; + const result = orderTaxSummaryCalculationStrategy.calculateOrderTotals(order); + order.subTotal = result.subTotal; + order.subTotalWithTax = result.subTotalWithTax; + order.shipping = result.shipping; + order.shippingWithTax = result.shippingWithTax; } private addPromotion(order: Order, promotion: Promotion) { From 5e8403b983cb68f044d5e94a6fbb462f5a6d8037 Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 14:17:09 +0100 Subject: [PATCH 2/9] chore(core): Revert accidentally changed @since version --- packages/core/src/config/vendure-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index ac46448489..53eacea2cf 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/packages/core/src/config/vendure-config.ts @@ -1031,7 +1031,7 @@ export interface JobQueueOptions { * @description * Options related to scheduled tasks.. * - * @since 3.6.0 + * @since 3.3.0 * @docsCategory scheduled-tasks */ export interface SchedulerOptions { @@ -1335,7 +1335,7 @@ export interface VendureConfig { * @description * Configures the scheduler mechanism and tasks. * - * @since 3.6.0 + * @since 3.3.0 */ schedulerOptions?: SchedulerOptions; /** From dd6ab41e4d2e4f8e47ea80890dcf18a73623e80e Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 14:21:20 +0100 Subject: [PATCH 3/9] fix(core): Fix tax summary rounding inconsistency in OrderLevelTaxSummaryCalculationStrategy --- .../tax/order-level-tax-summary-calculation-strategy.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts index 6416fdbd8e..f243d0d377 100644 --- a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts +++ b/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts @@ -67,14 +67,16 @@ export class OrderLevelTaxSummaryCalculationStrategy implements OrderTaxSummaryC calculateTaxSummary(order: Order): OrderTaxSummary[] { const { subTotalGroups, shippingGroups } = this.groupOrder(order); - const merged = new Map(); + const merged = new Map(); for (const groups of [subTotalGroups, shippingGroups]) { for (const [key, group] of groups) { + const roundedTax = Math.round(taxPayableOn(group.netBase, group.rate)); const existing = merged.get(key); if (existing) { existing.netBase += group.netBase; + existing.tax += roundedTax; } else { - merged.set(key, { ...group }); + merged.set(key, { ...group, tax: roundedTax }); } } } @@ -84,7 +86,7 @@ export class OrderLevelTaxSummaryCalculationStrategy implements OrderTaxSummaryC taxRate: group.rate, description: group.description, taxBase: group.netBase, - taxTotal: Math.round(taxPayableOn(group.netBase, group.rate)), + taxTotal: group.tax, }); } return taxSummary; From 15a35730a93deca43701f0b575f160884a30bb5b Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 14:21:46 +0100 Subject: [PATCH 4/9] fix(core): Add consistent null-safety guards for order relations in tax strategies --- .../tax/default-order-tax-summary-calculation-strategy.ts | 4 ++-- .../tax/order-level-tax-summary-calculation-strategy.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts index 7174c679b1..0f149a882e 100644 --- a/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts +++ b/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts @@ -24,11 +24,11 @@ export class DefaultOrderTaxSummaryCalculationStrategy implements OrderTaxSummar calculateOrderTotals(order: Order): OrderTotalsResult { let subTotal = 0; let subTotalWithTax = 0; - for (const line of order.lines) { + for (const line of order.lines ?? []) { subTotal += line.proratedLinePrice; subTotalWithTax += line.proratedLinePriceWithTax; } - for (const surcharge of order.surcharges) { + for (const surcharge of order.surcharges ?? []) { subTotal += surcharge.price; subTotalWithTax += surcharge.priceWithTax; } diff --git a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts index f243d0d377..bd0944162a 100644 --- a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts +++ b/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts @@ -98,11 +98,11 @@ export class OrderLevelTaxSummaryCalculationStrategy implements OrderTaxSummaryC const subTotalGroups = new Map(); const shippingGroups = new Map(); - for (const line of order.lines) { + for (const line of order.lines ?? []) { subTotal += line.proratedLinePrice; this.accumulateIntoGroups(subTotalGroups, line.taxLines, line.proratedLinePrice); } - for (const surcharge of order.surcharges) { + for (const surcharge of order.surcharges ?? []) { subTotal += surcharge.price; this.accumulateIntoGroups(subTotalGroups, surcharge.taxLines, surcharge.price); } From 41564a418559cf66e19eda7c193b5d7901b964c7 Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 14:22:02 +0100 Subject: [PATCH 5/9] test(core): Add test for same tax rate on items and shipping --- ...r-tax-summary-calculation-strategy.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts index 3b48f6938c..b8dd9ff9ca 100644 --- a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts +++ b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts @@ -304,6 +304,46 @@ describe('OrderTaxSummaryCalculationStrategy', () => { expect(orderLevelTotals.shippingWithTax).toBe(317 + 67); // 384 }); + it('merges same tax rate on items and shipping with consistent rounding', () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = createOrder({ + ctx, + lines: [ + { listPrice: 102, taxCategory: taxCategoryStandard, quantity: 1 }, + { listPrice: 215, taxCategory: taxCategoryStandard, quantity: 1 }, + ], + }); + order.lines[0].taxLines = [{ taxRate: 21, description: 'VAT' }]; + order.lines[1].taxLines = [{ taxRate: 21, description: 'VAT' }]; + order.shippingLines = [ + new ShippingLine({ + listPrice: 500, + listPriceIncludesTax: false, + adjustments: [], + taxLines: [{ taxRate: 21, description: 'VAT' }], + }), + ]; + + const totals = strategy.calculateOrderTotals(order); + const taxSummary = strategy.calculateTaxSummary(order); + + // Items: round(317 * 0.21) = round(66.57) = 67 + // Shipping: round(500 * 0.21) = round(105) = 105 + expect(totals.subTotal).toBe(317); + expect(totals.subTotalWithTax).toBe(384); + expect(totals.shipping).toBe(500); + expect(totals.shippingWithTax).toBe(605); + + // Tax summary should merge into one entry with tax = 67 + 105 = 172 + // (not re-rounded from combined netBase: round(817 * 0.21) = round(171.57) = 172) + expect(taxSummary).toEqual([{ description: 'VAT', taxRate: 21, taxBase: 817, taxTotal: 172 }]); + + // Verify taxTotal matches the sum of item tax + shipping tax from totals + const totalTaxFromTotals = + totals.subTotalWithTax - totals.subTotal + (totals.shippingWithTax - totals.shipping); + expect(taxSummary[0].taxTotal).toBe(totalTaxFromTotals); + }); + it('handles prices-include-tax mode', () => { const ctx = createRequestContext({ pricesIncludeTax: true }); const order = createOrder({ From 7db57d222a2c069199be0e9bf2e57d0c9a17ecf5 Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 14:25:07 +0100 Subject: [PATCH 6/9] test(core): Simplify tax consistency assertion in order-calculator test --- .../service/helpers/order-calculator/order-calculator.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index 0e009c4df8..6dcc4a5988 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts @@ -1686,9 +1686,7 @@ describe('OrderCalculator with OrderLevelTaxSummaryCalculationStrategy', () => { // taxSummary should be consistent with the totals const taxTotal = order.taxSummary.reduce((sum, s) => sum + s.taxTotal, 0); - expect(order.subTotalWithTax - order.subTotal).toBe( - taxTotal - (order.shippingWithTax - order.shipping), - ); + expect(order.subTotalWithTax - order.subTotal).toBe(taxTotal); assertOrderTotalsAddUp(order); }); From 3134d49055148d11873ca7d47706f115b716a9e2 Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Thu, 19 Feb 2026 14:40:45 +0100 Subject: [PATCH 7/9] test(core): Strengthen tax strategy test assertions --- .../tax/order-tax-summary-calculation-strategy.spec.ts | 4 ++++ .../helpers/order-calculator/order-calculator.spec.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts index b8dd9ff9ca..c35f0bf31a 100644 --- a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts +++ b/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts @@ -59,6 +59,7 @@ describe('OrderTaxSummaryCalculationStrategy', () => { const taxSummary = strategy.calculateTaxSummary(order); expect(totals.subTotal).toBe(600 + 400); + expect(totals.subTotalWithTax).toBe(1310); expect(taxSummary).toEqual([ { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, { description: 'tax 50', taxRate: 50, taxBase: 400, taxTotal: 200 }, @@ -88,6 +89,7 @@ describe('OrderTaxSummaryCalculationStrategy', () => { expect(totals.subTotal).toBe(600); expect(totals.shipping).toBe(500); expect(totals.shippingWithTax).toBe(600); + expect(taxSummary).toHaveLength(2); expect(taxSummary).toEqual( expect.arrayContaining([ { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, @@ -226,6 +228,7 @@ describe('OrderTaxSummaryCalculationStrategy', () => { const taxSummary = strategy.calculateTaxSummary(order); expect(totals.subTotal).toBe(1000); + expect(taxSummary).toHaveLength(3); expect(taxSummary).toEqual( expect.arrayContaining([ { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, @@ -259,6 +262,7 @@ describe('OrderTaxSummaryCalculationStrategy', () => { expect(totals.subTotal).toBe(600); expect(totals.shipping).toBe(500); expect(totals.shippingWithTax).toBe(600); + expect(taxSummary).toHaveLength(2); expect(taxSummary).toEqual( expect.arrayContaining([ { description: 'tax a', taxRate: 5, taxBase: 600, taxTotal: 30 }, diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index 6dcc4a5988..39e97ef6e5 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts @@ -1668,6 +1668,11 @@ describe('OrderCalculator with OrderLevelTaxSummaryCalculationStrategy', () => { }; }); + // Note: taxCategoryStandard uses a 20% rate, which means these inputs (102, 215) + // produce the same result for both OrderLevelTaxSummaryCalculationStrategy and + // DefaultOrderTaxSummaryCalculationStrategy. This test serves as a regression/integration + // smoke-check. See order-tax-summary-calculation-strategy.spec.ts for tests with a 21% + // rate that demonstrate the actual rounding difference between strategies. it('calculates order-level totals using grouped rounding', async () => { const ctx = createRequestContext({ pricesIncludeTax: false }); const order = createOrder({ From 246548f04867fe907000f5f6ea40dea05bb8a68d Mon Sep 17 00:00:00 2001 From: Colin Pieper Date: Mon, 9 Mar 2026 17:22:36 +0100 Subject: [PATCH 8/9] chore(core): Rename strategies --- packages/core/src/config/config.module.ts | 4 ++-- packages/core/src/config/default-config.ts | 4 ++-- packages/core/src/config/index.ts | 6 +++--- ... default-order-tax-calculation-strategy.ts} | 11 ++++------- ...=> order-level-tax-calculation-strategy.ts} | 17 +++++++---------- ... => order-tax-calculation-strategy.spec.ts} | 18 +++++++++--------- ...gy.ts => order-tax-calculation-strategy.ts} | 14 +++++++------- packages/core/src/config/vendure-config.ts | 8 ++++---- packages/core/src/entity/order/order.entity.ts | 4 ++-- .../order-calculator/order-calculator.spec.ts | 18 +++++++++--------- .../order-calculator/order-calculator.ts | 4 ++-- 11 files changed, 51 insertions(+), 57 deletions(-) rename packages/core/src/config/tax/{default-order-tax-summary-calculation-strategy.ts => default-order-tax-calculation-strategy.ts} (90%) rename packages/core/src/config/tax/{order-level-tax-summary-calculation-strategy.ts => order-level-tax-calculation-strategy.ts} (87%) rename packages/core/src/config/tax/{order-tax-summary-calculation-strategy.spec.ts => order-tax-calculation-strategy.spec.ts} (95%) rename packages/core/src/config/tax/{order-tax-summary-calculation-strategy.ts => order-tax-calculation-strategy.ts} (77%) diff --git a/packages/core/src/config/config.module.ts b/packages/core/src/config/config.module.ts index 268b7001a9..768042240b 100644 --- a/packages/core/src/config/config.module.ts +++ b/packages/core/src/config/config.module.ts @@ -87,7 +87,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo shopApiKeyStrategy, entityAccessControlStrategy, } = this.configService.authOptions; - const { taxZoneStrategy, taxLineCalculationStrategy, orderTaxSummaryCalculationStrategy } = + const { taxZoneStrategy, taxLineCalculationStrategy, orderTaxCalculationStrategy } = this.configService.taxOptions; const { jobQueueStrategy, jobBufferStorageStrategy } = this.configService.jobQueueOptions; const { schedulerStrategy } = this.configService.schedulerOptions; @@ -130,7 +130,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo assetStorageStrategy, taxZoneStrategy, taxLineCalculationStrategy, - orderTaxSummaryCalculationStrategy, + orderTaxCalculationStrategy, jobQueueStrategy, jobBufferStorageStrategy, mergeStrategy, diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index b05fc7cd82..4308fa866c 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -59,7 +59,7 @@ import { defaultShippingEligibilityChecker } from './shipping-method/default-shi import { DefaultShippingLineAssignmentStrategy } from './shipping-method/default-shipping-line-assignment-strategy'; import { InMemoryCacheStrategy } from './system/in-memory-cache-strategy'; import { NoopInstrumentationStrategy } from './system/noop-instrumentation-strategy'; -import { DefaultOrderTaxSummaryCalculationStrategy } from './tax/default-order-tax-summary-calculation-strategy'; +import { DefaultOrderTaxCalculationStrategy } from './tax/default-order-tax-calculation-strategy'; import { DefaultTaxLineCalculationStrategy } from './tax/default-tax-line-calculation-strategy'; import { DefaultTaxZoneStrategy } from './tax/default-tax-zone-strategy'; import { RuntimeVendureConfig } from './vendure-config'; @@ -197,7 +197,7 @@ export const defaultConfig: RuntimeVendureConfig = { taxOptions: { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), - orderTaxSummaryCalculationStrategy: new DefaultOrderTaxSummaryCalculationStrategy(), + orderTaxCalculationStrategy: new DefaultOrderTaxCalculationStrategy(), }, importExportOptions: { importAssetsDir: __dirname, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index f139149baa..b29b8bcbfe 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -100,11 +100,11 @@ export * from './system/error-handler-strategy'; export * from './system/health-check-strategy'; export * from './system/instrumentation-strategy'; export * from './tax/address-based-tax-zone-strategy'; -export * from './tax/default-order-tax-summary-calculation-strategy'; +export * from './tax/default-order-tax-calculation-strategy'; export * from './tax/default-tax-line-calculation-strategy'; export * from './tax/default-tax-zone-strategy'; -export * from './tax/order-level-tax-summary-calculation-strategy'; -export * from './tax/order-tax-summary-calculation-strategy'; +export * from './tax/order-level-tax-calculation-strategy'; +export * from './tax/order-tax-calculation-strategy'; export * from './tax/tax-line-calculation-strategy'; export * from './tax/tax-zone-strategy'; export * from './vendure-config'; diff --git a/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/default-order-tax-calculation-strategy.ts similarity index 90% rename from packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts rename to packages/core/src/config/tax/default-order-tax-calculation-strategy.ts index 0f149a882e..e950555d9f 100644 --- a/packages/core/src/config/tax/default-order-tax-summary-calculation-strategy.ts +++ b/packages/core/src/config/tax/default-order-tax-calculation-strategy.ts @@ -5,22 +5,19 @@ import { OrderLine } from '../../entity/order-line/order-line.entity'; import { Order } from '../../entity/order/order.entity'; import { Surcharge } from '../../entity/surcharge/surcharge.entity'; -import { - OrderTaxSummaryCalculationStrategy, - OrderTotalsResult, -} from './order-tax-summary-calculation-strategy'; +import { OrderTaxCalculationStrategy, OrderTotalsResult } from './order-tax-calculation-strategy'; /** * @description - * The default {@link OrderTaxSummaryCalculationStrategy}. Tax is rounded at the + * The default {@link OrderTaxCalculationStrategy}. Tax is rounded at the * individual line level and then summed. This matches the standard Vendure behaviour * prior to the introduction of this strategy. * * @docsCategory tax - * @docsPage OrderTaxSummaryCalculationStrategy + * @docsPage OrderTaxCalculationStrategy * @since 3.6.0 */ -export class DefaultOrderTaxSummaryCalculationStrategy implements OrderTaxSummaryCalculationStrategy { +export class DefaultOrderTaxCalculationStrategy implements OrderTaxCalculationStrategy { calculateOrderTotals(order: Order): OrderTotalsResult { let subTotal = 0; let subTotalWithTax = 0; diff --git a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/order-level-tax-calculation-strategy.ts similarity index 87% rename from packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts rename to packages/core/src/config/tax/order-level-tax-calculation-strategy.ts index bd0944162a..35866fb890 100644 --- a/packages/core/src/config/tax/order-level-tax-summary-calculation-strategy.ts +++ b/packages/core/src/config/tax/order-level-tax-calculation-strategy.ts @@ -3,10 +3,7 @@ import { OrderTaxSummary, TaxLine } from '@vendure/common/lib/generated-types'; import { taxPayableOn } from '../../common/tax-utils'; import { Order } from '../../entity/order/order.entity'; -import { - OrderTaxSummaryCalculationStrategy, - OrderTotalsResult, -} from './order-tax-summary-calculation-strategy'; +import { OrderTaxCalculationStrategy, OrderTotalsResult } from './order-tax-calculation-strategy'; interface TaxGroup { rate: number; @@ -16,9 +13,9 @@ interface TaxGroup { /** * @description - * An {@link OrderTaxSummaryCalculationStrategy} that groups net subtotals by tax rate + * An {@link OrderTaxCalculationStrategy} that groups net subtotals by tax rate * and rounds once per group. This eliminates per-line rounding accumulation errors - * present in the {@link DefaultOrderTaxSummaryCalculationStrategy}. + * present in the {@link DefaultOrderTaxCalculationStrategy}. * * This approach is required by certain jurisdictions and ERP systems that expect * tax to be calculated on the subtotal per tax rate rather than per line. @@ -29,20 +26,20 @@ interface TaxGroup { * * @example * ```ts - * import { OrderLevelTaxSummaryCalculationStrategy, VendureConfig } from '\@vendure/core'; + * import { OrderLevelTaxCalculationStrategy, VendureConfig } from '\@vendure/core'; * * export const config: VendureConfig = { * taxOptions: { - * orderTaxSummaryCalculationStrategy: new OrderLevelTaxSummaryCalculationStrategy(), + * orderTaxCalculationStrategy: new OrderLevelTaxCalculationStrategy(), * }, * }; * ``` * * @docsCategory tax - * @docsPage OrderTaxSummaryCalculationStrategy + * @docsPage OrderTaxCalculationStrategy * @since 3.6.0 */ -export class OrderLevelTaxSummaryCalculationStrategy implements OrderTaxSummaryCalculationStrategy { +export class OrderLevelTaxCalculationStrategy implements OrderTaxCalculationStrategy { calculateOrderTotals(order: Order): OrderTotalsResult { const { subTotal, subTotalGroups, shipping, shippingGroups } = this.groupOrder(order); diff --git a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts b/packages/core/src/config/tax/order-tax-calculation-strategy.spec.ts similarity index 95% rename from packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts rename to packages/core/src/config/tax/order-tax-calculation-strategy.spec.ts index c35f0bf31a..cdb7c56a44 100644 --- a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.spec.ts +++ b/packages/core/src/config/tax/order-tax-calculation-strategy.spec.ts @@ -5,16 +5,16 @@ import { Surcharge } from '../../entity/surcharge/surcharge.entity'; import { createOrder, createRequestContext, taxCategoryStandard } from '../../testing/order-test-utils'; import { ensureConfigLoaded } from '../config-helpers'; -import { DefaultOrderTaxSummaryCalculationStrategy } from './default-order-tax-summary-calculation-strategy'; -import { OrderLevelTaxSummaryCalculationStrategy } from './order-level-tax-summary-calculation-strategy'; +import { DefaultOrderTaxCalculationStrategy } from './default-order-tax-calculation-strategy'; +import { OrderLevelTaxCalculationStrategy } from './order-level-tax-calculation-strategy'; -describe('OrderTaxSummaryCalculationStrategy', () => { +describe('OrderTaxCalculationStrategy', () => { beforeAll(async () => { await ensureConfigLoaded(); }); - describe('DefaultOrderTaxSummaryCalculationStrategy', () => { - const strategy = new DefaultOrderTaxSummaryCalculationStrategy(); + describe('DefaultOrderTaxCalculationStrategy', () => { + const strategy = new DefaultOrderTaxCalculationStrategy(); it('sums per-line totals for a single tax rate', () => { const ctx = createRequestContext({ pricesIncludeTax: false }); @@ -115,8 +115,8 @@ describe('OrderTaxSummaryCalculationStrategy', () => { }); }); - describe('OrderLevelTaxSummaryCalculationStrategy', () => { - const strategy = new OrderLevelTaxSummaryCalculationStrategy(); + describe('OrderLevelTaxCalculationStrategy', () => { + const strategy = new OrderLevelTaxCalculationStrategy(); it('rounds tax on grouped net subtotal rather than per line', () => { const ctx = createRequestContext({ pricesIncludeTax: false }); @@ -130,7 +130,7 @@ describe('OrderTaxSummaryCalculationStrategy', () => { order.lines[0].taxLines = [{ taxRate: 21, description: 'VAT' }]; order.lines[1].taxLines = [{ taxRate: 21, description: 'VAT' }]; - const defaultStrategy = new DefaultOrderTaxSummaryCalculationStrategy(); + const defaultStrategy = new DefaultOrderTaxCalculationStrategy(); const defaultTotals = defaultStrategy.calculateOrderTotals(order); const defaultTaxSummary = defaultStrategy.calculateTaxSummary(order); const orderLevelTotals = strategy.calculateOrderTotals(order); @@ -293,7 +293,7 @@ describe('OrderTaxSummaryCalculationStrategy', () => { }), ]; - const defaultStrategy = new DefaultOrderTaxSummaryCalculationStrategy(); + const defaultStrategy = new DefaultOrderTaxCalculationStrategy(); const defaultTotals = defaultStrategy.calculateOrderTotals(order); const orderLevelTotals = strategy.calculateOrderTotals(order); diff --git a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts b/packages/core/src/config/tax/order-tax-calculation-strategy.ts similarity index 77% rename from packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts rename to packages/core/src/config/tax/order-tax-calculation-strategy.ts index 1cfdaf439b..57daf51f99 100644 --- a/packages/core/src/config/tax/order-tax-summary-calculation-strategy.ts +++ b/packages/core/src/config/tax/order-tax-calculation-strategy.ts @@ -5,10 +5,10 @@ import { Order } from '../../entity/order/order.entity'; /** * @description - * The result of an {@link OrderTaxSummaryCalculationStrategy}'s `calculateOrderTotals` method. + * The result of an {@link OrderTaxCalculationStrategy}'s `calculateOrderTotals` method. * * @docsCategory tax - * @docsPage OrderTaxSummaryCalculationStrategy + * @docsPage OrderTaxCalculationStrategy * @since 3.6.0 */ export interface OrderTotalsResult { @@ -22,26 +22,26 @@ export interface OrderTotalsResult { * @description * Defines how order-level tax totals and the tax summary are calculated. * - * The default implementation ({@link DefaultOrderTaxSummaryCalculationStrategy}) rounds + * The default implementation ({@link DefaultOrderTaxCalculationStrategy}) rounds * tax at the individual line level and then sums. This is the standard Vendure behaviour. * - * An alternative implementation ({@link OrderLevelTaxSummaryCalculationStrategy}) groups + * An alternative implementation ({@link OrderLevelTaxCalculationStrategy}) groups * net subtotals by tax rate and rounds once per group, which eliminates per-line rounding * accumulation errors. This approach is required by certain jurisdictions and ERP systems. * * :::info * - * This is configured via the `taxOptions.orderTaxSummaryCalculationStrategy` property of + * This is configured via the `taxOptions.orderTaxCalculationStrategy` property of * your VendureConfig. * * ::: * * @docsCategory tax - * @docsPage OrderTaxSummaryCalculationStrategy + * @docsPage OrderTaxCalculationStrategy * @docsWeight 0 * @since 3.6.0 */ -export interface OrderTaxSummaryCalculationStrategy extends InjectableStrategy { +export interface OrderTaxCalculationStrategy extends InjectableStrategy { /** * @description * Calculates the order totals (subTotal, subTotalWithTax, shipping, shippingWithTax) diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index 47075dd7d0..6a919ab8be 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/packages/core/src/config/vendure-config.ts @@ -65,7 +65,7 @@ import { CacheStrategy } from './system/cache-strategy'; import { ErrorHandlerStrategy } from './system/error-handler-strategy'; import { HealthCheckStrategy } from './system/health-check-strategy'; import { InstrumentationStrategy } from './system/instrumentation-strategy'; -import { OrderTaxSummaryCalculationStrategy } from './tax/order-tax-summary-calculation-strategy'; +import { OrderTaxCalculationStrategy } from './tax/order-tax-calculation-strategy'; import { TaxLineCalculationStrategy } from './tax/tax-line-calculation-strategy'; import { TaxZoneStrategy } from './tax/tax-zone-strategy'; @@ -971,14 +971,14 @@ export interface TaxOptions { * Defines how order-level tax totals and the tax summary are calculated. * * The default strategy rounds tax at the individual line level and then sums - * (per-line rounding). The {@link OrderLevelTaxSummaryCalculationStrategy} alternative + * (per-line rounding). The {@link OrderLevelTaxCalculationStrategy} alternative * groups net subtotals by tax rate and rounds once per group (per-total rounding), * which is required by certain jurisdictions and ERP systems. * - * @default DefaultOrderTaxSummaryCalculationStrategy + * @default DefaultOrderTaxCalculationStrategy * @since 3.6.0 */ - orderTaxSummaryCalculationStrategy?: OrderTaxSummaryCalculationStrategy; + orderTaxCalculationStrategy?: OrderTaxCalculationStrategy; } /** diff --git a/packages/core/src/entity/order/order.entity.ts b/packages/core/src/entity/order/order.entity.ts index bdaae34171..634940417a 100644 --- a/packages/core/src/entity/order/order.entity.ts +++ b/packages/core/src/entity/order/order.entity.ts @@ -296,8 +296,8 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField get taxSummary(): OrderTaxSummary[] { this.throwIfLinesNotJoined('taxSummary'); this.throwIfSurchargesNotJoined('taxSummary'); - const { orderTaxSummaryCalculationStrategy } = getConfig().taxOptions; - return orderTaxSummaryCalculationStrategy.calculateTaxSummary(this); + const { orderTaxCalculationStrategy } = getConfig().taxOptions; + return orderTaxCalculationStrategy.calculateTaxSummary(this); } private throwIfLinesNotJoined(propertyName: keyof Order) { diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index 39e97ef6e5..295ad26c52 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts @@ -10,10 +10,10 @@ import { ensureConfigLoaded } from '../../../config/config-helpers'; import { ConfigService } from '../../../config/config.service'; import { MockConfigService } from '../../../config/config.service.mock'; import { PromotionCondition } from '../../../config/promotion/promotion-condition'; -import { DefaultOrderTaxSummaryCalculationStrategy } from '../../../config/tax/default-order-tax-summary-calculation-strategy'; +import { DefaultOrderTaxCalculationStrategy } from '../../../config/tax/default-order-tax-calculation-strategy'; import { DefaultTaxLineCalculationStrategy } from '../../../config/tax/default-tax-line-calculation-strategy'; import { DefaultTaxZoneStrategy } from '../../../config/tax/default-tax-zone-strategy'; -import { OrderLevelTaxSummaryCalculationStrategy } from '../../../config/tax/order-level-tax-summary-calculation-strategy'; +import { OrderLevelTaxCalculationStrategy } from '../../../config/tax/order-level-tax-calculation-strategy'; import { CalculateTaxLinesArgs, TaxLineCalculationStrategy, @@ -52,7 +52,7 @@ describe('OrderCalculator', () => { mockConfigService.taxOptions = { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), - orderTaxSummaryCalculationStrategy: new DefaultOrderTaxSummaryCalculationStrategy(), + orderTaxCalculationStrategy: new DefaultOrderTaxCalculationStrategy(), }; }); @@ -1540,7 +1540,7 @@ describe('OrderCalculator with custom TaxLineCalculationStrategy', () => { mockConfigService.taxOptions = { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new CustomTaxLineCalculationStrategy(), - orderTaxSummaryCalculationStrategy: new DefaultOrderTaxSummaryCalculationStrategy(), + orderTaxCalculationStrategy: new DefaultOrderTaxCalculationStrategy(), }; }); @@ -1654,7 +1654,7 @@ describe('OrderCalculator with custom TaxLineCalculationStrategy', () => { }); }); -describe('OrderCalculator with OrderLevelTaxSummaryCalculationStrategy', () => { +describe('OrderCalculator with OrderLevelTaxCalculationStrategy', () => { let orderCalculator: OrderCalculator; beforeAll(async () => { @@ -1664,14 +1664,14 @@ describe('OrderCalculator with OrderLevelTaxSummaryCalculationStrategy', () => { mockConfigService.taxOptions = { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), - orderTaxSummaryCalculationStrategy: new OrderLevelTaxSummaryCalculationStrategy(), + orderTaxCalculationStrategy: new OrderLevelTaxCalculationStrategy(), }; }); // Note: taxCategoryStandard uses a 20% rate, which means these inputs (102, 215) - // produce the same result for both OrderLevelTaxSummaryCalculationStrategy and - // DefaultOrderTaxSummaryCalculationStrategy. This test serves as a regression/integration - // smoke-check. See order-tax-summary-calculation-strategy.spec.ts for tests with a 21% + // produce the same result for both OrderLevelTaxCalculationStrategy and + // DefaultOrderTaxCalculationStrategy. This test serves as a regression/integration + // smoke-check. See order-tax-calculation-strategy.spec.ts for tests with a 21% // rate that demonstrate the actual rounding difference between strategies. it('calculates order-level totals using grouped rounding', async () => { const ctx = createRequestContext({ pricesIncludeTax: false }); diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.ts index 976743af8f..72c50ee1fb 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.ts @@ -363,8 +363,8 @@ export class OrderCalculator { * totals. */ public calculateOrderTotals(order: Order) { - const { orderTaxSummaryCalculationStrategy } = this.configService.taxOptions; - const result = orderTaxSummaryCalculationStrategy.calculateOrderTotals(order); + const { orderTaxCalculationStrategy } = this.configService.taxOptions; + const result = orderTaxCalculationStrategy.calculateOrderTotals(order); order.subTotal = result.subTotal; order.subTotalWithTax = result.subTotalWithTax; order.shipping = result.shipping; From f60efe74663a3cd5e3c231ac2239c9da3fe085bd Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 11 Mar 2026 10:28:14 +0100 Subject: [PATCH 9/9] fix(core): Add missing orderTaxCalculationStrategy to shipping test mock config --- .../service/helpers/order-calculator/order-calculator.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index ce05524abc..e42a9c0abd 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts @@ -233,6 +233,7 @@ describe('OrderCalculator', () => { mockConfigService.taxOptions = { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), + orderTaxCalculationStrategy: new DefaultOrderTaxCalculationStrategy(), }; const ctx = createRequestContext({ pricesIncludeTax: false });