Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-amount-precision.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions src/computations/compute-totals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
21 changes: 15 additions & 6 deletions src/computations/compute-totals.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -27,18 +27,27 @@ import { computeRecurrenceAfterCashbackAmounts } from './compute-recurrence-afte

type ComputeAggregatedAndPriceTotalsOptions = {
redeemedPromos?: Array<RedeemedPromo>;
/**
* 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<PricingDetails, 'items'> & {
items: NonNullable<PricingDetails['items']>;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -142,7 +151,7 @@ export const computeAggregatedAndPriceTotals = (
);
}

return convertPricingPrecision(priceDetails, 2);
return convertPricingPrecision(priceDetails, precision);
};

/**
Expand Down
Loading