Skip to main content

ADR-002: Document Calculations Architecture

Status

Accepted

Context

FlowPOS has multiple document types (Sale, Order, Quote, Bill, etc.) that share similar line-item structures: quantity, unit price, discounts, taxes, and totals. Historically, each module implemented its own tax and total calculations, leading to:

  • Discrepancies between Sale and Order totals for equivalent line items
  • Double-counting of tax when order header added tax on top of already tax-inclusive line amounts
  • Rounding differences when deriving tax from stored snapshots (minor units) vs. using product tax definitions

Decision

1. Single Calculation Engine

All tax and total calculations MUST use LineItemTaxService from DocumentCalculationsModule. No module may implement its own tax/total logic.

Location: apps/backend/src/document-calculations/

2. Tax-Inclusive Pricing

Line amounts are gross (tax-inclusive). The formula:

lineGross = quantity × unitPriceFinal
taxableAmount = lineGross / (1 + taxRate)
taxAmount = lineGross - taxableAmount
grandTotal = sum(lineGross) // do NOT add tax again

3. Minor Units for Storage

Prices and amounts are stored in minor units (e.g., 800 = 8.00 for 2-decimal currency) where applicable to avoid float precision issues.

4. Use Product Tax Definitions When Available

When enriching order/quote items for display, prefer product.taxes (the source tax definition) over derived rates from taxAmountSnapshot. Snapshots lose precision when stored in minor units.

5. Shared Document Line Item Shape

Documents should output items conforming to DocumentLineItemShape so the frontend ItemsTotals / calculateTotals works identically.

Location: apps/backend/src/document-calculations/domain/document-line-item.types.ts

6. Canonical Document Totals Shape

Use DocumentTotalsShape for Order, Sale, Quote, Bill responses. Fields: totalAmount, totalBaseAmount, optional subtotal, taxAmount, cartDiscountTotal.

7. Header Total Invariant

Before persisting, validate:

expectedTotal = sum(lineGrossAmounts) - cartDiscountTotal
headerTotal must equal expectedTotal (within rounding)

Call LineItemTaxService.validateHeaderTotalMatchesLineTotals() before create/update when the client sends a header total (e.g. Sale create). Order always recomputes from items and does not need this check.

8. Naming Improvements (future)

  • discountTotal in LineDiscountDetail is per-unit; consider unitDiscountAmount and lineDiscountAmount for clarity.
  • cost on sale items is net/taxable amount, not inventory cost; consider netAmount or taxExclusiveAmount.
  • Standardize on minor units for persistence; convert to major only in responses/UI.

Consequences

  • Sale and Order (and future Quote, Invoice, etc.) display identical subtotal, tax, and amount for equivalent line items
  • Single place to fix calculation bugs
  • New document types must integrate with DocumentCalculationsModule
  • Order repository must join product.taxes when fetching items for enrichment

References