Skip to content

feat: OrderTotalCalculationStrategy - make order total & tax rounding calculation configurable #4474

@michaelbromley

Description

@michaelbromley

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:

  1. Iterates all order lines and sums their individually-rounded proratedLinePrice / proratedLinePriceWithTax values
  2. Sums surcharges
  3. Sums shipping lines
  4. 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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions