mumz-strapi-plugin-coupon 2.0.0 → 3.0.0
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.
- package/dist/admin/index.js +677 -0
- package/dist/admin/index.mjs +678 -0
- package/dist/server/index.js +1201 -0
- package/dist/server/index.mjs +1202 -0
- package/package.json +42 -18
- package/strapi-admin.js +3 -0
- package/strapi-server.js +3 -1
- package/dist/bootstrap.d.ts +0 -5
- package/dist/bootstrap.js +0 -6
- package/dist/config/index.d.ts +0 -5
- package/dist/config/index.js +0 -6
- package/dist/content-types/coupon/index.d.ts +0 -96
- package/dist/content-types/coupon/index.js +0 -9
- package/dist/content-types/coupon/schema.d.ts +0 -94
- package/dist/content-types/coupon/schema.js +0 -117
- package/dist/content-types/index.d.ts +0 -161
- package/dist/content-types/index.js +0 -11
- package/dist/content-types/redemption/index.d.ts +0 -64
- package/dist/content-types/redemption/index.js +0 -9
- package/dist/content-types/redemption/schema.d.ts +0 -62
- package/dist/content-types/redemption/schema.js +0 -74
- package/dist/controllers/coupon.d.ts +0 -41
- package/dist/controllers/coupon.js +0 -154
- package/dist/controllers/index.d.ts +0 -5
- package/dist/controllers/index.js +0 -9
- package/dist/destroy.d.ts +0 -5
- package/dist/destroy.js +0 -6
- package/dist/index.d.ts +0 -207
- package/dist/index.js +0 -24
- package/dist/middlewares/index.d.ts +0 -4
- package/dist/middlewares/index.js +0 -9
- package/dist/middlewares/rate-limit.d.ts +0 -6
- package/dist/middlewares/rate-limit.js +0 -42
- package/dist/register.d.ts +0 -5
- package/dist/register.js +0 -6
- package/dist/routes/content-api/index.d.ts +0 -23
- package/dist/routes/content-api/index.js +0 -76
- package/dist/routes/index.d.ts +0 -25
- package/dist/routes/index.js +0 -9
- package/dist/services/coupon.d.ts +0 -64
- package/dist/services/coupon.js +0 -432
- package/dist/services/index.d.ts +0 -5
- package/dist/services/index.js +0 -9
- package/dist/utils/validators.d.ts +0 -14
- package/dist/utils/validators.js +0 -41
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
const register = ({ strapi }) => {
|
|
2
|
+
strapi.log.info("Coupon plugin registered");
|
|
3
|
+
};
|
|
4
|
+
const bootstrap = ({ strapi }) => {
|
|
5
|
+
strapi.log.info("Coupon plugin bootstrapped");
|
|
6
|
+
};
|
|
7
|
+
const destroy = ({ strapi }) => {
|
|
8
|
+
strapi.log.info("Coupon plugin destroyed");
|
|
9
|
+
};
|
|
10
|
+
const config = {
|
|
11
|
+
default: {},
|
|
12
|
+
validator() {
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const schema$1 = {
|
|
16
|
+
kind: "collectionType",
|
|
17
|
+
collectionName: "coupons",
|
|
18
|
+
info: {
|
|
19
|
+
singularName: "coupon",
|
|
20
|
+
pluralName: "coupons",
|
|
21
|
+
displayName: "Coupon",
|
|
22
|
+
description: "Coupon management for discounts and promotions"
|
|
23
|
+
},
|
|
24
|
+
options: {
|
|
25
|
+
draftAndPublish: false
|
|
26
|
+
},
|
|
27
|
+
pluginOptions: {
|
|
28
|
+
"content-manager": {
|
|
29
|
+
visible: true
|
|
30
|
+
},
|
|
31
|
+
"content-type-builder": {
|
|
32
|
+
visible: true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
layouts: {
|
|
36
|
+
list: ["code", "discountType", "discountValue", "currentUsage", "maxUsage", "maxUsagePerUser", "isActive", "validFrom", "validTo"],
|
|
37
|
+
edit: [
|
|
38
|
+
// -- Coupon Info --
|
|
39
|
+
[
|
|
40
|
+
{ name: "code", size: 6 },
|
|
41
|
+
{ name: "isActive", size: 6 }
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
{ name: "discountType", size: 6 },
|
|
45
|
+
{ name: "discountValue", size: 6 }
|
|
46
|
+
],
|
|
47
|
+
// -- Usage Limits --
|
|
48
|
+
[
|
|
49
|
+
{ name: "maxUsage", size: 4 },
|
|
50
|
+
{ name: "maxUsagePerUser", size: 4 },
|
|
51
|
+
{ name: "currentUsage", size: 4 }
|
|
52
|
+
],
|
|
53
|
+
// -- Validity Period --
|
|
54
|
+
[
|
|
55
|
+
{ name: "validFrom", size: 6 },
|
|
56
|
+
{ name: "validTo", size: 6 }
|
|
57
|
+
],
|
|
58
|
+
// -- Additional Info --
|
|
59
|
+
[{ name: "description", size: 12 }],
|
|
60
|
+
// -- Restrictions --
|
|
61
|
+
[{ name: "userRestrictions", size: 12 }]
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
attributes: {
|
|
65
|
+
code: {
|
|
66
|
+
type: "string",
|
|
67
|
+
required: true,
|
|
68
|
+
unique: true,
|
|
69
|
+
configurable: false
|
|
70
|
+
},
|
|
71
|
+
discountType: {
|
|
72
|
+
type: "enumeration",
|
|
73
|
+
enum: ["percentage", "flat"],
|
|
74
|
+
required: true,
|
|
75
|
+
default: "percentage"
|
|
76
|
+
},
|
|
77
|
+
discountValue: {
|
|
78
|
+
type: "decimal",
|
|
79
|
+
required: true,
|
|
80
|
+
min: 0
|
|
81
|
+
},
|
|
82
|
+
maxUsage: {
|
|
83
|
+
type: "integer",
|
|
84
|
+
required: false,
|
|
85
|
+
min: 0,
|
|
86
|
+
default: null
|
|
87
|
+
},
|
|
88
|
+
maxUsagePerUser: {
|
|
89
|
+
type: "integer",
|
|
90
|
+
required: false,
|
|
91
|
+
min: 0,
|
|
92
|
+
default: null
|
|
93
|
+
},
|
|
94
|
+
currentUsage: {
|
|
95
|
+
type: "integer",
|
|
96
|
+
required: true,
|
|
97
|
+
default: 0,
|
|
98
|
+
min: 0
|
|
99
|
+
},
|
|
100
|
+
validFrom: {
|
|
101
|
+
type: "datetime",
|
|
102
|
+
required: true
|
|
103
|
+
},
|
|
104
|
+
validTo: {
|
|
105
|
+
type: "datetime",
|
|
106
|
+
required: true
|
|
107
|
+
},
|
|
108
|
+
isActive: {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
default: true,
|
|
111
|
+
required: true
|
|
112
|
+
},
|
|
113
|
+
description: {
|
|
114
|
+
type: "text",
|
|
115
|
+
required: false
|
|
116
|
+
},
|
|
117
|
+
userRestrictions: {
|
|
118
|
+
type: "json",
|
|
119
|
+
required: false,
|
|
120
|
+
default: null
|
|
121
|
+
},
|
|
122
|
+
redemptions: {
|
|
123
|
+
type: "relation",
|
|
124
|
+
relation: "oneToMany",
|
|
125
|
+
target: "plugin::coupon.redemption",
|
|
126
|
+
mappedBy: "coupon"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const coupon$1 = {
|
|
131
|
+
schema: schema$1
|
|
132
|
+
};
|
|
133
|
+
const schema = {
|
|
134
|
+
kind: "collectionType",
|
|
135
|
+
collectionName: "coupon_redemptions",
|
|
136
|
+
info: {
|
|
137
|
+
singularName: "redemption",
|
|
138
|
+
pluralName: "redemptions",
|
|
139
|
+
displayName: "Coupon Redemption",
|
|
140
|
+
description: "Tracks coupon redemptions for audit trail"
|
|
141
|
+
},
|
|
142
|
+
options: {
|
|
143
|
+
draftAndPublish: false
|
|
144
|
+
},
|
|
145
|
+
pluginOptions: {
|
|
146
|
+
"content-manager": {
|
|
147
|
+
visible: true
|
|
148
|
+
},
|
|
149
|
+
"content-type-builder": {
|
|
150
|
+
visible: true
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
layouts: {
|
|
154
|
+
list: ["orderId", "phoneNumber", "redemptionDate", "coupon"],
|
|
155
|
+
edit: [
|
|
156
|
+
[
|
|
157
|
+
{ name: "orderId", size: 6 },
|
|
158
|
+
{ name: "phoneNumber", size: 6 }
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
{ name: "redemptionDate", size: 6 },
|
|
162
|
+
{ name: "coupon", size: 6 }
|
|
163
|
+
],
|
|
164
|
+
[
|
|
165
|
+
{ name: "discountType", size: 6 },
|
|
166
|
+
{ name: "discountValue", size: 6 }
|
|
167
|
+
],
|
|
168
|
+
[{ name: "metadata", size: 12 }]
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
attributes: {
|
|
172
|
+
coupon: {
|
|
173
|
+
type: "relation",
|
|
174
|
+
relation: "manyToOne",
|
|
175
|
+
target: "plugin::coupon.coupon",
|
|
176
|
+
inversedBy: "redemptions"
|
|
177
|
+
},
|
|
178
|
+
orderId: {
|
|
179
|
+
type: "string",
|
|
180
|
+
required: true
|
|
181
|
+
},
|
|
182
|
+
phoneNumber: {
|
|
183
|
+
type: "string",
|
|
184
|
+
required: true
|
|
185
|
+
},
|
|
186
|
+
redemptionDate: {
|
|
187
|
+
type: "datetime",
|
|
188
|
+
required: true
|
|
189
|
+
},
|
|
190
|
+
discountType: {
|
|
191
|
+
type: "string",
|
|
192
|
+
required: true
|
|
193
|
+
},
|
|
194
|
+
discountValue: {
|
|
195
|
+
type: "decimal",
|
|
196
|
+
required: true
|
|
197
|
+
},
|
|
198
|
+
metadata: {
|
|
199
|
+
type: "json",
|
|
200
|
+
required: false,
|
|
201
|
+
default: null
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const redemption = {
|
|
206
|
+
schema
|
|
207
|
+
};
|
|
208
|
+
const contentTypes = {
|
|
209
|
+
coupon: coupon$1,
|
|
210
|
+
redemption
|
|
211
|
+
};
|
|
212
|
+
const coupon = ({ strapi }) => ({
|
|
213
|
+
/**
|
|
214
|
+
* Validate coupon endpoint
|
|
215
|
+
* POST /api/coupons/validate
|
|
216
|
+
*/
|
|
217
|
+
async validate(ctx) {
|
|
218
|
+
try {
|
|
219
|
+
const { couponCode, phoneNumber, orderAmount } = ctx.request.body;
|
|
220
|
+
if (!couponCode || !phoneNumber) {
|
|
221
|
+
return ctx.badRequest("Missing required fields: couponCode, phoneNumber");
|
|
222
|
+
}
|
|
223
|
+
const result = await strapi.plugin("coupon").service("coupon").validate({
|
|
224
|
+
couponCode,
|
|
225
|
+
phoneNumber,
|
|
226
|
+
orderAmount
|
|
227
|
+
});
|
|
228
|
+
ctx.body = result;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
strapi.log.error("Controller error in validate:", error);
|
|
231
|
+
ctx.internalServerError("An error occurred while validating the coupon");
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
/**
|
|
235
|
+
* Redeem coupon endpoint
|
|
236
|
+
* POST /api/coupons/redeem
|
|
237
|
+
*/
|
|
238
|
+
async redeem(ctx) {
|
|
239
|
+
try {
|
|
240
|
+
const { couponCode, phoneNumber, orderId, orderAmount } = ctx.request.body;
|
|
241
|
+
if (!couponCode || !phoneNumber || !orderId) {
|
|
242
|
+
return ctx.badRequest(
|
|
243
|
+
"Missing required fields: couponCode, phoneNumber, orderId"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const result = await strapi.plugin("coupon").service("coupon").redeem({
|
|
247
|
+
couponCode,
|
|
248
|
+
phoneNumber,
|
|
249
|
+
orderId,
|
|
250
|
+
orderAmount
|
|
251
|
+
});
|
|
252
|
+
ctx.body = result;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
strapi.log.error("Controller error in redeem:", error);
|
|
255
|
+
ctx.internalServerError("An error occurred while redeeming the coupon");
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
/**
|
|
259
|
+
* Create coupon endpoint (Admin only)
|
|
260
|
+
* POST /api/coupons
|
|
261
|
+
*/
|
|
262
|
+
async create(ctx) {
|
|
263
|
+
try {
|
|
264
|
+
const {
|
|
265
|
+
code,
|
|
266
|
+
discountType,
|
|
267
|
+
discountValue,
|
|
268
|
+
maxUsage,
|
|
269
|
+
validFrom,
|
|
270
|
+
validTo,
|
|
271
|
+
description,
|
|
272
|
+
userRestrictions
|
|
273
|
+
} = ctx.request.body;
|
|
274
|
+
if (!code || !discountType || !discountValue || !validFrom || !validTo) {
|
|
275
|
+
return ctx.badRequest(
|
|
276
|
+
"Missing required fields: code, discountType, discountValue, validFrom, validTo"
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const result = await strapi.plugin("coupon").service("coupon").create({
|
|
280
|
+
code,
|
|
281
|
+
discountType,
|
|
282
|
+
discountValue,
|
|
283
|
+
maxUsage,
|
|
284
|
+
validFrom,
|
|
285
|
+
validTo,
|
|
286
|
+
description,
|
|
287
|
+
userRestrictions
|
|
288
|
+
});
|
|
289
|
+
if (!result.success) {
|
|
290
|
+
return ctx.badRequest(result.message, { errorCode: result.errorCode });
|
|
291
|
+
}
|
|
292
|
+
ctx.body = result;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
strapi.log.error("Controller error in create:", error);
|
|
295
|
+
ctx.internalServerError("An error occurred while creating the coupon");
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
/**
|
|
299
|
+
* Find all coupons
|
|
300
|
+
* GET /api/coupons
|
|
301
|
+
*/
|
|
302
|
+
async find(ctx) {
|
|
303
|
+
try {
|
|
304
|
+
const coupons = await strapi.plugin("coupon").service("coupon").findAll(ctx.query);
|
|
305
|
+
ctx.body = coupons;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
strapi.log.error("Controller error in find:", error);
|
|
308
|
+
ctx.internalServerError("An error occurred while fetching coupons");
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
/**
|
|
312
|
+
* Find one coupon
|
|
313
|
+
* GET /api/coupons/:id
|
|
314
|
+
*/
|
|
315
|
+
async findOne(ctx) {
|
|
316
|
+
try {
|
|
317
|
+
const { id } = ctx.params;
|
|
318
|
+
const coupon2 = await strapi.plugin("coupon").service("coupon").findOne(id);
|
|
319
|
+
if (!coupon2) {
|
|
320
|
+
return ctx.notFound("Coupon not found");
|
|
321
|
+
}
|
|
322
|
+
ctx.body = coupon2;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
strapi.log.error("Controller error in findOne:", error);
|
|
325
|
+
ctx.internalServerError("An error occurred while fetching the coupon");
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Update coupon
|
|
330
|
+
* PUT /api/coupons/:id
|
|
331
|
+
*/
|
|
332
|
+
async update(ctx) {
|
|
333
|
+
try {
|
|
334
|
+
const { id } = ctx.params;
|
|
335
|
+
const data = ctx.request.body;
|
|
336
|
+
const coupon2 = await strapi.plugin("coupon").service("coupon").update(id, data);
|
|
337
|
+
ctx.body = coupon2;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
strapi.log.error("Controller error in update:", error);
|
|
340
|
+
ctx.internalServerError("An error occurred while updating the coupon");
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
/**
|
|
344
|
+
* Delete coupon
|
|
345
|
+
* DELETE /api/coupons/:id
|
|
346
|
+
*/
|
|
347
|
+
async delete(ctx) {
|
|
348
|
+
try {
|
|
349
|
+
const { id } = ctx.params;
|
|
350
|
+
const coupon2 = await strapi.plugin("coupon").service("coupon").delete(id);
|
|
351
|
+
ctx.body = coupon2;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
strapi.log.error("Controller error in delete:", error);
|
|
354
|
+
ctx.internalServerError("An error occurred while deleting the coupon");
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
/**
|
|
358
|
+
* Download CSV template
|
|
359
|
+
* GET /api/coupons/csv-template
|
|
360
|
+
*/
|
|
361
|
+
async getCsvTemplate(ctx) {
|
|
362
|
+
try {
|
|
363
|
+
const csvContent = strapi.plugin("coupon").service("coupon").generateCsvTemplate();
|
|
364
|
+
ctx.set("Content-Type", "text/csv");
|
|
365
|
+
ctx.set("Content-Disposition", 'attachment; filename="coupon-template.csv"');
|
|
366
|
+
ctx.body = csvContent;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
strapi.log.error("Controller error in getCsvTemplate:", error);
|
|
369
|
+
ctx.internalServerError("An error occurred while generating the CSV template");
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
/**
|
|
373
|
+
* Bulk create coupons from CSV data
|
|
374
|
+
* POST /api/coupons/bulk-create
|
|
375
|
+
*
|
|
376
|
+
* Request body format:
|
|
377
|
+
* {
|
|
378
|
+
* coupons: Array<{
|
|
379
|
+
* code: string,
|
|
380
|
+
* discountType: 'percentage' | 'flat',
|
|
381
|
+
* discountValue: number,
|
|
382
|
+
* maxUsage?: number,
|
|
383
|
+
* maxUsagePerUser?: number,
|
|
384
|
+
* validFrom: string,
|
|
385
|
+
* validTo: string,
|
|
386
|
+
* description?: string,
|
|
387
|
+
* userRestrictions?: string[]
|
|
388
|
+
* }>
|
|
389
|
+
* }
|
|
390
|
+
*/
|
|
391
|
+
async bulkCreate(ctx) {
|
|
392
|
+
try {
|
|
393
|
+
const { coupons } = ctx.request.body;
|
|
394
|
+
if (!coupons || !Array.isArray(coupons)) {
|
|
395
|
+
return ctx.badRequest("Missing or invalid coupons array");
|
|
396
|
+
}
|
|
397
|
+
if (coupons.length === 0) {
|
|
398
|
+
return ctx.badRequest("Coupons array is empty");
|
|
399
|
+
}
|
|
400
|
+
if (coupons.length > 1e4) {
|
|
401
|
+
return ctx.badRequest("Maximum 10,000 coupons allowed per bulk import");
|
|
402
|
+
}
|
|
403
|
+
const couponService2 = strapi.plugin("coupon").service("coupon");
|
|
404
|
+
const parsedCoupons = coupons.map((row, index) => {
|
|
405
|
+
if (row._row !== void 0) {
|
|
406
|
+
return row;
|
|
407
|
+
}
|
|
408
|
+
return couponService2.parseCsvRow(row, index + 1);
|
|
409
|
+
});
|
|
410
|
+
const result = await couponService2.bulkCreate(parsedCoupons);
|
|
411
|
+
ctx.body = result;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
strapi.log.error("Controller error in bulkCreate:", error);
|
|
414
|
+
ctx.internalServerError("An error occurred while bulk creating coupons");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
const controllers = {
|
|
419
|
+
coupon
|
|
420
|
+
};
|
|
421
|
+
const rateLimitStore = /* @__PURE__ */ new Map();
|
|
422
|
+
setInterval(() => {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
for (const [key, value] of rateLimitStore.entries()) {
|
|
425
|
+
if (now > value.resetAt) {
|
|
426
|
+
rateLimitStore.delete(key);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}, 36e5);
|
|
430
|
+
const rateLimit = (config2) => {
|
|
431
|
+
return async (ctx, next) => {
|
|
432
|
+
const identifier = ctx.ip || ctx.request.ip || "unknown";
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
const record = rateLimitStore.get(identifier);
|
|
435
|
+
if (!record || now > record.resetAt) {
|
|
436
|
+
rateLimitStore.set(identifier, {
|
|
437
|
+
count: 1,
|
|
438
|
+
resetAt: now + config2.windowMs
|
|
439
|
+
});
|
|
440
|
+
return next();
|
|
441
|
+
}
|
|
442
|
+
if (record.count >= config2.maxRequests) {
|
|
443
|
+
ctx.status = 429;
|
|
444
|
+
ctx.body = {
|
|
445
|
+
error: "Too many requests",
|
|
446
|
+
message: `Rate limit exceeded. Please try again in ${Math.ceil((record.resetAt - now) / 1e3)} seconds.`,
|
|
447
|
+
retryAfter: Math.ceil((record.resetAt - now) / 1e3)
|
|
448
|
+
};
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
record.count++;
|
|
452
|
+
return next();
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
const contentApi = {
|
|
456
|
+
type: "content-api",
|
|
457
|
+
routes: [
|
|
458
|
+
// Static paths MUST come before dynamic :id routes
|
|
459
|
+
{
|
|
460
|
+
method: "GET",
|
|
461
|
+
path: "/csv-template",
|
|
462
|
+
handler: "coupon.getCsvTemplate",
|
|
463
|
+
config: {
|
|
464
|
+
policies: [],
|
|
465
|
+
auth: false
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
method: "POST",
|
|
470
|
+
path: "/bulk-create",
|
|
471
|
+
handler: "coupon.bulkCreate",
|
|
472
|
+
config: {
|
|
473
|
+
policies: [],
|
|
474
|
+
auth: false
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
method: "POST",
|
|
479
|
+
path: "/validate",
|
|
480
|
+
handler: "coupon.validate",
|
|
481
|
+
config: {
|
|
482
|
+
policies: [],
|
|
483
|
+
middlewares: [rateLimit({ maxRequests: 10, windowMs: 6e4 })],
|
|
484
|
+
auth: false
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
method: "POST",
|
|
489
|
+
path: "/redeem",
|
|
490
|
+
handler: "coupon.redeem",
|
|
491
|
+
config: {
|
|
492
|
+
policies: [],
|
|
493
|
+
middlewares: [rateLimit({ maxRequests: 5, windowMs: 6e4 })],
|
|
494
|
+
auth: false
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
method: "POST",
|
|
499
|
+
path: "/",
|
|
500
|
+
handler: "coupon.create",
|
|
501
|
+
config: {
|
|
502
|
+
policies: [],
|
|
503
|
+
auth: false
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
method: "GET",
|
|
508
|
+
path: "/",
|
|
509
|
+
handler: "coupon.find",
|
|
510
|
+
config: {
|
|
511
|
+
policies: [],
|
|
512
|
+
auth: false
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
// Dynamic :id routes MUST come last
|
|
516
|
+
{
|
|
517
|
+
method: "GET",
|
|
518
|
+
path: "/:id",
|
|
519
|
+
handler: "coupon.findOne",
|
|
520
|
+
config: {
|
|
521
|
+
policies: [],
|
|
522
|
+
auth: false
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
method: "PUT",
|
|
527
|
+
path: "/:id",
|
|
528
|
+
handler: "coupon.update",
|
|
529
|
+
config: {
|
|
530
|
+
policies: [],
|
|
531
|
+
auth: false
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
method: "DELETE",
|
|
536
|
+
path: "/:id",
|
|
537
|
+
handler: "coupon.delete",
|
|
538
|
+
config: {
|
|
539
|
+
policies: [],
|
|
540
|
+
auth: false
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
]
|
|
544
|
+
};
|
|
545
|
+
const routes = {
|
|
546
|
+
"content-api": contentApi
|
|
547
|
+
};
|
|
548
|
+
const PHONE_REGEX = /^\+?[1-9]\d{1,14}$/;
|
|
549
|
+
const ORDER_ID_REGEX = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
550
|
+
const COUPON_CODE_REGEX = /^[A-Z0-9]{3,50}$/;
|
|
551
|
+
function validatePhoneNumber(phone) {
|
|
552
|
+
return typeof phone === "string" && phone.length >= 10 && phone.length <= 15 && PHONE_REGEX.test(phone);
|
|
553
|
+
}
|
|
554
|
+
function validateOrderId(orderId) {
|
|
555
|
+
return typeof orderId === "string" && orderId.length >= 1 && orderId.length <= 100 && ORDER_ID_REGEX.test(orderId);
|
|
556
|
+
}
|
|
557
|
+
function validateCouponCode(code) {
|
|
558
|
+
return typeof code === "string" && code.length >= 3 && code.length <= 50 && COUPON_CODE_REGEX.test(code);
|
|
559
|
+
}
|
|
560
|
+
function validateDiscountValue(discountType, discountValue) {
|
|
561
|
+
if (typeof discountValue !== "number" || discountValue <= 0) {
|
|
562
|
+
return { valid: false, error: "Discount value must be greater than 0" };
|
|
563
|
+
}
|
|
564
|
+
if (discountType === "percentage" && discountValue > 100) {
|
|
565
|
+
return { valid: false, error: "Percentage discount cannot exceed 100%" };
|
|
566
|
+
}
|
|
567
|
+
return { valid: true };
|
|
568
|
+
}
|
|
569
|
+
function validateDateRange(validFrom, validTo) {
|
|
570
|
+
const validFromDate = new Date(validFrom);
|
|
571
|
+
const validToDate = new Date(validTo);
|
|
572
|
+
if (isNaN(validFromDate.getTime()) || isNaN(validToDate.getTime())) {
|
|
573
|
+
return { valid: false, error: "Invalid date format" };
|
|
574
|
+
}
|
|
575
|
+
if (validFromDate >= validToDate) {
|
|
576
|
+
return { valid: false, error: "validFrom must be before validTo" };
|
|
577
|
+
}
|
|
578
|
+
return { valid: true };
|
|
579
|
+
}
|
|
580
|
+
const ERROR_CODES = {
|
|
581
|
+
COUPON_NOT_FOUND: "COUPON_NOT_FOUND",
|
|
582
|
+
COUPON_EXPIRED: "COUPON_EXPIRED",
|
|
583
|
+
COUPON_ALREADY_USED: "COUPON_ALREADY_USED",
|
|
584
|
+
USAGE_LIMIT_REACHED: "USAGE_LIMIT_REACHED",
|
|
585
|
+
INVALID_REQUEST: "INVALID_REQUEST",
|
|
586
|
+
USER_RESTRICTED: "USER_RESTRICTED"
|
|
587
|
+
};
|
|
588
|
+
const ERROR_MESSAGES = {
|
|
589
|
+
COUPON_NOT_FOUND: "Coupon code does not exist.",
|
|
590
|
+
COUPON_EXPIRED: "This coupon has expired.",
|
|
591
|
+
COUPON_ALREADY_USED: "This coupon has been already used.",
|
|
592
|
+
USAGE_LIMIT_REACHED: "Coupon usage limit has been reached.",
|
|
593
|
+
INVALID_REQUEST: "Invalid request parameters.",
|
|
594
|
+
USER_RESTRICTED: "This coupon is not available for your phone number."
|
|
595
|
+
};
|
|
596
|
+
const CSV_COLUMNS = [
|
|
597
|
+
"code",
|
|
598
|
+
"discountType",
|
|
599
|
+
"discountValue",
|
|
600
|
+
"maxUsage",
|
|
601
|
+
"maxUsagePerUser",
|
|
602
|
+
"validFrom",
|
|
603
|
+
"validTo",
|
|
604
|
+
"isActive",
|
|
605
|
+
"description",
|
|
606
|
+
"userRestrictions"
|
|
607
|
+
];
|
|
608
|
+
const couponService = ({ strapi }) => ({
|
|
609
|
+
/**
|
|
610
|
+
* Validate a coupon for a specific service and user
|
|
611
|
+
*/
|
|
612
|
+
async validate(params) {
|
|
613
|
+
const { couponCode, phoneNumber, orderAmount = 0 } = params;
|
|
614
|
+
try {
|
|
615
|
+
if (!validateCouponCode(couponCode)) {
|
|
616
|
+
return {
|
|
617
|
+
isValid: false,
|
|
618
|
+
errorCode: ERROR_CODES.INVALID_REQUEST,
|
|
619
|
+
message: "Invalid coupon code format"
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
if (!validatePhoneNumber(phoneNumber)) {
|
|
623
|
+
return {
|
|
624
|
+
isValid: false,
|
|
625
|
+
errorCode: ERROR_CODES.INVALID_REQUEST,
|
|
626
|
+
message: "Invalid phone number format"
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (orderAmount < 0) {
|
|
630
|
+
return {
|
|
631
|
+
isValid: false,
|
|
632
|
+
errorCode: ERROR_CODES.INVALID_REQUEST,
|
|
633
|
+
message: "Order amount cannot be negative"
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
const coupons = await strapi.entityService.findMany(
|
|
637
|
+
"plugin::coupon.coupon",
|
|
638
|
+
{
|
|
639
|
+
filters: {
|
|
640
|
+
code: couponCode
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
);
|
|
644
|
+
const coupon2 = Array.isArray(coupons) && coupons.length > 0 ? coupons[0] : null;
|
|
645
|
+
if (!coupon2) {
|
|
646
|
+
return {
|
|
647
|
+
isValid: false,
|
|
648
|
+
errorCode: ERROR_CODES.COUPON_NOT_FOUND,
|
|
649
|
+
message: ERROR_MESSAGES.COUPON_NOT_FOUND
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (!coupon2.isActive) {
|
|
653
|
+
return {
|
|
654
|
+
isValid: false,
|
|
655
|
+
errorCode: ERROR_CODES.COUPON_EXPIRED,
|
|
656
|
+
message: ERROR_MESSAGES.COUPON_EXPIRED
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const now = /* @__PURE__ */ new Date();
|
|
660
|
+
const validFrom = new Date(coupon2.validFrom);
|
|
661
|
+
const validTo = new Date(coupon2.validTo);
|
|
662
|
+
if (now < validFrom || now > validTo) {
|
|
663
|
+
return {
|
|
664
|
+
isValid: false,
|
|
665
|
+
errorCode: ERROR_CODES.COUPON_EXPIRED,
|
|
666
|
+
message: ERROR_MESSAGES.COUPON_EXPIRED
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (coupon2.maxUsage && coupon2.currentUsage >= coupon2.maxUsage) {
|
|
670
|
+
return {
|
|
671
|
+
isValid: false,
|
|
672
|
+
errorCode: ERROR_CODES.USAGE_LIMIT_REACHED,
|
|
673
|
+
message: ERROR_MESSAGES.USAGE_LIMIT_REACHED
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
const existingRedemptions = await strapi.entityService.findMany(
|
|
677
|
+
"plugin::coupon.redemption",
|
|
678
|
+
{
|
|
679
|
+
filters: {
|
|
680
|
+
coupon: coupon2.id,
|
|
681
|
+
phoneNumber
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
);
|
|
685
|
+
const userRedemptionCount = Array.isArray(existingRedemptions) ? existingRedemptions.length : 0;
|
|
686
|
+
const perUserLimit = coupon2.maxUsagePerUser ?? 1;
|
|
687
|
+
if (perUserLimit === 0 || userRedemptionCount >= perUserLimit) {
|
|
688
|
+
return {
|
|
689
|
+
isValid: false,
|
|
690
|
+
errorCode: ERROR_CODES.COUPON_ALREADY_USED,
|
|
691
|
+
message: ERROR_MESSAGES.COUPON_ALREADY_USED
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
let discountAmount = 0;
|
|
695
|
+
let finalAmount = orderAmount;
|
|
696
|
+
if (coupon2.discountType === "percentage") {
|
|
697
|
+
discountAmount = orderAmount * coupon2.discountValue / 100;
|
|
698
|
+
finalAmount = orderAmount - discountAmount;
|
|
699
|
+
} else if (coupon2.discountType === "flat") {
|
|
700
|
+
discountAmount = coupon2.discountValue;
|
|
701
|
+
finalAmount = Math.max(0, orderAmount - discountAmount);
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
isValid: true,
|
|
705
|
+
discountType: coupon2.discountType,
|
|
706
|
+
discountValue: coupon2.discountValue,
|
|
707
|
+
discountAmount,
|
|
708
|
+
finalAmount,
|
|
709
|
+
message: "Coupon applied successfully."
|
|
710
|
+
};
|
|
711
|
+
} catch (error) {
|
|
712
|
+
strapi.log.error("Error validating coupon:", error);
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
/**
|
|
717
|
+
* Redeem a coupon after successful payment
|
|
718
|
+
* Uses database transaction to prevent race conditions
|
|
719
|
+
*/
|
|
720
|
+
async redeem(params) {
|
|
721
|
+
const { couponCode, phoneNumber, orderId, orderAmount = 0 } = params;
|
|
722
|
+
try {
|
|
723
|
+
if (!validateOrderId(orderId)) {
|
|
724
|
+
return {
|
|
725
|
+
success: false,
|
|
726
|
+
errorCode: ERROR_CODES.INVALID_REQUEST,
|
|
727
|
+
message: "Invalid order ID format"
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const coupons = await strapi.db.query("plugin::coupon.coupon").findMany({
|
|
731
|
+
where: { code: couponCode }
|
|
732
|
+
});
|
|
733
|
+
const coupon2 = Array.isArray(coupons) && coupons.length > 0 ? coupons[0] : null;
|
|
734
|
+
if (!coupon2) {
|
|
735
|
+
return {
|
|
736
|
+
success: false,
|
|
737
|
+
errorCode: ERROR_CODES.COUPON_NOT_FOUND,
|
|
738
|
+
message: ERROR_MESSAGES.COUPON_NOT_FOUND
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (!validatePhoneNumber(phoneNumber)) {
|
|
742
|
+
return {
|
|
743
|
+
success: false,
|
|
744
|
+
errorCode: ERROR_CODES.INVALID_REQUEST,
|
|
745
|
+
message: "Invalid phone number format"
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (!coupon2.isActive) {
|
|
749
|
+
return {
|
|
750
|
+
success: false,
|
|
751
|
+
errorCode: ERROR_CODES.COUPON_EXPIRED,
|
|
752
|
+
message: ERROR_MESSAGES.COUPON_EXPIRED
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const now = /* @__PURE__ */ new Date();
|
|
756
|
+
const validFrom = new Date(coupon2.validFrom);
|
|
757
|
+
const validTo = new Date(coupon2.validTo);
|
|
758
|
+
if (now < validFrom || now > validTo) {
|
|
759
|
+
return {
|
|
760
|
+
success: false,
|
|
761
|
+
errorCode: ERROR_CODES.COUPON_EXPIRED,
|
|
762
|
+
message: ERROR_MESSAGES.COUPON_EXPIRED
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
if (coupon2.userRestrictions && Array.isArray(coupon2.userRestrictions)) {
|
|
766
|
+
if (coupon2.userRestrictions.includes(phoneNumber)) {
|
|
767
|
+
return {
|
|
768
|
+
success: false,
|
|
769
|
+
errorCode: ERROR_CODES.USER_RESTRICTED,
|
|
770
|
+
message: ERROR_MESSAGES.USER_RESTRICTED
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const existingRedemptions = await strapi.db.query("plugin::coupon.redemption").findMany({
|
|
775
|
+
where: {
|
|
776
|
+
coupon: coupon2.id,
|
|
777
|
+
phoneNumber
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
const userRedemptionCount = Array.isArray(existingRedemptions) ? existingRedemptions.length : 0;
|
|
781
|
+
const perUserLimit = coupon2.maxUsagePerUser ?? 1;
|
|
782
|
+
if (perUserLimit === 0 || userRedemptionCount >= perUserLimit) {
|
|
783
|
+
return {
|
|
784
|
+
success: false,
|
|
785
|
+
errorCode: ERROR_CODES.COUPON_ALREADY_USED,
|
|
786
|
+
message: ERROR_MESSAGES.COUPON_ALREADY_USED
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (coupon2.maxUsage && coupon2.currentUsage >= coupon2.maxUsage) {
|
|
790
|
+
return {
|
|
791
|
+
success: false,
|
|
792
|
+
errorCode: ERROR_CODES.USAGE_LIMIT_REACHED,
|
|
793
|
+
message: ERROR_MESSAGES.USAGE_LIMIT_REACHED
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
let discountAmount = 0;
|
|
797
|
+
let finalAmount = orderAmount;
|
|
798
|
+
if (coupon2.discountType === "percentage") {
|
|
799
|
+
discountAmount = orderAmount * coupon2.discountValue / 100;
|
|
800
|
+
finalAmount = orderAmount - discountAmount;
|
|
801
|
+
} else if (coupon2.discountType === "flat") {
|
|
802
|
+
discountAmount = coupon2.discountValue;
|
|
803
|
+
finalAmount = Math.max(0, orderAmount - discountAmount);
|
|
804
|
+
}
|
|
805
|
+
let redemptionId;
|
|
806
|
+
try {
|
|
807
|
+
const updateResult = await strapi.db.query("plugin::coupon.coupon").update({
|
|
808
|
+
where: {
|
|
809
|
+
id: coupon2.id,
|
|
810
|
+
currentUsage: coupon2.currentUsage
|
|
811
|
+
// Optimistic lock
|
|
812
|
+
},
|
|
813
|
+
data: {
|
|
814
|
+
currentUsage: coupon2.currentUsage + 1
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
if (!updateResult || coupon2.maxUsage && coupon2.currentUsage + 1 > coupon2.maxUsage) {
|
|
818
|
+
return {
|
|
819
|
+
success: false,
|
|
820
|
+
errorCode: ERROR_CODES.USAGE_LIMIT_REACHED,
|
|
821
|
+
message: ERROR_MESSAGES.USAGE_LIMIT_REACHED
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
const redemption2 = await strapi.db.query("plugin::coupon.redemption").create({
|
|
825
|
+
data: {
|
|
826
|
+
coupon: coupon2.id,
|
|
827
|
+
phoneNumber,
|
|
828
|
+
orderId,
|
|
829
|
+
discountType: coupon2.discountType,
|
|
830
|
+
discountValue: coupon2.discountValue,
|
|
831
|
+
redemptionDate: /* @__PURE__ */ new Date(),
|
|
832
|
+
metadata: {
|
|
833
|
+
orderAmount,
|
|
834
|
+
finalAmount,
|
|
835
|
+
discountAmount
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
redemptionId = `redeem_${redemption2.id}`;
|
|
840
|
+
} catch (error) {
|
|
841
|
+
strapi.log.error("Transaction error during redemption:", error);
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
success: true,
|
|
846
|
+
message: "Coupon redeemed successfully.",
|
|
847
|
+
redemptionId
|
|
848
|
+
};
|
|
849
|
+
} catch (error) {
|
|
850
|
+
strapi.log.error("Error redeeming coupon:", error);
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
/**
|
|
855
|
+
* Create a new coupon (Admin only)
|
|
856
|
+
*/
|
|
857
|
+
async create(params) {
|
|
858
|
+
try {
|
|
859
|
+
const {
|
|
860
|
+
code,
|
|
861
|
+
discountType,
|
|
862
|
+
discountValue,
|
|
863
|
+
maxUsage,
|
|
864
|
+
maxUsagePerUser,
|
|
865
|
+
validFrom,
|
|
866
|
+
validTo,
|
|
867
|
+
description,
|
|
868
|
+
userRestrictions
|
|
869
|
+
} = params;
|
|
870
|
+
if (!validateCouponCode(code)) {
|
|
871
|
+
return {
|
|
872
|
+
success: false,
|
|
873
|
+
errorCode: "INVALID_COUPON_CODE",
|
|
874
|
+
message: "Coupon code must be 3-50 uppercase alphanumeric characters"
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
const discountValidation = validateDiscountValue(
|
|
878
|
+
discountType,
|
|
879
|
+
discountValue
|
|
880
|
+
);
|
|
881
|
+
if (!discountValidation.valid) {
|
|
882
|
+
return {
|
|
883
|
+
success: false,
|
|
884
|
+
errorCode: "INVALID_DISCOUNT",
|
|
885
|
+
message: discountValidation.error || "Invalid discount value"
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
const dateValidation = validateDateRange(validFrom, validTo);
|
|
889
|
+
if (!dateValidation.valid) {
|
|
890
|
+
return {
|
|
891
|
+
success: false,
|
|
892
|
+
errorCode: "INVALID_DATE_RANGE",
|
|
893
|
+
message: dateValidation.error || "Invalid date range"
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
if (maxUsage !== void 0 && (typeof maxUsage !== "number" || maxUsage < 1)) {
|
|
897
|
+
return {
|
|
898
|
+
success: false,
|
|
899
|
+
errorCode: "INVALID_MAX_USAGE",
|
|
900
|
+
message: "maxUsage must be a positive number"
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const existingCoupons = await strapi.entityService.findMany(
|
|
904
|
+
"plugin::coupon.coupon",
|
|
905
|
+
{
|
|
906
|
+
filters: {
|
|
907
|
+
code
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
);
|
|
911
|
+
if (Array.isArray(existingCoupons) && existingCoupons.length > 0) {
|
|
912
|
+
return {
|
|
913
|
+
success: false,
|
|
914
|
+
errorCode: "COUPON_ALREADY_EXISTS",
|
|
915
|
+
message: "A coupon with this code already exists."
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const coupon2 = await strapi.entityService.create(
|
|
919
|
+
"plugin::coupon.coupon",
|
|
920
|
+
{
|
|
921
|
+
data: {
|
|
922
|
+
code,
|
|
923
|
+
discountType,
|
|
924
|
+
discountValue,
|
|
925
|
+
maxUsage: maxUsage || null,
|
|
926
|
+
maxUsagePerUser: maxUsagePerUser ?? null,
|
|
927
|
+
currentUsage: 0,
|
|
928
|
+
validFrom,
|
|
929
|
+
validTo,
|
|
930
|
+
isActive: true,
|
|
931
|
+
description: description || null,
|
|
932
|
+
userRestrictions: userRestrictions || null
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
);
|
|
936
|
+
return {
|
|
937
|
+
success: true,
|
|
938
|
+
couponId: `coupon_${coupon2.id}`,
|
|
939
|
+
message: "Coupon created successfully."
|
|
940
|
+
};
|
|
941
|
+
} catch (error) {
|
|
942
|
+
strapi.log.error("Error creating coupon:", error);
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
/**
|
|
947
|
+
* Get all coupons
|
|
948
|
+
*/
|
|
949
|
+
async findAll(query = {}) {
|
|
950
|
+
try {
|
|
951
|
+
return await strapi.entityService.findMany(
|
|
952
|
+
"plugin::coupon.coupon",
|
|
953
|
+
query
|
|
954
|
+
);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
strapi.log.error("Error finding coupons:", error);
|
|
957
|
+
throw error;
|
|
958
|
+
}
|
|
959
|
+
},
|
|
960
|
+
/**
|
|
961
|
+
* Get a single coupon
|
|
962
|
+
*/
|
|
963
|
+
async findOne(id) {
|
|
964
|
+
try {
|
|
965
|
+
return await strapi.entityService.findOne("plugin::coupon.coupon", id);
|
|
966
|
+
} catch (error) {
|
|
967
|
+
strapi.log.error("Error finding coupon:", error);
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
/**
|
|
972
|
+
* Update a coupon
|
|
973
|
+
*/
|
|
974
|
+
async update(id, data) {
|
|
975
|
+
try {
|
|
976
|
+
return await strapi.entityService.update("plugin::coupon.coupon", id, {
|
|
977
|
+
data
|
|
978
|
+
});
|
|
979
|
+
} catch (error) {
|
|
980
|
+
strapi.log.error("Error updating coupon:", error);
|
|
981
|
+
throw error;
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
/**
|
|
985
|
+
* Delete a coupon
|
|
986
|
+
*/
|
|
987
|
+
async delete(id) {
|
|
988
|
+
try {
|
|
989
|
+
return await strapi.entityService.delete("plugin::coupon.coupon", id);
|
|
990
|
+
} catch (error) {
|
|
991
|
+
strapi.log.error("Error deleting coupon:", error);
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
/**
|
|
996
|
+
* Generate CSV template with headers and example row
|
|
997
|
+
*/
|
|
998
|
+
generateCsvTemplate() {
|
|
999
|
+
const headers = CSV_COLUMNS.join(",");
|
|
1000
|
+
const exampleRow = [
|
|
1001
|
+
"SUMMER25",
|
|
1002
|
+
"percentage",
|
|
1003
|
+
"25",
|
|
1004
|
+
"1000",
|
|
1005
|
+
"1",
|
|
1006
|
+
"2025-06-01T00:00:00.000Z",
|
|
1007
|
+
"2025-08-31T23:59:59.000Z",
|
|
1008
|
+
"true",
|
|
1009
|
+
"Summer sale discount",
|
|
1010
|
+
""
|
|
1011
|
+
].join(",");
|
|
1012
|
+
const exampleRow2 = [
|
|
1013
|
+
"WELCOME10",
|
|
1014
|
+
"flat",
|
|
1015
|
+
"10",
|
|
1016
|
+
"",
|
|
1017
|
+
"2",
|
|
1018
|
+
"2025-01-01T00:00:00.000Z",
|
|
1019
|
+
"2025-12-31T23:59:59.000Z",
|
|
1020
|
+
"true",
|
|
1021
|
+
"Welcome offer",
|
|
1022
|
+
'"[""971501234567"",""971509876543""]"'
|
|
1023
|
+
].join(",");
|
|
1024
|
+
return `${headers}
|
|
1025
|
+
${exampleRow}
|
|
1026
|
+
${exampleRow2}`;
|
|
1027
|
+
},
|
|
1028
|
+
/**
|
|
1029
|
+
* Parse CSV row into CreateCouponRequest
|
|
1030
|
+
*/
|
|
1031
|
+
parseCsvRow(row, rowIndex) {
|
|
1032
|
+
const parseNumber = (value) => {
|
|
1033
|
+
if (!value || value.trim() === "") return void 0;
|
|
1034
|
+
const num = parseFloat(value);
|
|
1035
|
+
return isNaN(num) ? void 0 : num;
|
|
1036
|
+
};
|
|
1037
|
+
const parseInteger = (value) => {
|
|
1038
|
+
if (!value || value.trim() === "") return void 0;
|
|
1039
|
+
const num = parseInt(value, 10);
|
|
1040
|
+
return isNaN(num) ? void 0 : num;
|
|
1041
|
+
};
|
|
1042
|
+
const parseUserRestrictions = (value) => {
|
|
1043
|
+
if (!value || value.trim() === "") return void 0;
|
|
1044
|
+
try {
|
|
1045
|
+
const parsed = JSON.parse(value);
|
|
1046
|
+
if (Array.isArray(parsed)) {
|
|
1047
|
+
return parsed.filter((item) => typeof item === "string");
|
|
1048
|
+
}
|
|
1049
|
+
return void 0;
|
|
1050
|
+
} catch {
|
|
1051
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
return {
|
|
1055
|
+
_row: rowIndex,
|
|
1056
|
+
code: (row.code || "").trim().toUpperCase(),
|
|
1057
|
+
discountType: (row.discountType || "percentage").trim().toLowerCase(),
|
|
1058
|
+
discountValue: parseNumber(row.discountValue) || 0,
|
|
1059
|
+
maxUsage: parseInteger(row.maxUsage),
|
|
1060
|
+
maxUsagePerUser: parseInteger(row.maxUsagePerUser),
|
|
1061
|
+
validFrom: (row.validFrom || "").trim(),
|
|
1062
|
+
validTo: (row.validTo || "").trim(),
|
|
1063
|
+
description: row.description?.trim() || void 0,
|
|
1064
|
+
userRestrictions: parseUserRestrictions(row.userRestrictions)
|
|
1065
|
+
// isActive is handled during creation, default to true
|
|
1066
|
+
};
|
|
1067
|
+
},
|
|
1068
|
+
/**
|
|
1069
|
+
* Validate coupons before bulk creation (pre-check for duplicates within batch)
|
|
1070
|
+
*/
|
|
1071
|
+
async validateBulkCoupons(coupons) {
|
|
1072
|
+
const valid = [];
|
|
1073
|
+
const invalid = [];
|
|
1074
|
+
const seenCodes = /* @__PURE__ */ new Set();
|
|
1075
|
+
for (const coupon2 of coupons) {
|
|
1076
|
+
if (seenCodes.has(coupon2.code)) {
|
|
1077
|
+
invalid.push({ coupon: coupon2, error: `Duplicate code "${coupon2.code}" within CSV file` });
|
|
1078
|
+
} else {
|
|
1079
|
+
seenCodes.add(coupon2.code);
|
|
1080
|
+
valid.push(coupon2);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (valid.length > 0) {
|
|
1084
|
+
const codes = valid.map((c) => c.code);
|
|
1085
|
+
const existingCoupons = await strapi.entityService.findMany(
|
|
1086
|
+
"plugin::coupon.coupon",
|
|
1087
|
+
{
|
|
1088
|
+
filters: {
|
|
1089
|
+
code: { $in: codes }
|
|
1090
|
+
},
|
|
1091
|
+
fields: ["code"]
|
|
1092
|
+
}
|
|
1093
|
+
);
|
|
1094
|
+
const existingCodes = new Set(
|
|
1095
|
+
(Array.isArray(existingCoupons) ? existingCoupons : []).map((c) => c.code)
|
|
1096
|
+
);
|
|
1097
|
+
const stillValid = [];
|
|
1098
|
+
for (const coupon2 of valid) {
|
|
1099
|
+
if (existingCodes.has(coupon2.code)) {
|
|
1100
|
+
invalid.push({ coupon: coupon2, error: `Coupon code "${coupon2.code}" already exists in database` });
|
|
1101
|
+
} else {
|
|
1102
|
+
stillValid.push(coupon2);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return { valid: stillValid, invalid };
|
|
1106
|
+
}
|
|
1107
|
+
return { valid, invalid };
|
|
1108
|
+
},
|
|
1109
|
+
/**
|
|
1110
|
+
* Bulk create coupons from parsed CSV data
|
|
1111
|
+
* Processes in batches of 100 for performance
|
|
1112
|
+
*/
|
|
1113
|
+
async bulkCreate(coupons) {
|
|
1114
|
+
const BATCH_SIZE = 100;
|
|
1115
|
+
const BATCH_DELAY_MS = 50;
|
|
1116
|
+
const results = {
|
|
1117
|
+
success: true,
|
|
1118
|
+
summary: {
|
|
1119
|
+
total: coupons.length,
|
|
1120
|
+
created: 0,
|
|
1121
|
+
failed: 0
|
|
1122
|
+
},
|
|
1123
|
+
created: [],
|
|
1124
|
+
failed: []
|
|
1125
|
+
};
|
|
1126
|
+
if (coupons.length === 0) {
|
|
1127
|
+
return results;
|
|
1128
|
+
}
|
|
1129
|
+
const { valid, invalid } = await this.validateBulkCoupons(coupons);
|
|
1130
|
+
for (const { coupon: coupon2, error } of invalid) {
|
|
1131
|
+
results.failed.push({
|
|
1132
|
+
code: coupon2.code,
|
|
1133
|
+
row: coupon2._row || 0,
|
|
1134
|
+
error,
|
|
1135
|
+
errorCode: "DUPLICATE_CODE"
|
|
1136
|
+
});
|
|
1137
|
+
results.summary.failed++;
|
|
1138
|
+
}
|
|
1139
|
+
for (let i = 0; i < valid.length; i += BATCH_SIZE) {
|
|
1140
|
+
const batch = valid.slice(i, i + BATCH_SIZE);
|
|
1141
|
+
for (const coupon2 of batch) {
|
|
1142
|
+
try {
|
|
1143
|
+
const result = await this.create({
|
|
1144
|
+
code: coupon2.code,
|
|
1145
|
+
discountType: coupon2.discountType,
|
|
1146
|
+
discountValue: coupon2.discountValue,
|
|
1147
|
+
maxUsage: coupon2.maxUsage,
|
|
1148
|
+
maxUsagePerUser: coupon2.maxUsagePerUser,
|
|
1149
|
+
validFrom: coupon2.validFrom,
|
|
1150
|
+
validTo: coupon2.validTo,
|
|
1151
|
+
description: coupon2.description,
|
|
1152
|
+
userRestrictions: coupon2.userRestrictions
|
|
1153
|
+
});
|
|
1154
|
+
if (result.success && result.couponId) {
|
|
1155
|
+
results.created.push({
|
|
1156
|
+
code: coupon2.code,
|
|
1157
|
+
id: result.couponId,
|
|
1158
|
+
row: coupon2._row || 0
|
|
1159
|
+
});
|
|
1160
|
+
results.summary.created++;
|
|
1161
|
+
} else {
|
|
1162
|
+
results.failed.push({
|
|
1163
|
+
code: coupon2.code,
|
|
1164
|
+
row: coupon2._row || 0,
|
|
1165
|
+
error: result.message,
|
|
1166
|
+
errorCode: result.errorCode
|
|
1167
|
+
});
|
|
1168
|
+
results.summary.failed++;
|
|
1169
|
+
}
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
results.failed.push({
|
|
1172
|
+
code: coupon2.code,
|
|
1173
|
+
row: coupon2._row || 0,
|
|
1174
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1175
|
+
});
|
|
1176
|
+
results.summary.failed++;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (i + BATCH_SIZE < valid.length) {
|
|
1180
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
results.success = results.summary.failed === 0;
|
|
1184
|
+
return results;
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
const services = {
|
|
1188
|
+
coupon: couponService
|
|
1189
|
+
};
|
|
1190
|
+
const plugin = {
|
|
1191
|
+
register,
|
|
1192
|
+
bootstrap,
|
|
1193
|
+
destroy,
|
|
1194
|
+
config,
|
|
1195
|
+
controllers,
|
|
1196
|
+
routes,
|
|
1197
|
+
services,
|
|
1198
|
+
contentTypes
|
|
1199
|
+
};
|
|
1200
|
+
export {
|
|
1201
|
+
plugin as default
|
|
1202
|
+
};
|