mumz-strapi-plugin-coupon 1.1.1 → 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.
Files changed (46) hide show
  1. package/README.md +15 -13
  2. package/dist/admin/index.js +677 -0
  3. package/dist/admin/index.mjs +678 -0
  4. package/dist/server/index.js +1201 -0
  5. package/dist/server/index.mjs +1202 -0
  6. package/package.json +44 -20
  7. package/strapi-admin.js +3 -0
  8. package/strapi-server.js +3 -1
  9. package/dist/bootstrap.d.ts +0 -5
  10. package/dist/bootstrap.js +0 -6
  11. package/dist/config/index.d.ts +0 -5
  12. package/dist/config/index.js +0 -6
  13. package/dist/content-types/coupon/index.d.ts +0 -96
  14. package/dist/content-types/coupon/index.js +0 -9
  15. package/dist/content-types/coupon/schema.d.ts +0 -94
  16. package/dist/content-types/coupon/schema.js +0 -117
  17. package/dist/content-types/index.d.ts +0 -161
  18. package/dist/content-types/index.js +0 -11
  19. package/dist/content-types/redemption/index.d.ts +0 -64
  20. package/dist/content-types/redemption/index.js +0 -9
  21. package/dist/content-types/redemption/schema.d.ts +0 -62
  22. package/dist/content-types/redemption/schema.js +0 -74
  23. package/dist/controllers/coupon.d.ts +0 -41
  24. package/dist/controllers/coupon.js +0 -154
  25. package/dist/controllers/index.d.ts +0 -5
  26. package/dist/controllers/index.js +0 -9
  27. package/dist/destroy.d.ts +0 -5
  28. package/dist/destroy.js +0 -6
  29. package/dist/index.d.ts +0 -207
  30. package/dist/index.js +0 -24
  31. package/dist/middlewares/index.d.ts +0 -4
  32. package/dist/middlewares/index.js +0 -9
  33. package/dist/middlewares/rate-limit.d.ts +0 -6
  34. package/dist/middlewares/rate-limit.js +0 -42
  35. package/dist/register.d.ts +0 -5
  36. package/dist/register.js +0 -6
  37. package/dist/routes/content-api/index.d.ts +0 -23
  38. package/dist/routes/content-api/index.js +0 -76
  39. package/dist/routes/index.d.ts +0 -25
  40. package/dist/routes/index.js +0 -9
  41. package/dist/services/coupon.d.ts +0 -64
  42. package/dist/services/coupon.js +0 -432
  43. package/dist/services/index.d.ts +0 -5
  44. package/dist/services/index.js +0 -9
  45. package/dist/utils/validators.d.ts +0 -14
  46. 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
+ };