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"
+ ) && (
+ <>
+