@lodashventure/medusa-campaign 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.medusa/server/src/api/admin/buy-x-get-y/[id]/route.js +116 -0
  2. package/.medusa/server/src/api/admin/buy-x-get-y/route.js +83 -0
  3. package/.medusa/server/src/api/admin/campaigns/fix-dates/route.js +103 -0
  4. package/.medusa/server/src/api/admin/campaigns/sync/route.js +138 -0
  5. package/.medusa/server/src/api/admin/flash-sales/[id]/route.js +49 -34
  6. package/.medusa/server/src/api/admin/flash-sales/route.js +46 -19
  7. package/.medusa/server/src/api/admin/force-fix/route.js +176 -0
  8. package/.medusa/server/src/api/admin/test-campaign/route.js +132 -0
  9. package/.medusa/server/src/api/store/buy-x-get-y/[id]/route.js +109 -0
  10. package/.medusa/server/src/api/store/buy-x-get-y/products/[productId]/route.js +94 -0
  11. package/.medusa/server/src/api/store/buy-x-get-y/route.js +114 -0
  12. package/.medusa/server/src/api/store/campaigns/[id]/route.js +132 -70
  13. package/.medusa/server/src/api/store/campaigns/route.js +119 -26
  14. package/.medusa/server/src/index.js +15 -0
  15. package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251018000000.js +40 -0
  16. package/.medusa/server/src/modules/custom-campaigns/models/buy-x-get-y-config.js +20 -0
  17. package/.medusa/server/src/modules/custom-campaigns/service.js +3 -1
  18. package/.medusa/server/src/modules/custom-campaigns/types/campaign-type.enum.js +2 -1
  19. package/.medusa/server/src/subscribers/cart-updated.js +23 -0
  20. package/.medusa/server/src/subscribers/order-placed.js +9 -2
  21. package/.medusa/server/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.js +150 -0
  22. package/.medusa/server/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.js +127 -0
  23. package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.js +114 -0
  24. package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.js +51 -0
  25. package/package.json +2 -2
  26. package/src/admin/components/BuyXGetYForm.tsx +422 -0
  27. package/src/api/admin/buy-x-get-y/[id]/route.ts +164 -0
  28. package/src/api/admin/buy-x-get-y/route.ts +104 -0
  29. package/src/api/admin/campaigns/fix-dates/route.ts +107 -0
  30. package/src/api/admin/campaigns/sync/route.ts +153 -0
  31. package/src/api/admin/flash-sales/[id]/route.ts +62 -36
  32. package/src/api/admin/flash-sales/route.ts +57 -21
  33. package/src/api/admin/force-fix/route.ts +184 -0
  34. package/src/api/admin/test-campaign/route.ts +141 -0
  35. package/src/api/store/buy-x-get-y/[id]/route.ts +146 -0
  36. package/src/api/store/buy-x-get-y/products/[productId]/route.ts +129 -0
  37. package/src/api/store/buy-x-get-y/route.ts +134 -0
  38. package/src/api/store/campaigns/[id]/route.ts +159 -79
  39. package/src/api/store/campaigns/route.ts +141 -30
  40. package/src/index.ts +10 -0
  41. package/src/modules/custom-campaigns/migrations/Migration20251018000000.ts +42 -0
  42. package/src/modules/custom-campaigns/models/buy-x-get-y-config.ts +19 -0
  43. package/src/modules/custom-campaigns/service.ts +2 -0
  44. package/src/modules/custom-campaigns/types/campaign-type.enum.ts +1 -0
  45. package/src/subscribers/cart-updated.ts +23 -0
  46. package/src/subscribers/order-placed.ts +9 -1
  47. package/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.ts +222 -0
  48. package/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.ts +210 -0
  49. package/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.ts +190 -0
  50. package/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.ts +86 -0
@@ -0,0 +1,222 @@
1
+ import { container } from "@medusajs/framework";
2
+ import { Modules } from "@medusajs/framework/utils";
3
+ import {
4
+ createStep,
5
+ createWorkflow,
6
+ StepResponse,
7
+ WorkflowResponse,
8
+ } from "@medusajs/framework/workflows-sdk";
9
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../modules/custom-campaigns";
10
+ import CustomCampaignModuleService from "../../modules/custom-campaigns/service";
11
+ import { CampaignTypeEnum } from "../../modules/custom-campaigns/types/campaign-type.enum";
12
+
13
+ interface ApplyBuyXGetYInput {
14
+ cart_id: string;
15
+ }
16
+
17
+ interface RewardItem {
18
+ product_id: string;
19
+ variant_id: string;
20
+ quantity: number;
21
+ metadata?: {
22
+ is_bogo_reward?: boolean;
23
+ bogo_config_id?: string;
24
+ reward_type?: string;
25
+ reward_value?: number;
26
+ };
27
+ }
28
+
29
+ const applyBuyXGetYToCartStep = createStep(
30
+ "apply-buy-x-get-y-to-cart-step",
31
+ async (data: ApplyBuyXGetYInput) => {
32
+ const customCampaignModuleService =
33
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
34
+ const cartService = container.resolve(Modules.CART);
35
+ const productService = container.resolve(Modules.PRODUCT);
36
+ const linkService = container.resolve("linkModuleService");
37
+
38
+ // Fetch the cart with items
39
+ const cart = await cartService.retrieve(data.cart_id, {
40
+ relations: ["items", "items.variant", "items.product"],
41
+ });
42
+
43
+ if (!cart || !cart.items || cart.items.length === 0) {
44
+ return new StepResponse({ rewardItemsAdded: [] });
45
+ }
46
+
47
+ // Get all active Buy X Get Y campaigns
48
+ const now = new Date();
49
+ const campaignTypes =
50
+ await customCampaignModuleService.listCustomCampaignTypes({
51
+ type: CampaignTypeEnum.BuyXGetY,
52
+ });
53
+
54
+ if (campaignTypes.length === 0) {
55
+ return new StepResponse({ rewardItemsAdded: [] });
56
+ }
57
+
58
+ // Get campaigns through link service
59
+ const campaignLinks = await linkService.list({
60
+ [CUSTOM_CAMPAIGN_MODULE]: {
61
+ custom_campaign_type_id: campaignTypes.map((ct) => ct.id),
62
+ },
63
+ });
64
+
65
+ const campaignIds = campaignLinks.map(
66
+ (link: any) => link[Modules.PROMOTION].campaign_id
67
+ );
68
+
69
+ const promotionService = container.resolve(Modules.PROMOTION);
70
+ const campaigns = await promotionService.listCampaigns({
71
+ id: campaignIds,
72
+ });
73
+
74
+ // Filter active campaigns
75
+ const activeCampaigns = campaigns.filter(
76
+ (campaign: any) =>
77
+ new Date(campaign.starts_at) <= now &&
78
+ new Date(campaign.ends_at) >= now
79
+ );
80
+
81
+ if (activeCampaigns.length === 0) {
82
+ return new StepResponse({ rewardItemsAdded: [] });
83
+ }
84
+
85
+ // Get BOGO configurations for active campaigns
86
+ const buyXGetYConfigs =
87
+ await customCampaignModuleService.listBuyXGetYConfigs({
88
+ campaign_id: activeCampaigns.map((c: any) => c.id),
89
+ });
90
+
91
+ // Remove ALL existing BOGO reward items to recalculate from scratch
92
+ const existingBogoItems = cart.items.filter(
93
+ (item: any) => item.metadata?.is_bogo_reward === true
94
+ );
95
+
96
+ const removedItemIds: string[] = [];
97
+ for (const bogoItem of existingBogoItems) {
98
+ try {
99
+ await cartService.removeLineItems(data.cart_id, [bogoItem.id]);
100
+ removedItemIds.push(bogoItem.id);
101
+ } catch (error) {
102
+ console.error(`Failed to remove existing BOGO item:`, error);
103
+ }
104
+ }
105
+
106
+ // Group cart items by product (excluding BOGO items which are now removed)
107
+ const cartItemsByProduct = new Map<string, any[]>();
108
+ cart.items.forEach((item: any) => {
109
+ if (item.metadata?.is_bogo_reward) return; // Skip BOGO items (should be none now)
110
+ const productId = item.product_id;
111
+ if (!cartItemsByProduct.has(productId)) {
112
+ cartItemsByProduct.set(productId, []);
113
+ }
114
+ cartItemsByProduct.get(productId)!.push(item);
115
+ });
116
+
117
+ const rewardItemsToAdd: RewardItem[] = [];
118
+
119
+ // Check each BOGO configuration
120
+ for (const config of buyXGetYConfigs) {
121
+ // Check if usage limit reached
122
+ if (config.limit && config.used >= config.limit) {
123
+ continue;
124
+ }
125
+
126
+ // Get cart items for the trigger product
127
+ const triggerItems = cartItemsByProduct.get(config.trigger_product_id);
128
+ if (!triggerItems || triggerItems.length === 0) {
129
+ continue;
130
+ }
131
+
132
+ // Calculate total quantity of trigger product
133
+ const totalTriggerQuantity = triggerItems.reduce(
134
+ (sum, item) => sum + item.quantity,
135
+ 0
136
+ );
137
+
138
+ // Check if trigger quantity is met
139
+ if (totalTriggerQuantity >= config.trigger_quantity) {
140
+ // Calculate how many times the reward should be given
141
+ const rewardMultiplier = Math.floor(
142
+ totalTriggerQuantity / config.trigger_quantity
143
+ );
144
+ const totalRewardQuantity = config.reward_quantity * rewardMultiplier;
145
+
146
+ // Get the reward product's default variant
147
+ const rewardProduct = await productService.retrieveProduct(
148
+ config.reward_product_id,
149
+ {
150
+ relations: ["variants"],
151
+ }
152
+ );
153
+
154
+ if (!rewardProduct.variants || rewardProduct.variants.length === 0) {
155
+ console.warn(
156
+ `No variants found for reward product ${config.reward_product_id}`
157
+ );
158
+ continue;
159
+ }
160
+
161
+ const defaultVariant = rewardProduct.variants[0];
162
+
163
+ rewardItemsToAdd.push({
164
+ product_id: config.reward_product_id,
165
+ variant_id: defaultVariant.id,
166
+ quantity: totalRewardQuantity,
167
+ metadata: {
168
+ is_bogo_reward: true,
169
+ bogo_config_id: config.id,
170
+ reward_type: config.reward_type,
171
+ reward_value: config.reward_value ?? undefined,
172
+ },
173
+ });
174
+ }
175
+ }
176
+
177
+ // Add reward items to cart
178
+ const addedItems: any[] = [];
179
+ for (const rewardItem of rewardItemsToAdd) {
180
+ try {
181
+ const addedItem = await cartService.addLineItems(data.cart_id, [
182
+ {
183
+ variant_id: rewardItem.variant_id,
184
+ quantity: rewardItem.quantity,
185
+ metadata: rewardItem.metadata,
186
+ },
187
+ ]);
188
+ addedItems.push(addedItem);
189
+ } catch (error) {
190
+ console.error(`Failed to add reward item:`, error);
191
+ }
192
+ }
193
+
194
+ return new StepResponse(
195
+ { rewardItemsAdded: addedItems },
196
+ { cart_id: data.cart_id, added_items: addedItems }
197
+ );
198
+ },
199
+ async (compensationData) => {
200
+ // Compensation: Remove added reward items if workflow fails
201
+ if (!compensationData || !compensationData.added_items) return;
202
+
203
+ const cartService = container.resolve(Modules.CART);
204
+
205
+ for (const item of compensationData.added_items) {
206
+ try {
207
+ await cartService.removeLineItems(compensationData.cart_id, [item.id]);
208
+ } catch (error) {
209
+ console.error(`Failed to remove reward item during compensation:`, error);
210
+ }
211
+ }
212
+ }
213
+ );
214
+
215
+ export const applyBuyXGetYToCartWorkflow = createWorkflow(
216
+ "apply-buy-x-get-y-to-cart",
217
+ (data: ApplyBuyXGetYInput) => {
218
+ const result = applyBuyXGetYToCartStep(data);
219
+
220
+ return new WorkflowResponse(result);
221
+ }
222
+ );
@@ -0,0 +1,210 @@
1
+ import { container } from "@medusajs/framework";
2
+ import {
3
+ CampaignDTO,
4
+ CreateApplicationMethodDTO,
5
+ CreatePromotionDTO,
6
+ PromotionDTO,
7
+ } from "@medusajs/framework/types";
8
+ import {
9
+ ContainerRegistrationKeys,
10
+ MedusaError,
11
+ Modules,
12
+ } from "@medusajs/framework/utils";
13
+ import {
14
+ createStep,
15
+ createWorkflow,
16
+ StepResponse,
17
+ WorkflowResponse,
18
+ } from "@medusajs/framework/workflows-sdk";
19
+ import {
20
+ createCampaignsWorkflow,
21
+ createPromotionsWorkflow,
22
+ deleteCampaignsWorkflow,
23
+ deletePromotionsWorkflow,
24
+ } from "@medusajs/medusa/core-flows";
25
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../modules/custom-campaigns";
26
+ import CustomCampaignModuleService from "../../modules/custom-campaigns/service";
27
+ import { CampaignTypeEnum } from "../../modules/custom-campaigns/types/campaign-type.enum";
28
+
29
+ export interface BuyXGetYProduct {
30
+ triggerProduct: {
31
+ id: string;
32
+ title: string;
33
+ };
34
+ triggerQuantity: number;
35
+ rewardProduct: {
36
+ id: string;
37
+ title: string;
38
+ };
39
+ rewardQuantity: number;
40
+ rewardType: "free" | "percentage" | "fixed";
41
+ rewardValue?: number;
42
+ limit?: number;
43
+ }
44
+
45
+ export interface BuyXGetYCampaign {
46
+ name: string;
47
+ description: string;
48
+ type: CampaignTypeEnum.BuyXGetY;
49
+ starts_at: Date;
50
+ ends_at: Date;
51
+ rules: BuyXGetYProduct[];
52
+ }
53
+
54
+ const createBuyXGetYCampaignStep = createStep(
55
+ "create-buy-x-get-y-campaign-step",
56
+ async (data: BuyXGetYCampaign) => {
57
+ const link = container.resolve(ContainerRegistrationKeys.LINK);
58
+ const customCampaignModuleService =
59
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
60
+ const campaignIdentifier = `${data.type}-${Date.now()}`;
61
+
62
+ let campaign: CampaignDTO | undefined;
63
+ let promotions: PromotionDTO[] | undefined;
64
+ let customCampaignType:
65
+ | Awaited<
66
+ ReturnType<CustomCampaignModuleService["createCustomCampaignTypes"]>
67
+ >[number]
68
+ | undefined;
69
+ let buyXGetYConfigs:
70
+ | Awaited<
71
+ ReturnType<CustomCampaignModuleService["createBuyXGetYConfigs"]>
72
+ >
73
+ | undefined;
74
+
75
+ // Create campaign
76
+ const {
77
+ result: [_campaign],
78
+ } = await createCampaignsWorkflow.run({
79
+ input: {
80
+ campaignsData: [
81
+ {
82
+ name: data.name,
83
+ campaign_identifier: campaignIdentifier,
84
+ description: data.description,
85
+ starts_at: new Date(data.starts_at),
86
+ ends_at: new Date(data.ends_at),
87
+ },
88
+ ],
89
+ },
90
+ });
91
+ campaign = _campaign;
92
+
93
+ try {
94
+ // Create promotions for each BOGO rule
95
+ // Store BOGO configuration in promotion code for easy lookup
96
+ const { result: _promotions } = await createPromotionsWorkflow.run({
97
+ input: {
98
+ promotionsData: data.rules.map(
99
+ (rule) =>
100
+ ({
101
+ code: `${campaignIdentifier}-${rule.triggerProduct.id}-${rule.rewardProduct.id}`,
102
+ type: "buyget",
103
+ status: "active",
104
+ is_automatic: true,
105
+ campaign_id: campaign.id,
106
+ application_method: {
107
+ target_type: "items",
108
+ allocation: "each",
109
+ type: rule.rewardType === "percentage" ? "percentage" : "fixed",
110
+ value: rule.rewardType === "free" ? 0 : (rule.rewardValue ?? 0),
111
+ buy_rules_min_quantity: rule.triggerQuantity,
112
+ apply_to_quantity: rule.rewardQuantity,
113
+ target_rules: [
114
+ {
115
+ attribute: "items.product.id",
116
+ operator: "eq",
117
+ values: [rule.triggerProduct.id],
118
+ },
119
+ ],
120
+ } satisfies CreateApplicationMethodDTO,
121
+ } satisfies CreatePromotionDTO)
122
+ ),
123
+ },
124
+ });
125
+ promotions = _promotions;
126
+
127
+ // Create custom campaign type
128
+ customCampaignType =
129
+ await customCampaignModuleService.createCustomCampaignTypes({
130
+ campaign_id: campaign.id,
131
+ type: data.type,
132
+ });
133
+
134
+ // Create Buy X Get Y configurations
135
+ buyXGetYConfigs =
136
+ await customCampaignModuleService.createBuyXGetYConfigs(
137
+ data.rules.map((rule, index) => ({
138
+ campaign_id: campaign.id,
139
+ promotion_id: promotions[index].id,
140
+ trigger_product_id: rule.triggerProduct.id,
141
+ trigger_quantity: rule.triggerQuantity,
142
+ reward_product_id: rule.rewardProduct.id,
143
+ reward_quantity: rule.rewardQuantity,
144
+ reward_type: rule.rewardType,
145
+ reward_value: rule.rewardValue ?? null,
146
+ limit: rule.limit ?? null,
147
+ used: 0,
148
+ }))
149
+ );
150
+
151
+ // Link campaign type
152
+ await link.create([
153
+ {
154
+ [Modules.PROMOTION]: {
155
+ campaign_id: campaign.id,
156
+ },
157
+ [CUSTOM_CAMPAIGN_MODULE]: {
158
+ custom_campaign_type_id: customCampaignType.id,
159
+ },
160
+ },
161
+ ]);
162
+
163
+ return new StepResponse({
164
+ campaign,
165
+ promotions,
166
+ buyXGetYConfigs,
167
+ });
168
+ } catch (error) {
169
+ console.log({ error });
170
+
171
+ // Cleanup on error
172
+ await deleteCampaignsWorkflow.run({
173
+ input: {
174
+ ids: campaign.id,
175
+ },
176
+ });
177
+
178
+ if (promotions) {
179
+ await deletePromotionsWorkflow.run({
180
+ input: {
181
+ ids: promotions.map((promotion) => promotion.id),
182
+ },
183
+ });
184
+ }
185
+
186
+ if (customCampaignType) {
187
+ await customCampaignModuleService.deleteCustomCampaignTypes(
188
+ customCampaignType.id
189
+ );
190
+ }
191
+
192
+ if (buyXGetYConfigs) {
193
+ await customCampaignModuleService.deleteBuyXGetYConfigs(
194
+ buyXGetYConfigs.map((config) => config.id)
195
+ );
196
+ }
197
+
198
+ throw error;
199
+ }
200
+ }
201
+ );
202
+
203
+ export const createBuyXGetYCampaignWorkflow = createWorkflow(
204
+ "create-buy-x-get-y-campaign",
205
+ (data: BuyXGetYCampaign) => {
206
+ const campaign = createBuyXGetYCampaignStep(data);
207
+
208
+ return new WorkflowResponse(campaign);
209
+ }
210
+ );
@@ -0,0 +1,190 @@
1
+ import { container } from "@medusajs/framework";
2
+ import {
3
+ CreateApplicationMethodDTO,
4
+ CreatePromotionDTO,
5
+ UpdateCampaignDTO,
6
+ UpdatePromotionDTO,
7
+ } from "@medusajs/framework/types";
8
+ import { MedusaError, Modules } from "@medusajs/framework/utils";
9
+ import {
10
+ createStep,
11
+ createWorkflow,
12
+ StepResponse,
13
+ WorkflowResponse,
14
+ } from "@medusajs/framework/workflows-sdk";
15
+ import {
16
+ createPromotionsWorkflow,
17
+ deletePromotionsWorkflow,
18
+ updateCampaignsWorkflow,
19
+ } from "@medusajs/medusa/core-flows";
20
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../modules/custom-campaigns";
21
+ import CustomCampaignModuleService from "../../modules/custom-campaigns/service";
22
+ import { BuyXGetYCampaign } from "./createBuyXGetYCampaignWorkflow";
23
+
24
+ interface UpdateBuyXGetYInput extends BuyXGetYCampaign {
25
+ id: string;
26
+ }
27
+
28
+ const updateBuyXGetYCampaignStep = createStep(
29
+ "update-buy-x-get-y-campaign-step",
30
+ async (data: UpdateBuyXGetYInput) => {
31
+ const campaign_id = data.id;
32
+
33
+ const customCampaignModuleService =
34
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
35
+ const promotionService = container.resolve(Modules.PROMOTION);
36
+
37
+ if (!campaign_id) {
38
+ throw new MedusaError(
39
+ MedusaError.Types.INVALID_DATA,
40
+ "Campaign ID is required"
41
+ );
42
+ }
43
+
44
+ // Fetch the existing campaign
45
+ const campaign = await promotionService.retrieveCampaign(campaign_id);
46
+
47
+ // Fetch existing Buy X Get Y configurations
48
+ const existingConfigs =
49
+ await customCampaignModuleService.listBuyXGetYConfigs({
50
+ campaign_id: campaign_id,
51
+ });
52
+
53
+ // Create maps for existing configs
54
+ const existingConfigMapByKey = new Map(
55
+ existingConfigs.map((config) => [
56
+ `${config.trigger_product_id}-${config.reward_product_id}`,
57
+ config,
58
+ ])
59
+ );
60
+
61
+ const rulesToCreate = data.rules.filter(
62
+ (rule) =>
63
+ !existingConfigMapByKey.has(
64
+ `${rule.triggerProduct.id}-${rule.rewardProduct.id}`
65
+ )
66
+ );
67
+
68
+ const rulesToUpdate = data.rules.filter((rule) =>
69
+ existingConfigMapByKey.has(
70
+ `${rule.triggerProduct.id}-${rule.rewardProduct.id}`
71
+ )
72
+ );
73
+
74
+ const inputRuleKeySet = new Set(
75
+ data.rules.map(
76
+ (rule) => `${rule.triggerProduct.id}-${rule.rewardProduct.id}`
77
+ )
78
+ );
79
+
80
+ const configsToDelete = existingConfigs.filter(
81
+ (config) =>
82
+ !inputRuleKeySet.has(
83
+ `${config.trigger_product_id}-${config.reward_product_id}`
84
+ )
85
+ );
86
+
87
+ // 1. Update campaign details
88
+ await updateCampaignsWorkflow.run({
89
+ input: {
90
+ campaignsData: [
91
+ {
92
+ id: campaign_id,
93
+ name: data.name,
94
+ description: data.description,
95
+ starts_at: new Date(data.starts_at),
96
+ ends_at: new Date(data.ends_at),
97
+ } satisfies UpdateCampaignDTO,
98
+ ],
99
+ },
100
+ });
101
+
102
+ // 2. Update existing rules
103
+ await customCampaignModuleService.updateBuyXGetYConfigs(
104
+ rulesToUpdate.map((rule) => {
105
+ const key = `${rule.triggerProduct.id}-${rule.rewardProduct.id}`;
106
+ const config = existingConfigMapByKey.get(key);
107
+
108
+ return {
109
+ id: config?.id ?? "",
110
+ trigger_quantity: rule.triggerQuantity,
111
+ reward_quantity: rule.rewardQuantity,
112
+ reward_type: rule.rewardType,
113
+ reward_value: rule.rewardValue ?? null,
114
+ limit: rule.limit ?? null,
115
+ };
116
+ })
117
+ );
118
+
119
+ // 3. Delete old configurations and their promotions
120
+ if (configsToDelete.length > 0) {
121
+ await deletePromotionsWorkflow.run({
122
+ input: {
123
+ ids: configsToDelete.map((config) => config.promotion_id),
124
+ },
125
+ });
126
+ await customCampaignModuleService.softDeleteBuyXGetYConfigs(
127
+ configsToDelete.map((config) => config.id)
128
+ );
129
+ }
130
+
131
+ // 4. Create new rules
132
+ if (rulesToCreate.length > 0) {
133
+ const { result: createdPromotions } = await createPromotionsWorkflow.run({
134
+ input: {
135
+ promotionsData: rulesToCreate.map(
136
+ (rule) =>
137
+ ({
138
+ code: `${campaign.campaign_identifier}-${rule.triggerProduct.id}-${rule.rewardProduct.id}`,
139
+ type: "buyget",
140
+ status: "active",
141
+ is_automatic: true,
142
+ campaign_id: campaign_id,
143
+ application_method: {
144
+ target_type: "items",
145
+ allocation: "each",
146
+ type: rule.rewardType === "percentage" ? "percentage" : "fixed",
147
+ value: rule.rewardType === "free" ? 0 : (rule.rewardValue ?? 0),
148
+ buy_rules_min_quantity: rule.triggerQuantity,
149
+ apply_to_quantity: rule.rewardQuantity,
150
+ target_rules: [
151
+ {
152
+ attribute: "items.product.id",
153
+ operator: "eq",
154
+ values: [rule.triggerProduct.id],
155
+ },
156
+ ],
157
+ } satisfies CreateApplicationMethodDTO,
158
+ } satisfies CreatePromotionDTO)
159
+ ),
160
+ },
161
+ });
162
+
163
+ await customCampaignModuleService.createBuyXGetYConfigs(
164
+ rulesToCreate.map((rule, index) => ({
165
+ campaign_id: campaign.id,
166
+ promotion_id: createdPromotions[index].id,
167
+ trigger_product_id: rule.triggerProduct.id,
168
+ trigger_quantity: rule.triggerQuantity,
169
+ reward_product_id: rule.rewardProduct.id,
170
+ reward_quantity: rule.rewardQuantity,
171
+ reward_type: rule.rewardType,
172
+ reward_value: rule.rewardValue ?? null,
173
+ limit: rule.limit ?? null,
174
+ used: 0,
175
+ }))
176
+ );
177
+ }
178
+
179
+ return new StepResponse();
180
+ }
181
+ );
182
+
183
+ export const updateBuyXGetYCampaignWorkflow = createWorkflow(
184
+ "update-buy-x-get-y-campaign",
185
+ (data: UpdateBuyXGetYInput) => {
186
+ const updatedCampaign = updateBuyXGetYCampaignStep(data);
187
+
188
+ return new WorkflowResponse(updatedCampaign);
189
+ }
190
+ );
@@ -0,0 +1,86 @@
1
+ import { Modules } from "@medusajs/framework/utils";
2
+ import {
3
+ createStep,
4
+ createWorkflow,
5
+ WorkflowResponse,
6
+ } from "@medusajs/framework/workflows-sdk";
7
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../modules/custom-campaigns";
8
+ import CustomCampaignModuleService from "../../modules/custom-campaigns/service";
9
+
10
+ interface UpdateBuyXGetYUsageInput {
11
+ order_id: string;
12
+ }
13
+
14
+ const updateBuyXGetYUsageStep = createStep(
15
+ "update-buy-x-get-y-usage-step",
16
+ async ({ order_id }: UpdateBuyXGetYUsageInput, { container }) => {
17
+ const orderService = await container.resolve(Modules.ORDER);
18
+ const promotionService = await container.resolve(Modules.PROMOTION);
19
+ const customCampaignTypeService =
20
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
21
+
22
+ const order = await orderService.retrieveOrder(order_id, {
23
+ relations: ["items"],
24
+ });
25
+
26
+ // Find all BOGO reward items in the order
27
+ const bogoRewardItems = order.items?.filter(
28
+ (item) => item.metadata?.is_bogo_reward === true
29
+ );
30
+
31
+ if (!bogoRewardItems || bogoRewardItems.length === 0) {
32
+ return; // No BOGO items in this order
33
+ }
34
+
35
+ // Extract BOGO config IDs from reward items
36
+ const bogoConfigIds = Array.from(
37
+ new Set(
38
+ bogoRewardItems
39
+ .map((item) => item.metadata?.bogo_config_id as string)
40
+ .filter(Boolean)
41
+ )
42
+ );
43
+
44
+ if (bogoConfigIds.length === 0) {
45
+ return;
46
+ }
47
+
48
+ // Get the BOGO configurations
49
+ const buyXGetYConfigs =
50
+ await customCampaignTypeService.listBuyXGetYConfigs({
51
+ id: bogoConfigIds,
52
+ });
53
+
54
+ // Update usage count for each config
55
+ const updatePayload = buyXGetYConfigs.map((config) => ({
56
+ ...config,
57
+ used: config.used + 1,
58
+ }));
59
+
60
+ // Find configs that have reached their limit
61
+ const limitReachedConfigs = updatePayload.filter(
62
+ (config) => config.limit && config.used >= config.limit
63
+ );
64
+
65
+ // Mark promotions as inactive if limit reached
66
+ if (limitReachedConfigs.length > 0) {
67
+ await promotionService.updatePromotions(
68
+ limitReachedConfigs.map((config) => ({
69
+ id: config.promotion_id,
70
+ status: "inactive",
71
+ }))
72
+ );
73
+ }
74
+
75
+ // Update the usage counts
76
+ await customCampaignTypeService.updateBuyXGetYConfigs(updatePayload);
77
+ }
78
+ );
79
+
80
+ export const updateBuyXGetYUsageWorkflow = createWorkflow(
81
+ "update-buy-x-get-y-usage",
82
+ (data: UpdateBuyXGetYUsageInput) => {
83
+ updateBuyXGetYUsageStep(data);
84
+ return new WorkflowResponse({ success: true });
85
+ }
86
+ );