Skip to content

Commit 87d003d

Browse files
committed
feat: percentage TIP
1 parent e9a33d0 commit 87d003d

11 files changed

Lines changed: 236 additions & 4 deletions

File tree

integration-tests/http/__tests__/promotions/admin/promotions.spec.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,174 @@ medusaIntegrationTestRunner({
16871687
})
16881688
)
16891689
})
1690+
1691+
it("should add tax inclusive promotion to cart successfully with percentage discount", async () => {
1692+
const publishableKey = await generatePublishableKey(appContainer)
1693+
const storeHeaders = generateStoreHeaders({ publishableKey })
1694+
1695+
const salesChannel = (
1696+
await api.post(
1697+
"/admin/sales-channels",
1698+
{ name: "Webshop", description: "channel" },
1699+
adminHeaders
1700+
)
1701+
).data.sales_channel
1702+
1703+
await api.post(
1704+
"/admin/price-preferences",
1705+
{
1706+
attribute: "currency_code",
1707+
value: "dkk",
1708+
is_tax_inclusive: true,
1709+
},
1710+
adminHeaders
1711+
)
1712+
1713+
const region = (
1714+
await api.post(
1715+
"/admin/regions",
1716+
{
1717+
name: "DK",
1718+
currency_code: "dkk",
1719+
countries: ["dk"],
1720+
},
1721+
adminHeaders
1722+
)
1723+
).data.region
1724+
1725+
const product = (
1726+
await api.post(
1727+
"/admin/products",
1728+
{
1729+
...medusaTshirtProduct,
1730+
shipping_profile_id: shippingProfile.id,
1731+
},
1732+
adminHeaders
1733+
)
1734+
).data.product
1735+
1736+
const response = await api.post(
1737+
`/admin/promotions`,
1738+
{
1739+
code: "PERCENTAGE_10",
1740+
type: PromotionType.STANDARD,
1741+
status: PromotionStatus.ACTIVE,
1742+
is_tax_inclusive: true,
1743+
is_automatic: true,
1744+
application_method: {
1745+
target_type: "items",
1746+
type: "percentage",
1747+
allocation: "across",
1748+
currency_code: "DKK",
1749+
value: 10,
1750+
},
1751+
},
1752+
adminHeaders
1753+
)
1754+
1755+
expect(response.status).toEqual(200)
1756+
expect(response.data.promotion).toEqual(
1757+
expect.objectContaining({
1758+
id: expect.any(String),
1759+
code: "PERCENTAGE_10",
1760+
type: "standard",
1761+
is_tax_inclusive: true,
1762+
is_automatic: true,
1763+
application_method: expect.objectContaining({
1764+
value: 10,
1765+
type: "percentage",
1766+
target_type: "items",
1767+
allocation: "across",
1768+
}),
1769+
})
1770+
)
1771+
1772+
const cart = (
1773+
await api.post(
1774+
`/store/carts?fields=*items,*items.adjustments`,
1775+
{
1776+
currency_code: "dkk",
1777+
sales_channel_id: salesChannel.id,
1778+
region_id: region.id,
1779+
items: [
1780+
{
1781+
variant_id: product.variants[0].id,
1782+
quantity: 2,
1783+
},
1784+
],
1785+
promo_codes: [response.data.promotion.code],
1786+
},
1787+
storeHeaders
1788+
)
1789+
).data.cart
1790+
1791+
console.log(JSON.stringify(cart, null, 2))
1792+
1793+
/**
1794+
* Orignal total -> 2600 DKK (tax incl.)
1795+
* Tax rate -> 25%
1796+
* Promotion -> PERCENTAGE 10 (tax incl.)
1797+
*
1798+
* We want total to be 2600 DKK - 260 DKK = 2340 DKK
1799+
*/
1800+
expect(cart).toEqual(
1801+
expect.objectContaining({
1802+
currency_code: "dkk",
1803+
1804+
subtotal: 2080, // taxable_base = subtotal - discount_subtotal = 2080 - 208 = 1872
1805+
total: 2340, // total = taxable_base * (1 + tax rate) = 1872 * (1 + 0.25) = 2340
1806+
tax_total: 468,
1807+
1808+
original_total: 2600,
1809+
original_tax_total: 520,
1810+
1811+
discount_total: 260,
1812+
discount_subtotal: 208,
1813+
discount_tax_total: 52,
1814+
1815+
item_total: 2340,
1816+
item_subtotal: 2080,
1817+
item_tax_total: 468,
1818+
1819+
original_item_total: 2600,
1820+
original_item_subtotal: 2080,
1821+
original_item_tax_total: 520,
1822+
1823+
shipping_total: 0,
1824+
shipping_subtotal: 0,
1825+
shipping_tax_total: 0,
1826+
1827+
original_shipping_tax_total: 0,
1828+
original_shipping_subtotal: 0,
1829+
original_shipping_total: 0,
1830+
1831+
// items: expect.arrayContaining([
1832+
// expect.objectContaining({
1833+
// quantity: 1,
1834+
// unit_price: 1300,
1835+
1836+
// subtotal: 1040,
1837+
// tax_total: 240,
1838+
// total: 1200,
1839+
1840+
// original_total: 1300,
1841+
// original_tax_total: 260,
1842+
1843+
// discount_total: 100,
1844+
// discount_subtotal: 80,
1845+
// discount_tax_total: 20,
1846+
1847+
// adjustments: expect.arrayContaining([
1848+
// expect.objectContaining({
1849+
// amount: 100,
1850+
// is_tax_inclusive: true,
1851+
// }),
1852+
// ]),
1853+
// }),
1854+
// ]),
1855+
})
1856+
)
1857+
})
16901858
})
16911859

16921860
describe("DELETE /admin/promotions/:id", () => {

packages/cli/create-medusa-app/src/utils/prepare-project.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ async function prepareProject({
249249
message: "Ran Migrations",
250250
})
251251

252+
// create admin user
253+
factBoxOptions.interval = displayFactBox({
254+
...factBoxOptions,
255+
title: "Creating an admin user...",
256+
})
257+
252258
await processManager.runProcess({
253259
process: async () => {
254260
const proc = await execute(
@@ -264,6 +270,11 @@ async function prepareProject({
264270
},
265271
})
266272

273+
factBoxOptions.interval = displayFactBox({
274+
...factBoxOptions,
275+
message: "Created admin user",
276+
})
277+
267278
// TODO for now we just seed the default data
268279
// we should add onboarding seeding again if it makes
269280
// since once we re-introduce the onboarding flow.

packages/core/core-flows/src/cart/steps/prepare-adjustments-from-promotion-actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export const prepareAdjustmentsFromPromotionActionsStep = createStep(
135135
is_tax_inclusive: (action as AddItemAdjustmentAction).is_tax_inclusive,
136136
item_id: (action as AddItemAdjustmentAction).item_id,
137137
promotion_id: promotionsMap.get(action.code)?.id,
138+
promotion_type: (action as AddItemAdjustmentAction).promotion_type,
138139
}))
139140

140141
const lineItemAdjustmentIdsToRemove = actions

packages/core/types/src/cart/common.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export interface AdjustmentLineDTO {
4747
*/
4848
promotion_id?: string
4949

50+
/**
51+
* The type of the promotion.
52+
*/
53+
promotion_type?: "percentage" | "fixed"
54+
5055
/**
5156
* The ID of the associated provider.
5257
*/

packages/core/types/src/promotion/common/compute-actions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export interface AddItemAdjustmentAction {
6666
is_tax_inclusive?: boolean
6767

6868
/**
69+
* The type of the promotion.
70+
*/
71+
promotion_type: "percentage" | "fixed"
72+
6973
/**
7074
* The promotion's code.
7175
*/
@@ -186,6 +190,11 @@ export interface ComputeActionItemLine extends Record<string, unknown> {
186190
*/
187191
subtotal: BigNumberInput
188192

193+
/**
194+
* The total of the line item.
195+
*/
196+
total: BigNumberInput
197+
189198
/**
190199
* Whether the line item is discountable.
191200
*/

packages/core/utils/src/totals/adjustment/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ export function calculateAdjustmentTotal({
88
includesTax,
99
taxRate,
1010
}: {
11-
adjustments: Pick<AdjustmentLineDTO, "amount" | "is_tax_inclusive">[]
11+
adjustments: Pick<
12+
AdjustmentLineDTO,
13+
"amount" | "is_tax_inclusive" | "promotion_type"
14+
>[]
1215
includesTax?: boolean
1316
taxRate?: BigNumberInput
1417
}) {
@@ -29,15 +32,19 @@ export function calculateAdjustmentTotal({
2932
if (adj.is_tax_inclusive && isDefined(taxRate)) {
3033
adjustmentsSubtotal = MathBN.add(
3134
adjustmentsSubtotal,
32-
MathBN.div(adjustmentAmount, MathBN.add(1, taxRate))
35+
adj.promotion_type === "percentage"
36+
? adjustmentAmount
37+
: MathBN.div(adjustmentAmount, MathBN.add(1, taxRate))
3338
)
3439
} else {
3540
adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount)
3641
}
3742

3843
if (isDefined(taxRate)) {
3944
const adjustmentSubtotal = includesTax
40-
? MathBN.div(adjustmentAmount, MathBN.add(1, taxRate))
45+
? adj.is_tax_inclusive && adj.promotion_type === "percentage"
46+
? adjustmentAmount
47+
: MathBN.div(adjustmentAmount, MathBN.add(1, taxRate))
4148
: adjustmentAmount
4249

4350
const adjustmentTaxTotal = MathBN.mult(adjustmentSubtotal, taxRate)

packages/modules/cart/src/migrations/.snapshot-medusa-cart.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,19 @@
10261026
"nullable": true,
10271027
"mappedType": "text"
10281028
},
1029+
"promotion_type": {
1030+
"name": "promotion_type",
1031+
"type": "text",
1032+
"unsigned": false,
1033+
"autoincrement": false,
1034+
"primary": false,
1035+
"nullable": true,
1036+
"enumItems": [
1037+
"fixed",
1038+
"percentage"
1039+
],
1040+
"mappedType": "enum"
1041+
},
10291042
"metadata": {
10301043
"name": "metadata",
10311044
"type": "jsonb",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Migration } from '@mikro-orm/migrations';
2+
3+
export class Migration20250708075529 extends Migration {
4+
5+
override async up(): Promise<void> {
6+
this.addSql(`alter table if exists "cart_line_item_adjustment" add column if not exists "promotion_type" text check ("promotion_type" in ('fixed', 'percentage')) null;`);
7+
}
8+
9+
override async down(): Promise<void> {
10+
this.addSql(`alter table if exists "cart_line_item_adjustment" drop column if exists "promotion_type";`);
11+
}
12+
13+
}

packages/modules/cart/src/models/line-item-adjustment.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { model } from "@medusajs/framework/utils"
1+
import { model, PromotionUtils } from "@medusajs/framework/utils"
22
import LineItem from "./line-item"
33

44
const LineItemAdjustment = model
@@ -12,6 +12,9 @@ const LineItemAdjustment = model
1212
is_tax_inclusive: model.boolean().default(false),
1313
provider_id: model.text().nullable(),
1414
promotion_id: model.text().nullable(),
15+
promotion_type: model
16+
.enum(PromotionUtils.ApplicationMethodType)
17+
.nullable(),
1518
metadata: model.json().nullable(),
1619
item: model.belongsTo(() => LineItem, {
1720
mappedBy: "adjustments",

packages/modules/promotion/src/utils/compute-actions/buy-get.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export function getComputedActionsForBuyGet(
272272
item_id: item.id,
273273
amount,
274274
code: promotion.code!,
275+
promotion_type: promotion.application_method?.type!,
275276
})
276277
}
277278

0 commit comments

Comments
 (0)