diff --git a/.changeset/pink-baboons-wash.md b/.changeset/pink-baboons-wash.md new file mode 100644 index 0000000000..1d67271e3d --- /dev/null +++ b/.changeset/pink-baboons-wash.md @@ -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 diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index 4f863bb81b..8e9a0fbede 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -419,22 +419,22 @@ medusaIntegrationTestRunner({ compare_at_unit_price: null, is_tax_inclusive: true, quantity: 2, - tax_lines: [ + tax_lines: expect.arrayContaining([ expect.objectContaining({ description: "CA Default Rate", code: "CADEFAULT", rate: 5, provider_id: "system", }), - ], - adjustments: [ - { + ]), + adjustments: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String), code: "PROMOTION_APPLIED", promotion_id: promotion.id, amount: 100, - }, - ], + }), + ]), }), ]), }) @@ -456,14 +456,14 @@ medusaIntegrationTestRunner({ id: cart.id, items: expect.arrayContaining([ expect.objectContaining({ - adjustments: [ - { + adjustments: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String), code: "PROMOTION_APPLIED", promotion_id: promotion.id, amount: 100, - }, - ], + }), + ]), }), ]), }) @@ -823,22 +823,22 @@ medusaIntegrationTestRunner({ compare_at_unit_price: 1500, is_tax_inclusive: true, quantity: 2, - tax_lines: [ + tax_lines: expect.arrayContaining([ expect.objectContaining({ description: "CA Default Rate", code: "CADEFAULT", rate: 5, provider_id: "system", }), - ], - adjustments: [ - { + ]), + adjustments: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String), code: "PROMOTION_APPLIED", promotion_id: promotion.id, amount: 100, - }, - ], + }), + ]), }), ]), }) @@ -1235,7 +1235,7 @@ medusaIntegrationTestRunner({ id: expect.any(String), currency_code: "usd", credit_line_total: 2395, - discount_total: 100, + discount_total: 105, credit_lines: [ expect.objectContaining({ amount: 2395, @@ -2794,7 +2794,7 @@ medusaIntegrationTestRunner({ is_discountable: true, unit_price: 1500, total: 1395, - discount_total: 100, + discount_total: 105, adjustments: [ expect.objectContaining({ promotion_id: promotion.id, @@ -2825,14 +2825,14 @@ medusaIntegrationTestRunner({ id: cart.id, items: expect.arrayContaining([ expect.objectContaining({ - adjustments: [ - { + adjustments: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String), code: "PROMOTION_APPLIED", promotion_id: promotion.id, 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", () => { diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts index 6c2f0c77a3..1160471025 100644 --- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts +++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts @@ -1322,9 +1322,9 @@ medusaIntegrationTestRunner({ original_total: 1300, original_tax_total: 260, - discount_total: 100, + discount_total: 125, discount_subtotal: 100, - discount_tax_total: 20, + discount_tax_total: 25, item_total: 1175, item_subtotal: 1040, @@ -1354,14 +1354,13 @@ medusaIntegrationTestRunner({ original_total: 1300, original_tax_total: 260, - discount_total: 100, + discount_total: 125, discount_subtotal: 100, - discount_tax_total: 20, + discount_tax_total: 25, adjustments: expect.arrayContaining([ expect.objectContaining({ amount: 100, - is_tax_inclusive: false, }), ]), }), @@ -1403,9 +1402,9 @@ medusaIntegrationTestRunner({ original_total: 1300, original_tax_total: 260, - discount_total: 100, + discount_total: 125, discount_subtotal: 100, - discount_tax_total: 20, + discount_tax_total: 25, item_total: 1175, item_subtotal: 1040, @@ -1435,14 +1434,13 @@ medusaIntegrationTestRunner({ original_total: 1300, original_tax_total: 260, - discount_total: 100, + discount_total: 125, discount_subtotal: 100, - discount_tax_total: 20, + discount_tax_total: 25, adjustments: expect.arrayContaining([ expect.objectContaining({ amount: 100, - is_tax_inclusive: false, }), ]), }), @@ -1598,7 +1596,6 @@ medusaIntegrationTestRunner({ adjustments: expect.arrayContaining([ expect.objectContaining({ amount: 100, - is_tax_inclusive: false, }), ]), }), @@ -1679,7 +1676,6 @@ medusaIntegrationTestRunner({ adjustments: expect.arrayContaining([ expect.objectContaining({ amount: 100, - is_tax_inclusive: false, }), ]), }), diff --git a/packages/core/types/src/promotion/common/compute-actions.ts b/packages/core/types/src/promotion/common/compute-actions.ts index 49831a5b52..74dce1493d 100644 --- a/packages/core/types/src/promotion/common/compute-actions.ts +++ b/packages/core/types/src/promotion/common/compute-actions.ts @@ -61,11 +61,10 @@ export interface AddItemAdjustmentAction { amount: BigNumberInput /** - * Whether the adjustment amount includes tax. + * Whether the promotion amount includes tax. */ is_tax_inclusive?: boolean - /** /** * The promotion's code. */ @@ -186,6 +185,11 @@ export interface ComputeActionItemLine extends Record { */ subtotal: BigNumberInput + /** + * The total of the line item. + */ + total: BigNumberInput + /** * Whether the line item is discountable. */ @@ -211,6 +215,11 @@ export interface ComputeActionShippingLine extends Record { */ subtotal: BigNumberInput + /** + * The total of the shipping method. + */ + total: BigNumberInput + /** * The adjustments applied before on the shipping method. */ diff --git a/packages/core/utils/src/totals/__tests__/totals.ts b/packages/core/utils/src/totals/__tests__/totals.ts index 443a3a24b1..01351de969 100644 --- a/packages/core/utils/src/totals/__tests__/totals.ts +++ b/packages/core/utils/src/totals/__tests__/totals.ts @@ -124,8 +124,8 @@ describe("Total calculation", function () { adjustments: [ { amount: 10, - total: 11, subtotal: 10, + total: 11, }, ], subtotal: 100, @@ -244,16 +244,16 @@ describe("Total calculation", function () { adjustments: [ { amount: 9, - subtotal: 8.181818181818182, - total: 9, + subtotal: 9, + total: 9.9, }, ], subtotal: 90, total: 89.1, original_total: 99, - discount_total: 9, + discount_total: 9.9, discount_subtotal: 9, - discount_tax_total: 0.8181818181818182, + discount_tax_total: 0.9, tax_total: 8.1, original_tax_total: 9, }, @@ -298,17 +298,17 @@ describe("Total calculation", function () { adjustments: [ { amount: 9, - subtotal: 8.181818181818182, - total: 9, + subtotal: 9, + total: 9.9, }, ], amount: 99, subtotal: 90, total: 89.1, original_total: 99, - discount_total: 9, + discount_total: 9.9, discount_subtotal: 9, - discount_tax_total: 0.8181818181818182, + discount_tax_total: 0.9, tax_total: 8.1, original_tax_total: 9, }, @@ -342,9 +342,9 @@ describe("Total calculation", function () { total: 191.4, subtotal: 198, tax_total: 17.4, - discount_total: 24.6, + discount_total: 26.4, discount_subtotal: 24, - discount_tax_total: 2.2363636363636363, + discount_tax_total: 2.4, original_total: 217.8, original_tax_total: 19.8, item_total: 95.7, @@ -562,7 +562,7 @@ describe("Total calculation", function () { * TAX INCLUSIVE CART * * 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% */ @@ -574,8 +574,7 @@ describe("Total calculation", function () { is_tax_inclusive: true, adjustments: [ { - amount: 10, - is_tax_inclusive: true, + amount: 8.333333333333334, }, ], tax_lines: [ @@ -615,8 +614,7 @@ describe("Total calculation", function () { ], adjustments: [ { - is_tax_inclusive: true, - amount: 10, // <- amount is tax inclusive so it's equal to total + amount: 8.333333333333334, subtotal: 8.333333333333334, total: 10, }, @@ -625,7 +623,7 @@ describe("Total calculation", function () { ], subtotal: 100, tax_total: 18.333333333333332, - total: 110, // total is 120 - 10 tax inclusive discount + total: 110, original_item_subtotal: 100, original_item_tax_total: 20, @@ -882,4 +880,338 @@ describe("Total calculation", function () { 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, + }) + }) }) diff --git a/packages/core/utils/src/totals/adjustment/index.ts b/packages/core/utils/src/totals/adjustment/index.ts index 860f052e80..f9e0440264 100644 --- a/packages/core/utils/src/totals/adjustment/index.ts +++ b/packages/core/utils/src/totals/adjustment/index.ts @@ -5,11 +5,9 @@ import { MathBN } from "../math" export function calculateAdjustmentTotal({ adjustments, - includesTax, taxRate, }: { adjustments: Pick[] - includesTax?: boolean taxRate?: BigNumberInput }) { // the sum of all adjustment amounts excluding tax @@ -24,35 +22,22 @@ export function calculateAdjustmentTotal({ 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)) { - adjustmentsSubtotal = MathBN.add( - adjustmentsSubtotal, - MathBN.div(adjustmentAmount, MathBN.add(1, taxRate)) - ) - } else { - adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount) - } + const adjustmentTaxTotal = isDefined(taxRate) + ? MathBN.mult(adjustmentSubtotal, taxRate) + : 0 + const adjustmentTotal = MathBN.add(adjustmentSubtotal, adjustmentTaxTotal) - if (isDefined(taxRate)) { - const adjustmentSubtotal = includesTax - ? MathBN.div(adjustmentAmount, MathBN.add(1, taxRate)) - : adjustmentAmount + adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentSubtotal) + adjustmentsTaxTotal = MathBN.add(adjustmentsTaxTotal, adjustmentTaxTotal) + adjustmentsTotal = MathBN.add(adjustmentsTotal, adjustmentTotal) - const adjustmentTaxTotal = MathBN.mult(adjustmentSubtotal, taxRate) - const adjustmentTotal = MathBN.add(adjustmentSubtotal, adjustmentTaxTotal) - - 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) - } + adj["subtotal"] = new BigNumber(adjustmentsSubtotal) + adj["total"] = new BigNumber(adjustmentsTotal) } return { diff --git a/packages/core/utils/src/totals/line-item/index.ts b/packages/core/utils/src/totals/line-item/index.ts index dbe97099e5..2e1f2ad744 100644 --- a/packages/core/utils/src/totals/line-item/index.ts +++ b/packages/core/utils/src/totals/line-item/index.ts @@ -16,7 +16,7 @@ export interface GetItemTotalInput { quantity: BigNumber is_tax_inclusive?: boolean tax_lines?: Pick[] - adjustments?: Pick[] + adjustments?: Pick[] detail?: { fulfilled_quantity: BigNumber delivered_quantity: BigNumber @@ -133,7 +133,6 @@ function getLineItemTotals( adjustmentsTaxTotal: discountTaxTotal, } = calculateAdjustmentTotal({ adjustments: item.adjustments || [], - includesTax: isTaxInclusive, taxRate: sumTaxRate, }) diff --git a/packages/core/utils/src/totals/promotion/index.ts b/packages/core/utils/src/totals/promotion/index.ts index 8a443cd3fd..2033c53502 100644 --- a/packages/core/utils/src/totals/promotion/index.ts +++ b/packages/core/utils/src/totals/promotion/index.ts @@ -5,23 +5,23 @@ import { } from "../../promotion" import { MathBN } from "../math" -function getPromotionValueForPercentage(promotion, lineItemTotal) { - return MathBN.mult(MathBN.div(promotion.value, 100), lineItemTotal) +function getPromotionValueForPercentage(promotion, lineItemAmount) { + return MathBN.mult(MathBN.div(promotion.value, 100), lineItemAmount) } -function getPromotionValueForFixed(promotion, itemTotal, allItemsTotal) { +function getPromotionValueForFixed(promotion, lineItemAmount, lineItemsAmount) { if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { const promotionValueForItem = MathBN.mult( - MathBN.div(itemTotal, allItemsTotal), + MathBN.div(lineItemAmount, lineItemsAmount), promotion.value ) - if (MathBN.lte(promotionValueForItem, itemTotal)) { + if (MathBN.lte(promotionValueForItem, lineItemAmount)) { return promotionValueForItem } const percentage = MathBN.div( - MathBN.mult(itemTotal, 100), + MathBN.mult(lineItemAmount, 100), promotionValueForItem ) @@ -30,16 +30,15 @@ function getPromotionValueForFixed(promotion, itemTotal, allItemsTotal) { MathBN.div(percentage, 100) ).precision(4) } - return promotion.value } -export function getPromotionValue(promotion, lineItemTotal, lineItemsTotal) { +export function getPromotionValue(promotion, lineItemAmount, lineItemsAmount) { 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) { @@ -50,14 +49,18 @@ export function getApplicableQuantity(lineItem, maxQuantity) { return lineItem.quantity } -function getLineItemUnitPrice(lineItem) { +function getLineItemSubtotal(lineItem) { return MathBN.div(lineItem.subtotal, lineItem.quantity) } +function getLineItemTotal(lineItem) { + return MathBN.div(lineItem.total, lineItem.quantity) +} + export function calculateAdjustmentAmountFromPromotion( lineItem, 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. @@ -89,20 +92,26 @@ export function calculateAdjustmentAmountFromPromotion( */ if (promotion.allocation === ApplicationMethodAllocation.ACROSS) { 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)) { - return applicableTotal + const lineItemAmount = MathBN.mult( + 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( promotion, - applicableTotal, - lineItemsTotal + applicableAmount, + lineItemsAmount ) - return MathBN.min(promotionValue, applicableTotal) + return MathBN.min(promotionValue, applicableAmount) } /* @@ -124,26 +133,32 @@ export function calculateAdjustmentAmountFromPromotion( We then apply whichever is lower. */ - const remainingItemTotal = MathBN.sub( - lineItem.subtotal, + const remainingItemAmount = MathBN.sub( + promotion.is_tax_inclusive ? lineItem.total : lineItem.subtotal, promotion.applied_value ) - const unitPrice = MathBN.div(lineItem.subtotal, lineItem.quantity) - const maximumPromotionTotal = MathBN.mult( - unitPrice, + const itemAmount = MathBN.div( + promotion.is_tax_inclusive ? lineItem.total : lineItem.subtotal, + lineItem.quantity + ) + const maximumPromotionAmount = MathBN.mult( + itemAmount, 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) } const promotionValue = getPromotionValue( promotion, - applicableTotal, - lineItemsTotal + applicableAmount, + lineItemsAmount ) - return MathBN.min(promotionValue, applicableTotal) + return MathBN.min(promotionValue, applicableAmount) } diff --git a/packages/core/utils/src/totals/shipping-method/index.ts b/packages/core/utils/src/totals/shipping-method/index.ts index 8736eb9267..1c7c68adce 100644 --- a/packages/core/utils/src/totals/shipping-method/index.ts +++ b/packages/core/utils/src/totals/shipping-method/index.ts @@ -72,7 +72,6 @@ export function getShippingMethodTotals( adjustmentsTaxTotal: discountsTaxTotal, } = calculateAdjustmentTotal({ adjustments: shippingMethod.adjustments || [], - includesTax: isTaxInclusive, taxRate: sumTaxRate, }) diff --git a/packages/medusa/src/api/store/carts/query-config.ts b/packages/medusa/src/api/store/carts/query-config.ts index 5dadbb7bca..4b5ca64f66 100644 --- a/packages/medusa/src/api/store/carts/query-config.ts +++ b/packages/medusa/src/api/store/carts/query-config.ts @@ -35,6 +35,7 @@ export const defaultStoreCartFields = [ "promotions.id", "promotions.code", "promotions.is_automatic", + "promotions.is_tax_inclusive", "promotions.application_method.value", "promotions.application_method.type", "promotions.application_method.currency_code", @@ -77,6 +78,7 @@ export const defaultStoreCartFields = [ "items.adjustments.code", "items.adjustments.promotion_id", "items.adjustments.amount", + "items.adjustments.is_tax_inclusive", "customer.id", "customer.email", "customer.groups.id", diff --git a/packages/modules/promotion/src/utils/compute-actions/line-items.ts b/packages/modules/promotion/src/utils/compute-actions/line-items.ts index 9480d780a1..e2f270fcc9 100644 --- a/packages/modules/promotion/src/utils/compute-actions/line-items.ts +++ b/packages/modules/promotion/src/utils/compute-actions/line-items.ts @@ -103,24 +103,29 @@ function applyPromotionToItems( ? 1 : applicationMethod?.max_quantity! - let lineItemsTotal = MathBN.convert(0) + let lineItemsAmount = MathBN.convert(0) if (allocation === ApplicationMethodAllocation.ACROSS) { - lineItemsTotal = applicableItems.reduce( + lineItemsAmount = applicableItems.reduce( (acc, item) => MathBN.sub( - MathBN.add(acc, item.subtotal), + MathBN.add( + acc, + promotion.is_tax_inclusive ? item.total : item.subtotal + ), appliedPromotionsMap.get(item.id) ?? 0 ), MathBN.convert(0) ) - if (MathBN.lte(lineItemsTotal, 0)) { + if (MathBN.lte(lineItemsAmount, 0)) { return computedActions } } for (const item of applicableItems) { - if (MathBN.lte(item.subtotal, 0)) { + if ( + MathBN.lte(promotion.is_tax_inclusive ? item.total : item.subtotal, 0) + ) { continue } @@ -135,11 +140,12 @@ function applyPromotionToItems( { value: promotionValue, applied_value: appliedPromoValue, + is_tax_inclusive: promotion.is_tax_inclusive, max_quantity: maxQuantity, type: applicationMethod?.type!, allocation, }, - lineItemsTotal + lineItemsAmount ) if (MathBN.lte(amount, 0)) {