diff --git a/src/exports.test.ts b/src/exports.test.ts index 6122d51..0f563e1 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -35,6 +35,7 @@ const expectedNamedExports = [ 'extractGetAgConfig', 'getAmountWithTax', 'getTaxValue', + 'computePriceDiff', 'isVariablePrice', 'isVariablePriceItem', ]; diff --git a/src/index.ts b/src/index.ts index ae4a669..d76ff59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,3 +39,5 @@ export { formatFeeAmountFromString } from './getag/formatters'; export { extractGetAgConfig } from './getag/extract-config'; export { getTaxValue } from './taxes/get-tax-value'; export { getAmountWithTax } from './taxes/get-amount-with-tax'; +export { computePriceDiff } from './prices/compute-price-diff'; +export type { PriceDiff, PriceDiffFormat, PriceDiffOptions } from './prices/compute-price-diff'; diff --git a/src/prices/compute-price-diff.test.ts b/src/prices/compute-price-diff.test.ts new file mode 100644 index 0000000..312052a --- /dev/null +++ b/src/prices/compute-price-diff.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { computePriceDiff } from './compute-price-diff'; + +describe('computePriceDiff', () => { + describe('returns null', () => { + it('when current amount is zero', () => { + expect(computePriceDiff('10.00', '0.00', { format: 'absolute' })).toBeNull(); + }); + + it('when offer and current amounts are equal', () => { + expect(computePriceDiff('30.00', '30.00', { format: 'absolute' })).toBeNull(); + }); + + it('when offer is worse and onlyIfBetter is true', () => { + expect(computePriceDiff('40.00', '30.00', { format: 'absolute', onlyIfBetter: true })).toBeNull(); + }); + }); + + describe('percentage format', () => { + it('returns correct percentage when offer is better (lower)', () => { + const result = computePriceDiff('25.00', '30.00', { format: 'percentage' }); + expect(result).not.toBeNull(); + expect(result!.isBetter).toBe(true); + expect(result!.formattedDiff).toBe('16.7%'); + }); + + it('returns correct percentage when offer is worse (higher)', () => { + const result = computePriceDiff('36.00', '30.00', { format: 'percentage' }); + expect(result).not.toBeNull(); + expect(result!.isBetter).toBe(false); + expect(result!.formattedDiff).toBe('20.0%'); + }); + }); + + describe('absolute format', () => { + it('returns formatted absolute diff when offer is better', () => { + const result = computePriceDiff('25.00', '30.00', { format: 'absolute', currency: 'EUR', locale: 'de-DE' }); + expect(result).not.toBeNull(); + expect(result!.isBetter).toBe(true); + expect(result!.formattedDiff).toContain('5'); + }); + + it('returns formatted absolute diff when offer is worse', () => { + const result = computePriceDiff('35.00', '30.00', { format: 'absolute', currency: 'EUR', locale: 'de-DE' }); + expect(result).not.toBeNull(); + expect(result!.isBetter).toBe(false); + expect(result!.formattedDiff).toContain('5'); + }); + }); + + describe('onlyIfBetter', () => { + it('returns result when offer is better and onlyIfBetter is true', () => { + const result = computePriceDiff('20.00', '30.00', { format: 'percentage', onlyIfBetter: true }); + expect(result).not.toBeNull(); + expect(result!.isBetter).toBe(true); + }); + + it('returns result when offer is worse and onlyIfBetter is false', () => { + const result = computePriceDiff('40.00', '30.00', { format: 'percentage', onlyIfBetter: false }); + expect(result).not.toBeNull(); + expect(result!.isBetter).toBe(false); + }); + }); +}); diff --git a/src/prices/compute-price-diff.ts b/src/prices/compute-price-diff.ts new file mode 100644 index 0000000..57492be --- /dev/null +++ b/src/prices/compute-price-diff.ts @@ -0,0 +1,70 @@ +import type { Currency as DineroCurrency } from 'dinero.js'; +import { DEFAULT_CURRENCY, DEFAULT_INTEGER_AMOUNT_PRECISION } from '../money/constants'; +import { formatAmount, parseDecimalValue } from '../money/formatters'; +import { toDinero } from '../money/to-dinero'; +import type { Currency } from '../shared/types'; + +export type PriceDiffFormat = 'percentage' | 'absolute'; + +export type PriceDiffOptions = { + format: PriceDiffFormat; + onlyIfBetter?: boolean; + currency?: string; + locale?: string; +}; + +export type PriceDiff = { + isBetter: boolean; + formattedDiff: string; +}; + +/** + * Computes the price difference between an offer price and a current price, + * returning the formatted savings or increase for display purposes. + * + * @param offerAmount - The new/offer price as a decimal string (e.g. "25.00") + * @param currentAmount - The current/reference price as a decimal string (e.g. "30.00") + * @param options - Configuration: format, onlyIfBetter, currency, locale + * @returns `{ isBetter, formattedDiff }` or `null` if not applicable + */ +export const computePriceDiff = ( + offerAmount: string, + currentAmount: string, + options: PriceDiffOptions, +): PriceDiff | null => { + const { format, onlyIfBetter = false, currency, locale } = options; + const resolvedCurrency = (currency || DEFAULT_CURRENCY) as DineroCurrency; + + const currentDinero = toDinero(parseDecimalValue(currentAmount), resolvedCurrency); + const offerDinero = toDinero(parseDecimalValue(offerAmount), resolvedCurrency); + + if (currentDinero.isZero()) return null; + + const diff = currentDinero.subtract(offerDinero); + + if (diff.isZero()) return null; + + const isBetter = !diff.isNegative(); + + if (onlyIfBetter && !isBetter) return null; + + const resolvedLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : undefined); + + const absDiff = diff.isNegative() ? diff.multiply(-1) : diff; + + let formattedDiff: string; + + if (format === 'percentage') { + const percentage = (absDiff.getAmount() / currentDinero.getAmount()) * 100; + + formattedDiff = `${percentage.toFixed(1)}%`; + } else { + formattedDiff = formatAmount({ + amount: absDiff.convertPrecision(DEFAULT_INTEGER_AMOUNT_PRECISION).getAmount(), + currency: resolvedCurrency as Currency, + locale: resolvedLocale, + }); + } + + return { isBetter, formattedDiff }; +};