Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 68 additions & 0 deletions src/__tests__/fixtures/pricing.results.ts

Large diffs are not rendered by default.

196 changes: 195 additions & 1 deletion src/computations/apply-discounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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
});
});

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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
});
});

Expand Down Expand Up @@ -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
});
});
});
32 changes: 19 additions & 13 deletions src/computations/apply-discounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
}),
{
Expand All @@ -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,
);

Expand Down Expand Up @@ -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(),
};
};
Loading