-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Summary
Introduce an OrderTotalCalculationStrategy that allows the order totals calculation to be delegated to a configurable strategy, replacing the current hardcoded OrderCalculator.calculateOrderTotals() method.
This addresses a real-world pain point: different tax regimes and businesses expect different rounding behaviour (line-level vs order-level), and the current implementation only supports line-level rounding with no way to override it.
Problem
Currently, OrderCalculator.calculateOrderTotals() (source) is a hardcoded method that:
- Iterates all order lines and sums their individually-rounded
proratedLinePrice/proratedLinePriceWithTaxvalues - Sums surcharges
- Sums shipping lines
- Sets
order.subTotal,order.subTotalWithTax,order.shipping,order.shippingWithTax
public calculateOrderTotals(order: Order) {
let totalPrice = 0;
let totalPriceWithTax = 0;
for (const line of order.lines) {
totalPrice += line.proratedLinePrice;
totalPriceWithTax += line.proratedLinePriceWithTax;
}
// ...
order.subTotal = totalPrice;
order.subTotalWithTax = totalPriceWithTax;
}The issue is that each OrderLine's calculated properties (proratedLinePrice, etc.) apply roundMoney() per line before the summation. This means the order total is the sum of individually-rounded values, which can differ from rounding the aggregate — the classic "off by a penny" problem.
Example
Line 1: £10.33 net × 20% VAT = £12.396 → rounded to £12.40
Line 2: £10.33 net × 20% VAT = £12.396 → rounded to £12.40
Line-level total: £24.80
vs.
Order-level: (£10.33 + £10.33) × 1.20 = £24.792 → rounded to £24.79
Many businesses and tax authorities expect order-level (or invoice-level) rounding. While both conventions are typically accepted as long as they're applied consistently, the inability to configure this in Vendure has caused issues for users (see Related Issues below).
Proposal
New Strategy Interface
export interface OrderTotalCalculationStrategy extends InjectableStrategy {
calculateOrderTotals(order: Order): OrderTotals;
}
export interface OrderTotals {
subTotal: number;
subTotalWithTax: number;
shipping: number;
shippingWithTax: number;
}Configuration
// vendure-config.ts
export interface OrderOptions {
// ... existing options ...
orderTotalCalculationStrategy?: OrderTotalCalculationStrategy;
}Default Implementation
The default implementation would replicate the current behaviour exactly, ensuring zero breaking changes:
export class DefaultOrderTotalCalculationStrategy implements OrderTotalCalculationStrategy {
calculateOrderTotals(order: Order): OrderTotals {
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 };
}
}Alternative Implementations
With this strategy in place, users could implement:
- Order-level tax rounding: Sum unrounded net values, apply tax to the total, round once
- Custom rounding rules: E.g. always round down for B2B invoicing
- Bundle-aware totals: Filter child order lines from the sum (see Separate variants into their own products (bundle products) #236 for bundle product discussion)
Additional Context
This strategy is also a prerequisite for product bundle support (#236). When implementing bundles via a parent/child OrderLine hierarchy, the strategy provides a clean extension point for controlling how bundle component lines participate in the order total calculation.
Display Value Considerations
Note that the @Calculated() getters on OrderLine (e.g. unitPriceWithTax) will still return individually-rounded values for the GraphQL API. When using order-level rounding, this means displayed line totals may not sum exactly to the order total. This is an inherent characteristic of order-level rounding, and is typically handled by either:
- Accepting the minor display discrepancy (common in B2B)
- Adding a small "rounding adjustment" surcharge to reconcile
Related Issues
- Tax calculations incorrectly rounding #2662 — Tax calculations incorrectly rounding (direct report of this problem)
- Price calculated wrong with discount coupon #3003 — Price calculated wrong with discount coupon (rounding artifact)
- Order promotion discounts shouldn't need to include tax #1237 — Order promotion discounts shouldn't need to include tax (related tax/discount interaction)
- Price with tax incorrect while using BigInt strategy #2527 — Price with tax incorrect while using BigInt strategy
- Separate variants into their own products (bundle products) #236 — Bundle products (this strategy is a building block for bundle support)