mumz-strapi-plugin-coupon 1.0.7

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 (41) hide show
  1. package/README.md +311 -0
  2. package/dist/bootstrap.d.ts +5 -0
  3. package/dist/bootstrap.js +6 -0
  4. package/dist/config/index.d.ts +5 -0
  5. package/dist/config/index.js +6 -0
  6. package/dist/content-types/coupon/index.d.ts +90 -0
  7. package/dist/content-types/coupon/index.js +9 -0
  8. package/dist/content-types/coupon/schema.d.ts +88 -0
  9. package/dist/content-types/coupon/schema.js +105 -0
  10. package/dist/content-types/index.d.ts +155 -0
  11. package/dist/content-types/index.js +11 -0
  12. package/dist/content-types/redemption/index.d.ts +64 -0
  13. package/dist/content-types/redemption/index.js +9 -0
  14. package/dist/content-types/redemption/schema.d.ts +62 -0
  15. package/dist/content-types/redemption/schema.js +74 -0
  16. package/dist/controllers/coupon.d.ts +41 -0
  17. package/dist/controllers/coupon.js +154 -0
  18. package/dist/controllers/index.d.ts +5 -0
  19. package/dist/controllers/index.js +9 -0
  20. package/dist/destroy.d.ts +5 -0
  21. package/dist/destroy.js +6 -0
  22. package/dist/index.d.ts +201 -0
  23. package/dist/index.js +24 -0
  24. package/dist/middlewares/index.d.ts +4 -0
  25. package/dist/middlewares/index.js +9 -0
  26. package/dist/middlewares/rate-limit.d.ts +6 -0
  27. package/dist/middlewares/rate-limit.js +42 -0
  28. package/dist/register.d.ts +5 -0
  29. package/dist/register.js +6 -0
  30. package/dist/routes/content-api/index.d.ts +23 -0
  31. package/dist/routes/content-api/index.js +76 -0
  32. package/dist/routes/index.d.ts +25 -0
  33. package/dist/routes/index.js +9 -0
  34. package/dist/services/coupon.d.ts +61 -0
  35. package/dist/services/coupon.js +399 -0
  36. package/dist/services/index.d.ts +5 -0
  37. package/dist/services/index.js +9 -0
  38. package/dist/utils/validators.d.ts +14 -0
  39. package/dist/utils/validators.js +41 -0
  40. package/package.json +71 -0
  41. package/strapi-server.js +1 -0
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ERROR_MESSAGES = exports.ERROR_CODES = void 0;
4
+ const validators_1 = require("../utils/validators");
5
+ // Error codes as per specification
6
+ exports.ERROR_CODES = {
7
+ COUPON_NOT_FOUND: 'COUPON_NOT_FOUND',
8
+ COUPON_EXPIRED: 'COUPON_EXPIRED',
9
+ COUPON_ALREADY_USED: 'COUPON_ALREADY_USED',
10
+ USAGE_LIMIT_REACHED: 'USAGE_LIMIT_REACHED',
11
+ INVALID_REQUEST: 'INVALID_REQUEST',
12
+ };
13
+ exports.ERROR_MESSAGES = {
14
+ COUPON_NOT_FOUND: 'Coupon code does not exist.',
15
+ COUPON_EXPIRED: 'This coupon has expired.',
16
+ COUPON_ALREADY_USED: 'This coupon has been already used.',
17
+ USAGE_LIMIT_REACHED: 'Coupon usage limit has been reached.',
18
+ INVALID_REQUEST: 'Invalid request parameters.',
19
+ };
20
+ const couponService = ({ strapi }) => ({
21
+ /**
22
+ * Validate a coupon for a specific service and user
23
+ */
24
+ async validate(params) {
25
+ const { couponCode, phoneNumber, orderAmount = 0 } = params;
26
+ try {
27
+ // Validate inputs
28
+ if (!(0, validators_1.validateCouponCode)(couponCode)) {
29
+ return {
30
+ isValid: false,
31
+ errorCode: exports.ERROR_CODES.INVALID_REQUEST,
32
+ message: 'Invalid coupon code format',
33
+ };
34
+ }
35
+ if (!(0, validators_1.validatePhoneNumber)(phoneNumber)) {
36
+ return {
37
+ isValid: false,
38
+ errorCode: exports.ERROR_CODES.INVALID_REQUEST,
39
+ message: 'Invalid phone number format',
40
+ };
41
+ }
42
+ if (orderAmount < 0) {
43
+ return {
44
+ isValid: false,
45
+ errorCode: exports.ERROR_CODES.INVALID_REQUEST,
46
+ message: 'Order amount cannot be negative',
47
+ };
48
+ }
49
+ // Find the coupon by code
50
+ const coupons = await strapi.entityService.findMany('plugin::coupon.coupon', {
51
+ filters: {
52
+ code: couponCode,
53
+ },
54
+ });
55
+ const coupon = Array.isArray(coupons) && coupons.length > 0 ? coupons[0] : null;
56
+ // Check if coupon exists
57
+ if (!coupon) {
58
+ return {
59
+ isValid: false,
60
+ errorCode: exports.ERROR_CODES.COUPON_NOT_FOUND,
61
+ message: exports.ERROR_MESSAGES.COUPON_NOT_FOUND,
62
+ };
63
+ }
64
+ // Check if coupon is active
65
+ if (!coupon.isActive) {
66
+ return {
67
+ isValid: false,
68
+ errorCode: exports.ERROR_CODES.COUPON_EXPIRED,
69
+ message: exports.ERROR_MESSAGES.COUPON_EXPIRED,
70
+ };
71
+ }
72
+ // Check validity period
73
+ const now = new Date();
74
+ const validFrom = new Date(coupon.validFrom);
75
+ const validTo = new Date(coupon.validTo);
76
+ if (now < validFrom || now > validTo) {
77
+ return {
78
+ isValid: false,
79
+ errorCode: exports.ERROR_CODES.COUPON_EXPIRED,
80
+ message: exports.ERROR_MESSAGES.COUPON_EXPIRED,
81
+ };
82
+ }
83
+ // Check usage limit
84
+ if (coupon.maxUsage && coupon.currentUsage >= coupon.maxUsage) {
85
+ return {
86
+ isValid: false,
87
+ errorCode: exports.ERROR_CODES.USAGE_LIMIT_REACHED,
88
+ message: exports.ERROR_MESSAGES.USAGE_LIMIT_REACHED,
89
+ };
90
+ }
91
+ // Check if user has already used this coupon
92
+ const existingRedemptions = await strapi.entityService.findMany('plugin::coupon.redemption', {
93
+ filters: {
94
+ coupon: coupon.id,
95
+ phoneNumber,
96
+ },
97
+ });
98
+ if (Array.isArray(existingRedemptions) && existingRedemptions.length > 0) {
99
+ return {
100
+ isValid: false,
101
+ errorCode: exports.ERROR_CODES.COUPON_ALREADY_USED,
102
+ message: exports.ERROR_MESSAGES.COUPON_ALREADY_USED,
103
+ };
104
+ }
105
+ // Calculate discount
106
+ let discountAmount = 0;
107
+ let finalAmount = orderAmount;
108
+ if (coupon.discountType === 'percentage') {
109
+ discountAmount = (orderAmount * coupon.discountValue) / 100;
110
+ finalAmount = orderAmount - discountAmount;
111
+ }
112
+ else if (coupon.discountType === 'flat') {
113
+ discountAmount = coupon.discountValue;
114
+ finalAmount = Math.max(0, orderAmount - discountAmount);
115
+ }
116
+ // Return valid response
117
+ return {
118
+ isValid: true,
119
+ discountType: coupon.discountType,
120
+ discountValue: coupon.discountValue,
121
+ discountAmount,
122
+ finalAmount,
123
+ message: 'Coupon applied successfully.',
124
+ };
125
+ }
126
+ catch (error) {
127
+ strapi.log.error('Error validating coupon:', error);
128
+ throw error;
129
+ }
130
+ },
131
+ /**
132
+ * Redeem a coupon after successful payment
133
+ * Uses database transaction to prevent race conditions
134
+ */
135
+ async redeem(params) {
136
+ const { couponCode, phoneNumber, orderId, orderAmount = 0 } = params;
137
+ try {
138
+ // Validate inputs
139
+ if (!(0, validators_1.validateOrderId)(orderId)) {
140
+ return {
141
+ success: false,
142
+ errorCode: exports.ERROR_CODES.INVALID_REQUEST,
143
+ message: 'Invalid order ID format',
144
+ };
145
+ }
146
+ // Find and validate the coupon
147
+ const coupons = await strapi.db.query('plugin::coupon.coupon').findMany({
148
+ where: { code: couponCode },
149
+ });
150
+ const coupon = Array.isArray(coupons) && coupons.length > 0 ? coupons[0] : null;
151
+ if (!coupon) {
152
+ return {
153
+ success: false,
154
+ errorCode: exports.ERROR_CODES.COUPON_NOT_FOUND,
155
+ message: exports.ERROR_MESSAGES.COUPON_NOT_FOUND,
156
+ };
157
+ }
158
+ // Validate phone number
159
+ if (!(0, validators_1.validatePhoneNumber)(phoneNumber)) {
160
+ return {
161
+ success: false,
162
+ errorCode: exports.ERROR_CODES.INVALID_REQUEST,
163
+ message: 'Invalid phone number format',
164
+ };
165
+ }
166
+ // Perform all validations
167
+ if (!coupon.isActive) {
168
+ return {
169
+ success: false,
170
+ errorCode: exports.ERROR_CODES.COUPON_EXPIRED,
171
+ message: exports.ERROR_MESSAGES.COUPON_EXPIRED,
172
+ };
173
+ }
174
+ const now = new Date();
175
+ const validFrom = new Date(coupon.validFrom);
176
+ const validTo = new Date(coupon.validTo);
177
+ if (now < validFrom || now > validTo) {
178
+ return {
179
+ success: false,
180
+ errorCode: exports.ERROR_CODES.COUPON_EXPIRED,
181
+ message: exports.ERROR_MESSAGES.COUPON_EXPIRED,
182
+ };
183
+ }
184
+ // Check for existing redemption
185
+ const existingRedemptions = await strapi.db.query('plugin::coupon.redemption').findMany({
186
+ where: {
187
+ coupon: coupon.id,
188
+ phoneNumber,
189
+ },
190
+ });
191
+ if (Array.isArray(existingRedemptions) && existingRedemptions.length > 0) {
192
+ return {
193
+ success: false,
194
+ errorCode: exports.ERROR_CODES.COUPON_ALREADY_USED,
195
+ message: exports.ERROR_MESSAGES.COUPON_ALREADY_USED,
196
+ };
197
+ }
198
+ // Check usage limit before attempting redemption
199
+ if (coupon.maxUsage && coupon.currentUsage >= coupon.maxUsage) {
200
+ return {
201
+ success: false,
202
+ errorCode: exports.ERROR_CODES.USAGE_LIMIT_REACHED,
203
+ message: exports.ERROR_MESSAGES.USAGE_LIMIT_REACHED,
204
+ };
205
+ }
206
+ // Calculate discount
207
+ let discountAmount = 0;
208
+ let finalAmount = orderAmount;
209
+ if (coupon.discountType === 'percentage') {
210
+ discountAmount = (orderAmount * coupon.discountValue) / 100;
211
+ finalAmount = orderAmount - discountAmount;
212
+ }
213
+ else if (coupon.discountType === 'flat') {
214
+ discountAmount = coupon.discountValue;
215
+ finalAmount = Math.max(0, orderAmount - discountAmount);
216
+ }
217
+ // Use transaction for atomic redemption
218
+ let redemptionId;
219
+ try {
220
+ // Atomic increment with optimistic locking
221
+ const updateResult = await strapi.db.query('plugin::coupon.coupon').update({
222
+ where: {
223
+ id: coupon.id,
224
+ currentUsage: coupon.currentUsage, // Optimistic lock
225
+ },
226
+ data: {
227
+ currentUsage: coupon.currentUsage + 1,
228
+ },
229
+ });
230
+ // If update failed due to concurrent modification, check usage limit
231
+ if (!updateResult || (coupon.maxUsage && coupon.currentUsage + 1 > coupon.maxUsage)) {
232
+ return {
233
+ success: false,
234
+ errorCode: exports.ERROR_CODES.USAGE_LIMIT_REACHED,
235
+ message: exports.ERROR_MESSAGES.USAGE_LIMIT_REACHED,
236
+ };
237
+ }
238
+ // Create redemption record
239
+ const redemption = await strapi.db.query('plugin::coupon.redemption').create({
240
+ data: {
241
+ coupon: coupon.id,
242
+ phoneNumber,
243
+ orderId,
244
+ discountType: coupon.discountType,
245
+ discountValue: coupon.discountValue,
246
+ redemptionDate: new Date(),
247
+ metadata: {
248
+ orderAmount,
249
+ finalAmount,
250
+ discountAmount,
251
+ },
252
+ },
253
+ });
254
+ redemptionId = `redeem_${redemption.id}`;
255
+ }
256
+ catch (error) {
257
+ strapi.log.error('Transaction error during redemption:', error);
258
+ throw error;
259
+ }
260
+ return {
261
+ success: true,
262
+ message: 'Coupon redeemed successfully.',
263
+ redemptionId,
264
+ };
265
+ }
266
+ catch (error) {
267
+ strapi.log.error('Error redeeming coupon:', error);
268
+ throw error;
269
+ }
270
+ },
271
+ /**
272
+ * Create a new coupon (Admin only)
273
+ */
274
+ async create(params) {
275
+ try {
276
+ const { code, discountType, discountValue, maxUsage, validFrom, validTo, description, userRestrictions, } = params;
277
+ // Validate coupon code
278
+ if (!(0, validators_1.validateCouponCode)(code)) {
279
+ return {
280
+ success: false,
281
+ errorCode: 'INVALID_COUPON_CODE',
282
+ message: 'Coupon code must be 3-50 uppercase alphanumeric characters',
283
+ };
284
+ }
285
+ // Validate discount value
286
+ const discountValidation = (0, validators_1.validateDiscountValue)(discountType, discountValue);
287
+ if (!discountValidation.valid) {
288
+ return {
289
+ success: false,
290
+ errorCode: 'INVALID_DISCOUNT',
291
+ message: discountValidation.error || 'Invalid discount value',
292
+ };
293
+ }
294
+ // Validate date range
295
+ const dateValidation = (0, validators_1.validateDateRange)(validFrom, validTo);
296
+ if (!dateValidation.valid) {
297
+ return {
298
+ success: false,
299
+ errorCode: 'INVALID_DATE_RANGE',
300
+ message: dateValidation.error || 'Invalid date range',
301
+ };
302
+ }
303
+ // Validate maxUsage
304
+ if (maxUsage !== undefined && (typeof maxUsage !== 'number' || maxUsage < 1)) {
305
+ return {
306
+ success: false,
307
+ errorCode: 'INVALID_MAX_USAGE',
308
+ message: 'maxUsage must be a positive number',
309
+ };
310
+ }
311
+ // Check if coupon code already exists
312
+ const existingCoupons = await strapi.entityService.findMany('plugin::coupon.coupon', {
313
+ filters: {
314
+ code,
315
+ },
316
+ });
317
+ if (Array.isArray(existingCoupons) && existingCoupons.length > 0) {
318
+ return {
319
+ success: false,
320
+ errorCode: 'COUPON_ALREADY_EXISTS',
321
+ message: 'A coupon with this code already exists.',
322
+ };
323
+ }
324
+ // Create the coupon
325
+ const coupon = await strapi.entityService.create('plugin::coupon.coupon', {
326
+ data: {
327
+ code,
328
+ discountType,
329
+ discountValue,
330
+ maxUsage: maxUsage || null,
331
+ currentUsage: 0,
332
+ validFrom,
333
+ validTo,
334
+ isActive: true,
335
+ description: description || null,
336
+ userRestrictions: userRestrictions || null,
337
+ },
338
+ });
339
+ return {
340
+ success: true,
341
+ couponId: `coupon_${coupon.id}`,
342
+ message: 'Coupon created successfully.',
343
+ };
344
+ }
345
+ catch (error) {
346
+ strapi.log.error('Error creating coupon:', error);
347
+ throw error;
348
+ }
349
+ },
350
+ /**
351
+ * Get all coupons
352
+ */
353
+ async findAll(query = {}) {
354
+ try {
355
+ return await strapi.entityService.findMany('plugin::coupon.coupon', query);
356
+ }
357
+ catch (error) {
358
+ strapi.log.error('Error finding coupons:', error);
359
+ throw error;
360
+ }
361
+ },
362
+ /**
363
+ * Get a single coupon
364
+ */
365
+ async findOne(id) {
366
+ try {
367
+ return await strapi.entityService.findOne('plugin::coupon.coupon', id);
368
+ }
369
+ catch (error) {
370
+ strapi.log.error('Error finding coupon:', error);
371
+ throw error;
372
+ }
373
+ },
374
+ /**
375
+ * Update a coupon
376
+ */
377
+ async update(id, data) {
378
+ try {
379
+ return await strapi.entityService.update('plugin::coupon.coupon', id, { data });
380
+ }
381
+ catch (error) {
382
+ strapi.log.error('Error updating coupon:', error);
383
+ throw error;
384
+ }
385
+ },
386
+ /**
387
+ * Delete a coupon
388
+ */
389
+ async delete(id) {
390
+ try {
391
+ return await strapi.entityService.delete('plugin::coupon.coupon', id);
392
+ }
393
+ catch (error) {
394
+ strapi.log.error('Error deleting coupon:', error);
395
+ throw error;
396
+ }
397
+ },
398
+ });
399
+ exports.default = couponService;
@@ -0,0 +1,5 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ declare const _default: Record<string, (params: {
3
+ strapi: Core.Strapi;
4
+ }) => any>;
5
+ export default _default;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const coupon_1 = __importDefault(require("./coupon"));
7
+ exports.default = {
8
+ coupon: coupon_1.default,
9
+ };
@@ -0,0 +1,14 @@
1
+ export declare const PHONE_REGEX: RegExp;
2
+ export declare const ORDER_ID_REGEX: RegExp;
3
+ export declare const COUPON_CODE_REGEX: RegExp;
4
+ export declare function validatePhoneNumber(phone: string): boolean;
5
+ export declare function validateOrderId(orderId: string): boolean;
6
+ export declare function validateCouponCode(code: string): boolean;
7
+ export declare function validateDiscountValue(discountType: string, discountValue: number): {
8
+ valid: boolean;
9
+ error?: string;
10
+ };
11
+ export declare function validateDateRange(validFrom: string, validTo: string): {
12
+ valid: boolean;
13
+ error?: string;
14
+ };
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ // Input validation utilities
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.COUPON_CODE_REGEX = exports.ORDER_ID_REGEX = exports.PHONE_REGEX = void 0;
5
+ exports.validatePhoneNumber = validatePhoneNumber;
6
+ exports.validateOrderId = validateOrderId;
7
+ exports.validateCouponCode = validateCouponCode;
8
+ exports.validateDiscountValue = validateDiscountValue;
9
+ exports.validateDateRange = validateDateRange;
10
+ exports.PHONE_REGEX = /^\+?[1-9]\d{1,14}$/; // E.164 format
11
+ exports.ORDER_ID_REGEX = /^[a-zA-Z0-9_-]{1,100}$/;
12
+ exports.COUPON_CODE_REGEX = /^[A-Z0-9]{3,50}$/;
13
+ function validatePhoneNumber(phone) {
14
+ return typeof phone === 'string' && phone.length >= 10 && phone.length <= 15 && exports.PHONE_REGEX.test(phone);
15
+ }
16
+ function validateOrderId(orderId) {
17
+ return typeof orderId === 'string' && orderId.length >= 1 && orderId.length <= 100 && exports.ORDER_ID_REGEX.test(orderId);
18
+ }
19
+ function validateCouponCode(code) {
20
+ return typeof code === 'string' && code.length >= 3 && code.length <= 50 && exports.COUPON_CODE_REGEX.test(code);
21
+ }
22
+ function validateDiscountValue(discountType, discountValue) {
23
+ if (typeof discountValue !== 'number' || discountValue <= 0) {
24
+ return { valid: false, error: 'Discount value must be greater than 0' };
25
+ }
26
+ if (discountType === 'percentage' && discountValue > 100) {
27
+ return { valid: false, error: 'Percentage discount cannot exceed 100%' };
28
+ }
29
+ return { valid: true };
30
+ }
31
+ function validateDateRange(validFrom, validTo) {
32
+ const validFromDate = new Date(validFrom);
33
+ const validToDate = new Date(validTo);
34
+ if (isNaN(validFromDate.getTime()) || isNaN(validToDate.getTime())) {
35
+ return { valid: false, error: 'Invalid date format' };
36
+ }
37
+ if (validFromDate >= validToDate) {
38
+ return { valid: false, error: 'validFrom must be before validTo' };
39
+ }
40
+ return { valid: true };
41
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "mumz-strapi-plugin-coupon",
3
+ "version": "1.0.7",
4
+ "description": "Strapi plugin for centralized coupon management across multiple services",
5
+ "strapi": {
6
+ "name": "coupon",
7
+ "displayName": "Coupon Management",
8
+ "description": "Centralized coupon management system for validation, redemption, and tracking",
9
+ "kind": "plugin"
10
+ },
11
+ "keywords": [
12
+ "strapi",
13
+ "plugin",
14
+ "coupon",
15
+ "discount",
16
+ "promotion"
17
+ ],
18
+ "license": "MIT",
19
+ "author": {
20
+ "name": "Mumzworld Tech"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/mumzworld-tech/strapi-plugin-coupon.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/mumzworld-tech/strapi-plugin-coupon/issues"
28
+ },
29
+ "homepage": "https://github.com/mumzworld-tech/strapi-plugin-coupon#readme",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "watch": "tsc --watch",
36
+ "clean": "rimraf dist",
37
+ "prepare": "npm run build",
38
+ "prepublishOnly": "npm run build",
39
+ "type-check": "tsc --noEmit"
40
+ },
41
+ "main": "./strapi-server.js",
42
+ "exports": {
43
+ "./strapi-server": {
44
+ "types": "./dist/index.d.ts",
45
+ "require": "./dist/index.js",
46
+ "default": "./dist/index.js"
47
+ },
48
+ "./package.json": "./package.json"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "strapi-admin.js",
53
+ "strapi-server.js"
54
+ ],
55
+ "dependencies": {
56
+ "typescript": "^5.0.0",
57
+ "rimraf": "^5.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@strapi/strapi": "^5.0.0",
61
+ "@strapi/typescript-utils": "^5.0.0",
62
+ "@types/node": "^20.0.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@strapi/strapi": "^5.0.0"
66
+ },
67
+ "engines": {
68
+ "node": ">=18.0.0",
69
+ "npm": ">=6.0.0"
70
+ }
71
+ }
@@ -0,0 +1 @@
1
+ module.exports = require('./dist').default;