From cc2064fafaa925e86414b979a6fe010da2075e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Carneiro?= Date: Mon, 18 Nov 2024 10:11:34 +0000 Subject: [PATCH 1/2] Add missing after_cashback_amount_total --- package-lock.json | 14 +- package.json | 2 +- src/__tests__/fixtures/coupon.samples.ts | 7 + src/__tests__/fixtures/price.samples.ts | 21 +++ src/__tests__/fixtures/pricing.results.ts | 165 +++++++++++++++++++++- src/normalizers/index.ts | 8 ++ src/pricing.test.ts | 9 +- src/pricing.ts | 19 +++ src/utils/index.ts | 23 ++- 9 files changed, 252 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5ff7b2..d480cdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "4.43.1", "license": "UNLICENSED", "dependencies": { - "@epilot/pricing-client": "^3.35.12", + "@epilot/pricing-client": "^3.35.14-beta.2", "@types/dinero.js": "^1.9.0", "dinero.js": "^1.9.1" }, @@ -893,9 +893,9 @@ } }, "node_modules/@epilot/pricing-client": { - "version": "3.35.12", - "resolved": "https://registry.npmjs.org/@epilot/pricing-client/-/pricing-client-3.35.12.tgz", - "integrity": "sha512-qhteTkwPJyT6o/EHZ+al/H59gBSfEED0BE442FA98ksgWARUhPR5y+Fii3Gyq7zBaX01YCNMXT5A80YdkCVPug==", + "version": "3.35.14-beta.2", + "resolved": "https://registry.npmjs.org/@epilot/pricing-client/-/pricing-client-3.35.14-beta.2.tgz", + "integrity": "sha512-BmQrlWMj47xR5ZSt7FFuVSapU94Yn/9DzuQYuOuozkK3e2Rr7Wt12Hg28Zbvfk5h9f3kFmNFZC1jsB84E2cduw==", "dependencies": { "@dazn/lambda-powertools-correlation-ids": "^1.28.1", "buffer": "^6.0.3", @@ -11715,9 +11715,9 @@ } }, "@epilot/pricing-client": { - "version": "3.35.12", - "resolved": "https://registry.npmjs.org/@epilot/pricing-client/-/pricing-client-3.35.12.tgz", - "integrity": "sha512-qhteTkwPJyT6o/EHZ+al/H59gBSfEED0BE442FA98ksgWARUhPR5y+Fii3Gyq7zBaX01YCNMXT5A80YdkCVPug==", + "version": "3.35.14-beta.2", + "resolved": "https://registry.npmjs.org/@epilot/pricing-client/-/pricing-client-3.35.14-beta.2.tgz", + "integrity": "sha512-BmQrlWMj47xR5ZSt7FFuVSapU94Yn/9DzuQYuOuozkK3e2Rr7Wt12Hg28Zbvfk5h9f3kFmNFZC1jsB84E2cduw==", "requires": { "@dazn/lambda-powertools-correlation-ids": "^1.28.1", "buffer": "^6.0.3", diff --git a/package.json b/package.json index befca40..2431466 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "author": "epilot GmbH", "license": "UNLICENSED", "dependencies": { - "@epilot/pricing-client": "^3.35.12", + "@epilot/pricing-client": "^3.35.14-beta.2", "@types/dinero.js": "^1.9.0", "dinero.js": "^1.9.1" }, diff --git a/src/__tests__/fixtures/coupon.samples.ts b/src/__tests__/fixtures/coupon.samples.ts index 06fe430..e86cdcb 100644 --- a/src/__tests__/fixtures/coupon.samples.ts +++ b/src/__tests__/fixtures/coupon.samples.ts @@ -39,6 +39,7 @@ export const fixedCashbackCoupon: Coupon = { fixed_value: 1000, fixed_value_decimal: '10.00', fixed_value_currency: 'EUR', + cashback_period: '12', }; export const percentageCashbackCoupon: Coupon = { @@ -52,4 +53,10 @@ export const percentageCashbackCoupon: Coupon = { type: 'percentage', category: 'cashback', percentage_value: '10', + cashback_period: '12', +}; + +export const immediateFixedCashbackCoupon: Coupon = { + ...fixedCashbackCoupon, + cashback_period: '0', }; diff --git a/src/__tests__/fixtures/price.samples.ts b/src/__tests__/fixtures/price.samples.ts index 0ea6370..8c6cd15 100644 --- a/src/__tests__/fixtures/price.samples.ts +++ b/src/__tests__/fixtures/price.samples.ts @@ -44,6 +44,9 @@ export const priceItem1: PriceItemDto = { is_tax_inclusive: true, }; +/** + * @todo Rename to recurringPriceItem + */ export const priceItem2: PriceItemDto = { quantity: 1, taxes: [ @@ -3078,3 +3081,21 @@ export const priceItemWithPercentageCashbackCoupon = { ...baseForPriceItemWithDiscount, _coupons: [percentageCashbackCoupon], }; + +export const recurringPriceItemWithFixedAmountCashbackCoupon = { + ...baseForPriceItemWithDiscount, + _price: { + ...baseForPriceItemWithDiscount._price, + type: 'recurring', + billing_period: 'monthly', + billing_duration_amount: 1, + billing_duration_unit: 'years', + notice_time_amount: 1, + notice_time_unit: 'months', + termination_time_amount: 2, + termination_time_unit: 'weeks', + renewal_duration_amount: 1, + renewal_duration_unit: 'years', + }, + _coupons: [fixedCashbackCoupon], +}; diff --git a/src/__tests__/fixtures/pricing.results.ts b/src/__tests__/fixtures/pricing.results.ts index 1a8740e..66d7941 100644 --- a/src/__tests__/fixtures/pricing.results.ts +++ b/src/__tests__/fixtures/pricing.results.ts @@ -6993,27 +6993,31 @@ export const computedPriceWithFixedAmountCashbackCoupon = { fixed_value: 1000, fixed_value_decimal: '10.00', fixed_value_currency: 'EUR', + cashback_period: '12', }, ], + currency: 'EUR', + description: 'Winter Sale', unit_amount: 10000, unit_amount_net: 9091, unit_amount_net_decimal: '90.909090909091', unit_amount_gross: 10000, unit_amount_gross_decimal: '100', + unit_amount_decimal: '100', amount_subtotal: 9091, amount_total: 10000, - amount_tax: 909, cashback_amount: 1000, cashback_amount_decimal: '10', - currency: 'EUR', - description: 'Winter Sale', - unit_amount_decimal: '100', + after_cashback_amount_total: 9000, + after_cashback_amount_total_decimal: '90', + amount_tax: 909, amount_subtotal_decimal: '90.909090909091', amount_total_decimal: '100', }, ], currency: 'EUR', }; + /** * Simple price with a fixed amount cashback coupon applied to it */ @@ -7124,21 +7128,172 @@ export const computedPriceWithPercentageCashbackCoupon = { type: 'percentage', category: 'cashback', percentage_value: '10', + cashback_period: '12', }, ], + currency: 'EUR', + description: 'Winter Sale', unit_amount: 10000, unit_amount_net: 9091, unit_amount_net_decimal: '90.909090909091', unit_amount_gross: 10000, unit_amount_gross_decimal: '100', + unit_amount_decimal: '100', amount_subtotal: 9091, amount_total: 10000, - amount_tax: 909, cashback_amount: 1000, cashback_amount_decimal: '10', + after_cashback_amount_total: 9000, + after_cashback_amount_total_decimal: '90', + amount_tax: 909, + amount_subtotal_decimal: '90.909090909091', + amount_total_decimal: '100', + }, + ], + currency: 'EUR', +}; + +/** + * Simple price with a fixed amount cashback coupon applied to it + */ +export const computedRecurringPriceWithFixedAmountCashbackCoupon = { + amount_subtotal: 9091, + amount_total: 10000, + amount_tax: 909, + total_details: { + amount_tax: 909, + breakdown: { + taxes: [ + { + tax: { + _id: '10', + type: 'VAT', + rate: 10, + }, + amount: 909, + }, + ], + recurrences: [ + { + type: 'recurring', + billing_period: 'monthly', + unit_amount_gross: 10000, + unit_amount_net: 9091, + amount_subtotal: 9091, + amount_total: 10000, + amount_subtotal_decimal: '90.909090909091', + amount_total_decimal: '100', + amount_tax: 909, + }, + ], + recurrencesByTax: [ + { + type: 'recurring', + billing_period: 'monthly', + amount_total: 10000, + amount_subtotal: 9091, + amount_tax: 909, + tax: { + tax: { + _id: '10', + type: 'VAT', + rate: 10, + }, + amount: 909, + }, + }, + ], + }, + }, + items: [ + { + quantity: 1, + product_id: 'prod-id#12324', + price_id: 'price#1', + taxes: [ + { + tax: { + _id: '10', + rate: 10, + type: 'VAT', + _schema: 'tax', + _org: '739224', + _title: '', + _created_at: '2022-06-29T20:26:19.020Z', + _updated_at: '2022-06-29T20:26:19.020Z', + }, + amount: 909, + }, + ], + _price: { + _id: 'price#1', + unit_amount: 10000, + unit_amount_currency: 'EUR', + unit_amount_decimal: '100', + type: 'recurring', + tax: [ + { + _id: '10', + rate: 10, + type: 'VAT', + _schema: 'tax', + _org: '739224', + _title: '', + _created_at: '2022-06-29T20:26:19.020Z', + _updated_at: '2022-06-29T20:26:19.020Z', + }, + ], + is_tax_inclusive: true, + description: 'Winter Sale', + _title: 'Winter Sale', + pricing_model: 'per_unit', + billing_period: 'monthly', + billing_duration_amount: 1, + billing_duration_unit: 'years', + notice_time_amount: 1, + notice_time_unit: 'months', + termination_time_amount: 2, + termination_time_unit: 'weeks', + renewal_duration_amount: 1, + renewal_duration_unit: 'years', + }, + _product: { + _tags: ['product-tag-1', 'product-tag-2'], + }, + pricing_model: 'per_unit', + is_tax_inclusive: true, + _coupons: [ + { + _id: 'coupon#3', + _schema: 'coupon', + _org: 'org#1', + _created_at: '2022-06-15T09:17:06.510Z', + _updated_at: '2022-06-17T11:48:20.104Z', + _title: 'Summer Cashback', + name: 'Summer Cashback', + type: 'fixed', + category: 'cashback', + fixed_value: 1000, + fixed_value_decimal: '10.00', + fixed_value_currency: 'EUR', + cashback_period: '12', + }, + ], currency: 'EUR', description: 'Winter Sale', + unit_amount: 10000, + unit_amount_net: 9091, + unit_amount_net_decimal: '90.909090909091', + unit_amount_gross: 10000, + unit_amount_gross_decimal: '100', unit_amount_decimal: '100', + amount_subtotal: 9091, + amount_total: 10000, + cashback_amount: 1000, + cashback_amount_decimal: '10', + after_cashback_amount_total: 9917, + after_cashback_amount_total_decimal: '99.166666666667', + amount_tax: 909, amount_subtotal_decimal: '90.909090909091', amount_total_decimal: '100', }, diff --git a/src/normalizers/index.ts b/src/normalizers/index.ts index 05e7b2a..991f2e6 100644 --- a/src/normalizers/index.ts +++ b/src/normalizers/index.ts @@ -64,6 +64,14 @@ export const normalizeTimeFrequencyToDinero = ( ): Dinero => { const dineroInputValue = toDinero(String(timeValue)); + return normalizeTimeFrequencyFromDineroInputValue(dineroInputValue, timeValueFrequency, targetTimeFrequency); +}; + +export const normalizeTimeFrequencyFromDineroInputValue = ( + dineroInputValue: Dinero, + timeValueFrequency: TimeFrequency, + targetTimeFrequency: TimeFrequency, +): Dinero => { if ( !timeFrequencyNormalizerMatrix[targetTimeFrequency] || !timeFrequencyNormalizerMatrix[targetTimeFrequency][timeValueFrequency] diff --git a/src/pricing.test.ts b/src/pricing.test.ts index c7470fd..966719c 100644 --- a/src/pricing.test.ts +++ b/src/pricing.test.ts @@ -515,15 +515,20 @@ describe('computeAggregatedAndPriceTotals', () => { expect(result).toEqual(results.computedResultWithPricesWithAndWithoutCoupons); }); - it('should compute cashbacks and totals correctly', () => { + it('should compute fixed amount cashbacks and totals correctly', () => { const result = computeAggregatedAndPriceTotals([samples.priceItemWithFixedAmountCashbackCoupon]); expect(result).toEqual(results.computedPriceWithFixedAmountCashbackCoupon); }); - it('should compute cashbacks and totals correctly', () => { + it('should compute percentage amount cashbacks and totals correctly', () => { const result = computeAggregatedAndPriceTotals([samples.priceItemWithPercentageCashbackCoupon]); expect(result).toEqual(results.computedPriceWithPercentageCashbackCoupon); }); + + it('should compute fixed amount cashbacks and totals correctly for recurring price', () => { + const result = computeAggregatedAndPriceTotals([samples.recurringPriceItemWithFixedAmountCashbackCoupon]); + expect(result).toEqual(results.computedRecurringPriceWithFixedAmountCashbackCoupon); + }); }); }); diff --git a/src/pricing.ts b/src/pricing.ts index e9c3a69..a90a10a 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -663,11 +663,17 @@ export const mapToProductSnapshot = (product?: Product): Product | undefined => */ export const computePriceItem = ( priceItem: PriceItemDto, + /** + * @todo Remove this redundant parameter as price can be accessed from priceItem._price + */ price: Price | undefined, applicableTax: Tax | undefined, quantity: number, priceMapping?: PriceInputMapping, externalFeeMapping?: ExternalFeeMapping, + /** + * @todo Remove this redundant parameter as coupons can be accessed from priceItem._coupons + */ coupons: ReadonlyArray = [], ): PriceItem => { const currency = (price?.unit_amount_currency || DEFAULT_CURRENCY).toUpperCase() as Currency; @@ -743,6 +749,7 @@ export const computePriceItem = ( unitAmountMultiplier, priceTax, coupons, + priceItem, ); } @@ -778,6 +785,12 @@ export const computePriceItem = ( cashback_amount: itemValues.cashback_amount, }), ...(itemValues.cashback_amount_decimal && { cashback_amount_decimal: itemValues.cashback_amount_decimal }), + ...(Number.isInteger(itemValues.after_cashback_amount_total) && { + after_cashback_amount_total: itemValues.after_cashback_amount_total, + }), + ...(itemValues.after_cashback_amount_total_decimal && { + after_cashback_amount_total_decimal: itemValues.after_cashback_amount_total_decimal, + }), ...(itemValues.before_discount_amount_total && { before_discount_amount_total: itemValues.before_discount_amount_total, }), @@ -885,6 +898,12 @@ const convertPriceItemPrecision = (priceItem: PriceItem, precision = 2): PriceIt cashback_amount: toDineroFromInteger(priceItem.cashback_amount).convertPrecision(precision).getAmount(), cashback_amount_decimal: toDineroFromInteger(priceItem.cashback_amount).toUnit().toString(), }), + ...(typeof priceItem.after_cashback_amount_total === 'number' && { + after_cashback_amount_total: toDineroFromInteger(priceItem.after_cashback_amount_total) + .convertPrecision(precision) + .getAmount(), + after_cashback_amount_total_decimal: toDineroFromInteger(priceItem.after_cashback_amount_total).toUnit().toString(), + }), amount_tax: toDineroFromInteger(priceItem.amount_tax || 0) .convertPrecision(precision) .getAmount(), diff --git a/src/utils/index.ts b/src/utils/index.ts index 65b9d87..d44e98e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,8 +3,9 @@ import type { Currency, Dinero } from 'dinero.js'; import { DEFAULT_CURRENCY } from '../currencies'; import { toDineroFromInteger, toDinero } from '../formatters'; import { TaxRates } from '../formatters/constants'; +import { normalizeTimeFrequencyFromDineroInputValue } from '../normalizers'; import { MarkupPricingModel, TypeGetAg } from '../pricing'; -import type { Coupon, Price, PriceGetAg, PriceItem, PriceTier, Tax } from '../types'; +import type { BillingPeriod, Coupon, Price, PriceGetAg, PriceItem, PriceTier, Tax } from '../types'; import { isPercentageCoupon, isValidCoupon, isCashbackCoupon, isFixedValueCoupon } from './guards/coupon'; @@ -29,6 +30,8 @@ export type PriceItemsTotals = Pick< | 'before_discount_amount_total' | 'cashback_amount' | 'cashback_amount_decimal' + | 'after_cashback_amount_total' + | 'after_cashback_amount_total_decimal' | 'get_ag' | 'tiers_details' > & { @@ -102,6 +105,7 @@ export const computePriceItemValues = ( unitAmountMultiplier: number, tax?: Tax, coupons: ReadonlyArray = [], + priceItem?: PriceItem, ): PriceItemsTotals => { const [coupon] = coupons.filter(isValidCoupon); @@ -112,6 +116,7 @@ export const computePriceItemValues = ( let unitDiscountAmount: Dinero | undefined; let unitDiscountAmountNet: Dinero | undefined; let cashbackAmount: Dinero | undefined; + let afterCashbackAmountTotal: Dinero | undefined; if (coupon) { if (isCashbackCoupon(coupon)) { @@ -121,6 +126,20 @@ export const computePriceItemValues = ( const cashbackPercentage = clamp(Number(coupon.percentage_value), 0, 100); cashbackAmount = unitAmount.multiply(cashbackPercentage).divide(100); } + + /** + * The cashback amount must take into account the price recurrence + * For 1 time prices we just subtract the cashback amount from the unit amount + * For recurring prices we divide the cashback amount by the number of periods in 1 year, and then subtract it + * from the unit amount + */ + const normalizedCashbackAmount = normalizeTimeFrequencyFromDineroInputValue( + cashbackAmount, + 'yearly', + priceItem?._price?.billing_period as BillingPeriod, + ); + + afterCashbackAmountTotal = unitAmount.subtract(normalizedCashbackAmount); } else { unitAmountBeforeDiscount = unitAmount; if (isPercentageCoupon(coupon)) { @@ -200,6 +219,8 @@ export const computePriceItemValues = ( ...(cashbackAmount && { cashback_amount: cashbackAmount.getAmount(), cashback_amount_decimal: cashbackAmount.toUnit().toString(), + after_cashback_amount_total: afterCashbackAmountTotal?.getAmount(), + after_cashback_amount_total_decimal: afterCashbackAmountTotal?.toUnit().toString(), }), }; }; From dc52014237a3b6ce72c6dd5d445d6a920ccb5617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Carneiro?= Date: Mon, 18 Nov 2024 12:07:49 +0000 Subject: [PATCH 2/2] Remove unecessary comment --- src/utils/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index 0c13335..780f33f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -135,12 +135,6 @@ export const computePriceItemValues = ( cashbackAmount = unitAmount.multiply(cashbackPercentage).divide(100); } - /** - * The cashback amount must take into account the price recurrence - * For 1 time prices we just subtract the cashback amount from the unit amount - * For recurring prices we divide the cashback amount by the number of periods in 1 year, and then subtract it - * from the unit amount - */ const normalizedCashbackAmount = normalizeTimeFrequencyFromDineroInputValue( cashbackAmount, 'yearly',