Saltar al contenido principal

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​