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)
discountTotalinLineDiscountDetailis per-unit; considerunitDiscountAmountandlineDiscountAmountfor clarity.coston sale items is net/taxable amount, not inventory cost; considernetAmountortaxExclusiveAmount.- 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.taxeswhen fetching items for enrichment
References
- docs/discounts/to-solve.md – analysis that identified the discrepancies
- document-line-item-shape.md – shared line item contract
- document-modules-checklist.md – code review checklist