From 852d568839bbbdfca9dfacd2866abc0b0e088aa4 Mon Sep 17 00:00:00 2001 From: Viljami + Claude Date: Tue, 5 May 2026 09:00:05 +0000 Subject: [PATCH] feat(compute): add precision option to computeAggregatedAndPriceTotals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-unit rates such as `0.1524 EUR/kWh` and sub-cent rates like `0.00352 EUR/kWh` are configured by tariff operators with full decimal precision but the engine's `amount_total` / `unit_amount` integer fields are rounded to whole cents, which downstream consumers display as `15 cents` / `€0` / `1 cent` even when the contracted rate is something else. Today consumers can read the `*_decimal` string fields to recover precision but every consumer has to remember to do so. Add an opt-in `precision` option to `computeAggregatedAndPriceTotals`, defaulting to `DEFAULT_INTEGER_AMOUNT_PRECISION` (2). Callers that need to render or compute on sub-cent rates can pass `{ precision: 12 }` (i.e. `DECIMAL_PRECISION`) and receive integer fields at the same precision the dinero math is done at, with the `*_decimal` strings unchanged. The option is threaded through the three internal `convert*Precision` calls. Default behaviour for every existing caller is identical — all 634 existing tests pass without modification — and three new tests cover the opt-in path against `0.1524 EUR/kWh` and `0.00352 EUR/kWh`. Refs https://e-pilot.atlassian.net/browse/STABLE360-11491 Co-authored-by: Claude --- .changeset/preserve-amount-precision.md | 5 ++ src/computations/compute-totals.test.ts | 76 +++++++++++++++++++++++++ src/computations/compute-totals.ts | 21 +++++-- 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 .changeset/preserve-amount-precision.md diff --git a/.changeset/preserve-amount-precision.md b/.changeset/preserve-amount-precision.md new file mode 100644 index 0000000..8e84ab3 --- /dev/null +++ b/.changeset/preserve-amount-precision.md @@ -0,0 +1,5 @@ +--- +'@epilot/pricing': minor +--- + +Add `precision` option to `computeAggregatedAndPriceTotals` so consumers can opt into preserving sub-cent precision in integer amount fields (`amount_total`, `unit_amount`, etc.). Default is unchanged (`DEFAULT_INTEGER_AMOUNT_PRECISION`, i.e. cents). Pass `{ precision: 12 }` to keep the full `DECIMAL_PRECISION` in returned integers — useful for rendering per-unit rates like `0.1524 EUR/kWh` without losing decimals to monetary rounding. diff --git a/src/computations/compute-totals.test.ts b/src/computations/compute-totals.test.ts index c70457a..8a33069 100644 --- a/src/computations/compute-totals.test.ts +++ b/src/computations/compute-totals.test.ts @@ -1062,3 +1062,79 @@ it('should apply cashbacks in composite price if it has requires_promo_code set }, ]); }); + +describe('computeAggregatedAndPriceTotals — precision option', () => { + const subCentRatePriceItem: PriceItemDto = { + quantity: 1, + _price: { + unit_amount: 15, + unit_amount_decimal: '0.1524', + unit_amount_currency: 'EUR', + pricing_model: 'per_unit', + is_tax_inclusive: false, + type: 'recurring', + billing_period: 'yearly', + tax: [], + } as unknown as Price, + }; + + it('defaults to precision 2 — integers rounded to whole cents (current behaviour)', () => { + const result = computeAggregatedAndPriceTotals([subCentRatePriceItem]); + const item = result.items?.[0]; + + expect(item).toEqual( + expect.objectContaining({ + unit_amount: 15, + unit_amount_gross: 15, + amount_total: 15, + unit_amount_gross_decimal: '0.1524', + amount_total_decimal: '0.1524', + }), + ); + expect(result.total_details?.breakdown?.recurrences?.[0]).toEqual( + expect.objectContaining({ amount_total: 15, amount_total_decimal: '0.1524' }), + ); + }); + + it('preserves sub-cent integer precision when precision is set to DECIMAL_PRECISION (12)', () => { + const result = computeAggregatedAndPriceTotals([subCentRatePriceItem], { precision: 12 }); + const item = result.items?.[0]; + + // Integer fields now carry the full 12-decimal precision: 0.1524 EUR = 152_400_000_000 at precision 12. + expect(item).toEqual( + expect.objectContaining({ + unit_amount: 152_400_000_000, + unit_amount_gross: 152_400_000_000, + amount_total: 152_400_000_000, + unit_amount_gross_decimal: '0.1524', + amount_total_decimal: '0.1524', + }), + ); + expect(result.total_details?.breakdown?.recurrences?.[0]).toEqual( + expect.objectContaining({ + amount_total: 152_400_000_000, + amount_total_decimal: '0.1524', + }), + ); + }); + + it('does not silently round to zero for sub-cent amounts when precision is preserved', () => { + const subCentItem: PriceItemDto = { + ...subCentRatePriceItem, + _price: { + ...(subCentRatePriceItem._price as Price), + unit_amount: 0, + unit_amount_decimal: '0.00352', + } as unknown as Price, + }; + + const rounded = computeAggregatedAndPriceTotals([subCentItem]); + const preserved = computeAggregatedAndPriceTotals([subCentItem], { precision: 12 }); + + expect(rounded.items?.[0]?.amount_total).toBe(0); + expect(rounded.items?.[0]?.amount_total_decimal).toBe('0.00352'); + + expect(preserved.items?.[0]?.amount_total).toBe(3_520_000_000); + expect(preserved.items?.[0]?.amount_total_decimal).toBe('0.00352'); + }); +}); diff --git a/src/computations/compute-totals.ts b/src/computations/compute-totals.ts index 4e20b44..6a6a76a 100644 --- a/src/computations/compute-totals.ts +++ b/src/computations/compute-totals.ts @@ -1,4 +1,4 @@ -import { DEFAULT_CURRENCY } from '../money/constants'; +import { DEFAULT_CURRENCY, DEFAULT_INTEGER_AMOUNT_PRECISION } from '../money/constants'; import { toDineroFromInteger } from '../money/to-dinero'; import { isOnRequestUnitAmountApproved } from '../prices/approval'; import { @@ -27,18 +27,27 @@ import { computeRecurrenceAfterCashbackAmounts } from './compute-recurrence-afte type ComputeAggregatedAndPriceTotalsOptions = { redeemedPromos?: Array; + /** + * Precision of the returned integer amount fields (`amount_total`, `unit_amount`, etc.). + * Defaults to `DEFAULT_INTEGER_AMOUNT_PRECISION` (2), which rounds to whole cents. + * Pass `DECIMAL_PRECISION` (12) to preserve sub-cent precision so consumers can + * render rates like `0.1524 EUR/kWh` without losing decimals to monetary rounding. + * The `*_decimal` string fields are always preserved at full precision regardless. + */ + precision?: number; }; /** * Computes all the integer amounts for the price items using the string decimal representation defined on prices unit_amount field. * All totals are computed with a decimal precision of DECIMAL_PRECISION. - * After the calculations the integer amounts are scaled to a precision of 2. + * After the calculations the integer amounts are scaled to the requested `precision` + * (defaults to `DEFAULT_INTEGER_AMOUNT_PRECISION`, i.e. cents). * * This function computes both line items and aggregated totals. */ export const computeAggregatedAndPriceTotals = ( priceItems: PriceItemsDto, - { redeemedPromos = [] }: ComputeAggregatedAndPriceTotalsOptions = {}, + { redeemedPromos = [], precision = DEFAULT_INTEGER_AMOUNT_PRECISION }: ComputeAggregatedAndPriceTotalsOptions = {}, ): PricingDetails => { const initialPricingDetails: Omit & { items: NonNullable; @@ -87,7 +96,7 @@ export const computeAggregatedAndPriceTotals = ( ...(typeof itemBreakdown?.amount_total === 'number' && { amount_total_decimal: toDineroFromInteger(itemBreakdown.amount_total).toUnit().toString(), }), - item_components: convertPriceComponentsPrecision(compositePriceItemToAppend.item_components ?? [], 2), + item_components: convertPriceComponentsPrecision(compositePriceItemToAppend.item_components ?? [], precision), }; return { @@ -123,7 +132,7 @@ export const computeAggregatedAndPriceTotals = ( ? recomputeDetailTotals(details, price, priceItemToAppend as PriceItem) : details; - const newItem = convertPriceItemPrecision(priceItemToAppend as PriceItem, 2); + const newItem = convertPriceItemPrecision(priceItemToAppend as PriceItem, precision); return { ...updatedTotals, @@ -142,7 +151,7 @@ export const computeAggregatedAndPriceTotals = ( ); } - return convertPricingPrecision(priceDetails, 2); + return convertPricingPrecision(priceDetails, precision); }; /**