diff --git a/package.json b/package.json index ab4ab19..29f4114 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "devDependencies": { "@epilot/eslint-config": "^3.0.5", - "@epilot/pricing-client": "^3.51.0", + "@epilot/pricing-client": "^3.53.4", "@types/dinero.js": "^1.9.4", "@vitest/coverage-v8": "^1.6.1", "concurrently": "^9.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c304bf..bd183e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^3.0.5 version: 3.0.5(eslint@9.28.0)(typescript@5.8.3) '@epilot/pricing-client': - specifier: ^3.51.0 - version: 3.51.0(axios@1.9.0)(js-yaml@4.1.0) + specifier: ^3.53.4 + version: 3.53.4(axios@1.9.0)(js-yaml@4.1.0) '@types/dinero.js': specifier: ^1.9.4 version: 1.9.4 @@ -105,8 +105,8 @@ packages: eslint: '>= 9.0.0' typescript: '>= 5.0.0' - '@epilot/pricing-client@3.51.0': - resolution: {integrity: sha512-49biU5yVVQbOWNGK2m7/foms+KzVxw4NHY55cJEwEB5xszFVHB9zKJN5HCdvcYKiYTJC5AOcxne78GeFROPZHQ==} + '@epilot/pricing-client@3.53.4': + resolution: {integrity: sha512-Sg9waNSdJlyoNM+LtG2yPbhh1UqgX3d8sK/Z4Ms+wbn57b4GtW2Em6qPGTZzZpraIhgvAnwwHgCzQp3YSQNV1w==} peerDependencies: axios: ^1.0.0 || >=0.25.0 <1.0.0 @@ -2332,7 +2332,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - '@epilot/pricing-client@3.51.0(axios@1.9.0)(js-yaml@4.1.0)': + '@epilot/pricing-client@3.53.4(axios@1.9.0)(js-yaml@4.1.0)': dependencies: '@dazn/lambda-powertools-correlation-ids': 1.28.1 axios: 1.9.0 diff --git a/src/__tests__/fixtures/pricing.results.ts b/src/__tests__/fixtures/pricing.results.ts index 4ab287c..109c49b 100644 --- a/src/__tests__/fixtures/pricing.results.ts +++ b/src/__tests__/fixtures/pricing.results.ts @@ -5952,6 +5952,8 @@ export const computedPriceWithFixedDiscount = { amount_tax: 864, before_discount_amount_total: 10000, before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount: 500, discount_amount_decimal: '5', }, @@ -6053,6 +6055,7 @@ export const computedPriceWithFixedDiscount = { amount_total: 9500, discount_amount: 500, before_discount_amount_total: 10000, + before_discount_amount_subtotal: 9091, amount_tax: 864, tax_discount_amount: 45, tax_discount_amount_decimal: '0.454545454545', @@ -6062,6 +6065,7 @@ export const computedPriceWithFixedDiscount = { amount_total_decimal: '95', discount_amount_decimal: '5', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount_net: 455, discount_amount_net_decimal: '4.545454545455', }, @@ -6151,6 +6155,7 @@ export const computedPriceWithMultipleFixedDiscounts = { discount_amount: 1000, discount_amount_net: 909, before_discount_amount_total: 10000, + before_discount_amount_subtotal: 9091, currency: 'EUR', description: 'Winter Sale', unit_amount_decimal: '90', @@ -6165,6 +6170,7 @@ export const computedPriceWithMultipleFixedDiscounts = { amount_total_decimal: '90', discount_amount_decimal: '10', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal_decimal: '90.909090909091', tax_discount_amount_decimal: '0.909090909091', discount_amount_net_decimal: '9.090909090909', before_discount_tax_amount_decimal: '9.090909090909', @@ -6198,6 +6204,8 @@ export const computedPriceWithMultipleFixedDiscounts = { amount_tax: 818, before_discount_amount_total: 10000, before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount: 1000, discount_amount_decimal: '10', }, @@ -6256,6 +6264,8 @@ export const computedPriceWithFixedDiscountAndPriceMappings = { amount_tax: 1727, before_discount_amount_total: 20000, before_discount_amount_total_decimal: '200', + before_discount_amount_subtotal: 18182, + before_discount_amount_subtotal_decimal: '181.818181818182', discount_amount: 1000, discount_amount_decimal: '10', }, @@ -6380,6 +6390,8 @@ export const computedPriceWithFixedDiscountAndPriceMappings = { amount_total_decimal: '190', discount_amount_decimal: '10', before_discount_amount_total_decimal: '200', + before_discount_amount_subtotal: 18182, + before_discount_amount_subtotal_decimal: '181.818181818182', tax_discount_amount_decimal: '0.90909090909', discount_amount_net_decimal: '9.09090909091', before_discount_tax_amount_decimal: '18.181818181818', @@ -6411,6 +6423,8 @@ export const computedPriceWithPercentageDiscount = { amount_tax: 682, before_discount_amount_total: 10000, before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount: 2500, discount_amount_decimal: '25', }, @@ -6520,6 +6534,8 @@ export const computedPriceWithPercentageDiscount = { amount_total_decimal: '75', discount_amount_decimal: '25', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount_net: 2273, discount_amount_net_decimal: '22.727272727273', }, @@ -6622,6 +6638,8 @@ export const computedPriceWithMultiplePercentageDiscounts = { amount_total_decimal: '50', discount_amount_decimal: '50', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', tax_discount_amount_decimal: '4.545454545455', discount_amount_net_decimal: '45.454545454545', before_discount_tax_amount_decimal: '9.090909090909', @@ -6655,6 +6673,8 @@ export const computedPriceWithMultiplePercentageDiscounts = { amount_tax: 455, before_discount_amount_total: 10000, before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount: 5000, discount_amount_decimal: '50', }, @@ -6846,6 +6866,8 @@ export const computedPriceWithAppliedCoupon = { amount_tax: 682, before_discount_amount_total: 10000, before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount: 2500, discount_amount_decimal: '25', }, @@ -6964,6 +6986,8 @@ export const computedPriceWithAppliedCoupon = { amount_total_decimal: '75', discount_amount_decimal: '25', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', tax_discount_amount_decimal: '2.272727272727', discount_amount_net_decimal: '22.727272727273', before_discount_tax_amount_decimal: '9.090909090909', @@ -7001,6 +7025,8 @@ export const computedPriceWithPercentageDiscountAndHighQuantity = { amount_tax: 3409, before_discount_amount_total: 50000, before_discount_amount_total_decimal: '500', + before_discount_amount_subtotal: 45455, + before_discount_amount_subtotal_decimal: '454.545454545455', discount_amount: 12500, discount_amount_decimal: '125', }, @@ -7119,6 +7145,8 @@ export const computedPriceWithPercentageDiscountAndHighQuantity = { amount_total_decimal: '375', discount_amount_decimal: '125', before_discount_amount_total_decimal: '500', + before_discount_amount_subtotal: 45455, + before_discount_amount_subtotal_decimal: '454.545454545455', discount_amount_net: 11364, discount_amount_net_decimal: '113.636363636365', }, @@ -7155,6 +7183,8 @@ export const computedPriceWithFixedDiscountAndHighQuantity = { amount_tax: 4318, before_discount_amount_total: 50000, before_discount_amount_total_decimal: '500', + before_discount_amount_subtotal: 45455, + before_discount_amount_subtotal_decimal: '454.545454545455', discount_amount: 2500, discount_amount_decimal: '25', }, @@ -7274,6 +7304,8 @@ export const computedPriceWithFixedDiscountAndHighQuantity = { amount_total_decimal: '475', discount_amount_decimal: '25', before_discount_amount_total_decimal: '500', + before_discount_amount_subtotal: 45455, + before_discount_amount_subtotal_decimal: '454.545454545455', discount_amount_net: 2273, discount_amount_net_decimal: '22.727272727275', }, @@ -7301,6 +7333,8 @@ export const computedPriceWithFixedDiscountAndNoTax = { amount_tax: 0, before_discount_amount_total: 10000, before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 10000, + before_discount_amount_subtotal_decimal: '100', discount_amount: 2500, discount_amount_decimal: '25', }, @@ -7385,6 +7419,8 @@ export const computedPriceWithFixedDiscountAndNoTax = { amount_total_decimal: '75', discount_amount_decimal: '25', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 10000, + before_discount_amount_subtotal_decimal: '100', discount_amount_net: 2500, discount_amount_net_decimal: '25', }, @@ -7424,6 +7460,8 @@ export const computedPriceWithPercentageDiscountAndExclusiveTax = { amount_tax: 750, before_discount_amount_total: 11000, before_discount_amount_total_decimal: '110', + before_discount_amount_subtotal: 10000, + before_discount_amount_subtotal_decimal: '100', discount_amount: 2750, discount_amount_decimal: '27.5', }, @@ -7542,6 +7580,8 @@ export const computedPriceWithPercentageDiscountAndExclusiveTax = { amount_total_decimal: '82.5', discount_amount_decimal: '27.5', before_discount_amount_total_decimal: '110', + before_discount_amount_subtotal: 10000, + before_discount_amount_subtotal_decimal: '100', discount_amount_net: 2500, discount_amount_net_decimal: '25', }, @@ -7578,6 +7618,8 @@ export const computedPriceWithFixedDiscountAndExclusiveTax = { amount_tax: 950, before_discount_amount_total: 11000, before_discount_amount_total_decimal: '110', + before_discount_amount_subtotal: 10000, + before_discount_amount_subtotal_decimal: '100', discount_amount: 550, discount_amount_decimal: '5.5', }, @@ -7699,6 +7741,8 @@ export const computedPriceWithFixedDiscountAndExclusiveTax = { amount_total_decimal: '104.5', discount_amount_decimal: '5.5', before_discount_amount_total_decimal: '110', + before_discount_amount_subtotal: 10000, + before_discount_amount_subtotal_decimal: '100', }, ], currency: 'EUR', @@ -7727,6 +7771,8 @@ export const computedResultWithPricesWithAndWithoutCoupons = { amount_tax: 1364, before_discount_amount_total: 388986739, before_discount_amount_total_decimal: '3889867.39', + before_discount_amount_subtotal: 388984921, + before_discount_amount_subtotal_decimal: '3889849.2081818185', discount_amount: 5000, discount_amount_decimal: '50', }, @@ -7836,6 +7882,8 @@ export const computedResultWithPricesWithAndWithoutCoupons = { amount_total_decimal: '75', discount_amount_decimal: '25', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount_net: 2273, discount_amount_net_decimal: '22.727272727273', }, @@ -7931,6 +7979,8 @@ export const computedResultWithPricesWithAndWithoutCoupons = { amount_total_decimal: '75', discount_amount_decimal: '25', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal: 9091, + before_discount_amount_subtotal_decimal: '90.909090909091', discount_amount_net: 2273, discount_amount_net_decimal: '22.727272727273', }, @@ -8640,6 +8690,8 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_tax: 45, before_discount_amount_total: 1000, before_discount_amount_total_decimal: '10', + before_discount_amount_subtotal: 909, + before_discount_amount_subtotal_decimal: '9.090909090909', discount_amount: 500, discount_amount_decimal: '5', }, @@ -8654,6 +8706,8 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_tax: 1727, before_discount_amount_total: 20000, before_discount_amount_total_decimal: '200', + before_discount_amount_subtotal: 18182, + before_discount_amount_subtotal_decimal: '181.818181818182', discount_amount: 1000, discount_amount_decimal: '10', }, @@ -8940,6 +8994,7 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_total: 500, discount_amount: 500, before_discount_amount_total: 1000, + before_discount_amount_subtotal: 909, amount_tax: 45, tax_discount_amount: 45, tax_discount_amount_decimal: '0.454545454545', @@ -8952,6 +9007,7 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_total_decimal: '5', discount_amount_decimal: '5', before_discount_amount_total_decimal: '10', + before_discount_amount_subtotal_decimal: '9.090909090909', }, { pricing_model: 'per_unit', @@ -9054,6 +9110,7 @@ export const computedCompositePriceWithComponentsWithDiscounts = { discount_amount: 1000, discount_percentage: 10, before_discount_amount_total: 10000, + before_discount_amount_subtotal: 9091, amount_tax: 818, tax_discount_amount: 91, tax_discount_amount_decimal: '0.909090909091', @@ -9066,6 +9123,7 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_total_decimal: '90', discount_amount_decimal: '10', before_discount_amount_total_decimal: '100', + before_discount_amount_subtotal_decimal: '90.909090909091', }, { pricing_model: 'per_unit', @@ -9166,6 +9224,8 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_tax: 45, before_discount_amount_total: 1000, before_discount_amount_total_decimal: '10', + before_discount_amount_subtotal: 909, + before_discount_amount_subtotal_decimal: '9.090909090909', discount_amount: 500, discount_amount_decimal: '5', }, @@ -9180,6 +9240,8 @@ export const computedCompositePriceWithComponentsWithDiscounts = { amount_tax: 1727, before_discount_amount_total: 20000, before_discount_amount_total_decimal: '200', + before_discount_amount_subtotal: 18182, + before_discount_amount_subtotal_decimal: '181.818181818182', discount_amount: 1000, discount_amount_decimal: '10', }, @@ -9886,6 +9948,7 @@ export const computedCompositePriceWithComponentsWithPromoCodeRequiredCoupon = { discount_amount_net: 227, discount_percentage: 25, before_discount_amount_total: 1000, + before_discount_amount_subtotal: 909, currency: 'EUR', description: 'Base price per month', is_tax_inclusive: true, @@ -9901,6 +9964,7 @@ export const computedCompositePriceWithComponentsWithPromoCodeRequiredCoupon = { amount_total_decimal: '7.5', discount_amount_decimal: '2.5', before_discount_amount_total_decimal: '10', + before_discount_amount_subtotal_decimal: '9.090909090909', tax_discount_amount_decimal: '0.227272727273', discount_amount_net_decimal: '2.272727272727', before_discount_tax_amount_decimal: '0.909090909091', @@ -9934,6 +9998,8 @@ export const computedCompositePriceWithComponentsWithPromoCodeRequiredCoupon = { amount_tax: 68, before_discount_amount_total: 1000, before_discount_amount_total_decimal: '10', + before_discount_amount_subtotal: 909, + before_discount_amount_subtotal_decimal: '9.090909090909', discount_amount: 250, discount_amount_decimal: '2.5', }, @@ -10011,6 +10077,8 @@ export const computedCompositePriceWithComponentsWithPromoCodeRequiredCoupon = { amount_tax: 68, before_discount_amount_total: 1000, before_discount_amount_total_decimal: '10', + before_discount_amount_subtotal: 909, + before_discount_amount_subtotal_decimal: '9.090909090909', discount_amount: 250, discount_amount_decimal: '2.5', }, diff --git a/src/computations/apply-discounts.test.ts b/src/computations/apply-discounts.test.ts index 068d5c1..ca4b4b4 100644 --- a/src/computations/apply-discounts.test.ts +++ b/src/computations/apply-discounts.test.ts @@ -2,7 +2,7 @@ import type { Currency } from 'dinero.js'; import { describe, expect, it } from 'vitest'; import { priceItem1 } from '../__tests__/fixtures/price.samples'; import { compositePriceItemWithTieredGraduatedComponent } from '../__tests__/fixtures/price.samples'; -import { tax19percent } from '../__tests__/fixtures/tax.samples'; +import { tax10percent, tax19percent } from '../__tests__/fixtures/tax.samples'; import { fixedCashbackCoupon, fixedDiscountCoupon, @@ -49,6 +49,8 @@ describe('applyDiscounts', () => { expect(result.amount_tax).toBe(94536176470571); // 789.46 * 0.75 - ((789.46 * 0.75) / 1.19) expect(result.discount_amount).toBe(197365000000000); // 789.46 * 0.25 expect(result.discount_percentage).toBe(25); + expect(result.before_discount_amount_total).toBe(789460000000000); // original gross + expect(result.before_discount_amount_subtotal).toBe(663411764705900); // original net }); it('should apply 50% discount correctly', () => { @@ -75,6 +77,8 @@ describe('applyDiscounts', () => { expect(result.amount_tax).toBe(63024117647041); // 789.46 * 0.5 - ((789.46 * 0.5) / 1.19) expect(result.discount_amount).toBe(394730000000000); // 789.46 * 0.5 expect(result.discount_percentage).toBe(50); + expect(result.before_discount_amount_total).toBe(789460000000000); // original gross + expect(result.before_discount_amount_subtotal).toBe(663411764705900); // original net }); }); @@ -102,6 +106,8 @@ describe('applyDiscounts', () => { expect(result.amount_total).toBe(784460000000000); // 789.46 - 5.00 expect(result.amount_tax).toBe(125249915966369); // (789.46 - 5.00) - ((789.46 - 5.00) / 1.19) expect(result.discount_amount).toBe(5000000000000); // 5.00 + expect(result.before_discount_amount_total).toBe(789460000000000); // original gross + expect(result.before_discount_amount_subtotal).toBe(663411764705900); // original net }); it('should not apply discount larger than the price', () => { @@ -123,6 +129,8 @@ describe('applyDiscounts', () => { expect(result.unit_amount).toBe(0); expect(result.amount_total).toBe(0); expect(result.unit_discount_amount).toBe(789460000000000); + expect(result.before_discount_amount_total).toBe(789460000000000); // original gross + expect(result.before_discount_amount_subtotal).toBe(663411764705900); // original net }); }); @@ -248,6 +256,192 @@ describe('applyDiscounts', () => { expect(result.amount_total).toBe(35325000000000000); // Weighted average of discounted tiers expect(result.amount_tax).toBe(5640126050420300); // Weighted average of discounted tiers expect(result.discount_percentage).toBe(10); + expect(result.before_discount_amount_total).toBe(39250000000000000); // sum of tier gross * qty + expect(result.before_discount_amount_subtotal).toBe(32983193277310650); // sum of tier net * qty + }); + }); + + describe('tax-exclusive percentage discounts', () => { + const taxExclusiveParams = { + ...baseParams, + isTaxInclusive: false, + priceItem: { + ...priceItem1, + is_tax_inclusive: false, + _price: { ...priceItem1._price!, is_tax_inclusive: false }, + }, + }; + + // Tax-exclusive: net=100, gross=119 (19% tax), qty=1 + const taxExclusiveValues = { + unit_amount: 100000000000000, // 100.00 (net is the base in tax-exclusive) + unit_amount_net: 100000000000000, // 100.00 + unit_amount_gross: 119000000000000, // 119.00 + amount_subtotal: 100000000000000, // 100.00 + amount_total: 119000000000000, // 119.00 + amount_tax: 19000000000000, // 19.00 + }; + + it('should apply 25% percentage discount correctly (tax-exclusive)', () => { + const result = applyDiscounts(taxExclusiveValues, { + ...taxExclusiveParams, + coupon: percentageDiscountCoupon, + }); + + // Tax-exclusive: discount is applied to net first + // unitDiscountAmountNet = 100 * 25/100 = 25 + // unitDiscountAmount = 25 * 1.19 = 29.75 + // afterDiscountNet = 100 - 25 = 75 + // afterDiscountGross = 75 * 1.19 = 89.25 + expect(result.unit_amount).toBe(75000000000000); // net after discount + expect(result.unit_amount_net).toBe(75000000000000); // 75.00 + expect(result.unit_amount_gross).toBe(89250000000000); // 89.25 + expect(result.amount_subtotal).toBe(75000000000000); + expect(result.amount_total).toBe(89250000000000); + expect(result.discount_percentage).toBe(25); + expect(result.before_discount_amount_total).toBe(119000000000000); // original gross + expect(result.before_discount_amount_subtotal).toBe(100000000000000); // original net + }); + + it('should apply fixed 5 EUR discount correctly (tax-exclusive)', () => { + const result = applyDiscounts(taxExclusiveValues, { + ...taxExclusiveParams, + coupon: fixedDiscountCoupon, + }); + + // Tax-exclusive: fixed discount applied to net + // unitDiscountAmountNet = min(5, 100) = 5 + // unitDiscountAmount = 5 * 1.19 = 5.95 + // afterDiscountNet = 100 - 5 = 95 + // afterDiscountGross = 95 * 1.19 = 113.05 + expect(result.unit_amount).toBe(95000000000000); // net after discount + expect(result.unit_amount_net).toBe(95000000000000); // 95.00 + expect(result.unit_amount_gross).toBe(113050000000000); // 113.05 + expect(result.amount_subtotal).toBe(95000000000000); + expect(result.amount_total).toBe(113050000000000); + expect(result.before_discount_amount_total).toBe(119000000000000); // original gross + expect(result.before_discount_amount_subtotal).toBe(100000000000000); // original net + }); + }); + + describe('tax-exclusive with multiplier', () => { + const taxExclusiveParams = { + ...baseParams, + isTaxInclusive: false, + unitAmountMultiplier: 3, + priceItem: { + ...priceItem1, + is_tax_inclusive: false, + _price: { ...priceItem1._price!, is_tax_inclusive: false }, + }, + }; + + // Tax-exclusive: net=100, gross=119 (19% tax) + const taxExclusiveValues = { + unit_amount: 100000000000000, + unit_amount_net: 100000000000000, + unit_amount_gross: 119000000000000, + amount_subtotal: 300000000000000, // 100 * 3 + amount_total: 357000000000000, // 119 * 3 + amount_tax: 57000000000000, // 19 * 3 + }; + + it('should apply 25% discount with multiplier=3 (tax-exclusive)', () => { + const result = applyDiscounts(taxExclusiveValues, { + ...taxExclusiveParams, + coupon: percentageDiscountCoupon, + }); + + // unitDiscountAmountNet = 100 * 25/100 = 25 + // afterDiscountNet = 75, afterDiscountGross = 89.25 + // amounts multiplied by 3 + expect(result.unit_amount_net).toBe(75000000000000); // 75.00 + expect(result.unit_amount_gross).toBe(89250000000000); // 89.25 + expect(result.amount_subtotal).toBe(225000000000000); // 75 * 3 + expect(result.amount_total).toBe(267750000000000); // 89.25 * 3 + expect(result.before_discount_amount_total).toBe(357000000000000); // 119 * 3 + expect(result.before_discount_amount_subtotal).toBe(300000000000000); // 100 * 3 + }); + }); + + describe('tax-inclusive with multiplier', () => { + it('should apply 25% discount with multiplier=3 (tax-inclusive)', () => { + const result = applyDiscounts( + { + unit_amount: 789460000000000, + unit_amount_net: 663411764705900, + unit_amount_gross: 789460000000000, + amount_subtotal: 1990235294117700, // 663.41 * 3 + amount_total: 2368380000000000, // 789.46 * 3 + amount_tax: 378144705882300, // 126.05 * 3 + }, + { + ...baseParams, + unitAmountMultiplier: 3, + coupon: percentageDiscountCoupon, + }, + ); + + expect(result.amount_subtotal).toBe(1492676470588287); // (789.46 * 0.75 / 1.19) * 3 + expect(result.amount_total).toBe(1776285000000000); // 789.46 * 0.75 * 3 + expect(result.before_discount_amount_total).toBe(2368380000000000); // 789.46 * 3 + expect(result.before_discount_amount_subtotal).toBe(1990235294117700); // 663.41 * 3 + }); + }); + + describe('tax-exclusive graduated tiered prices', () => { + it('should apply percentage discount to graduated tiered prices (tax-exclusive)', () => { + const result = applyDiscounts( + { + tiers_details: [ + { + quantity: 100, + unit_amount: 5000, + unit_amount_decimal: '050', + unit_amount_net: 50000000000000, // 50.00 (net is base in tax-exclusive) + unit_amount_gross: 55000000000000, // 55.00 (10% tax) + amount_subtotal: 5000000000000000, // 50 * 100 + amount_total: 5500000000000000, // 55 * 100 + amount_tax: 500000000000000, // 5 * 100 + }, + { + quantity: 200, + unit_amount: 4000, + unit_amount_decimal: '040', + unit_amount_net: 40000000000000, // 40.00 + unit_amount_gross: 44000000000000, // 44.00 + amount_subtotal: 8000000000000000, // 40 * 200 + amount_total: 8800000000000000, // 44 * 200 + amount_tax: 800000000000000, // 4 * 200 + }, + ], + unit_amount_gross: 99000000000000, + unit_amount_net: 90000000000000, + amount_subtotal: 13000000000000000, // 5000 + 8000 + amount_total: 14300000000000000, // 5500 + 8800 + amount_tax: 1300000000000000, + }, + { + ...baseParams, + tax: tax10percent, + isTaxInclusive: false, + coupon: percentage10DiscountCoupon, + priceItem: compositePriceItemWithTieredGraduatedComponent.item_components?.[0]!, + unitAmountMultiplier: 300, + }, + ); + + if (!result.tiers_details) { + throw new Error('Expected tiers_details to be defined'); + } + + // 10% discount on net: tier1 net=45*100=4500, tier2 net=36*200=7200 => subtotal=11700 + expect(result.amount_subtotal).toBe(11700000000000000); + // gross = net * 1.10: tier1=49.5*100=4950, tier2=39.6*200=7920 => total=12870 + expect(result.amount_total).toBe(12870000000000000); + expect(result.discount_percentage).toBe(10); + expect(result.before_discount_amount_total).toBe(14300000000000000); // original gross total + expect(result.before_discount_amount_subtotal).toBe(13000000000000000); // original net total }); }); }); diff --git a/src/computations/apply-discounts.ts b/src/computations/apply-discounts.ts index 1a51abc..9cb4d2a 100644 --- a/src/computations/apply-discounts.ts +++ b/src/computations/apply-discounts.ts @@ -3,7 +3,7 @@ import { toDineroFromInteger, toDinero } from '../money/to-dinero'; import { PricingModel } from '../prices/constants'; import type { PriceItemsTotals } from '../prices/types'; import { clamp } from '../shared/clamp'; -import type { Currency, Dinero, PriceItemDto, Tax, Coupon, BillingPeriod } from '../shared/types'; +import type { Currency, Dinero, PriceItemDto, Tax, Coupon, BillingPeriod, TierDetails } from '../shared/types'; import { getTaxValue } from '../taxes/get-tax-value'; import { normalizeTimeFrequencyFromDineroInputValue } from '../time-frequency/normalizers'; @@ -64,7 +64,7 @@ export const applyDiscounts = ( // Handle graduated tiered prices if (priceItem._price?.pricing_model === PricingModel.tieredGraduated && itemValues.tiers_details) { // Apply discount to each tier and sum up the results - const discountedTiers = itemValues.tiers_details.map((tier) => { + const discountedTiers = itemValues.tiers_details.map((tier: TierDetails) => { const tierUnitAmountNet = toDineroFromInteger(tier.unit_amount_net!, currency); const tierUnitAmountGross = toDineroFromInteger(tier.unit_amount_gross!, currency); @@ -126,12 +126,13 @@ export const applyDiscounts = ( discount_amount: unitDiscountAmount.multiply(tier.quantity).getAmount(), discount_amount_net: unitDiscountAmountNet.multiply(tier.quantity).getAmount(), before_discount_amount_total: tierUnitAmountGross.multiply(tier.quantity).getAmount(), + before_discount_amount_subtotal: tierUnitAmountNet.multiply(tier.quantity).getAmount(), }; }); // Sum up all the discounted tier values const totals = discountedTiers.reduce( - (acc, tier) => ({ + (acc: PriceItemsTotals, tier: PriceItemsTotals) => ({ unit_amount_gross: toDineroFromInteger(acc.unit_amount_gross!) .add(toDineroFromInteger(tier.unit_amount_gross!)) .getAmount(), @@ -144,34 +145,37 @@ export const applyDiscounts = ( amount_total: toDineroFromInteger(acc.amount_total).add(toDineroFromInteger(tier.amount_total)).getAmount(), amount_tax: toDineroFromInteger(acc.amount_tax).add(toDineroFromInteger(tier.amount_tax)).getAmount(), unit_discount_amount: toDineroFromInteger(acc.unit_discount_amount || 0) - .add(toDineroFromInteger(tier.unit_discount_amount)) + .add(toDineroFromInteger(tier.unit_discount_amount!)) .getAmount(), before_discount_unit_amount: toDineroFromInteger(acc.before_discount_unit_amount || 0) - .add(toDineroFromInteger(tier.before_discount_unit_amount)) + .add(toDineroFromInteger(tier.before_discount_unit_amount!)) .getAmount(), before_discount_unit_amount_gross: toDineroFromInteger(acc.before_discount_unit_amount_gross || 0) - .add(toDineroFromInteger(tier.before_discount_unit_amount_gross)) + .add(toDineroFromInteger(tier.before_discount_unit_amount_gross!)) .getAmount(), before_discount_unit_amount_net: toDineroFromInteger(acc.before_discount_unit_amount_net || 0) - .add(toDineroFromInteger(tier.before_discount_unit_amount_net)) + .add(toDineroFromInteger(tier.before_discount_unit_amount_net!)) .getAmount(), unit_discount_amount_net: toDineroFromInteger(acc.unit_discount_amount_net || 0) - .add(toDineroFromInteger(tier.unit_discount_amount_net)) + .add(toDineroFromInteger(tier.unit_discount_amount_net!)) .getAmount(), tax_discount_amount: toDineroFromInteger(acc.tax_discount_amount || 0) - .add(toDineroFromInteger(tier.tax_discount_amount)) + .add(toDineroFromInteger(tier.tax_discount_amount!)) .getAmount(), before_discount_tax_amount: toDineroFromInteger(acc.before_discount_tax_amount || 0) - .add(toDineroFromInteger(tier.before_discount_tax_amount)) + .add(toDineroFromInteger(tier.before_discount_tax_amount!)) .getAmount(), discount_amount: toDineroFromInteger(acc.discount_amount || 0) - .add(toDineroFromInteger(tier.discount_amount)) + .add(toDineroFromInteger(tier.discount_amount!)) .getAmount(), discount_amount_net: toDineroFromInteger(acc.discount_amount_net || 0) - .add(toDineroFromInteger(tier.discount_amount_net)) + .add(toDineroFromInteger(tier.discount_amount_net!)) .getAmount(), before_discount_amount_total: toDineroFromInteger(acc.before_discount_amount_total || 0) - .add(toDineroFromInteger(tier.before_discount_amount_total)) + .add(toDineroFromInteger(tier.before_discount_amount_total!)) + .getAmount(), + before_discount_amount_subtotal: toDineroFromInteger(acc.before_discount_amount_subtotal || 0) + .add(toDineroFromInteger(tier.before_discount_amount_subtotal!)) .getAmount(), }), { @@ -190,6 +194,7 @@ export const applyDiscounts = ( discount_amount: 0, discount_amount_net: 0, before_discount_amount_total: 0, + before_discount_amount_subtotal: 0, } as PriceItemsTotals, ); @@ -257,5 +262,6 @@ export const applyDiscounts = ( discount_amount_net: unitDiscountAmountNet.multiply(unitAmountMultiplier).getAmount(), ...(typeof discountPercentage === 'number' && { discount_percentage: discountPercentage }), before_discount_amount_total: unitAmountGross.multiply(unitAmountMultiplier).getAmount(), + before_discount_amount_subtotal: unitAmountNet.multiply(unitAmountMultiplier).getAmount(), }; }; diff --git a/src/computations/compute-totals.ts b/src/computations/compute-totals.ts index bf23117..c455e9d 100644 --- a/src/computations/compute-totals.ts +++ b/src/computations/compute-totals.ts @@ -202,6 +202,12 @@ const recomputeDetailTotals = ( typeof priceItemToAppend.before_discount_amount_total !== 'undefined' ? toDineroFromInteger(priceItemToAppend.before_discount_amount_total!) : undefined; + + const priceBeforeDiscountAmountSubtotal = + typeof priceItemToAppend.before_discount_amount_subtotal !== 'undefined' + ? toDineroFromInteger(priceItemToAppend.before_discount_amount_subtotal) + : undefined; + const priceTax = toDineroFromInteger(priceItemToAppend.taxes?.[0]?.amount || priceItemToAppend.amount_tax || 0); /** @@ -252,6 +258,10 @@ const recomputeDetailTotals = ( before_discount_amount_total: priceBeforeDiscountAmountTotal.getAmount(), before_discount_amount_total_decimal: priceBeforeDiscountAmountTotal.toUnit().toString(), }), + ...(priceBeforeDiscountAmountSubtotal && { + before_discount_amount_subtotal: priceBeforeDiscountAmountSubtotal.getAmount(), + before_discount_amount_subtotal_decimal: priceBeforeDiscountAmountSubtotal.toUnit().toString(), + }), ...(priceDiscountAmount && { discount_amount: priceDiscountAmount.getAmount(), discount_amount_decimal: priceDiscountAmount.toUnit().toString(), @@ -278,6 +288,10 @@ const recomputeDetailTotals = ( typeof recurrence.before_discount_amount_total !== 'undefined' ? toDineroFromInteger(recurrence.before_discount_amount_total) : undefined; + const existingRecurrenceBeforeDiscountAmountSubtotal = + typeof recurrence.before_discount_amount_subtotal !== 'undefined' + ? toDineroFromInteger(recurrence.before_discount_amount_subtotal) + : undefined; const discountAmount = typeof recurrence.discount_amount !== 'undefined' ? toDineroFromInteger(recurrence.discount_amount) : undefined; @@ -292,6 +306,17 @@ const recomputeDetailTotals = ( recurrence.before_discount_amount_total = recurrenceBeforeDiscountAmountTotal.getAmount(); recurrence.before_discount_amount_total_decimal = recurrenceBeforeDiscountAmountTotal.toUnit().toString(); } + if (priceBeforeDiscountAmountSubtotal || existingRecurrenceBeforeDiscountAmountSubtotal) { + // if recurrence doesn't have before_discount_amount_subtotal yet, initialize it with current subtotal (before adding this item) + const initializedExistingSubtotal = + existingRecurrenceBeforeDiscountAmountSubtotal ?? + toDineroFromInteger(recurrence.amount_subtotal).subtract(priceSubtotal); + + const baseAmount = priceBeforeDiscountAmountSubtotal || priceSubtotal; + const recurrenceBeforeDiscountAmountSubtotal = initializedExistingSubtotal.add(baseAmount); + recurrence.before_discount_amount_subtotal = recurrenceBeforeDiscountAmountSubtotal.getAmount(); + recurrence.before_discount_amount_subtotal_decimal = recurrenceBeforeDiscountAmountSubtotal.toUnit().toString(); + } if (priceDiscountAmount) { const recurrenceDiscountAmount = discountAmount?.add(priceDiscountAmount) ?? priceDiscountAmount; recurrence.discount_amount = recurrenceDiscountAmount.getAmount(); diff --git a/src/prices/convert-precision.ts b/src/prices/convert-precision.ts index 325608c..d9aec36 100644 --- a/src/prices/convert-precision.ts +++ b/src/prices/convert-precision.ts @@ -1,6 +1,16 @@ +import type { CashbackAmount, PriceGetAg, TaxAmount, TaxAmountBreakdown } from '@epilot/pricing-client'; import { toDineroFromInteger } from '../money/to-dinero'; import { PricingModel } from '../prices/constants'; -import type { CompositePriceItem, Price, PriceItem, PriceItemDto, PricingDetails } from '../shared/types'; +import type { + CompositePriceItem, + Price, + PriceItem, + PriceItemDto, + PricingDetails, + RecurrenceAmount, + RecurrenceAmountWithTax, + TierDetails, +} from '../shared/types'; export const convertPriceComponentsPrecision = (items: PriceItem[], precision = 2): PriceItem[] => items.map((component) => convertPriceItemPrecision(component, precision)); @@ -73,6 +83,14 @@ export const convertPriceItemPrecision = (priceItem: PriceItem, precision = 2): .toUnit() .toString(), }), + ...(typeof priceItem.before_discount_amount_subtotal === 'number' && { + before_discount_amount_subtotal: toDineroFromInteger(priceItem.before_discount_amount_subtotal) + .convertPrecision(precision) + .getAmount(), + before_discount_amount_subtotal_decimal: toDineroFromInteger(priceItem.before_discount_amount_subtotal) + .toUnit() + .toString(), + }), ...(typeof priceItem.cashback_amount === 'number' && { cashback_amount: toDineroFromInteger(priceItem.cashback_amount).convertPrecision(precision).getAmount(), cashback_amount_decimal: toDineroFromInteger(priceItem.cashback_amount).toUnit().toString(), @@ -100,14 +118,14 @@ export const convertPriceItemPrecision = (priceItem: PriceItem, precision = 2): .getAmount(), before_discount_tax_amount_decimal: toDineroFromInteger(priceItem.before_discount_tax_amount).toUnit().toString(), }), - taxes: priceItem.taxes!.map((tax) => ({ + taxes: priceItem.taxes!.map((tax: TaxAmount) => ({ ...tax, amount: toDineroFromInteger(tax.amount || 0) .convertPrecision(precision) .getAmount(), })), ...(priceItem.tiers_details && { - tiers_details: priceItem.tiers_details.map((tier) => { + tiers_details: priceItem.tiers_details.map((tier: TierDetails) => { /** * @todo Also output the decimal values */ @@ -142,7 +160,9 @@ export const convertPriceItemPrecision = (priceItem: PriceItem, precision = 2): markup_amount_gross_decimal: toDineroFromInteger(priceItem.get_ag.markup_amount_gross!).toUnit().toString(), ...(priceItem.get_ag.additional_markups_enabled && priceItem.get_ag.additional_markups && { - additional_markups: Object.entries(priceItem.get_ag.additional_markups).reduce( + additional_markups: Object.entries( + priceItem.get_ag.additional_markups as NonNullable, + ).reduce( (acc, [key, value]) => ({ ...acc, [key]: { @@ -232,11 +252,11 @@ const convertBreakDownPrecision = (details: PricingDetails | CompositePriceItem, amount_tax: toDineroFromInteger(details.total_details?.amount_tax!).convertPrecision(precision).getAmount(), breakdown: { ...details.total_details?.breakdown, - taxes: details.total_details?.breakdown?.taxes!.map((tax) => ({ + taxes: details.total_details?.breakdown?.taxes!.map((tax: TaxAmountBreakdown) => ({ ...tax, amount: toDineroFromInteger(tax.amount!).convertPrecision(precision).getAmount(), })), - recurrences: details.total_details?.breakdown?.recurrences!.map((recurrence) => { + recurrences: details.total_details?.breakdown?.recurrences!.map((recurrence: RecurrenceAmount) => { return { ...recurrence, unit_amount_gross: toDineroFromInteger(recurrence.unit_amount_gross!) @@ -256,6 +276,11 @@ const convertBreakDownPrecision = (details: PricingDetails | CompositePriceItem, .convertPrecision(precision) .getAmount(), }), + ...(Number.isInteger(recurrence.before_discount_amount_subtotal) && { + before_discount_amount_subtotal: toDineroFromInteger(recurrence.before_discount_amount_subtotal!) + .convertPrecision(precision) + .getAmount(), + }), ...(typeof recurrence.after_cashback_amount_total === 'number' && Number.isInteger(recurrence.after_cashback_amount_total) && { after_cashback_amount_total: toDineroFromInteger(recurrence.after_cashback_amount_total) @@ -264,19 +289,21 @@ const convertBreakDownPrecision = (details: PricingDetails | CompositePriceItem, }), }; }), - recurrencesByTax: details.total_details?.breakdown?.recurrencesByTax!.map((recurrence) => { - return { - ...recurrence, - amount_total: toDineroFromInteger(recurrence.amount_total).convertPrecision(precision).getAmount(), - amount_subtotal: toDineroFromInteger(recurrence.amount_subtotal).convertPrecision(precision).getAmount(), - amount_tax: toDineroFromInteger(recurrence.amount_tax!).convertPrecision(precision).getAmount(), - tax: { - ...recurrence.tax, - amount: toDineroFromInteger(recurrence.tax?.amount!).convertPrecision(precision).getAmount(), - }, - }; - }), - cashbacks: details.total_details?.breakdown?.cashbacks?.map((cashback) => ({ + recurrencesByTax: details.total_details?.breakdown?.recurrencesByTax!.map( + (recurrence: RecurrenceAmountWithTax) => { + return { + ...recurrence, + amount_total: toDineroFromInteger(recurrence.amount_total).convertPrecision(precision).getAmount(), + amount_subtotal: toDineroFromInteger(recurrence.amount_subtotal).convertPrecision(precision).getAmount(), + amount_tax: toDineroFromInteger(recurrence.amount_tax!).convertPrecision(precision).getAmount(), + tax: { + ...recurrence.tax, + amount: toDineroFromInteger(recurrence.tax?.amount!).convertPrecision(precision).getAmount(), + }, + }; + }, + ), + cashbacks: details.total_details?.breakdown?.cashbacks?.map((cashback: CashbackAmount) => ({ ...cashback, amount_total: toDineroFromInteger(cashback.amount_total!).convertPrecision(precision).getAmount(), })), @@ -292,7 +319,7 @@ const convertBreakDownPrecision = (details: PricingDetails | CompositePriceItem, */ export const convertPricingPrecision = (details: PricingDetails, precision: number): PricingDetails => ({ ...details, - items: details.items!.map((item) => { + items: details.items!.map((item: PriceItem | CompositePriceItem) => { if ((item as CompositePriceItem).total_details) { return { ...item, @@ -311,6 +338,8 @@ export const convertPricingPrecision = (details: PricingDetails, precision: numb export const convertPriceItemWithCouponAppliedToPriceItemDto = ({ before_discount_amount_total, before_discount_amount_total_decimal, + before_discount_amount_subtotal, + before_discount_amount_subtotal_decimal, before_discount_unit_amount, before_discount_unit_amount_decimal, before_discount_unit_amount_gross, diff --git a/src/prices/get-immutable-price-item.ts b/src/prices/get-immutable-price-item.ts index 5aac5d6..119035a 100644 --- a/src/prices/get-immutable-price-item.ts +++ b/src/prices/get-immutable-price-item.ts @@ -26,6 +26,7 @@ const convertAmountsToDinero = (ite discount_amount: toDinero(item.discount_amount_decimal || '0').getAmount(), discount_amount_net: toDinero(item.discount_amount_net_decimal || '0').getAmount(), before_discount_amount_total: toDinero(item.before_discount_amount_total_decimal || '0').getAmount(), + before_discount_amount_subtotal: toDinero(item.before_discount_amount_subtotal_decimal || '0').getAmount(), before_discount_tax_amount: toDinero(item.before_discount_tax_amount_decimal || '0').getAmount(), before_discount_unit_amount: toDinero(item.before_discount_unit_amount_decimal || '0').getAmount(), before_discount_unit_amount_gross: toDinero(item.before_discount_unit_amount_gross_decimal || '0').getAmount(), diff --git a/src/prices/types.ts b/src/prices/types.ts index 358e19a..4a7f718 100644 --- a/src/prices/types.ts +++ b/src/prices/types.ts @@ -23,6 +23,7 @@ export type PriceItemsTotals = Pick< | 'discount_amount' | 'discount_percentage' | 'before_discount_amount_total' + | 'before_discount_amount_subtotal' | 'cashback_amount' | 'cashback_amount_decimal' | 'after_cashback_amount_total' diff --git a/src/variables/process-order-table-data.ts b/src/variables/process-order-table-data.ts index 11ed18d..e845c4d 100644 --- a/src/variables/process-order-table-data.ts +++ b/src/variables/process-order-table-data.ts @@ -245,7 +245,7 @@ export const processOrderTableData = (data: any, i18n: I18n) => { !isCoupon && Boolean(item._coupons?.filter((coupon: Coupon) => coupon?.category === 'discount').length); const redeemedPromoCode: RedeemedPromo | undefined = data?.redeemed_promos?.find((promo: RedeemedPromo) => - promo.coupons.some((c) => c._id === couponId), + promo.coupons.some((c: Coupon) => c._id === couponId), ); /** @@ -277,7 +277,17 @@ export const processOrderTableData = (data: any, i18n: I18n) => { let unitAmountNetFormatted = originalUnitAmountNetFormatted; let unitAmountFormatted = originalUnitAmountFormatted; - const unitAmountSubtotal = isCoupon ? undefined : item.amount_subtotal || 0; + let unitAmountSubtotal; + + if (isItemContainingDiscountCoupon) { + unitAmountSubtotal = (item as PriceItem).before_discount_amount_subtotal ?? 0; + } else if (isDiscountCoupon) { + unitAmountSubtotal = -((item as PriceItem).discount_amount_net ?? 0); + } else if (isCashbackCoupon) { + unitAmountSubtotal = 0; + } else { + unitAmountSubtotal = (item as PriceItem).amount_subtotal ?? 0; + } let amountTax;