diff --git a/src/__tests__/fixtures/price-getag.samples.ts b/src/__tests__/fixtures/price-getag.samples.ts index c3f5f04..5ca9b82 100644 --- a/src/__tests__/fixtures/price-getag.samples.ts +++ b/src/__tests__/fixtures/price-getag.samples.ts @@ -1,6 +1,23 @@ -import type { PriceItemDto } from '../../shared/types'; +import type { Coupon, PriceItemDto } from '../../shared/types'; import { tax19percent } from './tax.samples'; +/** + * Attaches a coupon to every price component of a composite price, mirroring + * how coupons reach components (e.g. GetAG) in a journey. Useful for tests + * that exercise coupon/cashback behaviour on composite GetAG prices. + */ +export const withCouponOnComponents = (composite: PriceItemDto, coupon: Coupon): PriceItemDto => + ({ + ...composite, + _price: { + ...(composite._price as any), + price_components: (composite._price as any)?.price_components?.map((component: any) => ({ + ...component, + _coupons: [coupon], + })), + }, + }) as PriceItemDto; + export const priceGetAG: PriceItemDto = { quantity: 1, product_id: 'prod-id#1', diff --git a/src/__tests__/fixtures/pricing.results.ts b/src/__tests__/fixtures/pricing.results.ts index e219721..36663b0 100644 --- a/src/__tests__/fixtures/pricing.results.ts +++ b/src/__tests__/fixtures/pricing.results.ts @@ -8340,8 +8340,8 @@ export const computedPriceWithFixedAmountCashbackCouponAndPriceMappings = { amount_tax: 1818, cashback_amount: 2000, cashback_amount_decimal: '20', - after_cashback_amount_total: 8000, - after_cashback_amount_total_decimal: '80', + after_cashback_amount_total: 18000, + after_cashback_amount_total_decimal: '180', currency: 'EUR', description: 'Winter Sale', cashback_period: '12', diff --git a/src/computations/apply-discounts.ts b/src/computations/apply-discounts.ts index 9cb4d2a..a6b2a68 100644 --- a/src/computations/apply-discounts.ts +++ b/src/computations/apply-discounts.ts @@ -52,7 +52,10 @@ export const applyDiscounts = ( priceItem?._price?.billing_period as BillingPeriod, ); - afterCashbackAmountTotal = unitAmountGross.subtract(normalizedCashbackAmount); + // Deduct the cashback from the line total, not the per-unit gross. + const amountTotal = toDineroFromInteger(itemValues.amount_total!, currency); + + afterCashbackAmountTotal = amountTotal.subtract(normalizedCashbackAmount); return { ...itemValues, diff --git a/src/computations/pricing-getag.test.ts b/src/computations/pricing-getag.test.ts index 9871983..3b5483c 100644 --- a/src/computations/pricing-getag.test.ts +++ b/src/computations/pricing-getag.test.ts @@ -1,4 +1,4 @@ -import type { PriceItem, PriceItemDto } from '@epilot/pricing-client'; +import type { CompositePriceItem, PriceItem, PriceItemDto } from '@epilot/pricing-client'; import { describe, expect, it } from 'vitest'; import { compositePriceGetAG, @@ -8,7 +8,9 @@ import { priceGetAG, priceTieredFlatFeeGetAG, priceTieredVolumeGetAG, + withCouponOnComponents, } from '../__tests__/fixtures/price-getag.samples'; +import { percentageCashbackCoupon, percentageDiscountCoupon } from '../coupons/__tests__/coupon.fixtures'; import { computeAggregatedAndPriceTotals } from './compute-totals'; describe('GetAG - computeAggregatedAndPriceTotals', () => { @@ -679,4 +681,52 @@ describe('GetAG - computeAggregatedAndPriceTotals', () => { }); }); }); + + describe('when a coupon is applied to GetAG components', () => { + describe('percentage cashback', () => { + it('does not produce a negative after-cashback total for work prices (regression)', () => { + const result = computeAggregatedAndPriceTotals([ + withCouponOnComponents(compositePriceGetAG, percentageCashbackCoupon), + ]); + + const components = (result.items?.[0] as CompositePriceItem)?.item_components; + const basePriceComponent = components?.[0]; + const workPriceComponent = components?.[1]; + + // Base price component: amount_total === unit gross, so the value was already correct. + expect(basePriceComponent?.amount_total).toBe(1538); + expect(basePriceComponent?.cashback_amount).toBe(154); + expect(basePriceComponent?.after_cashback_amount_total).toBe(1525); + + // Work price component: amount_total = per-unit gross × consumption. + // Previously after_cashback was computed from the tiny per-unit gross + // (≈ 24) minus the whole cashback, yielding a negative total (-177). + // It must now be derived from the line total instead: 24144 - 201 = 23943. + expect(workPriceComponent?.amount_total).toBe(24144); + expect(workPriceComponent?.cashback_amount).toBe(2414); + expect(workPriceComponent?.after_cashback_amount_total).toBe(23943); + expect(workPriceComponent?.after_cashback_amount_total).toBeGreaterThan(0); + }); + }); + + describe('percentage discount', () => { + it('keeps before-discount totals above the discounted totals for GetAG components', () => { + const result = computeAggregatedAndPriceTotals([ + withCouponOnComponents(compositePriceGetAG, percentageDiscountCoupon), + ]); + + const components = (result.items?.[0] as CompositePriceItem)?.item_components; + const workPriceComponent = components?.[1]; + + // before_discount (24144) stays above the discounted total (18108). + expect(workPriceComponent?.before_discount_amount_total).toBe(24144); + expect(workPriceComponent?.amount_total).toBe(18108); + expect(workPriceComponent?.discount_amount).toBe(6036); + + const recurrence = result.total_details?.breakdown?.recurrences?.[0]; + expect(recurrence?.before_discount_amount_total).toBe(25682); + expect(recurrence?.amount_total).toBe(19262); + }); + }); + }); });