From a7700f116f269929526959f43fd35e6dc94d0aba Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Sun, 6 Jul 2025 22:32:17 +0200 Subject: [PATCH] fix(order, core-flows): Tax inclusive order line item adjustments (#12875) * fix(order, core-flows): Tax inclusive order line item adjustments * fix test --- .../promotions/admin/promotions.spec.ts | 384 ++++++++++++++++-- .../modules/__tests__/order/order.spec.ts | 3 +- .../src/cart/utils/prepare-line-item-data.ts | 3 +- packages/core/types/src/order/mutations.ts | 5 + .../migrations/.snapshot-medusa-order.json | 10 + .../src/migrations/Migration20250702095353.ts | 13 + .../order/src/models/line-item-adjustment.ts | 1 + 7 files changed, 391 insertions(+), 28 deletions(-) create mode 100644 packages/modules/order/src/migrations/Migration20250702095353.ts diff --git a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts index 289076342e..b00f113e42 100644 --- a/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts +++ b/integration-tests/http/__tests__/promotions/admin/promotions.spec.ts @@ -5,8 +5,8 @@ import { generatePublishableKey, generateStoreHeaders, } from "../../../../helpers/create-admin-user" -import { medusaTshirtProduct } from "../../../__fixtures__/product" import { setupTaxStructure } from "../../../../modules/__tests__/fixtures/tax" +import { medusaTshirtProduct } from "../../../__fixtures__/product" jest.setTimeout(50000) @@ -668,14 +668,7 @@ medusaIntegrationTestRunner({ ).data.region const product = ( - await api.post( - "/admin/products", - { - ...medusaTshirtProduct, - shipping_profile_id: shippingProfile.id, - }, - adminHeaders - ) + await api.post("/admin/products", medusaTshirtProduct, adminHeaders) ).data.product const response = await api.post( @@ -791,6 +784,93 @@ medusaIntegrationTestRunner({ expect.objectContaining({ amount: 100, is_tax_inclusive: true, + provider_id: null, + code: "FIXED_10", + }), + ]), + }), + ]), + }) + ) + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { cart_id: cart.id }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + const order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + /** + * 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(order).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, + provider_id: null, + code: "FIXED_10", }), ]), }), @@ -875,7 +955,6 @@ medusaIntegrationTestRunner({ ], }, ], - shipping_profile_id: shippingProfile.id, }, adminHeaders ) @@ -1028,6 +1107,111 @@ medusaIntegrationTestRunner({ ]), }) ) + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { cart_id: cart.id }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + const order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + /** + * 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(order).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 () => { @@ -1065,14 +1249,7 @@ medusaIntegrationTestRunner({ ).data.region const product = ( - await api.post( - "/admin/products", - { - ...medusaTshirtProduct, - shipping_profile_id: shippingProfile.id, - }, - adminHeaders - ) + await api.post("/admin/products", medusaTshirtProduct, adminHeaders) ).data.product const response = await api.post( @@ -1191,6 +1368,87 @@ medusaIntegrationTestRunner({ ]), }) ) + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { cart_id: cart.id }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + const order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + /** + * Orignal total -> 1300 DKK (tax incl.) + * Tax rate -> 25% + * Promotion -> FIXED 100 DKK (tax exclusive !) + */ + expect(order).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 () => { @@ -1228,14 +1486,7 @@ medusaIntegrationTestRunner({ ).data.region const product = ( - await api.post( - "/admin/products", - { - ...medusaTshirtProduct, - shipping_profile_id: shippingProfile.id, - }, - adminHeaders - ) + await api.post("/admin/products", medusaTshirtProduct, adminHeaders) ).data.product const response = await api.post( @@ -1354,6 +1605,87 @@ medusaIntegrationTestRunner({ ]), }) ) + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { cart_id: cart.id }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + const order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + /** + * Orignal total -> 1300 DKK (tax excl.) + * Tax rate -> 25% + * Promotion -> FIXED 100 DKK (tax exclusive !) + */ + expect(order).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, + }), + ]), + }), + ]), + }) + ) }) }) diff --git a/integration-tests/modules/__tests__/order/order.spec.ts b/integration-tests/modules/__tests__/order/order.spec.ts index 5f793a943e..45641e63f1 100644 --- a/integration-tests/modules/__tests__/order/order.spec.ts +++ b/integration-tests/modules/__tests__/order/order.spec.ts @@ -1,6 +1,6 @@ +import { createOrderChangeWorkflow } from "@medusajs/core-flows" import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { IOrderModuleService, OrderDTO } from "@medusajs/types" -import { createOrderChangeWorkflow } from "@medusajs/core-flows" import { Modules } from "@medusajs/utils" import { adminHeaders, @@ -192,6 +192,7 @@ medusaIntegrationTestRunner({ description: "VIP discount", promotion_id: expect.any(String), code: "VIP_25 ETH", + is_tax_inclusive: false, raw_amount: { value: "5e-18", precision: 20, diff --git a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts index 1d40993b81..a553ebbfd1 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts @@ -195,6 +195,7 @@ export function prepareAdjustmentsData(data: CreateOrderAdjustmentDTO[]) { amount: d.amount, description: d.description, promotion_id: d.promotion_id, - provider_id: d.promotion_id, + provider_id: d.provider_id, + is_tax_inclusive: d.is_tax_inclusive })) } diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index c44d5a4b7e..09db8d40cf 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -296,6 +296,11 @@ export interface CreateOrderAdjustmentDTO { * The associated provider's ID. */ provider_id?: string + + /** + * Whether the adjustment is tax inclusive. + */ + is_tax_inclusive?: boolean } /** diff --git a/packages/modules/order/src/migrations/.snapshot-medusa-order.json b/packages/modules/order/src/migrations/.snapshot-medusa-order.json index 2e256e0c3d..839fbd2f0f 100644 --- a/packages/modules/order/src/migrations/.snapshot-medusa-order.json +++ b/packages/modules/order/src/migrations/.snapshot-medusa-order.json @@ -2085,6 +2085,16 @@ "nullable": true, "mappedType": "text" }, + "is_tax_inclusive": { + "name": "is_tax_inclusive", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, "item_id": { "name": "item_id", "type": "text", diff --git a/packages/modules/order/src/migrations/Migration20250702095353.ts b/packages/modules/order/src/migrations/Migration20250702095353.ts new file mode 100644 index 0000000000..24a4746a63 --- /dev/null +++ b/packages/modules/order/src/migrations/Migration20250702095353.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250702095353 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "order_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 "order_line_item_adjustment" drop column if exists "is_tax_inclusive";`); + } + +} diff --git a/packages/modules/order/src/models/line-item-adjustment.ts b/packages/modules/order/src/models/line-item-adjustment.ts index 8808b9c193..832ac619b1 100644 --- a/packages/modules/order/src/models/line-item-adjustment.ts +++ b/packages/modules/order/src/models/line-item-adjustment.ts @@ -9,6 +9,7 @@ const _OrderLineItemAdjustment = model code: model.text().nullable(), amount: model.bigNumber(), provider_id: model.text().nullable(), + is_tax_inclusive: model.boolean().default(false), item: model.belongsTo<() => typeof OrderLineItem>(() => OrderLineItem, { mappedBy: "adjustments", }),