chore(): Reorganize modules (#7210)

**What**
Move all modules to the modules directory
This commit is contained in:
Adrien de Peretti
2024-05-02 17:33:34 +02:00
committed by GitHub
parent 7a351eef09
commit 4eae25e1ef
870 changed files with 91 additions and 62 deletions

6
packages/modules/promotion/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql

View File

@@ -0,0 +1,30 @@
# @medusajs/promotion
## 0.0.4
### Patch Changes
- Updated dependencies [[`0c0b425de7`](https://github.com/medusajs/medusa/commit/0c0b425de7b154b80b712ab17b16215cf62d1e83), [`8d356217bd`](https://github.com/medusajs/medusa/commit/8d356217bd31c97a196e861ee243822a4d924df7), [`1eeb1e9de3`](https://github.com/medusajs/medusa/commit/1eeb1e9de3e0b571735437b00968ee96e4aabad5), [`20e8df914e`](https://github.com/medusajs/medusa/commit/20e8df914ec5fdf8d562d4fa84f72c58c7056195), [`27f4f0d724`](https://github.com/medusajs/medusa/commit/27f4f0d7243367c2dfc6012bf1f6b7400a77ec7b), [`e0b02a1012`](https://github.com/medusajs/medusa/commit/e0b02a1012981c29830d7779f59ebe805bbfd137), [`e944a627f0`](https://github.com/medusajs/medusa/commit/e944a627f074fb39a56f4bc7b3d6d315736ebf7c), [`1a48fe0282`](https://github.com/medusajs/medusa/commit/1a48fe0282a8bc1f8548a4736255e457d173da09), [`86f499de2f`](https://github.com/medusajs/medusa/commit/86f499de2f31356ab36ad5e93f27345443b3e5f6), [`09a2220569`](https://github.com/medusajs/medusa/commit/09a22205693da62fbf8fd450535d5024cb9c01d1), [`78f603e4f1`](https://github.com/medusajs/medusa/commit/78f603e4f18c9d16f4b58a2189c959026453d8b2), [`cc557c8752`](https://github.com/medusajs/medusa/commit/cc557c8752fd0554f5a1b58522d9a88dc43a8509), [`dd35a4dbff`](https://github.com/medusajs/medusa/commit/dd35a4dbff10c86ea3c5f7f817c18b6e60d599e3), [`58c68f6715`](https://github.com/medusajs/medusa/commit/58c68f67156e993255fbc25d91db15ae23bc95c0), [`1bcb13f892`](https://github.com/medusajs/medusa/commit/1bcb13f892bc61db21b3fc6bdbce85f747aeec4c), [`82a176e30e`](https://github.com/medusajs/medusa/commit/82a176e30e47a7d11caaf31c3023bd8db588b465), [`11517f0faf`](https://github.com/medusajs/medusa/commit/11517f0fafdf00af256240448b58d149d8b6f600), [`62b9dcc6c1`](https://github.com/medusajs/medusa/commit/62b9dcc6c1ce46aadb7944215006c12da3c9f619), [`5d9aea053c`](https://github.com/medusajs/medusa/commit/5d9aea053ce6e04f242f86fb9053c13dec515d5b), [`e26cda4b6a`](https://github.com/medusajs/medusa/commit/e26cda4b6afb7fb25f0b0a7a7ce20b7f914d35db), [`bc06ad2db4`](https://github.com/medusajs/medusa/commit/bc06ad2db48c999023ab823fefc1375196976e9b), [`18f3aacee6`](https://github.com/medusajs/medusa/commit/18f3aacee6752854d377faa806f4cc67bc71456b), [`232322d035`](https://github.com/medusajs/medusa/commit/232322d03515f81e56867ff8c765b8409399ee68), [`38c971f111`](https://github.com/medusajs/medusa/commit/38c971f111af69f176e7e9892eb59f5bae831fa7), [`45c49e89f2`](https://github.com/medusajs/medusa/commit/45c49e89f28123ef622fc1c07253bae94fd74875), [`528ef4ca90`](https://github.com/medusajs/medusa/commit/528ef4ca90bb2cf6173dccc9fd6a9f9932ff9b76), [`65794f4bb5`](https://github.com/medusajs/medusa/commit/65794f4bb56e4fd3f0ccb7656a948f856f05324e), [`93ef94cad3`](https://github.com/medusajs/medusa/commit/93ef94cad3ddc5b6973b4e48e422b0aa0e6ddbbe), [`4cf71af07d`](https://github.com/medusajs/medusa/commit/4cf71af07d1807c83df3889c1774f82cbd1b9a6f), [`4b57c5d286`](https://github.com/medusajs/medusa/commit/4b57c5d286f9dc6e2098c67e9fecb0d93175b5a1), [`c78915c7c5`](https://github.com/medusajs/medusa/commit/c78915c7c5e91a99c1b1bae932656c8d86b17daf), [`18f3aacee6`](https://github.com/medusajs/medusa/commit/18f3aacee6752854d377faa806f4cc67bc71456b), [`667c8609cc`](https://github.com/medusajs/medusa/commit/667c8609ccf3850f5df8cf784723a95bd0d6d2a6), [`f175cac4af`](https://github.com/medusajs/medusa/commit/f175cac4af63b71066a8398ecf9beaa6f28b20cc), [`0a9b9b073d`](https://github.com/medusajs/medusa/commit/0a9b9b073dd2d3f4aa5e5cb1c16e2221a7200e0d), [`a6562d2a41`](https://github.com/medusajs/medusa/commit/a6562d2a41453cbe7aa43be352c4924e3e4c79d5), [`00e6b21bb5`](https://github.com/medusajs/medusa/commit/00e6b21bb50dbc886bc37ad052a1c40ce865294e), [`8fd1488938`](https://github.com/medusajs/medusa/commit/8fd148893850eb66c5eae00c4ca9391a80ea2eb9), [`1c6ba4468e`](https://github.com/medusajs/medusa/commit/1c6ba4468eab1440931c88929affd5b4c593f377)]:
- @medusajs/types@1.11.16
- @medusajs/modules-sdk@1.12.11
- @medusajs/utils@1.11.9
## 0.0.3
### Patch Changes
- Updated dependencies [[`06f22bb48a`](https://github.com/medusajs/medusa/commit/06f22bb48ad1fe73577657b8c5db055312f16a0d), [`deab12e27e`](https://github.com/medusajs/medusa/commit/deab12e27e8249e26d24d7bc904c18195679ff24), [`56481e683d`](https://github.com/medusajs/medusa/commit/56481e683d33ff98f0d4c4e144873bb23f993c9c), [`9073d7aba3`](https://github.com/medusajs/medusa/commit/9073d7aba3419e4dc0a206473291a46ebd79b8c1), [`4974f5e455`](https://github.com/medusajs/medusa/commit/4974f5e4557bd64a328a881ec02b91e15485bd23), [`05e857d256`](https://github.com/medusajs/medusa/commit/05e857d25657b5576a891c9b48c19c1759c70701), [`1ef9c78cea`](https://github.com/medusajs/medusa/commit/1ef9c78cea080c3b7c136f909c6cddec9d8f0c62)]:
- @medusajs/modules-sdk@1.12.10
- @medusajs/types@1.11.15
- @medusajs/utils@1.11.8
## 0.0.2
### Patch Changes
- [#6700](https://github.com/medusajs/medusa/pull/6700) [`8f8a4f9b13`](https://github.com/medusajs/medusa/commit/8f8a4f9b1353087d98f6cc75346d43a7f49901a8) Thanks [@olivermrbl](https://github.com/olivermrbl)! - chore: Version all modules to allow for initial testing
- Updated dependencies [[`1fd0457c15`](https://github.com/medusajs/medusa/commit/1fd0457c153b2ef7657c052878d8e5364e1b324a), [`9288f53327`](https://github.com/medusajs/medusa/commit/9288f53327b8ce617af92ed8d14d9459cbfeb13c), [`d4b921f3db`](https://github.com/medusajs/medusa/commit/d4b921f3dbe0a38f1565a8de759996c70798d58e), [`ac86362e81`](https://github.com/medusajs/medusa/commit/ac86362e81d8523cb8e3dfad026fc94658513018), [`e4acde1aa2`](https://github.com/medusajs/medusa/commit/e4acde1aa2eb57f07e6692fe8b61f728948b9a96), [`1a661adf3e`](https://github.com/medusajs/medusa/commit/1a661adf3ef4991aa6e237dd894b6a5c47cd4aca), [`56cbf88115`](https://github.com/medusajs/medusa/commit/56cbf88115994adea7037c3f2814f0c96af3cfc0), [`36a61658f9`](https://github.com/medusajs/medusa/commit/36a61658f969a7b19c84a1e621ad1464927cafb1), [`04a532e5ef`](https://github.com/medusajs/medusa/commit/04a532e5efabbf75b1e4155520b1da175b686ffc), [`c319edb8e0`](https://github.com/medusajs/medusa/commit/c319edb8e0ecd13d086652147667916e5abab2d8), [`0b9fcb6324`](https://github.com/medusajs/medusa/commit/0b9fcb6324eee9f2556c7e6317775fae93b12a47), [`586df9da25`](https://github.com/medusajs/medusa/commit/586df9da250e492442769f5bac2f8b3de1d46f05), [`b3d826497b`](https://github.com/medusajs/medusa/commit/b3d826497b3dae5e1b26b7924706c24fd5e87ca5), [`a86c87fe14`](https://github.com/medusajs/medusa/commit/a86c87fe1442afce9285e39255914e01012b4449), [`640eccd5dd`](https://github.com/medusajs/medusa/commit/640eccd5ddbb163e0f987ce6c772f1129c2e2632), [`8ea37d03c9`](https://github.com/medusajs/medusa/commit/8ea37d03c914a5004a3e42770668b2d1f7f8f564), [`339a946f38`](https://github.com/medusajs/medusa/commit/339a946f389033c21e05338f9dbf07d88e140533), [`ac829fc67f`](https://github.com/medusajs/medusa/commit/ac829fc67f7495b08f28e55923c59f0fd6320311), [`d9d5afc3cf`](https://github.com/medusajs/medusa/commit/d9d5afc3cfc29221d0e65bff7b78474a8fb8f31f), [`c3c4f49fc2`](https://github.com/medusajs/medusa/commit/c3c4f49fc2126f950e69e291ca939ca88a15afd3), [`9288f53327`](https://github.com/medusajs/medusa/commit/9288f53327b8ce617af92ed8d14d9459cbfeb13c), [`0d46abf0ff`](https://github.com/medusajs/medusa/commit/0d46abf0ffa4c5e03bf7d2a9cdf1db828a76bea8), [`fafde4f54d`](https://github.com/medusajs/medusa/commit/fafde4f54d3ef75a7d382e6cbf94e38b3deae99b), [`8dad2b51a2`](https://github.com/medusajs/medusa/commit/8dad2b51a26c4c3c14a6c95f70424c8bef2ad63e), [`0c705d7bd4`](https://github.com/medusajs/medusa/commit/0c705d7bd41a768c48017ae95b3c8414d96c6acb), [`a6d7070dd6`](https://github.com/medusajs/medusa/commit/a6d7070dd669c21ea19d70434d42c2f8167dc309), [`1d91b7429b`](https://github.com/medusajs/medusa/commit/1d91b7429beebd6f09d5027f7f7e1fe74ce3a8ff), [`168f02f138`](https://github.com/medusajs/medusa/commit/168f02f138ad101e1013f2c8c3f8dc19de12accf), [`1ed5f918c3`](https://github.com/medusajs/medusa/commit/1ed5f918c31794a70aca4a4e4cd83cf456593baa), [`c20eb15cd9`](https://github.com/medusajs/medusa/commit/c20eb15cd9b1bd90c8d01f68eca6f0f181cd902d), [`e5945479e0`](https://github.com/medusajs/medusa/commit/e5945479e091d9560ae3e7240306a31031ef4584), [`f5c2256286`](https://github.com/medusajs/medusa/commit/f5c22562867f412040f8bc6c55ab5de3a3735e62), [`000eb61e33`](https://github.com/medusajs/medusa/commit/000eb61e33e0302db95ee6ad1656ea9b430ed471), [`d550be3685`](https://github.com/medusajs/medusa/commit/d550be3685423218d47a20c57a5e06758f4a961a), [`62a7bcc30c`](https://github.com/medusajs/medusa/commit/62a7bcc30cbc7b234b2b51d7858439951a84edeb), [`8f8a4f9b13`](https://github.com/medusajs/medusa/commit/8f8a4f9b1353087d98f6cc75346d43a7f49901a8), [`6500f18b9b`](https://github.com/medusajs/medusa/commit/6500f18b9b80c5c9c473489e7e740d55dca74303), [`ce39b9b66e`](https://github.com/medusajs/medusa/commit/ce39b9b66e8c277ec0691ea6d0a950003be09cc1), [`a6a4b3f01a`](https://github.com/medusajs/medusa/commit/a6a4b3f01a6d2bd97b1580c59134279a1b033a5d), [`4d51f095b3`](https://github.com/medusajs/medusa/commit/4d51f095b3f98f468cefb760512563f7b77bb9cf), [`4625bd1241`](https://github.com/medusajs/medusa/commit/4625bd12416275b09c22cde4a09cb0f68df5d7c1), [`56b0b45304`](https://github.com/medusajs/medusa/commit/56b0b4530401a6ec5aa155874d371e45bb388fe2), [`cc1b66842c`](https://github.com/medusajs/medusa/commit/cc1b66842cbb37c6eab84e2d8b74844c214f38d7), [`24fb102a56`](https://github.com/medusajs/medusa/commit/24fb102a564b1253d1f8b039bb1e435cc5312fbb), [`e85463b2a7`](https://github.com/medusajs/medusa/commit/e85463b2a717751de2e21c39a4c745449b31affe)]:
- @medusajs/types@1.11.14
- @medusajs/utils@1.11.7
- @medusajs/modules-sdk@1.12.9

View File

@@ -0,0 +1,3 @@
# Promotion Module
The PromotionModule is Medusas promotion engine. It offers functionality to discount carts through coupon codes with a given set of rules governing when and how the code should be applied.

View File

@@ -0,0 +1,32 @@
import { CampaignBudgetType } from "@medusajs/utils"
export const defaultCampaignsData = [
{
id: "campaign-id-1",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-1",
starts_at: new Date("01/01/2023"),
ends_at: new Date("01/01/2024"),
budget: {
type: CampaignBudgetType.SPEND,
limit: 1000,
used: 0,
},
},
{
id: "campaign-id-2",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-2",
starts_at: new Date("01/01/2023"),
ends_at: new Date("01/01/2024"),
budget: {
type: CampaignBudgetType.USAGE,
limit: 1000,
used: 0,
},
},
]

View File

@@ -0,0 +1,23 @@
import { CreateCampaignDTO } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Campaign } from "@models"
import { defaultCampaignsData } from "./data"
export * from "./data"
export async function createCampaigns(
manager: SqlEntityManager,
campaignsData: CreateCampaignDTO[] = defaultCampaignsData
): Promise<Campaign[]> {
const campaigns: Campaign[] = []
for (let campaignData of campaignsData) {
let campaign = manager.create(Campaign, campaignData)
manager.persist(campaign)
await manager.flush()
}
return campaigns
}

View File

@@ -0,0 +1,14 @@
import { PromotionType } from "@medusajs/utils"
export const defaultPromotionsData = [
{
id: "promotion-id-1",
code: "PROMOTION_1",
type: PromotionType.STANDARD,
},
{
id: "promotion-id-2",
code: "PROMOTION_2",
type: PromotionType.STANDARD,
},
]

View File

@@ -0,0 +1,23 @@
import { CreatePromotionDTO } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Promotion } from "@models"
import { defaultPromotionsData } from "./data"
export * from "./data"
export async function createPromotions(
manager: SqlEntityManager,
promotionsData: CreatePromotionDTO[] = defaultPromotionsData
): Promise<Promotion[]> {
const promotions: Promotion[] = []
for (let promotionData of promotionsData) {
let promotion = manager.create(Promotion, promotionData)
manager.persist(promotion)
await manager.flush()
promotions.push(promotion)
}
return promotions
}

View File

@@ -0,0 +1,443 @@
import { Modules } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { createCampaigns } from "../../../__fixtures__/campaigns"
import { createPromotions } from "../../../__fixtures__/promotion"
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.PROMOTION,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Module Service: Campaigns", () => {
describe("listAndCountCampaigns", () => {
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
it("should return all campaigns and its count", async () => {
const [campaigns, count] = await service.listAndCountCampaigns()
expect(count).toEqual(2)
expect(campaigns).toEqual([
{
id: "campaign-id-1",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-1",
starts_at: expect.any(Date),
ends_at: expect.any(Date),
budget: expect.any(Object),
created_at: expect.any(Date),
updated_at: expect.any(Date),
deleted_at: null,
},
{
id: "campaign-id-2",
name: "campaign 1",
description: "test description",
currency: "USD",
campaign_identifier: "test-2",
starts_at: expect.any(Date),
ends_at: expect.any(Date),
budget: expect.any(Object),
created_at: expect.any(Date),
updated_at: expect.any(Date),
deleted_at: null,
},
])
})
it("should return all campaigns based on config select and relations param", async () => {
const [campaigns, count] = await service.listAndCountCampaigns(
{
id: ["campaign-id-1"],
},
{
relations: ["budget"],
select: ["name", "budget.limit"],
}
)
expect(count).toEqual(1)
expect(campaigns).toEqual([
{
id: "campaign-id-1",
name: "campaign 1",
budget: expect.objectContaining({
id: expect.any(String),
limit: 1000,
}),
},
])
})
})
describe("createCampaigns", () => {
it("should throw an error when required params are not passed", async () => {
const error = await service
.createCampaigns([
{
name: "test",
} as any,
])
.catch((e) => e)
expect(error.message).toContain(
"Value for Campaign.campaign_identifier is required, 'undefined' found"
)
})
it("should create a basic campaign successfully", async () => {
const startsAt = new Date("01/01/2024")
const endsAt = new Date("01/01/2025")
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
starts_at: startsAt,
ends_at: endsAt,
},
])
const campaign = await service.retrieveCampaign(createdCampaign.id)
expect(campaign).toEqual(
expect.objectContaining({
name: "test",
campaign_identifier: "test",
starts_at: startsAt,
ends_at: endsAt,
})
)
})
it("should create a campaign with campaign budget successfully", async () => {
const startsAt = new Date("01/01/2024")
const endsAt = new Date("01/01/2025")
const [createdPromotion] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
starts_at: startsAt,
ends_at: endsAt,
budget: {
limit: 1000,
type: "usage",
used: 10,
},
},
])
const campaign = await service.retrieveCampaign(createdPromotion.id, {
relations: ["budget"],
})
expect(campaign).toEqual(
expect.objectContaining({
name: "test",
campaign_identifier: "test",
starts_at: startsAt,
ends_at: endsAt,
budget: expect.objectContaining({
limit: 1000,
type: "usage",
used: 10,
}),
})
)
})
it("should create a basic campaign with promotions successfully", async () => {
await createPromotions(MikroOrmWrapper.forkManager())
const startsAt = new Date("01/01/2024")
const endsAt = new Date("01/01/2025")
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
starts_at: startsAt,
ends_at: endsAt,
promotions: [{ id: "promotion-id-1" }, { id: "promotion-id-2" }],
},
])
const campaign = await service.retrieveCampaign(createdCampaign.id, {
relations: ["promotions"],
})
expect(campaign).toEqual(
expect.objectContaining({
name: "test",
campaign_identifier: "test",
starts_at: startsAt,
ends_at: endsAt,
promotions: [
expect.objectContaining({
id: "promotion-id-1",
}),
expect.objectContaining({
id: "promotion-id-2",
}),
],
})
)
})
})
describe("updateCampaigns", () => {
it("should throw an error when required params are not passed", async () => {
const error = await service
.updateCampaigns([
{
name: "test",
} as any,
])
.catch((e) => e)
expect(error.message).toContain('Campaign with id "" not found')
})
it("should update the attributes of a campaign successfully", async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
const [updatedCampaign] = await service.updateCampaigns([
{
id: "campaign-id-1",
description: "test description 1",
currency: "EUR",
campaign_identifier: "new",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
},
])
expect(updatedCampaign).toEqual(
expect.objectContaining({
description: "test description 1",
currency: "EUR",
campaign_identifier: "new",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
})
)
})
it("should update the attributes of a campaign budget successfully", async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
const [updatedCampaign] = await service.updateCampaigns([
{
id: "campaign-id-1",
budget: {
limit: 100,
used: 100,
},
},
])
expect(updatedCampaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 100,
used: 100,
}),
})
)
})
it("should update promotions of a campaign successfully", async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
await createPromotions(MikroOrmWrapper.forkManager())
const [updatedCampaign] = await service.updateCampaigns([
{
id: "campaign-id-1",
description: "test description 1",
currency: "EUR",
campaign_identifier: "new",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
promotions: [{ id: "promotion-id-1" }, { id: "promotion-id-2" }],
},
])
expect(updatedCampaign).toEqual(
expect.objectContaining({
description: "test description 1",
currency: "EUR",
campaign_identifier: "new",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
promotions: [
expect.objectContaining({
id: "promotion-id-1",
}),
expect.objectContaining({
id: "promotion-id-2",
}),
],
})
)
})
it("should remove promotions of the campaign successfully", async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
await createPromotions(MikroOrmWrapper.forkManager())
await service.updateCampaigns({
id: "campaign-id-1",
promotions: [{ id: "promotion-id-1" }, { id: "promotion-id-2" }],
})
const updatedCampaign = await service.updateCampaigns({
id: "campaign-id-1",
promotions: [{ id: "promotion-id-1" }],
})
expect(updatedCampaign).toEqual(
expect.objectContaining({
promotions: [
expect.objectContaining({
id: "promotion-id-1",
}),
],
})
)
})
})
describe("retrieveCampaign", () => {
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
const id = "campaign-id-1"
it("should return campaign for the given id", async () => {
const campaign = await service.retrieveCampaign(id)
expect(campaign).toEqual(
expect.objectContaining({
id,
})
)
})
it("should throw an error when campaign with id does not exist", async () => {
let error
try {
await service.retrieveCampaign("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual(
"Campaign with id: does-not-exist was not found"
)
})
it("should throw an error when a id is not provided", async () => {
let error
try {
await service.retrieveCampaign(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual("campaign - id must be defined")
})
it("should return campaign based on config select param", async () => {
const campaign = await service.retrieveCampaign(id, {
select: ["id"],
})
const serialized = JSON.parse(JSON.stringify(campaign))
expect(serialized).toEqual({
id,
})
})
})
describe("deleteCampaigns", () => {
it("should delete the campaigns given an id successfully", async () => {
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
},
])
await service.deleteCampaigns([createdCampaign.id])
const campaigns = await service.listCampaigns(
{
id: [createdCampaign.id],
},
{ withDeleted: true }
)
expect(campaigns).toHaveLength(0)
})
})
describe("softDeleteCampaigns", () => {
it("should soft delete the campaigns given an id successfully", async () => {
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
},
])
await service.softDeleteCampaigns([createdCampaign.id])
const campaigns = await service.listCampaigns({
id: [createdCampaign.id],
})
expect(campaigns).toHaveLength(0)
})
})
describe("restoreCampaigns", () => {
it("should restore the campaigns given an id successfully", async () => {
const [createdCampaign] = await service.createCampaigns([
{
name: "test",
campaign_identifier: "test",
starts_at: new Date("01/01/2024"),
ends_at: new Date("01/01/2025"),
},
])
await service.softDeleteCampaigns([createdCampaign.id])
let campaigns = await service.listCampaigns({
id: [createdCampaign.id],
})
expect(campaigns).toHaveLength(0)
await service.restoreCampaigns([createdCampaign.id])
campaigns = await service.listCampaigns({ id: [createdCampaign.id] })
expect(campaigns).toHaveLength(1)
})
})
})
},
})

View File

@@ -0,0 +1,189 @@
import { Modules } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { createCampaigns } from "../../../__fixtures__/campaigns"
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.PROMOTION,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service: campaign usage", () => {
beforeEach(async () => {
await createCampaigns(MikroOrmWrapper.forkManager())
})
describe("registerUsage", () => {
it("should register usage for type spend", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_SPEND",
type: "standard",
campaign_id: "campaign-id-1",
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 500,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
})
expect(campaign.budget).toEqual(
expect.objectContaining({
type: "spend",
limit: 1000,
used: 700,
})
)
})
it("should register usage for type usage", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_USAGE",
type: "standard",
campaign_id: "campaign-id-2",
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 500,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-2", {
relations: ["budget"],
})
expect(campaign.budget).toEqual(
expect.objectContaining({
type: "usage",
limit: 1000,
used: 1,
})
)
})
it("should not throw an error when compute action with code does not exist", async () => {
const response = await service
.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: "DOESNOTEXIST",
},
])
.catch((e) => e)
expect(response).toEqual(undefined)
})
it("should not register usage when limit is exceed for type usage", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_USAGE",
type: "standard",
campaign_id: "campaign-id-2",
})
await service.updateCampaigns({
id: "campaign-id-2",
budget: { used: 1000, limit: 1000 },
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 200,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 500,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-2", {
relations: ["budget"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 1000,
used: 1000,
}),
})
)
})
it("should not register usage above limit when exceeded for type spend", async () => {
const createdPromotion = await service.create({
code: "TEST_PROMO_SPEND",
type: "standard",
campaign_id: "campaign-id-1",
})
await service.updateCampaigns({
id: "campaign-id-1",
budget: { used: 900, limit: 1000 },
})
await service.registerUsage([
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_express",
amount: 100,
code: createdPromotion.code!,
},
{
action: "addShippingMethodAdjustment",
shipping_method_id: "shipping_method_standard",
amount: 100,
code: createdPromotion.code!,
},
])
const campaign = await service.retrieveCampaign("campaign-id-1", {
relations: ["budget"],
})
expect(campaign).toEqual(
expect.objectContaining({
budget: expect.objectContaining({
limit: 1000,
used: 1000,
}),
})
)
})
})
})
},
})

View File

@@ -0,0 +1,58 @@
import { PromotionType } from "@medusajs/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { createPromotions } from "../../../__fixtures__/promotion"
import { IPromotionModuleService } from "@medusajs/types"
import { Modules } from "@medusajs/modules-sdk"
jest.setTimeout(30000)
moduleIntegrationTestRunner({
moduleName: Modules.PROMOTION,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IPromotionModuleService>) => {
describe("Promotion Service", () => {
beforeEach(async () => {
await createPromotions(MikroOrmWrapper.forkManager())
})
describe("create", () => {
it("should throw an error when required params are not passed", async () => {
const error = await service
.create([
{
type: PromotionType.STANDARD,
} as any,
])
.catch((e) => e)
expect(error.message).toContain(
"Value for Promotion.code is required, 'undefined' found"
)
})
it("should create a promotion successfully", async () => {
await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
},
])
const [promotion] = await service.list({
code: ["PROMOTION_TEST"],
})
expect(promotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: "standard",
})
)
})
})
})
},
})

View File

@@ -0,0 +1,21 @@
module.exports = {
moduleNameMapper: {
"^@models": "<rootDir>/src/models",
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/src/repositories",
"^@utils": "<rootDir>/src/utils",
"^@types": "<rootDir>/src/types",
},
transform: {
"^.+\\.[jt]s?$": [
"ts-jest",
{
tsconfig: "tsconfig.spec.json",
isolatedModules: true,
},
],
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
modulePathIgnorePatterns: ["dist/"],
}

View File

@@ -0,0 +1,8 @@
import * as entities from "./src/models"
module.exports = {
entities: Object.values(entities),
schema: "public",
clientUrl: "postgres://postgres@localhost/medusa-promotion",
type: "postgresql",
}

View File

@@ -0,0 +1,61 @@
{
"name": "@medusajs/promotion",
"version": "0.0.4",
"description": "Medusa Promotion module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"bin": {
"medusa-promotion-seed": "dist/scripts/bin/run-seed.js"
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/promotion"
},
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"watch": "tsc --build --watch",
"watch:test": "tsc --build tsconfig.spec.json --watch",
"prepublishOnly": "cross-env NODE_ENV=production tsc --build && tsc-alias -p tsconfig.json",
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
"test": "jest --runInBand --passWithNoTests --bail --forceExit -- src",
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts",
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"
},
"devDependencies": {
"@mikro-orm/cli": "5.9.7",
"cross-env": "^5.2.1",
"jest": "^29.6.3",
"medusa-test-utils": "^1.1.44",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"tsc-alias": "^1.8.6",
"typescript": "^5.1.6"
},
"dependencies": {
"@medusajs/modules-sdk": "^1.12.11",
"@medusajs/types": "^1.11.16",
"@medusajs/utils": "1.11.9",
"@mikro-orm/core": "5.9.7",
"@mikro-orm/migrations": "5.9.7",
"@mikro-orm/postgresql": "5.9.7",
"awilix": "^8.0.0",
"dotenv": "^16.4.5",
"knex": "2.4.2"
}
}

View File

@@ -0,0 +1,10 @@
import {
moduleDefinition,
revertMigration,
runMigrations,
} from "./module-definition"
export default moduleDefinition
export { revertMigration, runMigrations }
export * from "./initialize"

View File

@@ -0,0 +1,31 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
MODULE_PACKAGE_NAMES,
MedusaModule,
Modules,
} from "@medusajs/modules-sdk"
import { IPromotionModuleService, ModulesSdkTypes } from "@medusajs/types"
import { moduleDefinition } from "../module-definition"
import { InitializeModuleInjectableDependencies } from "../types"
export const initialize = async (
options?:
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
| ExternalModuleDeclaration
| InternalModuleDeclaration,
injectedDependencies?: InitializeModuleInjectableDependencies
): Promise<IPromotionModuleService> => {
const loaded = await MedusaModule.bootstrap<IPromotionModuleService>({
moduleKey: Modules.PROMOTION,
defaultPath: MODULE_PACKAGE_NAMES[Modules.PROMOTION],
declaration: options as
| InternalModuleDeclaration
| ExternalModuleDeclaration,
injectedDependencies,
moduleExports: moduleDefinition,
})
return loaded[Modules.PROMOTION]
}

View File

@@ -0,0 +1,40 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
import { generateLinkableKeysMap } from "@medusajs/utils"
import { Campaign, Promotion, PromotionRule } from "@models"
export const LinkableKeys = {
promotion_id: Promotion.name,
campaign_id: Campaign.name,
promotion_rule_id: PromotionRule.name,
}
export const entityNameToLinkableKeysMap = generateLinkableKeysMap(LinkableKeys)
export const joinerConfig: ModuleJoinerConfig = {
serviceName: Modules.PROMOTION,
primaryKeys: ["id"],
linkableKeys: LinkableKeys,
alias: [
{
name: ["promotion", "promotions"],
args: {
entity: Promotion.name,
},
},
{
name: ["campaign", "campaigns"],
args: {
entity: Campaign.name,
methodSuffix: "Campaigns",
},
},
{
name: ["promotion_rule", "promotion_rules"],
args: {
entity: PromotionRule.name,
methodSuffix: "PromotionRules",
},
},
],
}

View File

@@ -0,0 +1,34 @@
import {
InternalModuleDeclaration,
LoaderOptions,
Modules,
} from "@medusajs/modules-sdk"
import { ModulesSdkTypes } from "@medusajs/types"
import { ModulesSdkUtils } from "@medusajs/utils"
import { EntitySchema } from "@mikro-orm/core"
import * as PromotionModels from "../models"
export default async (
{
options,
container,
logger,
}: LoaderOptions<
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
>,
moduleDeclaration?: InternalModuleDeclaration
): Promise<void> => {
const entities = Object.values(PromotionModels) as unknown as EntitySchema[]
const pathToMigrations = __dirname + "/../migrations"
await ModulesSdkUtils.mikroOrmConnectionLoader({
moduleName: Modules.PROMOTION,
entities,
container,
options,
moduleDeclaration,
logger,
pathToMigrations,
})
}

View File

@@ -0,0 +1,10 @@
import { ModulesSdkUtils } from "@medusajs/utils"
import * as ModuleModels from "@models"
import * as ModuleRepositories from "@repositories"
import * as ModuleServices from "@services"
export default ModulesSdkUtils.moduleContainerLoaderFactory({
moduleModels: ModuleModels,
moduleRepositories: ModuleRepositories,
moduleServices: ModuleServices,
})

View File

@@ -0,0 +1,2 @@
export * from "./connection"
export * from "./container"

View File

@@ -0,0 +1,924 @@
{
"namespaces": ["public"],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"name": {
"name": "name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"description": {
"name": "description",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"currency": {
"name": "currency",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"campaign_identifier": {
"name": "campaign_identifier",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"starts_at": {
"name": "starts_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
},
"ends_at": {
"name": "ends_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion_campaign",
"schema": "public",
"indexes": [
{
"keyName": "IDX_campaign_identifier_unique",
"columnNames": ["campaign_identifier"],
"composite": false,
"primary": false,
"unique": true
},
{
"keyName": "promotion_campaign_pkey",
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["spend", "usage"],
"mappedType": "enum"
},
"campaign_id": {
"name": "campaign_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"limit": {
"name": "limit",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"raw_limit": {
"name": "raw_limit",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"used": {
"name": "used",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"raw_used": {
"name": "raw_used",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion_campaign_budget",
"schema": "public",
"indexes": [
{
"columnNames": ["type"],
"composite": false,
"keyName": "IDX_campaign_budget_type",
"primary": false,
"unique": false
},
{
"columnNames": ["campaign_id"],
"composite": false,
"keyName": "promotion_campaign_budget_campaign_id_unique",
"primary": false,
"unique": true
},
{
"keyName": "promotion_campaign_budget_pkey",
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"promotion_campaign_budget_campaign_id_foreign": {
"constraintName": "promotion_campaign_budget_campaign_id_foreign",
"columnNames": ["campaign_id"],
"localTableName": "public.promotion_campaign_budget",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_campaign",
"updateRule": "cascade"
}
}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"code": {
"name": "code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"campaign_id": {
"name": "campaign_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"is_automatic": {
"name": "is_automatic",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "false",
"mappedType": "boolean"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["standard", "buyget"],
"mappedType": "enum"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion",
"schema": "public",
"indexes": [
{
"columnNames": ["code"],
"composite": false,
"keyName": "IDX_promotion_code",
"primary": false,
"unique": false
},
{
"columnNames": ["type"],
"composite": false,
"keyName": "IDX_promotion_type",
"primary": false,
"unique": false
},
{
"keyName": "IDX_promotion_code_unique",
"columnNames": ["code"],
"composite": false,
"primary": false,
"unique": true
},
{
"keyName": "promotion_pkey",
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"promotion_campaign_id_foreign": {
"constraintName": "promotion_campaign_id_foreign",
"columnNames": ["campaign_id"],
"localTableName": "public.promotion",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_campaign",
"deleteRule": "set null"
}
}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"value": {
"name": "value",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"raw_value": {
"name": "raw_value",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"max_quantity": {
"name": "max_quantity",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"apply_to_quantity": {
"name": "apply_to_quantity",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"buy_rules_min_quantity": {
"name": "buy_rules_min_quantity",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["fixed", "percentage"],
"mappedType": "enum"
},
"target_type": {
"name": "target_type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["order", "shipping_methods", "items"],
"mappedType": "enum"
},
"allocation": {
"name": "allocation",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"enumItems": ["each", "across"],
"mappedType": "enum"
},
"promotion_id": {
"name": "promotion_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion_application_method",
"schema": "public",
"indexes": [
{
"columnNames": ["type"],
"composite": false,
"keyName": "IDX_application_method_type",
"primary": false,
"unique": false
},
{
"columnNames": ["target_type"],
"composite": false,
"keyName": "IDX_application_method_target_type",
"primary": false,
"unique": false
},
{
"columnNames": ["allocation"],
"composite": false,
"keyName": "IDX_application_method_allocation",
"primary": false,
"unique": false
},
{
"columnNames": ["promotion_id"],
"composite": false,
"keyName": "promotion_application_method_promotion_id_unique",
"primary": false,
"unique": true
},
{
"keyName": "promotion_application_method_pkey",
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"promotion_application_method_promotion_id_foreign": {
"constraintName": "promotion_application_method_promotion_id_foreign",
"columnNames": ["promotion_id"],
"localTableName": "public.promotion_application_method",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"description": {
"name": "description",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"attribute": {
"name": "attribute",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"operator": {
"name": "operator",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": ["gte", "lte", "gt", "lt", "eq", "ne", "in"],
"mappedType": "enum"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion_rule",
"schema": "public",
"indexes": [
{
"columnNames": ["attribute"],
"composite": false,
"keyName": "IDX_promotion_rule_attribute",
"primary": false,
"unique": false
},
{
"columnNames": ["operator"],
"composite": false,
"keyName": "IDX_promotion_rule_operator",
"primary": false,
"unique": false
},
{
"keyName": "promotion_rule_pkey",
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"promotion_id": {
"name": "promotion_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"promotion_rule_id": {
"name": "promotion_rule_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "promotion_promotion_rule",
"schema": "public",
"indexes": [
{
"keyName": "promotion_promotion_rule_pkey",
"columnNames": ["promotion_id", "promotion_rule_id"],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"promotion_promotion_rule_promotion_id_foreign": {
"constraintName": "promotion_promotion_rule_promotion_id_foreign",
"columnNames": ["promotion_id"],
"localTableName": "public.promotion_promotion_rule",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"promotion_promotion_rule_promotion_rule_id_foreign": {
"constraintName": "promotion_promotion_rule_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"localTableName": "public.promotion_promotion_rule",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"application_method_id": {
"name": "application_method_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"promotion_rule_id": {
"name": "promotion_rule_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "application_method_target_rules",
"schema": "public",
"indexes": [
{
"keyName": "application_method_target_rules_pkey",
"columnNames": ["application_method_id", "promotion_rule_id"],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"application_method_target_rules_application_method_id_foreign": {
"constraintName": "application_method_target_rules_application_method_id_foreign",
"columnNames": ["application_method_id"],
"localTableName": "public.application_method_target_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_application_method",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"application_method_target_rules_promotion_rule_id_foreign": {
"constraintName": "application_method_target_rules_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"localTableName": "public.application_method_target_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"application_method_id": {
"name": "application_method_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"promotion_rule_id": {
"name": "promotion_rule_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "application_method_buy_rules",
"schema": "public",
"indexes": [
{
"keyName": "application_method_buy_rules_pkey",
"columnNames": ["application_method_id", "promotion_rule_id"],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"application_method_buy_rules_application_method_id_foreign": {
"constraintName": "application_method_buy_rules_application_method_id_foreign",
"columnNames": ["application_method_id"],
"localTableName": "public.application_method_buy_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_application_method",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"application_method_buy_rules_promotion_rule_id_foreign": {
"constraintName": "application_method_buy_rules_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"localTableName": "public.application_method_buy_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"promotion_rule_id": {
"name": "promotion_rule_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"value": {
"name": "value",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "promotion_rule_value",
"schema": "public",
"indexes": [
{
"columnNames": ["promotion_rule_id"],
"composite": false,
"keyName": "IDX_promotion_rule_promotion_rule_value_id",
"primary": false,
"unique": false
},
{
"keyName": "promotion_rule_value_pkey",
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"promotion_rule_value_promotion_rule_id_foreign": {
"constraintName": "promotion_rule_value_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"localTableName": "public.promotion_rule_value",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
}
]
}

View File

@@ -0,0 +1,117 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240227120221 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "promotion_campaign" ("id" text not null, "name" text not null, "description" text null, "currency" text null, "campaign_identifier" text not null, "starts_at" timestamptz null, "ends_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_pkey" primary key ("id"));'
)
this.addSql(
'alter table if exists "promotion_campaign" add constraint "IDX_campaign_identifier_unique" unique ("campaign_identifier");'
)
this.addSql(
'create table if not exists "promotion_campaign_budget" ("id" text not null, "type" text check ("type" in (\'spend\', \'usage\')) not null, "campaign_id" text not null, "limit" numeric null, "raw_limit" jsonb null, "used" numeric null, "raw_used" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_campaign_budget_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_campaign_budget_type" on "promotion_campaign_budget" ("type");'
)
this.addSql(
'alter table if exists "promotion_campaign_budget" add constraint "promotion_campaign_budget_campaign_id_unique" unique ("campaign_id");'
)
this.addSql(
'create table if not exists "promotion" ("id" text not null, "code" text not null, "campaign_id" text null, "is_automatic" boolean not null default false, "type" text check ("type" in (\'standard\', \'buyget\')) not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_promotion_code" on "promotion" ("code");'
)
this.addSql(
'create index if not exists "IDX_promotion_type" on "promotion" ("type");'
)
this.addSql(
'alter table if exists "promotion" add constraint "IDX_promotion_code_unique" unique ("code");'
)
this.addSql(
'create table if not exists "promotion_application_method" ("id" text not null, "value" numeric null, "raw_value" jsonb null, "max_quantity" numeric null, "apply_to_quantity" numeric null, "buy_rules_min_quantity" numeric null, "type" text check ("type" in (\'fixed\', \'percentage\')) not null, "target_type" text check ("target_type" in (\'order\', \'shipping_methods\', \'items\')) not null, "allocation" text check ("allocation" in (\'each\', \'across\')) null, "promotion_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_application_method_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_application_method_type" on "promotion_application_method" ("type");'
)
this.addSql(
'create index if not exists "IDX_application_method_target_type" on "promotion_application_method" ("target_type");'
)
this.addSql(
'create index if not exists "IDX_application_method_allocation" on "promotion_application_method" ("allocation");'
)
this.addSql(
'alter table if exists "promotion_application_method" add constraint "promotion_application_method_promotion_id_unique" unique ("promotion_id");'
)
this.addSql(
'create table if not exists "promotion_rule" ("id" text not null, "description" text null, "attribute" text not null, "operator" text check ("operator" in (\'gte\', \'lte\', \'gt\', \'lt\', \'eq\', \'ne\', \'in\')) not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_rule_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_promotion_rule_attribute" on "promotion_rule" ("attribute");'
)
this.addSql(
'create index if not exists "IDX_promotion_rule_operator" on "promotion_rule" ("operator");'
)
this.addSql(
'create table if not exists "promotion_promotion_rule" ("promotion_id" text not null, "promotion_rule_id" text not null, constraint "promotion_promotion_rule_pkey" primary key ("promotion_id", "promotion_rule_id"));'
)
this.addSql(
'create table if not exists "application_method_target_rules" ("application_method_id" text not null, "promotion_rule_id" text not null, constraint "application_method_target_rules_pkey" primary key ("application_method_id", "promotion_rule_id"));'
)
this.addSql(
'create table if not exists "application_method_buy_rules" ("application_method_id" text not null, "promotion_rule_id" text not null, constraint "application_method_buy_rules_pkey" primary key ("application_method_id", "promotion_rule_id"));'
)
this.addSql(
'create table if not exists "promotion_rule_value" ("id" text not null, "promotion_rule_id" text not null, "value" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_rule_value_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_promotion_rule_promotion_rule_value_id" on "promotion_rule_value" ("promotion_rule_id");'
)
this.addSql(
'alter table if exists "promotion_campaign_budget" add constraint "promotion_campaign_budget_campaign_id_foreign" foreign key ("campaign_id") references "promotion_campaign" ("id") on update cascade;'
)
this.addSql(
'alter table if exists "promotion" add constraint "promotion_campaign_id_foreign" foreign key ("campaign_id") references "promotion_campaign" ("id") on delete set null;'
)
this.addSql(
'alter table if exists "promotion_application_method" add constraint "promotion_application_method_promotion_id_foreign" foreign key ("promotion_id") references "promotion" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "promotion_promotion_rule" add constraint "promotion_promotion_rule_promotion_id_foreign" foreign key ("promotion_id") references "promotion" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "promotion_promotion_rule" add constraint "promotion_promotion_rule_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "application_method_target_rules" add constraint "application_method_target_rules_application_method_id_foreign" foreign key ("application_method_id") references "promotion_application_method" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "application_method_target_rules" add constraint "application_method_target_rules_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "application_method_buy_rules" add constraint "application_method_buy_rules_application_method_id_foreign" foreign key ("application_method_id") references "promotion_application_method" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "application_method_buy_rules" add constraint "application_method_buy_rules_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "promotion_rule_value" add constraint "promotion_rule_value_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
}
}

View File

@@ -0,0 +1,125 @@
import {
ApplicationMethodAllocationValues,
ApplicationMethodTargetTypeValues,
ApplicationMethodTypeValues,
BigNumberRawValue,
DAL,
} from "@medusajs/types"
import {
BigNumber,
DALUtils,
MikroOrmBigNumberProperty,
PromotionUtils,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
Collection,
Entity,
Enum,
Filter,
Index,
ManyToMany,
OnInit,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import Promotion from "./promotion"
import PromotionRule from "./promotion-rule"
type OptionalFields =
| "value"
| "max_quantity"
| "apply_to_quantity"
| "buy_rules_min_quantity"
| "allocation"
| DAL.SoftDeletableEntityDateColumns
@Entity({ tableName: "promotion_application_method" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class ApplicationMethod {
[OptionalProps]?: OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@MikroOrmBigNumberProperty({ nullable: true })
value: BigNumber | number | null = null
@Property({ columnType: "jsonb", nullable: true })
raw_value: BigNumberRawValue | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
max_quantity?: number | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
apply_to_quantity?: number | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
buy_rules_min_quantity?: number | null = null
@Index({ name: "IDX_application_method_type" })
@Enum(() => PromotionUtils.ApplicationMethodType)
type: ApplicationMethodTypeValues
@Index({ name: "IDX_application_method_target_type" })
@Enum(() => PromotionUtils.ApplicationMethodTargetType)
target_type: ApplicationMethodTargetTypeValues
@Index({ name: "IDX_application_method_allocation" })
@Enum({
items: () => PromotionUtils.ApplicationMethodAllocation,
nullable: true,
})
allocation?: ApplicationMethodAllocationValues
@OneToOne({
entity: () => Promotion,
onDelete: "cascade",
})
promotion: Promotion
@ManyToMany(() => PromotionRule, "method_target_rules", {
owner: true,
pivotTable: "application_method_target_rules",
cascade: ["soft-remove"] as any,
})
target_rules = new Collection<PromotionRule>(this)
@ManyToMany(() => PromotionRule, "method_buy_rules", {
owner: true,
pivotTable: "application_method_buy_rules",
cascade: ["soft-remove"] as any,
})
buy_rules = new Collection<PromotionRule>(this)
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "proappmet")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "proappmet")
}
}

View File

@@ -0,0 +1,89 @@
import {
BigNumberRawValue,
CampaignBudgetTypeValues,
DAL,
} from "@medusajs/types"
import {
BigNumber,
DALUtils,
MikroOrmBigNumberProperty,
PromotionUtils,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
Entity,
Enum,
Filter,
Index,
OnInit,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import Campaign from "./campaign"
type OptionalFields =
| "description"
| "limit"
| "used"
| DAL.SoftDeletableEntityDateColumns
@Entity({ tableName: "promotion_campaign_budget" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class CampaignBudget {
[OptionalProps]?: OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@Index({ name: "IDX_campaign_budget_type" })
@Enum(() => PromotionUtils.CampaignBudgetType)
type: CampaignBudgetTypeValues
@OneToOne({
entity: () => Campaign,
})
campaign: Campaign | null = null
@MikroOrmBigNumberProperty({ nullable: true })
limit: BigNumber | number | null = null
@Property({ columnType: "jsonb", nullable: true })
raw_limit: BigNumberRawValue | null = null
@MikroOrmBigNumberProperty({ nullable: true })
used: BigNumber | number | null = null
@Property({ columnType: "jsonb", nullable: true })
raw_used: BigNumberRawValue | null = null
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "probudg")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "probudg")
}
}

View File

@@ -0,0 +1,105 @@
import { DAL } from "@medusajs/types"
import { DALUtils, Searchable, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Collection,
Entity,
Filter,
OnInit,
OneToMany,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import CampaignBudget from "./campaign-budget"
import Promotion from "./promotion"
type OptionalRelations = "budget"
type OptionalFields =
| "description"
| "currency"
| "starts_at"
| "ends_at"
| DAL.SoftDeletableEntityDateColumns
@Entity({ tableName: "promotion_campaign" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class Campaign {
[OptionalProps]?: OptionalFields | OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
name: string
@Searchable()
@Property({ columnType: "text", nullable: true })
description: string | null = null
@Property({ columnType: "text", nullable: true })
currency: string | null = null
@Property({ columnType: "text" })
@Unique({
name: "IDX_campaign_identifier_unique",
properties: ["campaign_identifier"],
})
campaign_identifier: string
@Property({
columnType: "timestamptz",
nullable: true,
})
starts_at: Date | null = null
@Property({
columnType: "timestamptz",
nullable: true,
})
ends_at: Date | null = null
@OneToOne({
entity: () => CampaignBudget,
mappedBy: (cb) => cb.campaign,
cascade: ["soft-remove"] as any,
nullable: true,
})
budget: CampaignBudget | null = null
@OneToMany(() => Promotion, (promotion) => promotion.campaign, {
orphanRemoval: true,
})
promotions = new Collection<Promotion>(this)
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "procamp")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "procamp")
}
}

View File

@@ -0,0 +1,6 @@
export { default as ApplicationMethod } from "./application-method"
export { default as Campaign } from "./campaign"
export { default as CampaignBudget } from "./campaign-budget"
export { default as Promotion } from "./promotion"
export { default as PromotionRule } from "./promotion-rule"
export { default as PromotionRuleValue } from "./promotion-rule-value"

View File

@@ -0,0 +1,56 @@
import { DALUtils, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Entity,
Filter,
ManyToOne,
OnInit,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import PromotionRule from "./promotion-rule"
@Entity({ tableName: "promotion_rule_value" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class PromotionRuleValue {
@PrimaryKey({ columnType: "text" })
id!: string
@ManyToOne(() => PromotionRule, {
onDelete: "cascade",
fieldName: "promotion_rule_id",
index: "IDX_promotion_rule_promotion_rule_value_id",
})
promotion_rule: PromotionRule
@Property({ columnType: "text" })
value: string
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "prorulval")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "prorulval")
}
}

View File

@@ -0,0 +1,91 @@
import { DAL, PromotionRuleOperatorValues } from "@medusajs/types"
import { DALUtils, PromotionUtils, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Cascade,
Collection,
Entity,
Enum,
Filter,
Index,
ManyToMany,
OnInit,
OneToMany,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import ApplicationMethod from "./application-method"
import Promotion from "./promotion"
import PromotionRuleValue from "./promotion-rule-value"
type OptionalFields = "description" | DAL.SoftDeletableEntityDateColumns
type OptionalRelations = "values" | "promotions"
@Entity({ tableName: "promotion_rule" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class PromotionRule {
[OptionalProps]?: OptionalFields | OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text", nullable: true })
description: string | null = null
@Index({ name: "IDX_promotion_rule_attribute" })
@Property({ columnType: "text" })
attribute: string
@Index({ name: "IDX_promotion_rule_operator" })
@Enum(() => PromotionUtils.PromotionRuleOperator)
operator: PromotionRuleOperatorValues
@OneToMany(() => PromotionRuleValue, (prv) => prv.promotion_rule, {
cascade: [Cascade.REMOVE],
})
values = new Collection<PromotionRuleValue>(this)
@ManyToMany(() => Promotion, (promotion) => promotion.rules)
promotions = new Collection<Promotion>(this)
@ManyToMany(
() => ApplicationMethod,
(applicationMethod) => applicationMethod.target_rules
)
method_target_rules = new Collection<ApplicationMethod>(this)
@ManyToMany(
() => ApplicationMethod,
(applicationMethod) => applicationMethod.buy_rules
)
method_buy_rules = new Collection<ApplicationMethod>(this)
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "prorul")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "prorul")
}
}

View File

@@ -0,0 +1,104 @@
import { DAL, PromotionTypeValues } from "@medusajs/types"
import {
DALUtils,
PromotionUtils,
Searchable,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
Collection,
Entity,
Enum,
Filter,
Index,
ManyToMany,
ManyToOne,
OnInit,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import ApplicationMethod from "./application-method"
import Campaign from "./campaign"
import PromotionRule from "./promotion-rule"
type OptionalFields = "is_automatic" | DAL.SoftDeletableEntityDateColumns
type OptionalRelations = "application_method" | "campaign"
@Entity({ tableName: "promotion" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class Promotion {
[OptionalProps]?: OptionalFields | OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
@Index({ name: "IDX_promotion_code" })
@Unique({
name: "IDX_promotion_code_unique",
properties: ["code"],
})
code: string
@Searchable()
@ManyToOne(() => Campaign, {
fieldName: "campaign_id",
nullable: true,
cascade: ["soft-remove"] as any,
})
campaign: Campaign | null = null
@Property({ columnType: "boolean", default: false })
is_automatic: boolean = false
@Index({ name: "IDX_promotion_type" })
@Enum(() => PromotionUtils.PromotionType)
type: PromotionTypeValues
@OneToOne({
entity: () => ApplicationMethod,
mappedBy: (am) => am.promotion,
cascade: ["soft-remove"] as any,
})
application_method: ApplicationMethod
@ManyToMany(() => PromotionRule, "promotions", {
owner: true,
pivotTable: "promotion_promotion_rule",
cascade: ["soft-remove"] as any,
})
rules = new Collection<PromotionRule>(this)
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "promo")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "promo")
}
}

View File

@@ -0,0 +1,30 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleExports } from "@medusajs/types"
import { ModulesSdkUtils } from "@medusajs/utils"
import * as Models from "@models"
import { PromotionModuleService } from "@services"
import loadConnection from "./loaders/connection"
import loadContainer from "./loaders/container"
const migrationScriptOptions = {
moduleName: Modules.PROMOTION,
models: Models,
pathToMigrations: __dirname + "/migrations",
}
export const runMigrations = ModulesSdkUtils.buildMigrationScript(
migrationScriptOptions
)
export const revertMigration = ModulesSdkUtils.buildRevertMigrationScript(
migrationScriptOptions
)
const service = PromotionModuleService
const loaders = [loadContainer, loadConnection] as any
export const moduleDefinition: ModuleExports = {
service,
loaders,
runMigrations,
revertMigration,
}

View File

@@ -0,0 +1,149 @@
import { Context } from "@medusajs/types"
import { DALUtils } from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Campaign, Promotion } from "@models"
import { CreateCampaignDTO, UpdateCampaignDTO } from "@types"
export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory<Campaign>(
Campaign
) {
async create(
data: CreateCampaignDTO[],
context: Context = {}
): Promise<Campaign[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const promotionIdsToUpsert: string[] = []
const campaignIdentifierPromotionsMap = new Map<string, string[]>()
data.forEach((campaignData) => {
const campaignPromotionIds =
campaignData.promotions?.map((p) => p.id) || []
promotionIdsToUpsert.push(...campaignPromotionIds)
campaignIdentifierPromotionsMap.set(
campaignData.campaign_identifier,
campaignPromotionIds
)
delete campaignData.promotions
})
const existingPromotions = await manager.find(Promotion, {
id: promotionIdsToUpsert,
})
const existingPromotionsMap = new Map<string, Promotion>(
existingPromotions.map((promotion) => [promotion.id, promotion])
)
const createdCampaigns = await super.create(data, context)
for (const createdCampaign of createdCampaigns) {
const campaignPromotionIds =
campaignIdentifierPromotionsMap.get(
createdCampaign.campaign_identifier
) || []
for (const campaignPromotionId of campaignPromotionIds) {
const promotion = existingPromotionsMap.get(campaignPromotionId)
if (!promotion) {
continue
}
createdCampaign.promotions.add(promotion)
}
}
return createdCampaigns
}
async update(
data: { entity: Campaign; update: UpdateCampaignDTO }[],
context: Context = {}
): Promise<Campaign[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const promotionIdsToUpsert: string[] = []
const campaignIds: string[] = []
const campaignPromotionIdsMap = new Map<string, string[]>()
data.forEach(({ update: campaignData }) => {
const campaignPromotionIds = campaignData.promotions?.map((p) => p.id)
campaignIds.push(campaignData.id)
if (campaignPromotionIds) {
promotionIdsToUpsert.push(...campaignPromotionIds)
campaignPromotionIdsMap.set(campaignData.id, campaignPromotionIds)
}
delete campaignData.promotions
})
const existingCampaigns = await manager.find(
Campaign,
{ id: campaignIds },
{ populate: ["promotions"] }
)
const promotionIds = existingCampaigns
.map((campaign) => campaign.promotions?.map((p) => p.id))
.flat(1)
.concat(promotionIdsToUpsert)
const existingPromotions = await manager.find(Promotion, {
id: promotionIds,
})
const existingCampaignsMap = new Map<string, Campaign>(
existingCampaigns.map((campaign) => [campaign.id, campaign])
)
const existingPromotionsMap = new Map<string, Promotion>(
existingPromotions.map((promotion) => [promotion.id, promotion])
)
const updatedCampaigns = await super.update(data, context)
for (const updatedCampaign of updatedCampaigns) {
const upsertPromotionIds = campaignPromotionIdsMap.get(updatedCampaign.id)
if (!upsertPromotionIds) {
continue
}
const existingPromotionIds = (
existingCampaignsMap.get(updatedCampaign.id)?.promotions || []
).map((p) => p.id)
for (const existingPromotionId of existingPromotionIds) {
const promotion = existingPromotionsMap.get(existingPromotionId)
if (!promotion) {
continue
}
if (!upsertPromotionIds.includes(existingPromotionId)) {
updatedCampaign.promotions.remove(promotion)
}
}
for (const promotionIdToAdd of upsertPromotionIds) {
const promotion = existingPromotionsMap.get(promotionIdToAdd)
if (!promotion) {
continue
}
if (existingPromotionIds.includes(promotionIdToAdd)) {
continue
} else {
updatedCampaign.promotions.add(promotion)
}
}
}
return updatedCampaigns
}
}

View File

@@ -0,0 +1,2 @@
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
export { CampaignRepository } from "./campaign"

View File

@@ -0,0 +1 @@
export { default as PromotionModuleService } from "./promotion-module"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import {
ApplicationMethodAllocationValues,
ApplicationMethodTargetTypeValues,
ApplicationMethodTypeValues,
PromotionDTO,
} from "@medusajs/types"
import { Promotion } from "@models"
export interface CreateApplicationMethodDTO {
type: ApplicationMethodTypeValues
target_type: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
value?: number
promotion: Promotion | string | PromotionDTO
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
}
export interface UpdateApplicationMethodDTO {
id: string
type?: ApplicationMethodTypeValues
target_type?: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
value?: number
promotion?: Promotion | string | PromotionDTO
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
}

View File

@@ -0,0 +1,16 @@
import { CampaignBudgetTypeValues } from "@medusajs/types"
import { Campaign } from "@models"
export interface CreateCampaignBudgetDTO {
type: CampaignBudgetTypeValues
limit: number | null
used?: number
campaign?: Campaign | string
}
export interface UpdateCampaignBudgetDTO {
id: string
type?: CampaignBudgetTypeValues
limit?: number | null
used?: number
}

View File

@@ -0,0 +1,23 @@
import { PromotionDTO } from "@medusajs/types"
import { Promotion } from "@models"
export interface CreateCampaignDTO {
name: string
description?: string
currency?: string
campaign_identifier: string
starts_at: Date
ends_at: Date
promotions?: (PromotionDTO | Promotion)[]
}
export interface UpdateCampaignDTO {
id: string
name?: string
description?: string
currency?: string
campaign_identifier?: string
starts_at?: Date
ends_at?: Date
promotions?: (PromotionDTO | Promotion)[]
}

View File

@@ -0,0 +1,12 @@
import { Logger } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
}
export * from "./application-method"
export * from "./campaign"
export * from "./campaign-budget"
export * from "./promotion"
export * from "./promotion-rule"
export * from "./promotion-rule-value"

View File

@@ -0,0 +1,13 @@
import { PromotionRuleDTO } from "@medusajs/types"
import { PromotionRule } from "@models"
export interface CreatePromotionRuleValueDTO {
value: any
promotion_rule: string | PromotionRuleDTO | PromotionRule
}
export interface UpdatePromotionRuleValueDTO {
id: string
value: any
promotion_rule: string | PromotionRuleDTO | PromotionRule
}

View File

@@ -0,0 +1,16 @@
import { PromotionRuleOperatorValues } from "@medusajs/types"
export interface CreatePromotionRuleDTO {
description?: string | null
attribute: string
operator: PromotionRuleOperatorValues
}
export interface UpdatePromotionRuleDTO {
id: string
}
export enum ApplicationMethodRuleTypes {
TARGET_RULES = "target_rules",
BUY_RULES = "buy_rules",
}

View File

@@ -0,0 +1,16 @@
import { PromotionTypeValues } from "@medusajs/types"
export interface CreatePromotionDTO {
code: string
type: PromotionTypeValues
is_automatic?: boolean
campaign?: string
}
export interface UpdatePromotionDTO {
id: string
code?: string
type?: PromotionTypeValues
is_automatic?: boolean
campaign?: string
}

View File

@@ -0,0 +1,107 @@
import { PromotionTypes } from "@medusajs/types"
import {
ApplicationMethodTargetType,
ComputedActions,
MedusaError,
PromotionType,
isPresent,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
// TODO: calculations should eventually move to a totals util outside of the module
export function getComputedActionsForBuyGet(
promotion: PromotionTypes.PromotionDTO,
itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
methodIdPromoValueMap: Map<string, number>
): PromotionTypes.ComputeActions[] {
const buyRulesMinQuantity =
promotion.application_method?.buy_rules_min_quantity
const applyToQuantity = promotion.application_method?.apply_to_quantity
const buyRules = promotion.application_method?.buy_rules
const targetRules = promotion.application_method?.target_rules
const computedActions: PromotionTypes.ComputeActions[] = []
if (!itemsContext) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"items" should be present as an array in the context to compute actions`
)
}
if (!Array.isArray(buyRules) || !Array.isArray(targetRules)) {
return []
}
const validQuantity = itemsContext
.filter((item) => areRulesValidForContext(buyRules, item))
.reduce((acc, next) => acc + next.quantity, 0)
if (
!buyRulesMinQuantity ||
!applyToQuantity ||
buyRulesMinQuantity > validQuantity
) {
return []
}
const validItemsForTargetRules = itemsContext
.filter((item) => areRulesValidForContext(targetRules, item))
.filter((item) => isPresent(item.subtotal) && isPresent(item.quantity))
.sort((a, b) => {
const aPrice = a.subtotal / a.quantity
const bPrice = b.subtotal / b.quantity
return bPrice - aPrice
})
let remainingQtyToApply = applyToQuantity
for (const method of validItemsForTargetRules) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
const multiplier = Math.min(method.quantity, remainingQtyToApply)
const amount = (method.subtotal / method.quantity) * multiplier
const newRemainingQtyToApply = remainingQtyToApply - multiplier
if (newRemainingQtyToApply < 0 || amount <= 0) {
break
} else {
remainingQtyToApply = newRemainingQtyToApply
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: method.id,
amount,
code: promotion.code!,
})
}
return computedActions
}
export function sortByBuyGetType(a, b) {
if (a.type === PromotionType.BUYGET && b.type !== PromotionType.BUYGET) {
return -1
} else if (
a.type !== PromotionType.BUYGET &&
b.type === PromotionType.BUYGET
) {
return 1
} else {
return 0
}
}

View File

@@ -0,0 +1,3 @@
export * from "./buy-get"
export * from "./line-items"
export * from "./usage"

View File

@@ -0,0 +1,180 @@
import {
ApplicationMethodAllocationValues,
PromotionTypes,
} from "@medusajs/types"
import {
ApplicationMethodAllocation,
ComputedActions,
MedusaError,
ApplicationMethodTargetType as TargetType,
calculateAdjustmentAmountFromPromotion,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
function validateContext(
contextKey: string,
context: PromotionTypes.ComputeActionContext[TargetType]
) {
if (!context) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"${contextKey}" should be present as an array in the context for computeActions`
)
}
}
export function getComputedActionsForItems(
promotion: PromotionTypes.PromotionDTO,
items: PromotionTypes.ComputeActionContext[TargetType.ITEMS],
appliedPromotionsMap: Map<string, number>,
allocationOverride?: ApplicationMethodAllocationValues
): PromotionTypes.ComputeActions[] {
validateContext("items", items)
return applyPromotionToItems(
promotion,
items,
appliedPromotionsMap,
allocationOverride
)
}
export function getComputedActionsForShippingMethods(
promotion: PromotionTypes.PromotionDTO,
shippingMethods: PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS],
appliedPromotionsMap: Map<string, number>
): PromotionTypes.ComputeActions[] {
validateContext("shipping_methods", shippingMethods)
return applyPromotionToItems(promotion, shippingMethods, appliedPromotionsMap)
}
export function getComputedActionsForOrder(
promotion: PromotionTypes.PromotionDTO,
itemApplicationContext: PromotionTypes.ComputeActionContext,
methodIdPromoValueMap: Map<string, number>
): PromotionTypes.ComputeActions[] {
return getComputedActionsForItems(
promotion,
itemApplicationContext[TargetType.ITEMS],
methodIdPromoValueMap,
ApplicationMethodAllocation.ACROSS
)
}
function applyPromotionToItems(
promotion: PromotionTypes.PromotionDTO,
items:
| PromotionTypes.ComputeActionContext[TargetType.ITEMS]
| PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS],
appliedPromotionsMap: Map<string, number>,
allocationOverride?: ApplicationMethodAllocationValues
): PromotionTypes.ComputeActions[] {
const { application_method: applicationMethod } = promotion
const allocation = applicationMethod?.allocation! || allocationOverride
const computedActions: PromotionTypes.ComputeActions[] = []
const applicableItems = getValidItemsForPromotion(items, promotion)
const target = applicationMethod?.target_type
const isTargetShippingMethod = target === TargetType.SHIPPING_METHODS
const isTargetLineItems = target === TargetType.ITEMS
const isTargetOrder = target === TargetType.ORDER
let lineItemsTotal = 0
if (allocation === ApplicationMethodAllocation.ACROSS) {
lineItemsTotal = applicableItems.reduce(
(acc, item) =>
acc + item.subtotal - (appliedPromotionsMap.get(item.id) ?? 0),
0
)
}
for (const item of applicableItems!) {
const appliedPromoValue = appliedPromotionsMap.get(item.id) ?? 0
const maxQuantity = isTargetShippingMethod
? 1
: applicationMethod?.max_quantity!
if (isTargetShippingMethod) {
item.quantity = 1
}
const amount = calculateAdjustmentAmountFromPromotion(
item,
{
value: applicationMethod?.value ?? 0,
applied_value: appliedPromoValue,
max_quantity: maxQuantity,
type: applicationMethod?.type!,
allocation,
},
lineItemsTotal
)
if (amount <= 0) {
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
appliedPromotionsMap.set(item.id, appliedPromoValue + amount)
if (isTargetLineItems || isTargetOrder) {
computedActions.push({
action: ComputedActions.ADD_ITEM_ADJUSTMENT,
item_id: item.id,
amount,
code: promotion.code!,
})
}
if (isTargetShippingMethod) {
computedActions.push({
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: item.id,
amount,
code: promotion.code!,
})
}
}
return computedActions
}
function getValidItemsForPromotion(
items:
| PromotionTypes.ComputeActionContext[TargetType.ITEMS]
| PromotionTypes.ComputeActionContext[TargetType.SHIPPING_METHODS],
promotion: PromotionTypes.PromotionDTO
) {
const isTargetShippingMethod =
promotion.application_method?.target_type === TargetType.SHIPPING_METHODS
return (
items?.filter((item) => {
const isSubtotalPresent = "subtotal" in item
const isQuantityPresent = "quantity" in item
const isPromotionApplicableToItem = areRulesValidForContext(
promotion?.application_method?.target_rules!,
item
)
return (
isPromotionApplicableToItem &&
(isQuantityPresent || isTargetShippingMethod) &&
isSubtotalPresent
)
}) || []
)
}

View File

@@ -0,0 +1,157 @@
import { PromotionTypes } from "@medusajs/types"
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
ComputedActions,
MedusaError,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
export function getComputedActionsForShippingMethods(
promotion: PromotionTypes.PromotionDTO,
shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
methodIdPromoValueMap: Map<string, number>
): PromotionTypes.ComputeActions[] {
const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] =
[]
if (!shippingMethodApplicationContext) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"shipping_methods" should be present as an array in the context for computeActions`
)
}
for (const shippingMethodContext of shippingMethodApplicationContext) {
const isPromotionApplicableToItem = areRulesValidForContext(
promotion.application_method?.target_rules!,
shippingMethodContext
)
if (!isPromotionApplicableToItem) {
continue
}
applicableShippingItems.push(shippingMethodContext)
}
return applyPromotionToShippingMethods(
promotion,
applicableShippingItems,
methodIdPromoValueMap
)
}
export function applyPromotionToShippingMethods(
promotion: PromotionTypes.PromotionDTO,
shippingMethods: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS],
methodIdPromoValueMap: Map<string, number>
): PromotionTypes.ComputeActions[] {
const { application_method: applicationMethod } = promotion
const allocation = applicationMethod?.allocation!
const computedActions: PromotionTypes.ComputeActions[] = []
if (allocation === ApplicationMethodAllocation.EACH) {
for (const method of shippingMethods!) {
if (!method.subtotal) {
continue
}
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
let promotionValue = applicationMethod?.value ?? 0
const applicableTotal = method.subtotal - appliedPromoValue
if (applicationMethod?.type === ApplicationMethodType.PERCENTAGE) {
promotionValue = (promotionValue / 100) * applicableTotal
}
const amount = Math.min(promotionValue, applicableTotal)
if (amount <= 0) {
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: method.id,
amount,
code: promotion.code!,
})
}
}
if (allocation === ApplicationMethodAllocation.ACROSS) {
const totalApplicableValue = shippingMethods!.reduce((acc, method) => {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
return acc + (method.subtotal ?? 0) - appliedPromoValue
}, 0)
if (totalApplicableValue <= 0) {
return computedActions
}
for (const method of shippingMethods!) {
if (!method.subtotal) {
continue
}
const promotionValue = applicationMethod?.value ?? 0
const applicableTotal = method.subtotal
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
// TODO: should we worry about precision here?
let applicablePromotionValue =
(applicableTotal / totalApplicableValue) * promotionValue -
appliedPromoValue
if (applicationMethod?.type === ApplicationMethodType.PERCENTAGE) {
applicablePromotionValue =
(promotionValue / 100) * (applicableTotal - appliedPromoValue)
}
const amount = Math.min(applicablePromotionValue, applicableTotal)
if (amount <= 0) {
continue
}
const budgetExceededAction = computeActionForBudgetExceeded(
promotion,
amount
)
if (budgetExceededAction) {
computedActions.push(budgetExceededAction)
continue
}
methodIdPromoValueMap.set(method.id, appliedPromoValue + amount)
computedActions.push({
action: ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
shipping_method_id: method.id,
amount,
code: promotion.code!,
})
}
}
return computedActions
}

View File

@@ -0,0 +1,39 @@
import {
CampaignBudgetExceededAction,
ComputeActions,
PromotionDTO,
} from "@medusajs/types"
import { CampaignBudgetType, ComputedActions } from "@medusajs/utils"
export function canRegisterUsage(computedAction: ComputeActions): boolean {
return (
[
ComputedActions.ADD_ITEM_ADJUSTMENT,
ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT,
] as string[]
).includes(computedAction.action)
}
export function computeActionForBudgetExceeded(
promotion: PromotionDTO,
amount: number
): CampaignBudgetExceededAction | void {
const campaignBudget = promotion.campaign?.budget
if (!campaignBudget) {
return
}
const campaignBudgetUsed = campaignBudget.used ?? 0
const totalUsed =
campaignBudget.type === CampaignBudgetType.SPEND
? campaignBudgetUsed + amount
: campaignBudgetUsed + 1
if (campaignBudget.limit && totalUsed > campaignBudget.limit) {
return {
action: ComputedActions.CAMPAIGN_BUDGET_EXCEEDED,
code: promotion.code!,
}
}
}

View File

@@ -0,0 +1,2 @@
export * as ComputeActionUtils from "./compute-actions"
export * from "./validations"

View File

@@ -0,0 +1,137 @@
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
isDefined,
isPresent,
MedusaError,
PromotionType,
} from "@medusajs/utils"
import { Promotion } from "@models"
import { CreateApplicationMethodDTO, UpdateApplicationMethodDTO } from "@types"
export const allowedAllocationTargetTypes: string[] = [
ApplicationMethodTargetType.SHIPPING_METHODS,
ApplicationMethodTargetType.ITEMS,
]
export const allowedAllocationTypes: string[] = [
ApplicationMethodAllocation.ACROSS,
ApplicationMethodAllocation.EACH,
]
export const allowedAllocationForQuantity: string[] = [
ApplicationMethodAllocation.EACH,
]
export function validateApplicationMethodAttributes(
data: UpdateApplicationMethodDTO | CreateApplicationMethodDTO,
promotion: Promotion
) {
const applicationMethod = promotion?.application_method || {}
const buyRulesMinQuantity =
data.buy_rules_min_quantity || applicationMethod?.buy_rules_min_quantity
const applyToQuantity =
data.apply_to_quantity || applicationMethod?.apply_to_quantity
const targetType = data.target_type || applicationMethod?.target_type
const type = data.type || applicationMethod?.type
const applicationMethodType = data.type || applicationMethod?.type
const value = data.value || applicationMethod.value
const maxQuantity = data.max_quantity || applicationMethod.max_quantity
const allocation = data.allocation || applicationMethod.allocation
const allTargetTypes: string[] = Object.values(ApplicationMethodTargetType)
if (
type === ApplicationMethodType.PERCENTAGE &&
(typeof value !== "number" || value <= 0 || value > 100)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Application Method value should be a percentage number between 0 and 100`
)
}
if (promotion?.type === PromotionType.BUYGET) {
if (!isPresent(applyToQuantity)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`apply_to_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
)
}
if (!isPresent(buyRulesMinQuantity)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`buy_rules_min_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
)
}
}
if (
allocation === ApplicationMethodAllocation.ACROSS &&
isPresent(maxQuantity)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.max_quantity is not allowed to be set for allocation (${ApplicationMethodAllocation.ACROSS})`
)
}
if (!allTargetTypes.includes(targetType)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.target_type should be one of ${allTargetTypes.join(
", "
)}`
)
}
const allTypes: string[] = Object.values(ApplicationMethodType)
if (!allTypes.includes(applicationMethodType)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.type should be one of ${allTypes.join(", ")}`
)
}
if (
allowedAllocationTargetTypes.includes(targetType) &&
!allowedAllocationTypes.includes(allocation || "")
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.allocation should be either '${allowedAllocationTypes.join(
" OR "
)}' when application_method.target_type is either '${allowedAllocationTargetTypes.join(
" OR "
)}'`
)
}
const allAllocationTypes: string[] = Object.values(
ApplicationMethodAllocation
)
if (allocation && !allAllocationTypes.includes(allocation)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.allocation should be one of ${allAllocationTypes.join(
", "
)}`
)
}
if (
allocation &&
allowedAllocationForQuantity.includes(allocation) &&
!isDefined(maxQuantity)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.max_quantity is required when application_method.allocation is '${allowedAllocationForQuantity.join(
" OR "
)}'`
)
}
}

View File

@@ -0,0 +1,2 @@
export * from "./application-method"
export * from "./promotion-rule"

View File

@@ -0,0 +1,104 @@
import { PromotionRuleDTO, PromotionRuleOperatorValues } from "@medusajs/types"
import {
isPresent,
isString,
MedusaError,
pickValueFromObject,
PromotionRuleOperator,
} from "@medusajs/utils"
import { CreatePromotionRuleDTO } from "@types"
export function validatePromotionRuleAttributes(
promotionRulesData: CreatePromotionRuleDTO[]
) {
const errors: string[] = []
for (const promotionRuleData of promotionRulesData) {
if (!isPresent(promotionRuleData.attribute)) {
errors.push("rules[].attribute is a required field")
}
if (!isPresent(promotionRuleData.operator)) {
errors.push("rules[].operator is a required field")
}
if (isPresent(promotionRuleData.operator)) {
const allowedOperators: PromotionRuleOperatorValues[] = Object.values(
PromotionRuleOperator
)
if (!allowedOperators.includes(promotionRuleData.operator)) {
errors.push(
`rules[].operator (${
promotionRuleData.operator
}) is invalid. It should be one of ${allowedOperators.join(", ")}`
)
}
} else {
errors.push("rules[].operator is a required field")
}
}
if (!errors.length) return
throw new MedusaError(MedusaError.Types.INVALID_DATA, errors.join(", "))
}
export function areRulesValidForContext(
rules: PromotionRuleDTO[],
context: Record<string, any>
): boolean {
return rules.every((rule) => {
const validRuleValues = rule.values?.map((ruleValue) => ruleValue.value)
if (!rule.attribute) {
return false
}
const valuesToCheck = pickValueFromObject(rule.attribute, context)
return evaluateRuleValueCondition(
validRuleValues.filter(isString),
rule.operator!,
valuesToCheck
)
})
}
export function evaluateRuleValueCondition(
ruleValues: string[],
operator: string,
ruleValuesToCheck: string[] | string
) {
if (!Array.isArray(ruleValuesToCheck)) {
ruleValuesToCheck = [ruleValuesToCheck]
}
return ruleValuesToCheck.every((ruleValueToCheck: string) => {
if (operator === "in" || operator === "eq") {
return ruleValues.some((ruleValue) => ruleValue === ruleValueToCheck)
}
if (operator === "ne") {
return ruleValues.some((ruleValue) => ruleValue !== ruleValueToCheck)
}
if (operator === "gt") {
return ruleValues.some((ruleValue) => ruleValue > ruleValueToCheck)
}
if (operator === "gte") {
return ruleValues.some((ruleValue) => ruleValue >= ruleValueToCheck)
}
if (operator === "lt") {
return ruleValues.some((ruleValue) => ruleValue < ruleValueToCheck)
}
if (operator === "lte") {
return ruleValues.some((ruleValue) => ruleValue <= ruleValueToCheck)
}
return false
})
}

View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
"lib": ["es2020"],
"target": "es2020",
"outDir": "./dist",
"esModuleInterop": true,
"declarationMap": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": false,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@models": ["./src/models"],
"@services": ["./src/services"],
"@repositories": ["./src/repositories"],
"@types": ["./src/types"],
"@utils": ["./src/utils"]
}
},
"include": ["src"],
"exclude": [
"dist",
"./src/**/__tests__",
"./src/**/__mocks__",
"./src/**/__fixtures__",
"node_modules"
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": ["src", "integration-tests"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"sourceMap": true
}
}