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