From 29479b252eeae5fa38e747f8742bb77dd60b6507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Carneiro?= Date: Mon, 27 Apr 2026 12:52:16 +0100 Subject: [PATCH 1/2] Include each cashback individually in computation result --- src/__tests__/fixtures/pricing.results.ts | 12 +++++-- .../compute-composite-price-cashbacks.ts | 21 +++++------ ...mpute-recurrence-after-cashback-amounts.ts | 22 ++++++++---- src/computations/compute-totals.test.ts | 36 +++++++++++++++---- src/computations/compute-totals.ts | 19 ++++------ 5 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/__tests__/fixtures/pricing.results.ts b/src/__tests__/fixtures/pricing.results.ts index 109c49bc..44031f7c 100644 --- a/src/__tests__/fixtures/pricing.results.ts +++ b/src/__tests__/fixtures/pricing.results.ts @@ -9368,7 +9368,11 @@ export const computedCompositePriceWithComponentsWithCashbacks = { cashbacks: [ { cashback_period: '12', - amount_total: 2000, + amount_total: 1000, + }, + { + cashback_period: '12', + amount_total: 1000, }, ], }, @@ -9771,7 +9775,11 @@ export const computedCompositePriceWithComponentsWithCashbacks = { cashbacks: [ { cashback_period: '12', - amount_total: 2000, + amount_total: 1000, + }, + { + cashback_period: '12', + amount_total: 1000, }, ], }, diff --git a/src/computations/compute-composite-price-cashbacks.ts b/src/computations/compute-composite-price-cashbacks.ts index 59a0536a..11578fc6 100644 --- a/src/computations/compute-composite-price-cashbacks.ts +++ b/src/computations/compute-composite-price-cashbacks.ts @@ -1,5 +1,5 @@ import { getAppliedCompositeCashbackCoupons } from '../coupons/utils'; -import { toDinero, toDineroFromInteger } from '../money/to-dinero'; +import { toDinero } from '../money/to-dinero'; import { convertCashbackAmountsPrecision } from '../prices/convert-precision'; import { getSafeQuantity } from '../shared/get-safe-quantity'; import type { RedeemedPromo, PricingDetails, CompositePriceItem, Dinero, Coupon } from '../shared/types'; @@ -35,18 +35,13 @@ export const computeCompositePriceCashbacks = ( cashback_amount_decimal: cashbackAmountWithPrecision.cashback_amount_decimal!, }); - // Update existing breakdown - const cashbackMatch = cashbacks.find((cashback) => cashback.cashback_period === cashbackPeriod); - - if (cashbackMatch) { - const cashbackAmountTotal = toDineroFromInteger(cashbackMatch.amount_total); - cashbackMatch.amount_total = cashbackAmountTotal.add(toDineroFromInteger(cashback_amount)).getAmount(); - } else { - cashbacks.push({ - cashback_period: cashbackPeriod, - amount_total: cashback_amount, - }); - } + // Preserve one entry per applied cashback rather than summing entries + // that share the same cashback_period, so consumers can render each + // cashback as its own line. + cashbacks.push({ + cashback_period: cashbackPeriod, + amount_total: cashback_amount, + }); } return { diff --git a/src/computations/compute-recurrence-after-cashback-amounts.ts b/src/computations/compute-recurrence-after-cashback-amounts.ts index ed132962..6f2f5c51 100644 --- a/src/computations/compute-recurrence-after-cashback-amounts.ts +++ b/src/computations/compute-recurrence-after-cashback-amounts.ts @@ -5,21 +5,31 @@ import { normalizeTimeFrequencyFromDineroInputValue } from '../time-frequency/no /** * Recurrence amounts after cashbacks can only be computed * after all recurrences and cashbacks have been computed. + * + * Each cashback entry is subtracted from the recurrence total. Multiple + * entries may share the same cashback_period (one entry per applied + * cashback coupon) so we accumulate across the full array. */ export const computeRecurrenceAfterCashbackAmounts = (recurrence: RecurrenceAmount, cashbacks: CashbackAmount[]) => { - /* Only the first cashback is taken into account */ - const cashback = cashbacks[0]; + if (!recurrence.type) { + return recurrence; + } + + const validCashbacks = cashbacks.filter(Boolean); - if (!cashback || !recurrence.type) { + if (!validCashbacks.length) { return recurrence; } - const cashbackAmount = toDineroFromInteger(cashback.amount_total); + const summedCashback = validCashbacks.reduce( + (acc, cashback) => acc.add(toDineroFromInteger(cashback.amount_total)), + toDineroFromInteger(0), + ); const normalizedCashbackAmount = recurrence.type === 'recurring' - ? normalizeTimeFrequencyFromDineroInputValue(cashbackAmount, 'yearly', recurrence.billing_period!) - : cashbackAmount; + ? normalizeTimeFrequencyFromDineroInputValue(summedCashback, 'yearly', recurrence.billing_period!) + : summedCashback; const afterCashbackAmountTotal = toDineroFromInteger(recurrence.amount_total).subtract(normalizedCashbackAmount); diff --git a/src/computations/compute-totals.test.ts b/src/computations/compute-totals.test.ts index ffe7a8a2..ebb35502 100644 --- a/src/computations/compute-totals.test.ts +++ b/src/computations/compute-totals.test.ts @@ -910,7 +910,7 @@ it('should compute multiple fixed cashbacks correctly when applied at the compos expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.[1].amount_total).toEqual(500); }); -it('should compute multiple fixed cashbacks correctly when applied at the composite price level with same cashback periods', () => { +it('should preserve each cashback as its own breakdown entry when multiple coupons share the same cashback period', () => { const priceItems = [ { ...samples.compositePriceWithFixedCashbackCoupon, @@ -933,9 +933,30 @@ it('should compute multiple fixed cashbacks correctly when applied at the compos cashback_period: '12', }, ]); - expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.length).toEqual(1); - expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.[0].cashback_period).toEqual('12'); - expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.[0].amount_total).toEqual(2000); + expect(computedPriceItem?.total_details?.breakdown?.cashbacks).toEqual([ + expect.objectContaining({ cashback_period: '12', amount_total: 1000 }), + expect.objectContaining({ cashback_period: '12', amount_total: 1000 }), + ]); +}); + +it('should preserve each cashback as its own entry when same-period coupons have different amounts', () => { + const sameImmediatePeriodCashback = { + ...coupons.lowFixedCashbackCoupon, + fixed_value: 250, + fixed_value_decimal: '2.50', + }; + const priceItems = [ + { + ...samples.compositePriceWithFixedCashbackCoupon, + _coupons: [coupons.lowFixedCashbackCoupon, sameImmediatePeriodCashback], + }, + ]; + const result = computeAggregatedAndPriceTotals(priceItems); + const computedPriceItem = result.items?.[0] as CompositePriceItem; + expect(computedPriceItem?.total_details?.breakdown?.cashbacks).toEqual([ + expect.objectContaining({ cashback_period: '0', amount_total: 500 }), + expect.objectContaining({ cashback_period: '0', amount_total: 250 }), + ]); }); it('should compute fixed cashbacks correctly when applied at the composite price level + component level with the same cashback period', () => { @@ -953,9 +974,10 @@ it('should compute fixed cashbacks correctly when applied at the composite price expect(computedPriceItem?.item_components?.[1].cashback_amount_decimal).toEqual('10'); expect(computedPriceItem?.item_components?.[1].after_cashback_amount_total).toEqual(9981); expect(computedPriceItem?.item_components?.[1].after_cashback_amount_total_decimal).toEqual('99.807692307692'); - expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.length).toEqual(1); - expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.[0].cashback_period).toEqual('12'); - expect(computedPriceItem?.total_details?.breakdown?.cashbacks?.[0].amount_total).toEqual(2000); + expect(computedPriceItem?.total_details?.breakdown?.cashbacks).toEqual([ + expect.objectContaining({ cashback_period: '12', amount_total: 1000 }), + expect.objectContaining({ cashback_period: '12', amount_total: 1000 }), + ]); }); it('should compute fixed cashbacks correctly when applied at the composite price level + component level with different cashback periods', () => { diff --git a/src/computations/compute-totals.ts b/src/computations/compute-totals.ts index c455e9d2..852e6e29 100644 --- a/src/computations/compute-totals.ts +++ b/src/computations/compute-totals.ts @@ -360,19 +360,14 @@ const recomputeDetailTotals = ( ? toDineroFromInteger(priceItemToAppend.cashback_amount!) : undefined; - // Cashback totals + // Cashback totals — preserve one entry per applied cashback rather than + // summing entries that share the same cashback_period, so consumers can + // render each cashback as its own line. if (priceCashBackAmount && Boolean(coupon)) { - const cashbackMatch = cashbacks.find((cashback) => cashback.cashback_period === cashbackPeriod); - - if (cashbackMatch) { - const cashbackAmountTotal = toDineroFromInteger(cashbackMatch.amount_total); - cashbackMatch.amount_total = cashbackAmountTotal.add(priceCashBackAmount).getAmount(); - } else { - cashbacks.push({ - cashback_period: cashbackPeriod, - amount_total: priceCashBackAmount.getAmount(), - }); - } + cashbacks.push({ + cashback_period: cashbackPeriod, + amount_total: priceCashBackAmount.getAmount(), + }); } // Remove empty cashbacks from the breakdown From c88deec9d687be0725e097f69145d38e8a114eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Carneiro?= Date: Mon, 27 Apr 2026 13:23:04 +0100 Subject: [PATCH 2/2] chore: add changeset for cashback computation update --- .changeset/major-weeks-stare.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/major-weeks-stare.md diff --git a/.changeset/major-weeks-stare.md b/.changeset/major-weeks-stare.md new file mode 100644 index 00000000..5757aee9 --- /dev/null +++ b/.changeset/major-weeks-stare.md @@ -0,0 +1,5 @@ +--- +'@epilot/pricing': patch +--- + +Include each cashback individually in computation result