From 2621f00bb035a6b909f9498a2bc98fdba8570ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:07:11 +0200 Subject: [PATCH] feat(promotion, dashboard, core-flows, cart, types, utils, medusa): tax inclusive promotions (#12412) * feat: tax inclusive promotions * feat: add a totals test case * feat: add integration test * chore: changeset * fix: typo * chore: refactor * fix: tests * fix: rest of buyget action tests * fix: cart spec * chore: expand integration test with item level totals * feat: add a few more test cases --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/metal-kangaroos-push.md | 11 + .../promotions/admin/promotions.spec.ts | 728 +++++++++++++++++- .../src/i18n/translations/$schema.json | 18 + .../dashboard/src/i18n/translations/en.json | 5 + .../create-promotion-form.tsx | 47 ++ .../create-promotion-form/form-schema.ts | 1 + .../create-promotion-form/templates.ts | 6 +- .../promotion-general-section.tsx | 12 + ...pare-adjustments-from-promotion-actions.ts | 1 + packages/core/types/src/cart/common.ts | 5 + packages/core/types/src/cart/mutations.ts | 5 + .../src/http/promotion/admin/payloads.ts | 32 +- .../core/types/src/http/promotion/common.ts | 11 +- .../src/promotion/common/compute-actions.ts | 6 + .../types/src/promotion/common/promotion.ts | 10 + .../core/utils/src/totals/__tests__/totals.ts | 90 +++ .../core/utils/src/totals/adjustment/index.ts | 12 +- packages/core/utils/src/totals/cart/index.ts | 2 +- .../src/api/admin/promotions/query-config.ts | 1 + .../src/api/admin/promotions/validators.ts | 1 + .../services/cart-module/index.spec.ts | 2 + .../src/migrations/.snapshot-medusa-cart.json | 10 + .../src/migrations/Migration20250508081553.ts | 13 + .../cart/src/models/line-item-adjustment.ts | 1 + .../promotion-module/compute-actions.spec.ts | 58 ++ .../.snapshot-medusa-promotion.json | 10 + .../src/migrations/Migration20250508081510.ts | 15 + .../modules/promotion/src/models/promotion.ts | 1 + .../src/utils/compute-actions/line-items.ts | 1 + 29 files changed, 1091 insertions(+), 24 deletions(-) create mode 100644 .changeset/metal-kangaroos-push.md create mode 100644 packages/modules/cart/src/migrations/Migration20250508081553.ts create mode 100644 packages/modules/promotion/src/migrations/Migration20250508081510.ts diff --git a/.changeset/metal-kangaroos-push.md b/.changeset/metal-kangaroos-push.md new file mode 100644 index 0000000000..1603b6f7c5 --- /dev/null +++ b/.changeset/metal-kangaroos-push.md @@ -0,0 +1,11 @@ +--- +"@medusajs/promotion": patch +"@medusajs/dashboard": patch +"@medusajs/core-flows": patch +"@medusajs/cart": patch +"@medusajs/types": patch +"@medusajs/utils": patch +"@medusajs/medusa": patch +--- + +feat(promotion, dashboard, core-flows, cart, types, utils, medusa): tax inclusive promotions diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts index 0f7ab1a52b..289076342e 100644 --- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts +++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts @@ -1,11 +1,12 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" -import { PromotionStatus, PromotionType } from "@medusajs/utils" +import { Modules, PromotionStatus, PromotionType } from "@medusajs/utils" import { createAdminUser, generatePublishableKey, generateStoreHeaders, } from "../../../../helpers/create-admin-user" import { medusaTshirtProduct } from "../../../__fixtures__/product" +import { setupTaxStructure } from "../../../../modules/__tests__/fixtures/tax" jest.setTimeout(50000) @@ -71,6 +72,8 @@ medusaIntegrationTestRunner({ beforeEach(async () => { await createAdminUser(dbConnection, adminHeaders, appContainer) + await setupTaxStructure(appContainer.resolve(Modules.TAX)) + promotion = standardPromotion = ( await api.post( `/admin/promotions`, @@ -629,6 +632,729 @@ medusaIntegrationTestRunner({ ) }) }) + + it("should add tax inclusive promotion to cart successfully in a tax inclusive currency", async () => { + const publishableKey = await generatePublishableKey(appContainer) + const storeHeaders = generateStoreHeaders({ publishableKey }) + + const salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "dkk", + is_tax_inclusive: true, + }, + adminHeaders + ) + + const region = ( + await api.post( + "/admin/regions", + { + name: "DK", + currency_code: "dkk", + countries: ["dk"], + }, + adminHeaders + ) + ).data.region + + const product = ( + await api.post( + "/admin/products", + { + ...medusaTshirtProduct, + shipping_profile_id: shippingProfile.id, + }, + adminHeaders + ) + ).data.product + + const response = await api.post( + `/admin/promotions`, + { + code: "FIXED_10", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: true, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "across", + currency_code: "DKK", + value: 100, + }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: expect.any(String), + code: "FIXED_10", + type: "standard", + is_tax_inclusive: true, + is_automatic: true, + application_method: expect.objectContaining({ + value: 100, + type: "fixed", + target_type: "items", + allocation: "across", + }), + }) + ) + + const cart = ( + await api.post( + `/store/carts?fields=*items,*items.adjustments`, + { + currency_code: "dkk", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + promo_codes: [response.data.promotion.code], + }, + storeHeaders + ) + ).data.cart + + /** + * Orignal total -> 1300 DKK (tax incl.) + * Tax rate -> 25% + * Promotion -> FIXED 100 DKK (tax incl.) + * + * We want total to be 1300 DKK - 100 DKK = 1200 DKK + */ + expect(cart).toEqual( + expect.objectContaining({ + currency_code: "dkk", + + subtotal: 1040, // taxable base (item subtotal - discount subtotal) = 1040 - 80 = 960 + total: 1200, // total = taxable base * (1 + tax rate) = 960 * (1 + 0.25) = 1200 + tax_total: 240, + + original_total: 1300, + original_tax_total: 260, + + discount_total: 100, + discount_subtotal: 80, + discount_tax_total: 20, + + item_total: 1200, + item_subtotal: 1040, + item_tax_total: 240, + + original_item_total: 1300, + original_item_subtotal: 1040, + original_item_tax_total: 260, + + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + + items: expect.arrayContaining([ + expect.objectContaining({ + quantity: 1, + unit_price: 1300, + + subtotal: 1040, + tax_total: 240, + total: 1200, + + original_total: 1300, + original_tax_total: 260, + + discount_total: 100, + discount_subtotal: 80, + discount_tax_total: 20, + + adjustments: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + is_tax_inclusive: true, + }), + ]), + }), + ]), + }) + ) + }) + + it("should add tax inclusive promotion to cart successfully in a tax inclusive currency with 2 items and each allocation", async () => { + const publishableKey = await generatePublishableKey(appContainer) + const storeHeaders = generateStoreHeaders({ publishableKey }) + + const salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "dkk", + is_tax_inclusive: true, + }, + adminHeaders + ) + + const region = ( + await api.post( + "/admin/regions", + { + name: "DK", + currency_code: "dkk", + countries: ["dk"], + }, + adminHeaders + ) + ).data.region + + const product = ( + await api.post( + "/admin/products", + { + title: "Discounted Medusa T-Shirt", + handle: "discounted-medusa-t-shirt", + options: [ + { + title: "Size", + values: ["S", "M"], + }, + ], + variants: [ + { + title: "S", + sku: "SHIRT-S", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 1000, + currency_code: "dkk", + }, + ], + }, + { + title: "M", + sku: "SHIRT-M", + options: { + Size: "S", + }, + manage_inventory: false, + prices: [ + { + amount: 500, + currency_code: "dkk", + }, + ], + }, + ], + shipping_profile_id: shippingProfile.id, + }, + adminHeaders + ) + ).data.product + + const response = await api.post( + `/admin/promotions`, + { + code: "FIXED_10", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: true, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + currency_code: "DKK", + value: 100, + max_quantity: 2, + }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: expect.any(String), + code: "FIXED_10", + type: "standard", + is_tax_inclusive: true, + is_automatic: true, + application_method: expect.objectContaining({ + value: 100, + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 2, + }), + }) + ) + + const cart = ( + await api.post( + `/store/carts?fields=*items,*items.adjustments`, + { + currency_code: "dkk", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + { + variant_id: product.variants[1].id, + quantity: 1, + }, + ], + promo_codes: [response.data.promotion.code], + }, + storeHeaders + ) + ).data.cart + + /** + * Orignal total -> 1500 DKK (tax incl.) + * Promotion -> FIXED 100 DKK per item (tax incl.) + * Tax rate -> 25% + * + * We want total to be 1500 DKK - 100 DKK - 100 DKK = 1300 DKK + */ + expect(cart).toEqual( + expect.objectContaining({ + currency_code: "dkk", + + total: 1300, + subtotal: 1200, // taxable base (item subtotal - discount subtotal) = 1200 - 200 = 1000 + tax_total: 260, + + discount_total: 200, // 2 * 100 DKK fixed tax inclusive + discount_subtotal: 160, + discount_tax_total: 40, + + original_total: 1500, + original_tax_total: 300, + + item_total: 1300, + item_subtotal: 1200, + item_tax_total: 260, + + original_item_total: 1500, + original_item_subtotal: 1200, + original_item_tax_total: 300, + + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + + items: expect.arrayContaining([ + expect.objectContaining({ + quantity: 1, + unit_price: 500, + + subtotal: 400, + total: 400, // 400 - 80 = 320 -> 320 * 1.25 = 400 + tax_total: 80, + + original_total: 500, + original_tax_total: 100, + + discount_total: 100, + discount_subtotal: 80, + discount_tax_total: 20, + + adjustments: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + is_tax_inclusive: true, + }), + ]), + }), + expect.objectContaining({ + quantity: 1, + unit_price: 1000, + + subtotal: 800, // 800 - 80 = 720 -> 720 * 1.25 = 900 + total: 900, + tax_total: 180, + + original_total: 1000, + original_tax_total: 200, + + discount_total: 100, + discount_subtotal: 80, + discount_tax_total: 20, + + adjustments: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + is_tax_inclusive: true, + }), + ]), + }), + ]), + }) + ) + }) + + it("should add tax exclusive promotion to cart successfully for tax inclusive currency", async () => { + const publishableKey = await generatePublishableKey(appContainer) + const storeHeaders = generateStoreHeaders({ publishableKey }) + + const salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "dkk", + is_tax_inclusive: true, + }, + adminHeaders + ) + + const region = ( + await api.post( + "/admin/regions", + { + name: "DK", + currency_code: "dkk", + countries: ["dk"], + }, + adminHeaders + ) + ).data.region + + const product = ( + await api.post( + "/admin/products", + { + ...medusaTshirtProduct, + shipping_profile_id: shippingProfile.id, + }, + adminHeaders + ) + ).data.product + + const response = await api.post( + `/admin/promotions`, + { + code: "FIXED_10", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "across", + currency_code: "DKK", + value: 100, + }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: expect.any(String), + code: "FIXED_10", + type: "standard", + is_tax_inclusive: false, // tax exclusive by default + is_automatic: true, + application_method: expect.objectContaining({ + value: 100, + type: "fixed", + target_type: "items", + allocation: "across", + }), + }) + ) + + const cart = ( + await api.post( + `/store/carts?fields=*items,*items.adjustments`, + { + currency_code: "dkk", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + promo_codes: [response.data.promotion.code], + }, + storeHeaders + ) + ).data.cart + + /** + * Orignal total -> 1300 DKK (tax incl.) + * Tax rate -> 25% + * Promotion -> FIXED 100 DKK (tax exclusive !) + */ + expect(cart).toEqual( + expect.objectContaining({ + currency_code: "dkk", + + subtotal: 1040, // taxable base (item subtotal - discount subtotal) = 1040 - 100 = 940 + total: 1175, // total = taxable base * (1 + tax rate) = 940 * (1 + 0.25) = 1175 + tax_total: 235, + + original_total: 1300, + original_tax_total: 260, + + discount_total: 100, + discount_subtotal: 100, + discount_tax_total: 20, + + item_total: 1175, + item_subtotal: 1040, + item_tax_total: 235, + + original_item_total: 1300, + original_item_subtotal: 1040, + original_item_tax_total: 260, + + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + + items: expect.arrayContaining([ + expect.objectContaining({ + quantity: 1, + unit_price: 1300, + + subtotal: 1040, + tax_total: 235, + total: 1175, + + original_total: 1300, + original_tax_total: 260, + + discount_total: 100, + discount_subtotal: 100, + discount_tax_total: 20, + + adjustments: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + is_tax_inclusive: false, + }), + ]), + }), + ]), + }) + ) + }) + + it("should add tax exclusive promotion to cart successfully for tax exclusive currency", async () => { + const publishableKey = await generatePublishableKey(appContainer) + const storeHeaders = generateStoreHeaders({ publishableKey }) + + const salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "dkk", + is_tax_inclusive: false, + }, + adminHeaders + ) + + const region = ( + await api.post( + "/admin/regions", + { + name: "DK", + currency_code: "dkk", + countries: ["dk"], + }, + adminHeaders + ) + ).data.region + + const product = ( + await api.post( + "/admin/products", + { + ...medusaTshirtProduct, + shipping_profile_id: shippingProfile.id, + }, + adminHeaders + ) + ).data.product + + const response = await api.post( + `/admin/promotions`, + { + code: "FIXED_10", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "across", + currency_code: "DKK", + value: 100, + }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: expect.any(String), + code: "FIXED_10", + type: "standard", + is_tax_inclusive: false, // tax exclusive by default + is_automatic: true, + application_method: expect.objectContaining({ + value: 100, + type: "fixed", + target_type: "items", + allocation: "across", + }), + }) + ) + + const cart = ( + await api.post( + `/store/carts?fields=*items,*items.adjustments`, + { + currency_code: "dkk", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + promo_codes: [response.data.promotion.code], + }, + storeHeaders + ) + ).data.cart + + /** + * Orignal total -> 1300 DKK (tax excl.) + * Tax rate -> 25% + * Promotion -> FIXED 100 DKK (tax exclusive !) + */ + expect(cart).toEqual( + expect.objectContaining({ + currency_code: "dkk", + + subtotal: 1300, // taxable base (item subtotal - discount subtotal) = 1300 - 100 = 1200 + total: 1500, // total = taxable base * (1 + tax rate) = 1200 * (1 + 0.25) = 1500 + tax_total: 300, + + original_total: 1625, + original_tax_total: 325, + + discount_total: 125, + discount_subtotal: 100, + discount_tax_total: 25, + + item_total: 1500, + item_subtotal: 1300, + item_tax_total: 300, + + original_item_total: 1625, + original_item_subtotal: 1300, + original_item_tax_total: 325, + + shipping_total: 0, + shipping_subtotal: 0, + shipping_tax_total: 0, + + original_shipping_tax_total: 0, + original_shipping_subtotal: 0, + original_shipping_total: 0, + + items: expect.arrayContaining([ + expect.objectContaining({ + quantity: 1, + unit_price: 1300, + + subtotal: 1300, + total: 1500, + tax_total: 300, + + discount_total: 125, + discount_subtotal: 100, + discount_tax_total: 25, + + original_total: 1625, + original_tax_total: 325, + + adjustments: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + is_tax_inclusive: false, + }), + ]), + }), + ]), + }) + ) + }) }) describe("DELETE /admin/promotions/:id", () => { diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index a65eb0ee97..a035252d62 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -7362,6 +7362,9 @@ "clearAll": { "type": "string" }, + "taxInclusive": { + "type": "string" + }, "amount": { "type": "object", "properties": { @@ -7428,6 +7431,7 @@ "allocation", "addCondition", "clearAll", + "taxInclusive", "amount", "conditions" ], @@ -7626,6 +7630,19 @@ "required": ["existing", "new", "none"], "additionalProperties": false }, + "taxInclusive": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["title", "description"], + "additionalProperties": false + }, "status": { "type": "object", "properties": { @@ -7852,6 +7869,7 @@ "and", "selectAttribute", "campaign", + "taxInclusive", "status", "method", "max_quantity", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index ff86747a88..cd8993825c 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1969,6 +1969,7 @@ "allocation": "Allocation", "addCondition": "Add condition", "clearAll": "Clear all", + "taxInclusive": "Tax Inclusive", "amount": { "tooltip": "Select the currency code to enable setting the amount" }, @@ -2045,6 +2046,10 @@ "description": "Proceed without associating promotion with campaign" } }, + "taxInclusive": { + "title": "Does promotion include taxes?", + "description": "Whether the promotion will be applied before or after taxes" + }, "status": { "label": "Status", "draft": { diff --git a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx index 9401703e90..26722e5c2a 100644 --- a/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx +++ b/packages/admin/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx @@ -19,6 +19,7 @@ import { ProgressStatus, ProgressTabs, RadioGroup, + Switch, Text, toast, } from "@medusajs/ui" @@ -52,6 +53,7 @@ const defaultValues = { type: "standard" as PromotionTypeValues, status: "draft" as PromotionStatusValues, rules: [], + is_tax_inclusive: false, application_method: { allocation: "each" as ApplicationMethodAllocationValues, type: "fixed" as ApplicationMethodTypeValues, @@ -89,6 +91,7 @@ export const CreatePromotionForm = () => { const { campaign_choice: _campaignChoice, is_automatic, + is_tax_inclusive, template_id: _templateId, application_method, rules, @@ -142,6 +145,7 @@ export const CreatePromotionForm = () => { target_rules: buildRulesData(targetRulesData), buy_rules: buildRulesData(buyRulesData), }, + is_tax_inclusive, is_automatic: is_automatic === "true", }, { @@ -583,6 +587,49 @@ export const CreatePromotionForm = () => { /> + {!currentTemplate?.hiddenFields?.includes( + "is_tax_inclusive" + ) && ( + <> + +
+ { + return ( + +
+
+ + {t("promotions.form.taxInclusive.title")} + + + {t( + "promotions.form.taxInclusive.description" + )} + +
+ + + +
+ +
+ ) + }} + /> +
+ + )} + {!currentTemplate?.hiddenFields?.includes("type") && ( + +
+ + {t("promotions.fields.taxInclusive")} + + +
+ + {promotion.is_tax_inclusive ? t("fields.true") : t("fields.false")} + +
+
) } diff --git a/packages/core/core-flows/src/cart/steps/prepare-adjustments-from-promotion-actions.ts b/packages/core/core-flows/src/cart/steps/prepare-adjustments-from-promotion-actions.ts index a4832c2a38..17119a9ab5 100644 --- a/packages/core/core-flows/src/cart/steps/prepare-adjustments-from-promotion-actions.ts +++ b/packages/core/core-flows/src/cart/steps/prepare-adjustments-from-promotion-actions.ts @@ -132,6 +132,7 @@ export const prepareAdjustmentsFromPromotionActionsStep = createStep( .map((action) => ({ code: action.code, amount: (action as AddItemAdjustmentAction).amount, + is_tax_inclusive: (action as AddItemAdjustmentAction).is_tax_inclusive, item_id: (action as AddItemAdjustmentAction).item_id, promotion_id: promotionsMap.get(action.code)?.id, })) diff --git a/packages/core/types/src/cart/common.ts b/packages/core/types/src/cart/common.ts index b13f59c07f..5015da7d90 100644 --- a/packages/core/types/src/cart/common.ts +++ b/packages/core/types/src/cart/common.ts @@ -22,6 +22,11 @@ export interface AdjustmentLineDTO { */ amount: BigNumberValue + /** + * Whether the adjustment is tax inclusive. + */ + is_tax_inclusive?: boolean + /** * The raw amount to adjust the original amount with. */ diff --git a/packages/core/types/src/cart/mutations.ts b/packages/core/types/src/cart/mutations.ts index fa76f681ca..5cc2467e33 100644 --- a/packages/core/types/src/cart/mutations.ts +++ b/packages/core/types/src/cart/mutations.ts @@ -236,6 +236,11 @@ export interface CreateAdjustmentDTO { */ amount: BigNumberInput + /** + * Whether the adjustment amount includes tax. + */ + is_tax_inclusive?: boolean + /** * The description of the adjustment. */ diff --git a/packages/core/types/src/http/promotion/admin/payloads.ts b/packages/core/types/src/http/promotion/admin/payloads.ts index 954d9b40ca..46e442af54 100644 --- a/packages/core/types/src/http/promotion/admin/payloads.ts +++ b/packages/core/types/src/http/promotion/admin/payloads.ts @@ -10,8 +10,8 @@ import { AdminCreateCampaign } from "../../campaign" export interface AdminCreatePromotionRule { /** - * The operator used to check whether the buy rule applies on a cart. - * For example, `eq` means that the cart's value for the specified attribute + * The operator used to check whether the buy rule applies on a cart. + * For example, `eq` means that the cart's value for the specified attribute * must match the specified value. */ operator: PromotionRuleOperatorValues @@ -21,14 +21,14 @@ export interface AdminCreatePromotionRule { description?: string | null /** * The attribute to compare against when checking whether a promotion can be applied on a cart. - * + * * @example * items.product_id */ attribute: string /** * The value to compare against when checking whether a promotion can be applied on a cart. - * + * * @example * prod_123 */ @@ -54,7 +54,7 @@ export interface AdminCreateApplicationMethod { value: number /** * The currency code of the application method. - * + * * @example * usd */ @@ -68,12 +68,12 @@ export interface AdminCreateApplicationMethod { */ type: ApplicationMethodTypeValues /** - * The target type of the application method indicating whether the associated promotion is applied + * The target type of the application method indicating whether the associated promotion is applied * to the cart's items, shipping methods, or the whole order. */ target_type: ApplicationMethodTargetTypeValues /** - * The allocation value that indicates whether the associated promotion is applied on each + * The allocation value that indicates whether the associated promotion is applied on each * item in a cart or split between the items in the cart. */ allocation?: ApplicationMethodAllocationValues @@ -90,7 +90,7 @@ export interface AdminCreateApplicationMethod { */ apply_to_quantity?: number | null /** - * The minimum quantity required for a `buyget` promotion to be applied. For example, + * The minimum quantity required for a `buyget` promotion to be applied. For example, * if the promotion is a "Buy 2 shirts get 1 free", the value of this attribute is 2. */ buy_rules_min_quantity?: number | null @@ -111,7 +111,7 @@ export interface AdminUpdateApplicationMethod { max_quantity?: number | null /** * The currency code of the application method. - * + * * @example * usd */ @@ -121,12 +121,12 @@ export interface AdminUpdateApplicationMethod { */ type?: ApplicationMethodTypeValues /** - * The target type of the application method indicating whether the associated promotion is applied + * The target type of the application method indicating whether the associated promotion is applied * to the cart's items, shipping methods, or the whole order. */ target_type?: ApplicationMethodTargetTypeValues /** - * The allocation value that indicates whether the associated promotion is applied on each + * The allocation value that indicates whether the associated promotion is applied on each * item in a cart or split between the items in the cart. */ allocation?: ApplicationMethodAllocationValues @@ -143,7 +143,7 @@ export interface AdminUpdateApplicationMethod { */ apply_to_quantity?: number | null /** - * The minimum quantity required for a `buyget` promotion to be applied. For example, + * The minimum quantity required for a `buyget` promotion to be applied. For example, * if the promotion is a "Buy 2 shirts get 1 free", the value of this attribute is 2. */ buy_rules_min_quantity?: number | null @@ -155,11 +155,15 @@ export interface AdminCreatePromotion { */ code: string /** - * Whether the promotion is applied automatically + * Whether the promotion is applied automatically * or requires the customer to manually apply it * by entering the code at checkout. */ is_automatic?: boolean + /** + * Whether the promotion is tax inclusive. + */ + is_tax_inclusive?: boolean /** * The type of promotion. */ @@ -188,7 +192,7 @@ export interface AdminUpdatePromotion { */ code?: string /** - * Whether the promotion is applied automatically + * Whether the promotion is applied automatically * or requires the customer to manually apply it * by entering the code at checkout. */ diff --git a/packages/core/types/src/http/promotion/common.ts b/packages/core/types/src/http/promotion/common.ts index 6321269ae4..685d420b3c 100644 --- a/packages/core/types/src/http/promotion/common.ts +++ b/packages/core/types/src/http/promotion/common.ts @@ -19,23 +19,23 @@ export interface BasePromotionRule { description?: string | null /** * The attribute to compare against when checking whether a promotion can be applied on a cart. - * + * * @example * items.product_id */ attribute?: string /** - * The operator used to check whether the buy rule applies on a cart. - * For example, `eq` means that the cart's value for the specified attribute + * The operator used to check whether the buy rule applies on a cart. + * For example, `eq` means that the cart's value for the specified attribute * must match the specified value. - * + * * @example * eq */ operator?: PromotionRuleOperatorValues /** * The values to compare against when checking whether a promotion can be applied on a cart. - * + * * @example * prod_123 */ @@ -62,6 +62,7 @@ export interface BasePromotion { code?: string type?: PromotionTypeValues is_automatic?: boolean + is_tax_inclusive?: boolean application_method?: BaseApplicationMethod rules?: BasePromotionRule[] status?: PromotionStatusValues diff --git a/packages/core/types/src/promotion/common/compute-actions.ts b/packages/core/types/src/promotion/common/compute-actions.ts index c49e306d5d..49831a5b52 100644 --- a/packages/core/types/src/promotion/common/compute-actions.ts +++ b/packages/core/types/src/promotion/common/compute-actions.ts @@ -60,6 +60,12 @@ export interface AddItemAdjustmentAction { */ amount: BigNumberInput + /** + * Whether the adjustment amount includes tax. + */ + is_tax_inclusive?: boolean + + /** /** * The promotion's code. */ diff --git a/packages/core/types/src/promotion/common/promotion.ts b/packages/core/types/src/promotion/common/promotion.ts index 32c5c9523c..cb6f63c968 100644 --- a/packages/core/types/src/promotion/common/promotion.ts +++ b/packages/core/types/src/promotion/common/promotion.ts @@ -60,6 +60,11 @@ export interface PromotionDTO { */ is_automatic?: boolean + /** + * Whether the promotion is tax inclusive. + */ + is_tax_inclusive?: boolean + /** * The associated application method. */ @@ -113,6 +118,11 @@ export interface CreatePromotionDTO { */ is_automatic?: boolean + /** + * Whether the promotion is tax inclusive. + */ + is_tax_inclusive?: boolean + /** * The associated application method. */ diff --git a/packages/core/utils/src/totals/__tests__/totals.ts b/packages/core/utils/src/totals/__tests__/totals.ts index 7cbddda32b..443a3a24b1 100644 --- a/packages/core/utils/src/totals/__tests__/totals.ts +++ b/packages/core/utils/src/totals/__tests__/totals.ts @@ -557,6 +557,96 @@ describe("Total calculation", function () { }) }) + it("should calculate tax inclusive carts with items + taxes with tax inclusive adjustments", function () { + /** + * TAX INCLUSIVE CART + * + * Total price -> 120 tax inclusive + * Fixed discount -> 10 tax inclusive + * Tax rate -> 20% + */ + + const cart = { + items: [ + { + unit_price: 60, + quantity: 2, + is_tax_inclusive: true, + adjustments: [ + { + amount: 10, + is_tax_inclusive: true, + }, + ], + tax_lines: [ + { + rate: 20, + }, + ], + }, + ], + } + + const serialized = JSON.parse(JSON.stringify(decorateCartTotals(cart))) + + expect(serialized).toEqual({ + items: [ + { + unit_price: 60, + quantity: 2, + subtotal: 100, + tax_total: 18.333333333333332, + total: 110, + is_tax_inclusive: true, + + original_total: 120, + original_tax_total: 20, + + discount_subtotal: 8.333333333333334, + discount_tax_total: 1.6666666666666667, + discount_total: 10, + + tax_lines: [ + { + rate: 20, + total: 18.333333333333332, + subtotal: 20, + }, + ], + adjustments: [ + { + is_tax_inclusive: true, + amount: 10, // <- amount is tax inclusive so it's equal to total + subtotal: 8.333333333333334, + total: 10, + }, + ], + }, + ], + subtotal: 100, + tax_total: 18.333333333333332, + total: 110, // total is 120 - 10 tax inclusive discount + + original_item_subtotal: 100, + original_item_tax_total: 20, + original_item_total: 120, + original_tax_total: 20, + original_total: 120, + + discount_subtotal: 8.333333333333334, + discount_tax_total: 1.6666666666666667, + discount_total: 10, + + item_subtotal: 100, + item_tax_total: 18.333333333333332, + item_total: 110, + + credit_line_subtotal: 0, + credit_line_tax_total: 0, + credit_line_total: 0, + }) + }) + it("should calculate carts with items + taxes + adjustments + shipping methods", function () { const cart = { items: [ diff --git a/packages/core/utils/src/totals/adjustment/index.ts b/packages/core/utils/src/totals/adjustment/index.ts index 64fde1ae86..860f052e80 100644 --- a/packages/core/utils/src/totals/adjustment/index.ts +++ b/packages/core/utils/src/totals/adjustment/index.ts @@ -8,7 +8,7 @@ export function calculateAdjustmentTotal({ includesTax, taxRate, }: { - adjustments: Pick[] + adjustments: Pick[] includesTax?: boolean taxRate?: BigNumberInput }) { @@ -25,7 +25,15 @@ export function calculateAdjustmentTotal({ } const adjustmentAmount = MathBN.convert(adj.amount) - adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount) + + if (adj.is_tax_inclusive && isDefined(taxRate)) { + adjustmentsSubtotal = MathBN.add( + adjustmentsSubtotal, + MathBN.div(adjustmentAmount, MathBN.add(1, taxRate)) + ) + } else { + adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount) + } if (isDefined(taxRate)) { const adjustmentSubtotal = includesTax diff --git a/packages/core/utils/src/totals/cart/index.ts b/packages/core/utils/src/totals/cart/index.ts index 5db41f2b40..2237141309 100644 --- a/packages/core/utils/src/totals/cart/index.ts +++ b/packages/core/utils/src/totals/cart/index.ts @@ -22,7 +22,7 @@ export interface DecorateCartLikeInputDTO { unit_price: BigNumberInput is_tax_inclusive?: boolean quantity: BigNumberInput - adjustments?: { amount: BigNumberInput }[] + adjustments?: { amount: BigNumberInput; is_tax_inclusive?: boolean }[] tax_lines?: { rate: BigNumberInput }[] diff --git a/packages/medusa/src/api/admin/promotions/query-config.ts b/packages/medusa/src/api/admin/promotions/query-config.ts index babd23b3cc..3223b140ac 100644 --- a/packages/medusa/src/api/admin/promotions/query-config.ts +++ b/packages/medusa/src/api/admin/promotions/query-config.ts @@ -2,6 +2,7 @@ export const defaultAdminPromotionFields = [ "id", "code", "is_automatic", + "is_tax_inclusive", "type", "status", "created_at", diff --git a/packages/medusa/src/api/admin/promotions/validators.ts b/packages/medusa/src/api/admin/promotions/validators.ts index 28057c24b6..869525d4d4 100644 --- a/packages/medusa/src/api/admin/promotions/validators.ts +++ b/packages/medusa/src/api/admin/promotions/validators.ts @@ -163,6 +163,7 @@ export const CreatePromotion = z code: z.string(), is_automatic: z.boolean().optional(), type: z.nativeEnum(PromotionType), + is_tax_inclusive: z.boolean().optional(), status: z.nativeEnum(PromotionStatus).default(PromotionStatus.DRAFT), campaign_id: z.string().nullish(), campaign: CreateCampaign.optional(), diff --git a/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts b/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts index 021be6355e..69988030b9 100644 --- a/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts +++ b/packages/modules/cart/integration-tests/__tests__/services/cart-module/index.spec.ts @@ -2913,6 +2913,7 @@ moduleIntegrationTestRunner({ created_at: expect.any(String), updated_at: expect.any(String), item_id: expect.any(String), + is_tax_inclusive: false, promotion_id: null, deleted_at: null, amount: 100, @@ -3020,6 +3021,7 @@ moduleIntegrationTestRunner({ created_at: expect.any(String), updated_at: expect.any(String), item_id: expect.any(String), + is_tax_inclusive: false, promotion_id: null, deleted_at: null, amount: 200, diff --git a/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json b/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json index b35d4c2175..aa60359243 100644 --- a/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json +++ b/packages/modules/cart/src/migrations/.snapshot-medusa-cart.json @@ -998,6 +998,16 @@ "nullable": false, "mappedType": "decimal" }, + "is_tax_inclusive": { + "name": "is_tax_inclusive", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, "provider_id": { "name": "provider_id", "type": "text", diff --git a/packages/modules/cart/src/migrations/Migration20250508081553.ts b/packages/modules/cart/src/migrations/Migration20250508081553.ts new file mode 100644 index 0000000000..c8e1d3a952 --- /dev/null +++ b/packages/modules/cart/src/migrations/Migration20250508081553.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250508081553 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "cart_line_item_adjustment" add column if not exists "is_tax_inclusive" boolean not null default false;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "cart_line_item_adjustment" drop column if exists "is_tax_inclusive";`); + } + +} diff --git a/packages/modules/cart/src/models/line-item-adjustment.ts b/packages/modules/cart/src/models/line-item-adjustment.ts index 2a1cc8edc2..0a79e5631f 100644 --- a/packages/modules/cart/src/models/line-item-adjustment.ts +++ b/packages/modules/cart/src/models/line-item-adjustment.ts @@ -9,6 +9,7 @@ const LineItemAdjustment = model description: model.text().nullable(), code: model.text().nullable(), amount: model.bigNumber(), + is_tax_inclusive: model.boolean().default(false), provider_id: model.text().nullable(), promotion_id: model.text().nullable(), metadata: model.json().nullable(), diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index e2eb239a08..0c084d2c4b 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -189,12 +189,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 100, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) @@ -326,18 +328,21 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 50, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 30, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -434,12 +439,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -497,6 +504,7 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 500, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -624,12 +632,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 10, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 15, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -726,24 +736,28 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 30, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 45, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 10.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, ]) }) @@ -813,12 +827,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMO_PERCENTAGE_1", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 50, code: "PROMO_PERCENTAGE_2", + is_tax_inclusive: false, }, ]) }) @@ -915,12 +931,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1103,12 +1121,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 100, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 300, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1177,12 +1197,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 100, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 300, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1278,24 +1300,28 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 12.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 37.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 7.5, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 22.5, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1391,12 +1417,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1574,12 +1602,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 20, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 60, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1648,12 +1678,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 20, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 60, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -1748,24 +1780,28 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 5, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 15, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 4.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 13.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, ]) }) @@ -1838,24 +1874,28 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 150, code: "PROMO_PERCENTAGE_1", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_wool_tshirt", amount: 50, code: "PROMO_PERCENTAGE_1", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 75, code: "PROMO_PERCENTAGE_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_wool_tshirt", amount: 25, code: "PROMO_PERCENTAGE_2", + is_tax_inclusive: false, }, ]) @@ -1914,12 +1954,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 300, code: "PROMO_PERCENTAGE_3", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_wool_tshirt", amount: 100, code: "PROMO_PERCENTAGE_3", + is_tax_inclusive: false, }, ]) }) @@ -2014,24 +2056,28 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 5, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 15, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 4.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 13.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, ]) }) @@ -4174,12 +4220,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -4241,12 +4289,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -4329,24 +4379,28 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 12.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 37.5, code: "PROMOTION_TEST_2", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_tshirt", amount: 7.5, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 22.5, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -4429,12 +4483,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 50, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) @@ -4519,12 +4575,14 @@ moduleIntegrationTestRunner({ item_id: "item_cotton_tshirt", amount: 100, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, { action: "addItemAdjustment", item_id: "item_cotton_sweater", amount: 150, code: "PROMOTION_TEST", + is_tax_inclusive: false, }, ]) }) diff --git a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json index c754f0dbbe..3563e14ab0 100644 --- a/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json +++ b/packages/modules/promotion/src/migrations/.snapshot-medusa-promotion.json @@ -332,6 +332,16 @@ "default": "false", "mappedType": "boolean" }, + "is_tax_inclusive": { + "name": "is_tax_inclusive", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, "type": { "name": "type", "type": "text", diff --git a/packages/modules/promotion/src/migrations/Migration20250508081510.ts b/packages/modules/promotion/src/migrations/Migration20250508081510.ts new file mode 100644 index 0000000000..b8f4f71152 --- /dev/null +++ b/packages/modules/promotion/src/migrations/Migration20250508081510.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20250508081510 extends Migration { + override async up(): Promise { + this.addSql( + `alter table if exists "promotion" add column if not exists "is_tax_inclusive" boolean not null default false;` + ) + } + + override async down(): Promise { + this.addSql( + `alter table if exists "promotion" drop column if exists "is_tax_inclusive";` + ) + } +} diff --git a/packages/modules/promotion/src/models/promotion.ts b/packages/modules/promotion/src/models/promotion.ts index 679ddf3db9..de9ed84195 100644 --- a/packages/modules/promotion/src/models/promotion.ts +++ b/packages/modules/promotion/src/models/promotion.ts @@ -8,6 +8,7 @@ const Promotion = model id: model.id({ prefix: "promo" }).primaryKey(), code: model.text().searchable(), is_automatic: model.boolean().default(false), + is_tax_inclusive: model.boolean().default(false), type: model.enum(PromotionUtils.PromotionType).index("IDX_promotion_type"), status: model .enum(PromotionUtils.PromotionStatus) 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 1bb6d96f49..9480d780a1 100644 --- a/packages/modules/promotion/src/utils/compute-actions/line-items.ts +++ b/packages/modules/promotion/src/utils/compute-actions/line-items.ts @@ -164,6 +164,7 @@ function applyPromotionToItems( item_id: item.id, amount, code: promotion.code!, + is_tax_inclusive: promotion.is_tax_inclusive, }) } else if (isTargetShippingMethod) { computedActions.push({