This fixes the discount_ calculation logic and promotion tax inclusiveness calculation (#12960)

* This fixes the discount_ calculation logic

* This fixes the adjustment to be handled as a subtotal value in every calculation and applies the tax inclusive logic on the promotion value itself

* Added some testcases and revoked some changes to improve testing output

* Fixed a test case based on feedback

* Corrected promotion/admin test cases

* Corrected cart/store test case

* Improved cart/store test cases for more robust promotion testing considering tax inclusion flags

* Remove unnessary changes as adjustments now automatically are subtotals and therefore the tax inclusive flag does not need to be applied again

* Remove adjustments->is_tax_inclusive usage everywhere

* Migration script to remove is_tax_inclusive in cart line item adjustment

* Forgot to adjust one more testcase

* Corrections based on fPolic feedback

* Refactored PR to consider feedback from oliver

* Added more testcases for promotion in cart

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
scherddel
2025-07-31 13:27:43 +02:00
committed by GitHub
co-authored by Oli Juhl
parent 75320e744f
commit 1bdf602f1c
11 changed files with 1076 additions and 119 deletions
+11
View File
@@ -0,0 +1,11 @@
---
"@medusajs/promotion": patch
"@medusajs/core-flows": patch
"integration-tests-http": patch
"@medusajs/cart": patch
"@medusajs/types": patch
"@medusajs/utils": patch
"@medusajs/medusa": patch
---
This fixes the discount\_ calculation logic and promotion tax inclusiveness calculation
@@ -419,22 +419,22 @@ medusaIntegrationTestRunner({
compare_at_unit_price: null, compare_at_unit_price: null,
is_tax_inclusive: true, is_tax_inclusive: true,
quantity: 2, quantity: 2,
tax_lines: [ tax_lines: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
description: "CA Default Rate", description: "CA Default Rate",
code: "CADEFAULT", code: "CADEFAULT",
rate: 5, rate: 5,
provider_id: "system", provider_id: "system",
}), }),
], ]),
adjustments: [ adjustments: expect.arrayContaining([
{ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
code: "PROMOTION_APPLIED", code: "PROMOTION_APPLIED",
promotion_id: promotion.id, promotion_id: promotion.id,
amount: 100, amount: 100,
}, }),
], ]),
}), }),
]), ]),
}) })
@@ -456,14 +456,14 @@ medusaIntegrationTestRunner({
id: cart.id, id: cart.id,
items: expect.arrayContaining([ items: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
adjustments: [ adjustments: expect.arrayContaining([
{ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
code: "PROMOTION_APPLIED", code: "PROMOTION_APPLIED",
promotion_id: promotion.id, promotion_id: promotion.id,
amount: 100, amount: 100,
}, }),
], ]),
}), }),
]), ]),
}) })
@@ -823,22 +823,22 @@ medusaIntegrationTestRunner({
compare_at_unit_price: 1500, compare_at_unit_price: 1500,
is_tax_inclusive: true, is_tax_inclusive: true,
quantity: 2, quantity: 2,
tax_lines: [ tax_lines: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
description: "CA Default Rate", description: "CA Default Rate",
code: "CADEFAULT", code: "CADEFAULT",
rate: 5, rate: 5,
provider_id: "system", provider_id: "system",
}), }),
], ]),
adjustments: [ adjustments: expect.arrayContaining([
{ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
code: "PROMOTION_APPLIED", code: "PROMOTION_APPLIED",
promotion_id: promotion.id, promotion_id: promotion.id,
amount: 100, amount: 100,
}, }),
], ]),
}), }),
]), ]),
}) })
@@ -1235,7 +1235,7 @@ medusaIntegrationTestRunner({
id: expect.any(String), id: expect.any(String),
currency_code: "usd", currency_code: "usd",
credit_line_total: 2395, credit_line_total: 2395,
discount_total: 100, discount_total: 105,
credit_lines: [ credit_lines: [
expect.objectContaining({ expect.objectContaining({
amount: 2395, amount: 2395,
@@ -2794,7 +2794,7 @@ medusaIntegrationTestRunner({
is_discountable: true, is_discountable: true,
unit_price: 1500, unit_price: 1500,
total: 1395, total: 1395,
discount_total: 100, discount_total: 105,
adjustments: [ adjustments: [
expect.objectContaining({ expect.objectContaining({
promotion_id: promotion.id, promotion_id: promotion.id,
@@ -2825,14 +2825,14 @@ medusaIntegrationTestRunner({
id: cart.id, id: cart.id,
items: expect.arrayContaining([ items: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
adjustments: [ adjustments: expect.arrayContaining([
{ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
code: "PROMOTION_APPLIED", code: "PROMOTION_APPLIED",
promotion_id: promotion.id, promotion_id: promotion.id,
amount: 100, amount: 100,
}, }),
], ]),
}), }),
]), ]),
}) })
@@ -2860,6 +2860,609 @@ medusaIntegrationTestRunner({
}) })
) )
}) })
it("should add a 100 USD tax exclusive promotion for a 105 USD tax inclusive item and logically result in a 0 total with tax 5%", async () => {
const taxExclPromotion = (
await api.post(
`/admin/promotions`,
{
code: "PROMOTION_TAX_EXCLUSIVE",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_tax_inclusive: false, //Here we apply a tax exclusive promotion to a tax inclusive item in a way that the total SHOULD be 0
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
currency_code: "usd",
value: 100,
apply_to_quantity: 1,
},
},
adminHeaders
)
).data.promotion
const product = (
await api.post(
`/admin/products`,
{
title: "Product for free",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 105,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
},
storeHeadersWithCustomer
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[0].id,
quantity: 1,
},
storeHeaders
)
).data.cart
let updated = await api.post(
`/store/carts/${cart.id}`,
{ promo_codes: [taxExclPromotion.code] },
storeHeaders
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
discount_total: 105,
discount_subtotal: 100,
discount_tax_total: 5,
original_total: 105,
total: 0, // 105 - 100 tax excl promotion + 5 promotion tax
items: expect.arrayContaining([
expect.objectContaining({
is_tax_inclusive: true,
adjustments: expect.arrayContaining([
expect.objectContaining({
code: taxExclPromotion.code,
amount: 100,
}),
]),
}),
]),
promotions: expect.arrayContaining([
expect.objectContaining({
code: "PROMOTION_TAX_EXCLUSIVE",
application_method: expect.objectContaining({
value: 100,
}),
}),
]),
})
)
})
it("should add a 105 USD tax inclusive promotion (fixed, across, apply_to_quantity=1) for a 105 USD tax inclusive item and logically result in a 0 total with tax 5%", async () => {
const taxInclPromotion = (
await api.post(
`/admin/promotions`,
{
code: "PROMOTION_TAX_INCLUSIVE",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_tax_inclusive: true, //Here we apply a tax inclusive promotion to a tax inclusive item in a way that the total SHOULD be 0
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
currency_code: "usd",
value: 105,
apply_to_quantity: 1,
},
},
adminHeaders
)
).data.promotion
const product = (
await api.post(
`/admin/products`,
{
title: "Product for free",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 105,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
},
storeHeadersWithCustomer
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[0].id,
quantity: 1,
},
storeHeaders
)
).data.cart
let updated = await api.post(
`/store/carts/${cart.id}`,
{ promo_codes: [taxInclPromotion.code] },
storeHeaders
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
discount_total: 105,
discount_subtotal: 100,
discount_tax_total: 5,
original_total: 105,
total: 0, // 105 - 100 tax excl promotion + 5 promotion tax
items: expect.arrayContaining([
expect.objectContaining({
is_tax_inclusive: true,
adjustments: expect.arrayContaining([
expect.objectContaining({
code: taxInclPromotion.code,
amount: 105,
is_tax_inclusive: true,
}),
]),
}),
]),
promotions: expect.arrayContaining([
expect.objectContaining({
code: "PROMOTION_TAX_INCLUSIVE",
is_tax_inclusive: true,
application_method: expect.objectContaining({
value: 105,
}),
}),
]),
})
)
})
it("should add a 105 USD tax inclusive promotion (fixed, across, apply_to_quantity=1) for two 105 USD tax inclusive items and logically result in a 105 total with tax 5%", async () => {
const taxInclPromotion = (
await api.post(
`/admin/promotions`,
{
code: "PROMOTION_TAX_INCLUSIVE",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_tax_inclusive: true, //Here we apply a tax inclusive promotion to a tax inclusive item in a way that the total SHOULD be 0
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
currency_code: "usd",
value: 105,
apply_to_quantity: 1,
},
},
adminHeaders
)
).data.promotion
const product = (
await api.post(
`/admin/products`,
{
title: "Product for free",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 105,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
},
storeHeadersWithCustomer
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[0].id,
quantity: 2,
},
storeHeaders
)
).data.cart
let updated = await api.post(
`/store/carts/${cart.id}`,
{ promo_codes: [taxInclPromotion.code] },
storeHeaders
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
discount_total: 105,
discount_subtotal: 100,
discount_tax_total: 5,
original_total: 210,
total: 105, // 210 - 100 tax excl promotion + 5 promotion tax
items: expect.arrayContaining([
expect.objectContaining({
is_tax_inclusive: true,
adjustments: expect.arrayContaining([
expect.objectContaining({
code: taxInclPromotion.code,
amount: 105,
is_tax_inclusive: true,
}),
]),
}),
]),
promotions: expect.arrayContaining([
expect.objectContaining({
code: "PROMOTION_TAX_INCLUSIVE",
is_tax_inclusive: true,
application_method: expect.objectContaining({
value: 105,
}),
}),
]),
})
)
})
it("should add a 105 USD tax inclusive promotion (fixed, each, max_quantity=2) for two 105 USD tax inclusive items and logically result in a 0 total with tax 5%", async () => {
const taxInclPromotion = (
await api.post(
`/admin/promotions`,
{
code: "PROMOTION_TAX_INCLUSIVE",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_tax_inclusive: true, //Here we apply a tax inclusive promotion to a tax inclusive item in a way that the total SHOULD be 0
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
currency_code: "usd",
value: 105,
max_quantity: 2,
},
},
adminHeaders
)
).data.promotion
const product = (
await api.post(
`/admin/products`,
{
title: "Product for free",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 105,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
},
storeHeadersWithCustomer
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[0].id,
quantity: 2,
},
storeHeaders
)
).data.cart
let updated = await api.post(
`/store/carts/${cart.id}`,
{ promo_codes: [taxInclPromotion.code] },
storeHeaders
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
discount_total: 105,
discount_subtotal: 100,
discount_tax_total: 5,
original_total: 210,
total: 105, // 210 - 100 tax excl promotion + 5 promotion tax
items: expect.arrayContaining([
expect.objectContaining({
is_tax_inclusive: true,
adjustments: expect.arrayContaining([
expect.objectContaining({
code: taxInclPromotion.code,
amount: 105,
is_tax_inclusive: true,
}),
]),
}),
]),
promotions: expect.arrayContaining([
expect.objectContaining({
code: "PROMOTION_TAX_INCLUSIVE",
is_tax_inclusive: true,
application_method: expect.objectContaining({
value: 105,
}),
}),
]),
})
)
})
it("should add two tax inclusive promotions (50,100) (fixed, across) for two 105 USD tax inclusive items", async () => {
const taxInclPromotion50 = (
await api.post(
`/admin/promotions`,
{
code: "PROMOTION_TAX_INCLUSIVE_50",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_tax_inclusive: true,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
currency_code: "usd",
value: 50,
},
},
adminHeaders
)
).data.promotion
const taxInclPromotion100 = (
await api.post(
`/admin/promotions`,
{
code: "PROMOTION_TAX_INCLUSIVE_100",
type: PromotionType.STANDARD,
status: PromotionStatus.ACTIVE,
is_tax_inclusive: true,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
currency_code: "usd",
value: 100,
},
},
adminHeaders
)
).data.promotion
const product = (
await api.post(
`/admin/products`,
{
title: "Product for free",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 105,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
},
storeHeadersWithCustomer
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[0].id,
quantity: 2,
},
storeHeaders
)
).data.cart
let updated = await api.post(
`/store/carts/${cart.id}`,
{
promo_codes: [taxInclPromotion50.code, taxInclPromotion100.code],
},
storeHeaders
)
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
discount_total: 150,
original_total: 210,
total: 60, // 210 - (100 + 50 tax incl promotion)
items: expect.arrayContaining([
expect.objectContaining({
is_tax_inclusive: true,
adjustments: expect.arrayContaining([
expect.objectContaining({
code: taxInclPromotion50.code,
amount: 50,
is_tax_inclusive: true,
}),
expect.objectContaining({
code: taxInclPromotion100.code,
amount: 100,
is_tax_inclusive: true,
}),
]),
}),
]),
})
)
})
}) })
describe("POST /store/carts/:id/customer", () => { describe("POST /store/carts/:id/customer", () => {
@@ -1322,9 +1322,9 @@ medusaIntegrationTestRunner({
original_total: 1300, original_total: 1300,
original_tax_total: 260, original_tax_total: 260,
discount_total: 100, discount_total: 125,
discount_subtotal: 100, discount_subtotal: 100,
discount_tax_total: 20, discount_tax_total: 25,
item_total: 1175, item_total: 1175,
item_subtotal: 1040, item_subtotal: 1040,
@@ -1354,14 +1354,13 @@ medusaIntegrationTestRunner({
original_total: 1300, original_total: 1300,
original_tax_total: 260, original_tax_total: 260,
discount_total: 100, discount_total: 125,
discount_subtotal: 100, discount_subtotal: 100,
discount_tax_total: 20, discount_tax_total: 25,
adjustments: expect.arrayContaining([ adjustments: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
amount: 100, amount: 100,
is_tax_inclusive: false,
}), }),
]), ]),
}), }),
@@ -1403,9 +1402,9 @@ medusaIntegrationTestRunner({
original_total: 1300, original_total: 1300,
original_tax_total: 260, original_tax_total: 260,
discount_total: 100, discount_total: 125,
discount_subtotal: 100, discount_subtotal: 100,
discount_tax_total: 20, discount_tax_total: 25,
item_total: 1175, item_total: 1175,
item_subtotal: 1040, item_subtotal: 1040,
@@ -1435,14 +1434,13 @@ medusaIntegrationTestRunner({
original_total: 1300, original_total: 1300,
original_tax_total: 260, original_tax_total: 260,
discount_total: 100, discount_total: 125,
discount_subtotal: 100, discount_subtotal: 100,
discount_tax_total: 20, discount_tax_total: 25,
adjustments: expect.arrayContaining([ adjustments: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
amount: 100, amount: 100,
is_tax_inclusive: false,
}), }),
]), ]),
}), }),
@@ -1598,7 +1596,6 @@ medusaIntegrationTestRunner({
adjustments: expect.arrayContaining([ adjustments: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
amount: 100, amount: 100,
is_tax_inclusive: false,
}), }),
]), ]),
}), }),
@@ -1679,7 +1676,6 @@ medusaIntegrationTestRunner({
adjustments: expect.arrayContaining([ adjustments: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
amount: 100, amount: 100,
is_tax_inclusive: false,
}), }),
]), ]),
}), }),
@@ -61,11 +61,10 @@ export interface AddItemAdjustmentAction {
amount: BigNumberInput amount: BigNumberInput
/** /**
* Whether the adjustment amount includes tax. * Whether the promotion amount includes tax.
*/ */
is_tax_inclusive?: boolean is_tax_inclusive?: boolean
/**
/** /**
* The promotion's code. * The promotion's code.
*/ */
@@ -186,6 +185,11 @@ export interface ComputeActionItemLine extends Record<string, unknown> {
*/ */
subtotal: BigNumberInput subtotal: BigNumberInput
/**
* The total of the line item.
*/
total: BigNumberInput
/** /**
* Whether the line item is discountable. * Whether the line item is discountable.
*/ */
@@ -211,6 +215,11 @@ export interface ComputeActionShippingLine extends Record<string, unknown> {
*/ */
subtotal: BigNumberInput subtotal: BigNumberInput
/**
* The total of the shipping method.
*/
total: BigNumberInput
/** /**
* The adjustments applied before on the shipping method. * The adjustments applied before on the shipping method.
*/ */
@@ -124,8 +124,8 @@ describe("Total calculation", function () {
adjustments: [ adjustments: [
{ {
amount: 10, amount: 10,
total: 11,
subtotal: 10, subtotal: 10,
total: 11,
}, },
], ],
subtotal: 100, subtotal: 100,
@@ -244,16 +244,16 @@ describe("Total calculation", function () {
adjustments: [ adjustments: [
{ {
amount: 9, amount: 9,
subtotal: 8.181818181818182, subtotal: 9,
total: 9, total: 9.9,
}, },
], ],
subtotal: 90, subtotal: 90,
total: 89.1, total: 89.1,
original_total: 99, original_total: 99,
discount_total: 9, discount_total: 9.9,
discount_subtotal: 9, discount_subtotal: 9,
discount_tax_total: 0.8181818181818182, discount_tax_total: 0.9,
tax_total: 8.1, tax_total: 8.1,
original_tax_total: 9, original_tax_total: 9,
}, },
@@ -298,17 +298,17 @@ describe("Total calculation", function () {
adjustments: [ adjustments: [
{ {
amount: 9, amount: 9,
subtotal: 8.181818181818182, subtotal: 9,
total: 9, total: 9.9,
}, },
], ],
amount: 99, amount: 99,
subtotal: 90, subtotal: 90,
total: 89.1, total: 89.1,
original_total: 99, original_total: 99,
discount_total: 9, discount_total: 9.9,
discount_subtotal: 9, discount_subtotal: 9,
discount_tax_total: 0.8181818181818182, discount_tax_total: 0.9,
tax_total: 8.1, tax_total: 8.1,
original_tax_total: 9, original_tax_total: 9,
}, },
@@ -342,9 +342,9 @@ describe("Total calculation", function () {
total: 191.4, total: 191.4,
subtotal: 198, subtotal: 198,
tax_total: 17.4, tax_total: 17.4,
discount_total: 24.6, discount_total: 26.4,
discount_subtotal: 24, discount_subtotal: 24,
discount_tax_total: 2.2363636363636363, discount_tax_total: 2.4,
original_total: 217.8, original_total: 217.8,
original_tax_total: 19.8, original_tax_total: 19.8,
item_total: 95.7, item_total: 95.7,
@@ -562,7 +562,7 @@ describe("Total calculation", function () {
* TAX INCLUSIVE CART * TAX INCLUSIVE CART
* *
* Total price -> 120 tax inclusive * Total price -> 120 tax inclusive
* Fixed discount -> 10 tax inclusive * Fixed discount -> 10 tax inclusive total (which results in a subtotal of 8.33 of the discount)
* Tax rate -> 20% * Tax rate -> 20%
*/ */
@@ -574,8 +574,7 @@ describe("Total calculation", function () {
is_tax_inclusive: true, is_tax_inclusive: true,
adjustments: [ adjustments: [
{ {
amount: 10, amount: 8.333333333333334,
is_tax_inclusive: true,
}, },
], ],
tax_lines: [ tax_lines: [
@@ -615,8 +614,7 @@ describe("Total calculation", function () {
], ],
adjustments: [ adjustments: [
{ {
is_tax_inclusive: true, amount: 8.333333333333334,
amount: 10, // <- amount is tax inclusive so it's equal to total
subtotal: 8.333333333333334, subtotal: 8.333333333333334,
total: 10, total: 10,
}, },
@@ -625,7 +623,7 @@ describe("Total calculation", function () {
], ],
subtotal: 100, subtotal: 100,
tax_total: 18.333333333333332, tax_total: 18.333333333333332,
total: 110, // total is 120 - 10 tax inclusive discount total: 110,
original_item_subtotal: 100, original_item_subtotal: 100,
original_item_tax_total: 20, original_item_tax_total: 20,
@@ -882,4 +880,338 @@ describe("Total calculation", function () {
credit_line_total: 40, credit_line_total: 40,
}) })
}) })
it("should calculate carts with items + taxes + adjustments", function () {
const cart = {
items: [
{
unit_price: 119,
quantity: 1,
tax_lines: [
{
rate: 19,
},
],
adjustments: [
{
amount: 119,
},
],
},
],
}
const serialized = JSON.parse(JSON.stringify(decorateCartTotals(cart)))
expect(serialized).toEqual({
items: [
{
unit_price: 119,
quantity: 1,
tax_lines: [
{
rate: 19,
subtotal: 22.61,
},
],
adjustments: [
{
amount: 119,
subtotal: 119,
total: 141.61,
},
],
subtotal: 119,
total: 0,
original_total: 141.61,
discount_total: 141.61,
discount_subtotal: 119,
discount_tax_total: 22.61,
tax_total: 0,
original_tax_total: 22.61,
},
],
total: 0,
subtotal: 119,
tax_total: 0,
discount_total: 141.61,
discount_subtotal: 119,
discount_tax_total: 22.61,
original_total: 141.61,
original_tax_total: 22.61,
item_total: 0,
item_subtotal: 119,
item_tax_total: 0,
original_item_total: 141.61,
original_item_subtotal: 119,
original_item_tax_total: 22.61,
credit_line_subtotal: 0,
credit_line_tax_total: 0,
credit_line_total: 0,
})
})
it("should calculate carts with items + taxes with is_tax_inclusive", function () {
const cartWithTax = {
items: [
{
unit_price: 119,
quantity: 1,
is_tax_inclusive: true,
tax_lines: [
{
rate: 19,
},
],
},
],
}
const cartWithoutTax = {
items: [
{
unit_price: 119,
quantity: 1,
is_tax_inclusive: false,
tax_lines: [
{
rate: 19,
},
],
},
],
}
const cartMixed = {
items: [...cartWithTax.items, ...cartWithoutTax.items],
}
const serializedWith = JSON.parse(
JSON.stringify(decorateCartTotals(cartWithTax))
)
const serializedWithout = JSON.parse(
JSON.stringify(decorateCartTotals(cartWithoutTax))
)
const serializedMixed = JSON.parse(
JSON.stringify(decorateCartTotals(cartMixed))
)
expect(serializedWith).toEqual({
credit_line_subtotal: 0,
credit_line_tax_total: 0,
credit_line_total: 0,
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
item_subtotal: 100,
item_tax_total: 19,
item_total: 119,
items: [
{
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
is_tax_inclusive: true,
original_tax_total: 19,
original_total: 119,
quantity: 1,
subtotal: 100,
tax_lines: [
{
rate: 19,
subtotal: 19,
total: 19,
},
],
tax_total: 19,
total: 119,
unit_price: 119,
},
],
original_item_subtotal: 100,
original_item_tax_total: 19,
original_item_total: 119,
original_tax_total: 19,
original_total: 119,
subtotal: 100,
tax_total: 19,
total: 119,
})
expect(serializedWithout).toEqual({
credit_line_subtotal: 0,
credit_line_tax_total: 0,
credit_line_total: 0,
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
item_subtotal: 119,
item_tax_total: 22.61,
item_total: 141.61,
items: [
{
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
is_tax_inclusive: false,
original_tax_total: 22.61,
original_total: 141.61,
quantity: 1,
subtotal: 119,
tax_lines: [
{
rate: 19,
subtotal: 22.61,
total: 22.61,
},
],
tax_total: 22.61,
total: 141.61,
unit_price: 119,
},
],
original_item_subtotal: 119,
original_item_tax_total: 22.61,
original_item_total: 141.61,
original_tax_total: 22.61,
original_total: 141.61,
subtotal: 119,
tax_total: 22.61,
total: 141.61,
})
expect(serializedMixed).toEqual({
credit_line_subtotal: 0,
credit_line_tax_total: 0,
credit_line_total: 0,
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
item_subtotal: 219,
item_tax_total: 41.61,
item_total: 260.61,
items: [
{
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
is_tax_inclusive: true,
original_tax_total: 19,
original_total: 119,
quantity: 1,
subtotal: 100,
tax_lines: [
{
rate: 19,
subtotal: 19,
total: 19,
},
],
tax_total: 19,
total: 119,
unit_price: 119,
},
{
discount_subtotal: 0,
discount_tax_total: 0,
discount_total: 0,
is_tax_inclusive: false,
original_tax_total: 22.61,
original_total: 141.61,
quantity: 1,
subtotal: 119,
tax_lines: [
{
rate: 19,
subtotal: 22.61,
total: 22.61,
},
],
tax_total: 22.61,
total: 141.61,
unit_price: 119,
},
],
original_item_subtotal: 219,
original_item_tax_total: 41.61,
original_item_total: 260.61,
original_tax_total: 41.61,
original_total: 260.61,
subtotal: 219,
tax_total: 41.61,
total: 260.61,
})
})
it("should calculate tax inclusive carts with items + taxes with tax inclusive adjustments", function () {
const cart = {
items: [
{
unit_price: 119,
quantity: 1,
is_tax_inclusive: true,
adjustments: [
{
amount: 100,
},
],
tax_lines: [
{
rate: 19,
},
],
},
],
}
const serialized = JSON.parse(JSON.stringify(decorateCartTotals(cart)))
expect(serialized).toEqual({
credit_line_subtotal: 0,
credit_line_tax_total: 0,
credit_line_total: 0,
discount_subtotal: 100,
discount_tax_total: 19,
discount_total: 119,
item_subtotal: 100,
item_tax_total: 0,
item_total: 0,
items: [
{
adjustments: [
{
amount: 100,
subtotal: 100,
total: 119,
},
],
discount_subtotal: 100,
discount_tax_total: 19,
discount_total: 119,
is_tax_inclusive: true,
original_tax_total: 19,
original_total: 119,
quantity: 1,
subtotal: 100,
tax_lines: [
{
rate: 19,
subtotal: 19,
},
],
tax_total: 0,
total: 0,
unit_price: 119,
},
],
original_item_subtotal: 100,
original_item_tax_total: 19,
original_item_total: 119,
original_tax_total: 19,
original_total: 119,
subtotal: 100,
tax_total: 0,
total: 0,
})
})
}) })
@@ -5,11 +5,9 @@ import { MathBN } from "../math"
export function calculateAdjustmentTotal({ export function calculateAdjustmentTotal({
adjustments, adjustments,
includesTax,
taxRate, taxRate,
}: { }: {
adjustments: Pick<AdjustmentLineDTO, "amount" | "is_tax_inclusive">[] adjustments: Pick<AdjustmentLineDTO, "amount" | "is_tax_inclusive">[]
includesTax?: boolean
taxRate?: BigNumberInput taxRate?: BigNumberInput
}) { }) {
// the sum of all adjustment amounts excluding tax // the sum of all adjustment amounts excluding tax
@@ -24,35 +22,22 @@ export function calculateAdjustmentTotal({
continue continue
} }
const adjustmentAmount = MathBN.convert(adj.amount) const adjustmentSubtotal =
isDefined(taxRate) && adj.is_tax_inclusive
? MathBN.div(adj.amount, MathBN.add(1, taxRate))
: adj.amount
if (adj.is_tax_inclusive && isDefined(taxRate)) { const adjustmentTaxTotal = isDefined(taxRate)
adjustmentsSubtotal = MathBN.add( ? MathBN.mult(adjustmentSubtotal, taxRate)
adjustmentsSubtotal, : 0
MathBN.div(adjustmentAmount, MathBN.add(1, taxRate)) const adjustmentTotal = MathBN.add(adjustmentSubtotal, adjustmentTaxTotal)
)
} else {
adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount)
}
if (isDefined(taxRate)) { adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentSubtotal)
const adjustmentSubtotal = includesTax adjustmentsTaxTotal = MathBN.add(adjustmentsTaxTotal, adjustmentTaxTotal)
? MathBN.div(adjustmentAmount, MathBN.add(1, taxRate)) adjustmentsTotal = MathBN.add(adjustmentsTotal, adjustmentTotal)
: adjustmentAmount
const adjustmentTaxTotal = MathBN.mult(adjustmentSubtotal, taxRate) adj["subtotal"] = new BigNumber(adjustmentsSubtotal)
const adjustmentTotal = MathBN.add(adjustmentSubtotal, adjustmentTaxTotal) adj["total"] = new BigNumber(adjustmentsTotal)
adj["subtotal"] = new BigNumber(adjustmentSubtotal)
adj["total"] = new BigNumber(adjustmentTotal)
adjustmentsTotal = MathBN.add(adjustmentsTotal, adjustmentTotal)
adjustmentsTaxTotal = MathBN.add(adjustmentsTaxTotal, adjustmentTaxTotal)
} else {
adj["subtotal"] = new BigNumber(adjustmentAmount)
adj["adjustmentAmount"] = new BigNumber(adjustmentAmount)
adjustmentsTotal = MathBN.add(adjustmentsTotal, adjustmentAmount)
}
} }
return { return {
@@ -16,7 +16,7 @@ export interface GetItemTotalInput {
quantity: BigNumber quantity: BigNumber
is_tax_inclusive?: boolean is_tax_inclusive?: boolean
tax_lines?: Pick<TaxLineDTO, "rate">[] tax_lines?: Pick<TaxLineDTO, "rate">[]
adjustments?: Pick<AdjustmentLineDTO, "amount">[] adjustments?: Pick<AdjustmentLineDTO, "amount" | "is_tax_inclusive">[]
detail?: { detail?: {
fulfilled_quantity: BigNumber fulfilled_quantity: BigNumber
delivered_quantity: BigNumber delivered_quantity: BigNumber
@@ -133,7 +133,6 @@ function getLineItemTotals(
adjustmentsTaxTotal: discountTaxTotal, adjustmentsTaxTotal: discountTaxTotal,
} = calculateAdjustmentTotal({ } = calculateAdjustmentTotal({
adjustments: item.adjustments || [], adjustments: item.adjustments || [],
includesTax: isTaxInclusive,
taxRate: sumTaxRate, taxRate: sumTaxRate,
}) })
@@ -5,23 +5,23 @@ import {
} from "../../promotion" } from "../../promotion"
import { MathBN } from "../math" import { MathBN } from "../math"
function getPromotionValueForPercentage(promotion, lineItemTotal) { function getPromotionValueForPercentage(promotion, lineItemAmount) {
return MathBN.mult(MathBN.div(promotion.value, 100), lineItemTotal) return MathBN.mult(MathBN.div(promotion.value, 100), lineItemAmount)
} }
function getPromotionValueForFixed(promotion, itemTotal, allItemsTotal) { function getPromotionValueForFixed(promotion, lineItemAmount, lineItemsAmount) {
if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { if (promotion.allocation === ApplicationMethodAllocation.ACROSS) {
const promotionValueForItem = MathBN.mult( const promotionValueForItem = MathBN.mult(
MathBN.div(itemTotal, allItemsTotal), MathBN.div(lineItemAmount, lineItemsAmount),
promotion.value promotion.value
) )
if (MathBN.lte(promotionValueForItem, itemTotal)) { if (MathBN.lte(promotionValueForItem, lineItemAmount)) {
return promotionValueForItem return promotionValueForItem
} }
const percentage = MathBN.div( const percentage = MathBN.div(
MathBN.mult(itemTotal, 100), MathBN.mult(lineItemAmount, 100),
promotionValueForItem promotionValueForItem
) )
@@ -30,16 +30,15 @@ function getPromotionValueForFixed(promotion, itemTotal, allItemsTotal) {
MathBN.div(percentage, 100) MathBN.div(percentage, 100)
).precision(4) ).precision(4)
} }
return promotion.value return promotion.value
} }
export function getPromotionValue(promotion, lineItemTotal, lineItemsTotal) { export function getPromotionValue(promotion, lineItemAmount, lineItemsAmount) {
if (promotion.type === ApplicationMethodType.PERCENTAGE) { if (promotion.type === ApplicationMethodType.PERCENTAGE) {
return getPromotionValueForPercentage(promotion, lineItemTotal) return getPromotionValueForPercentage(promotion, lineItemAmount)
} }
return getPromotionValueForFixed(promotion, lineItemTotal, lineItemsTotal) return getPromotionValueForFixed(promotion, lineItemAmount, lineItemsAmount)
} }
export function getApplicableQuantity(lineItem, maxQuantity) { export function getApplicableQuantity(lineItem, maxQuantity) {
@@ -50,14 +49,18 @@ export function getApplicableQuantity(lineItem, maxQuantity) {
return lineItem.quantity return lineItem.quantity
} }
function getLineItemUnitPrice(lineItem) { function getLineItemSubtotal(lineItem) {
return MathBN.div(lineItem.subtotal, lineItem.quantity) return MathBN.div(lineItem.subtotal, lineItem.quantity)
} }
function getLineItemTotal(lineItem) {
return MathBN.div(lineItem.total, lineItem.quantity)
}
export function calculateAdjustmentAmountFromPromotion( export function calculateAdjustmentAmountFromPromotion(
lineItem, lineItem,
promotion, promotion,
lineItemsTotal: BigNumberInput = 0 lineItemsAmount: BigNumberInput = 0
) { ) {
/* /*
For a promotion with an across allocation, we consider not only the line item total, but also the total of all other line items in the order. For a promotion with an across allocation, we consider not only the line item total, but also the total of all other line items in the order.
@@ -89,20 +92,26 @@ export function calculateAdjustmentAmountFromPromotion(
*/ */
if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { if (promotion.allocation === ApplicationMethodAllocation.ACROSS) {
const quantity = getApplicableQuantity(lineItem, promotion.max_quantity) const quantity = getApplicableQuantity(lineItem, promotion.max_quantity)
const lineItemTotal = MathBN.mult(getLineItemUnitPrice(lineItem), quantity)
const applicableTotal = MathBN.sub(lineItemTotal, promotion.applied_value)
if (MathBN.lte(applicableTotal, 0)) { const lineItemAmount = MathBN.mult(
return applicableTotal promotion.is_tax_inclusive
? getLineItemTotal(lineItem)
: getLineItemSubtotal(lineItem),
quantity
)
const applicableAmount = MathBN.sub(lineItemAmount, promotion.applied_value)
if (MathBN.lte(applicableAmount, 0)) {
return applicableAmount
} }
const promotionValue = getPromotionValue( const promotionValue = getPromotionValue(
promotion, promotion,
applicableTotal, applicableAmount,
lineItemsTotal lineItemsAmount
) )
return MathBN.min(promotionValue, applicableTotal) return MathBN.min(promotionValue, applicableAmount)
} }
/* /*
@@ -124,26 +133,32 @@ export function calculateAdjustmentAmountFromPromotion(
We then apply whichever is lower. We then apply whichever is lower.
*/ */
const remainingItemTotal = MathBN.sub( const remainingItemAmount = MathBN.sub(
lineItem.subtotal, promotion.is_tax_inclusive ? lineItem.total : lineItem.subtotal,
promotion.applied_value promotion.applied_value
) )
const unitPrice = MathBN.div(lineItem.subtotal, lineItem.quantity) const itemAmount = MathBN.div(
const maximumPromotionTotal = MathBN.mult( promotion.is_tax_inclusive ? lineItem.total : lineItem.subtotal,
unitPrice, lineItem.quantity
)
const maximumPromotionAmount = MathBN.mult(
itemAmount,
promotion.max_quantity ?? MathBN.convert(1) promotion.max_quantity ?? MathBN.convert(1)
) )
const applicableTotal = MathBN.min(remainingItemTotal, maximumPromotionTotal) const applicableAmount = MathBN.min(
remainingItemAmount,
maximumPromotionAmount
)
if (MathBN.lte(applicableTotal, 0)) { if (MathBN.lte(applicableAmount, 0)) {
return MathBN.convert(0) return MathBN.convert(0)
} }
const promotionValue = getPromotionValue( const promotionValue = getPromotionValue(
promotion, promotion,
applicableTotal, applicableAmount,
lineItemsTotal lineItemsAmount
) )
return MathBN.min(promotionValue, applicableTotal) return MathBN.min(promotionValue, applicableAmount)
} }
@@ -72,7 +72,6 @@ export function getShippingMethodTotals(
adjustmentsTaxTotal: discountsTaxTotal, adjustmentsTaxTotal: discountsTaxTotal,
} = calculateAdjustmentTotal({ } = calculateAdjustmentTotal({
adjustments: shippingMethod.adjustments || [], adjustments: shippingMethod.adjustments || [],
includesTax: isTaxInclusive,
taxRate: sumTaxRate, taxRate: sumTaxRate,
}) })
@@ -35,6 +35,7 @@ export const defaultStoreCartFields = [
"promotions.id", "promotions.id",
"promotions.code", "promotions.code",
"promotions.is_automatic", "promotions.is_automatic",
"promotions.is_tax_inclusive",
"promotions.application_method.value", "promotions.application_method.value",
"promotions.application_method.type", "promotions.application_method.type",
"promotions.application_method.currency_code", "promotions.application_method.currency_code",
@@ -77,6 +78,7 @@ export const defaultStoreCartFields = [
"items.adjustments.code", "items.adjustments.code",
"items.adjustments.promotion_id", "items.adjustments.promotion_id",
"items.adjustments.amount", "items.adjustments.amount",
"items.adjustments.is_tax_inclusive",
"customer.id", "customer.id",
"customer.email", "customer.email",
"customer.groups.id", "customer.groups.id",
@@ -103,24 +103,29 @@ function applyPromotionToItems(
? 1 ? 1
: applicationMethod?.max_quantity! : applicationMethod?.max_quantity!
let lineItemsTotal = MathBN.convert(0) let lineItemsAmount = MathBN.convert(0)
if (allocation === ApplicationMethodAllocation.ACROSS) { if (allocation === ApplicationMethodAllocation.ACROSS) {
lineItemsTotal = applicableItems.reduce( lineItemsAmount = applicableItems.reduce(
(acc, item) => (acc, item) =>
MathBN.sub( MathBN.sub(
MathBN.add(acc, item.subtotal), MathBN.add(
acc,
promotion.is_tax_inclusive ? item.total : item.subtotal
),
appliedPromotionsMap.get(item.id) ?? 0 appliedPromotionsMap.get(item.id) ?? 0
), ),
MathBN.convert(0) MathBN.convert(0)
) )
if (MathBN.lte(lineItemsTotal, 0)) { if (MathBN.lte(lineItemsAmount, 0)) {
return computedActions return computedActions
} }
} }
for (const item of applicableItems) { for (const item of applicableItems) {
if (MathBN.lte(item.subtotal, 0)) { if (
MathBN.lte(promotion.is_tax_inclusive ? item.total : item.subtotal, 0)
) {
continue continue
} }
@@ -135,11 +140,12 @@ function applyPromotionToItems(
{ {
value: promotionValue, value: promotionValue,
applied_value: appliedPromoValue, applied_value: appliedPromoValue,
is_tax_inclusive: promotion.is_tax_inclusive,
max_quantity: maxQuantity, max_quantity: maxQuantity,
type: applicationMethod?.type!, type: applicationMethod?.type!,
allocation, allocation,
}, },
lineItemsTotal lineItemsAmount
) )
if (MathBN.lte(amount, 0)) { if (MathBN.lte(amount, 0)) {