@cloudcommerce/app-discounts 2.29.9 → 2.29.11

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.
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-await-in-loop */
1
2
  import ecomUtils from '@ecomplus/utils';
2
3
  import api from '@cloudcommerce/api';
3
4
  import {
@@ -6,38 +7,95 @@ import {
6
7
  checkOpenPromotion,
7
8
  getValidDiscountRules,
8
9
  matchDiscountRule,
9
- checkCampaignProducts,
10
+ matchFreebieRule,
11
+ mapCampaignProducts,
10
12
  } from './helpers.mjs';
11
13
 
12
14
  export default async ({ params, application }) => {
13
- // app configured options
14
- const config = {
15
- ...application.data,
16
- ...application.hidden_data,
17
- };
18
-
19
- // setup response object
20
- // https://apx-mods.e-com.plus/api/v1/apply_discount/response_schema.json?store_id=100
15
+ const config = { ...application.data, ...application.hidden_data };
16
+ if (config.advanced && typeof config.advanced === 'object') {
17
+ Object.assign(config, config.advanced);
18
+ }
21
19
  const response = {};
20
+ const discountPerSku = {};
22
21
  const respondSuccess = () => {
23
22
  if (response.available_extra_discount && !response.available_extra_discount.value) {
24
23
  delete response.available_extra_discount;
25
24
  }
25
+ if (response.discount_rule) {
26
+ if (!response.discount_rule.extra_discount?.value) {
27
+ delete response.discount_rule;
28
+ } else if (!response.discount_rule.description && config.describe_discounted_items) {
29
+ const discountedSkus = Object.keys(discountPerSku);
30
+ if (discountedSkus.length) {
31
+ response.discount_rule.description = `
32
+ Descontos por SKU:
33
+ ---
34
+ ${discountedSkus.map((sku) => `\n${sku}: ${discountPerSku[sku].toFixed(2)}`)}
35
+ `.substring(0, 999);
36
+ }
37
+ }
38
+ }
39
+ return response;
40
+ };
41
+
42
+ const checkUsageLimit = async (discountRule, label) => {
43
+ const { customer } = params;
44
+ if (!label) {
45
+ label = discountRule.label;
46
+ }
26
47
  if (
27
- response.discount_rule
28
- && (!response.discount_rule.extra_discount || !response.discount_rule.extra_discount.value)
48
+ label
49
+ && customer && (customer._id || customer.doc_number)
50
+ && (discountRule.usage_limit > 0 || discountRule.total_usage_limit > 0)
29
51
  ) {
30
- delete response.discount_rule;
52
+ // list orders to check discount usage limits
53
+ const endpoint = 'orders?fields=status'
54
+ + `&extra_discount.app.label${(discountRule.case_insensitive ? '%=' : '=')}`
55
+ + encodeURIComponent(label);
56
+ const usageLimits = [{
57
+ // limit by customer
58
+ query: customer.doc_number
59
+ ? `&buyers.doc_number=${customer.doc_number}`
60
+ : `&buyers._id=${customer._id}`,
61
+ max: discountRule.usage_limit,
62
+ }, {
63
+ // total limit
64
+ query: '',
65
+ max: discountRule.total_usage_limit,
66
+ }];
67
+ for (let i = 0; i < usageLimits.length; i++) {
68
+ const { query, max } = usageLimits[i];
69
+ if (max) {
70
+ // send Store API request to list orders with filters
71
+ const { data } = await api.get(`${endpoint}${query}`);
72
+ const countOrders = data.result
73
+ .filter(({ status }) => status !== 'cancelled')
74
+ .length;
75
+ if (countOrders >= max) {
76
+ // limit reached
77
+ return false;
78
+ }
79
+ }
80
+ }
31
81
  }
32
- return response;
82
+ return true;
33
83
  };
34
84
 
35
- const addDiscount = (discount, flag, label, maxDiscount) => {
85
+ const getDiscountValue = (discount, maxDiscount) => {
36
86
  let value;
37
87
  if (typeof maxDiscount !== 'number') {
38
- maxDiscount = params.amount[discount.apply_at || 'total'];
88
+ const applyAt = discount.apply_at || 'total';
89
+ maxDiscount = params.amount[applyAt];
90
+ if (applyAt === 'total' && response.discount_rule) {
91
+ maxDiscount -= response.discount_rule.extra_discount.value;
92
+ }
93
+ if (applyAt !== 'freight') {
94
+ // eslint-disable-next-line
95
+ maxDiscount -= getFreebiesPreview().value;
96
+ }
39
97
  }
40
- if (maxDiscount) {
98
+ if (maxDiscount > 0) {
41
99
  // update amount discount and total
42
100
  if (discount.type === 'percentage') {
43
101
  value = maxDiscount * (discount.value / 100);
@@ -48,7 +106,11 @@ export default async ({ params, application }) => {
48
106
  value = maxDiscount;
49
107
  }
50
108
  }
109
+ return value !== undefined ? Number(value) : value;
110
+ };
51
111
 
112
+ const addDiscount = (discount, flag, label, maxDiscount) => {
113
+ const value = getDiscountValue(discount, maxDiscount);
52
114
  if (value) {
53
115
  if (response.discount_rule) {
54
116
  // accumulate discount
@@ -66,9 +128,145 @@ export default async ({ params, application }) => {
66
128
  },
67
129
  };
68
130
  }
69
- return true;
131
+ return value;
132
+ }
133
+ return null;
134
+ };
135
+
136
+ const pointDiscountToSku = (discountValue, sku) => {
137
+ if (!discountValue || !sku) return;
138
+ if (!discountPerSku[sku]) discountPerSku[sku] = 0;
139
+ discountPerSku[sku] += discountValue;
140
+ };
141
+ const pointDiscountToEachItem = (discountValue, filteredItems) => {
142
+ const itemsAmount = filteredItems.reduce(
143
+ (amount, item) => amount + (ecomUtils.price(item) * (item.quantity || 1)),
144
+ 0,
145
+ );
146
+ const discountMultiplier = discountValue / itemsAmount;
147
+ filteredItems.forEach((item) => {
148
+ const discountPerItem = discountMultiplier * (ecomUtils.price(item) * (item.quantity || 1));
149
+ return pointDiscountToSku(discountPerItem, item.sku);
150
+ });
151
+ };
152
+
153
+ const getFreebiesPreview = () => {
154
+ if (params.items && params.items.length) {
155
+ // gift products (freebies) campaings
156
+ if (Array.isArray(config.freebies_rules)) {
157
+ const validFreebiesRules = config.freebies_rules.filter((rule) => {
158
+ return validateDateRange(rule)
159
+ && validateCustomerId(rule, params)
160
+ && mapCampaignProducts({ product_ids: rule.check_product_ids }, params).valid
161
+ && Array.isArray(rule.product_ids)
162
+ && rule.product_ids.length
163
+ && matchFreebieRule(rule, params);
164
+ });
165
+ if (validFreebiesRules) {
166
+ const cumulativeRules = [];
167
+ let bestRule;
168
+ let discountValue = 0;
169
+ for (let i = 0; i < validFreebiesRules.length; i++) {
170
+ const rule = validFreebiesRules[i];
171
+ let subtotal = 0;
172
+ const subtotalItems = [];
173
+ params.items.forEach((item) => {
174
+ if (Array.isArray(rule.category_ids)) {
175
+ if (Array.isArray(item.categories)) {
176
+ for (let ii = 0; ii < item.categories.length; ii++) {
177
+ const category = item.categories[ii];
178
+ if (rule.category_ids.indexOf(category._id) > -1) {
179
+ subtotal += (item.quantity * ecomUtils.price(item));
180
+ subtotalItems.push(item);
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ return;
186
+ }
187
+ subtotal += (item.quantity * ecomUtils.price(item));
188
+ subtotalItems.push(item);
189
+ });
190
+ if (subtotal <= 0) {
191
+ continue;
192
+ }
193
+ // start calculating discount
194
+ let value = 0;
195
+ let fixedSubtotal = subtotal;
196
+ rule.product_ids.forEach((productId) => {
197
+ const item = params.items.find((_item) => productId === _item.product_id);
198
+ if (item) {
199
+ const price = ecomUtils.price(item);
200
+ value += price;
201
+ if (subtotalItems.find((_item) => productId === _item.product_id)) {
202
+ fixedSubtotal -= price;
203
+ }
204
+ }
205
+ });
206
+ if (rule.deduct_discounts) {
207
+ if (response.discount_rule) {
208
+ fixedSubtotal -= response.discount_rule.extra_discount.value;
209
+ }
210
+ if (params.amount.discount) {
211
+ fixedSubtotal -= params.amount.discount;
212
+ }
213
+ }
214
+ if (rule.cumulative_freebie === true && !(rule.min_subtotal > fixedSubtotal)) {
215
+ cumulativeRules.push({ rule, value });
216
+ }
217
+ if (!bestRule || value > discountValue || bestRule.min_subtotal < rule.min_subtotal) {
218
+ if (!(rule.min_subtotal > fixedSubtotal)) {
219
+ bestRule = rule;
220
+ discountValue = value;
221
+ } else if (!discountValue && fixedSubtotal >= rule.min_subtotal) {
222
+ // discount not applicable yet but additional freebies are available
223
+ bestRule = rule;
224
+ }
225
+ }
226
+ }
227
+
228
+ if (bestRule) {
229
+ // provide freebie products \o/
230
+ response.freebie_product_ids = bestRule.product_ids;
231
+ if (discountValue) {
232
+ if (bestRule.cumulative_freebie === true) {
233
+ cumulativeRules.forEach(({ rule, value }) => {
234
+ for (let i = 0; i < response.freebie_product_ids.length; i++) {
235
+ const productId = response.freebie_product_ids[i];
236
+ if (rule.product_ids.includes(productId)) {
237
+ // ignoring cumulative freebie rules with repeated products
238
+ return;
239
+ }
240
+ }
241
+ discountValue += value;
242
+ rule.product_ids.forEach((productId) => {
243
+ response.freebie_product_ids.push(productId);
244
+ });
245
+ });
246
+ }
247
+ return {
248
+ value: discountValue,
249
+ label: bestRule.label,
250
+ };
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ return { value: 0 };
257
+ };
258
+
259
+ const addFreebies = () => {
260
+ const { value, label } = getFreebiesPreview();
261
+ if (value) {
262
+ const maxDiscount = Math.min(value, params.amount.total || 0);
263
+ addDiscount(
264
+ { type: 'fixed', value },
265
+ 'FREEBIES',
266
+ label,
267
+ maxDiscount,
268
+ );
70
269
  }
71
- return false;
72
270
  };
73
271
 
74
272
  if (params.items && params.items.length) {
@@ -76,7 +274,7 @@ export default async ({ params, application }) => {
76
274
  if (Array.isArray(config.product_kit_discounts)) {
77
275
  config.product_kit_discounts = config.product_kit_discounts.map((kitDiscount) => {
78
276
  if (!kitDiscount.product_ids) {
79
- // kit with any items
277
+ // kit with any items (or per category)
80
278
  kitDiscount.product_ids = [];
81
279
  }
82
280
  return kitDiscount;
@@ -91,41 +289,60 @@ export default async ({ params, application }) => {
91
289
  }
92
290
  if (a.min_quantity > b.min_quantity) {
93
291
  return -1;
94
- }
95
- if (b.min_quantity > a.min_quantity) {
292
+ } if (b.min_quantity > a.min_quantity) {
96
293
  return 1;
97
- }
98
- if (a.discount.min_amount > b.discount.min_amount) {
294
+ } if (a.discount.min_amount > b.discount.min_amount) {
99
295
  return -1;
100
- }
101
- if (b.discount.min_amount > a.discount.min_amount) {
296
+ } if (b.discount.min_amount > a.discount.min_amount) {
102
297
  return 1;
103
298
  }
104
299
  return 0;
105
300
  });
106
- // prevent applying duplicated kit discount for same items
301
+ // prevent applying duplicated kit discount for same items
107
302
  let discountedItemIds = [];
108
303
  // check buy together recommendations
109
304
  const buyTogether = [];
110
305
 
111
- kitDiscounts.forEach((kitDiscount, index) => {
306
+ for (let index = 0; index < kitDiscounts.length; index++) {
307
+ const kitDiscount = kitDiscounts[index];
112
308
  if (kitDiscount) {
113
309
  const productIds = Array.isArray(kitDiscount.product_ids)
114
310
  ? kitDiscount.product_ids
115
311
  : [];
116
- let kitItems = productIds.length
117
- ? params.items.filter((item) => item.quantity && productIds.indexOf(item.product_id) > -1)
118
- : params.items.filter((item) => item.quantity);
119
- kitItems = kitItems.filter((item) => discountedItemIds.indexOf(item.product_id) === -1);
312
+ const categoryIds = Array.isArray(kitDiscount.category_ids)
313
+ ? kitDiscount.category_ids
314
+ : [];
315
+ let kitItems = [];
316
+ if (productIds.length) {
317
+ kitItems = params.items.filter((item) => productIds.indexOf(item.product_id) > -1);
318
+ } else if (categoryIds.length) {
319
+ kitItems = params.items.filter((item) => {
320
+ if (Array.isArray(item.categories)) {
321
+ for (let i = 0; i < item.categories.length; i++) {
322
+ const category = item.categories[i];
323
+ if (categoryIds.indexOf(category._id) > -1) {
324
+ return true;
325
+ }
326
+ }
327
+ }
328
+ return false;
329
+ });
330
+ } else {
331
+ kitItems = [...params.items];
332
+ }
333
+ // eslint-disable-next-line no-loop-func
334
+ kitItems = kitItems.filter((item) => {
335
+ return item.quantity && discountedItemIds.indexOf(item.product_id) === -1;
336
+ });
120
337
  if (!kitItems.length) {
121
- return;
338
+ continue;
122
339
  }
123
340
 
124
341
  const recommendBuyTogether = () => {
125
342
  if (
126
343
  params.items.length === 1
127
- && productIds.length <= 4
128
- && buyTogether.length < 300
344
+ && productIds.length <= 4
345
+ && buyTogether.length < 300
129
346
  ) {
130
347
  const baseProductId = params.items[0].product_id;
131
348
  if (productIds.indexOf(baseProductId) === -1) {
@@ -145,8 +362,8 @@ export default async ({ params, application }) => {
145
362
  buyTogether.push({
146
363
  products: buyTogetherProducts,
147
364
  discount: {
148
- type: kitDiscount.discount.type,
149
- value: kitDiscount.discount.value,
365
+ type: kitDiscount.originalDiscount?.type || kitDiscount.discount.type,
366
+ value: kitDiscount.originalDiscount?.value || kitDiscount.discount.value,
150
367
  },
151
368
  });
152
369
  }
@@ -166,18 +383,25 @@ export default async ({ params, application }) => {
166
383
  if (totalQuantity < kitDiscount.min_quantity) {
167
384
  if (productIds.length > 1 && kitDiscount.check_all_items !== false) {
168
385
  recommendBuyTogether();
169
- return;
170
386
  }
171
- return;
387
+ continue;
172
388
  }
173
- if (discount.type === 'fixed' && kitDiscount.cumulative_discount !== false) {
389
+ if (
390
+ discount.type === 'fixed'
391
+ && kitDiscount.cumulative_discount !== false
392
+ && !kitDiscount.usage_limit
393
+ ) {
174
394
  discount.value *= Math.floor(totalQuantity / kitDiscount.min_quantity);
175
395
  }
176
396
  }
177
397
  }
178
398
 
179
- if (!params.amount || !(discount.min_amount > params.amount.total)) {
399
+ if (
400
+ !params.amount
401
+ || !(discount.min_amount > params.amount.total - getFreebiesPreview().value)
402
+ ) {
180
403
  if (kitDiscount.check_all_items !== false) {
404
+ let isSkip = false;
181
405
  for (let i = 0; i < productIds.length; i++) {
182
406
  const productId = productIds[i];
183
407
  if (
@@ -186,99 +410,103 @@ export default async ({ params, application }) => {
186
410
  ) {
187
411
  // product not on current cart
188
412
  recommendBuyTogether();
189
- return;
413
+ isSkip = true;
414
+ break;
415
+ }
416
+ }
417
+ if (categoryIds.length) {
418
+ for (let i = 0; i < kitItems.length; i++) {
419
+ const { categories } = kitItems[i];
420
+ let hasListedCategory = false;
421
+ if (categories) {
422
+ for (let ii = 0; ii < categories.length; ii++) {
423
+ const category = categories[ii];
424
+ if (categoryIds.find((categoryId) => categoryId === category._id)) {
425
+ hasListedCategory = true;
426
+ continue;
427
+ }
428
+ }
429
+ }
430
+ if (!hasListedCategory) {
431
+ recommendBuyTogether();
432
+ isSkip = true;
433
+ break;
434
+ }
190
435
  }
191
436
  }
437
+ if (isSkip) continue;
192
438
  }
193
- // apply cumulative discount \o/
194
- if (kitDiscount.same_product_quantity) {
195
- kitItems.forEach((item, i) => {
196
- addDiscount(
197
- discount,
198
- `KIT-${(index + 1)}-${i}`,
199
- kitDiscount.label,
200
- ecomUtils.price(item) * (item.quantity || 1),
201
- );
202
- });
203
- } else {
204
- addDiscount(discount, `KIT-${(index + 1)}`, kitDiscount.label);
439
+
440
+ try {
441
+ const isAvailable = await checkUsageLimit(kitDiscount);
442
+ if (isAvailable) {
443
+ // apply cumulative discount \o/
444
+ if (kitDiscount.same_product_quantity) {
445
+ kitItems.forEach((item, i) => {
446
+ const discountValue = addDiscount(
447
+ discount,
448
+ `KIT-${(index + 1)}-${i}`,
449
+ kitDiscount.label,
450
+ ecomUtils.price(item) * (item.quantity || 1),
451
+ );
452
+ pointDiscountToSku(discountValue, item.sku);
453
+ });
454
+ } else {
455
+ const discountValue = addDiscount(discount, `KIT-${(index + 1)}`, kitDiscount.label);
456
+ pointDiscountToEachItem(discountValue, kitItems);
457
+ }
458
+ discountedItemIds = discountedItemIds.concat(kitItems.map((item) => item.product_id));
459
+ }
460
+ } catch (err) {
461
+ return {
462
+ error: 'CANT_CHECK_USAGE_LIMITS',
463
+ message: err.message,
464
+ };
205
465
  }
206
- discountedItemIds = discountedItemIds.concat(kitItems.map((item) => item.product_id));
207
466
  }
208
467
  }
209
- });
468
+ }
210
469
  if (buyTogether.length) {
211
470
  response.buy_together = buyTogether;
212
471
  }
472
+ }
213
473
 
214
- // gift products (freebies) campaings
215
- if (Array.isArray(config.freebies_rules)) {
216
- const validFreebiesRules = config.freebies_rules.filter((rule) => {
217
- return validateDateRange(rule)
218
- && validateCustomerId(rule, params)
219
- && checkCampaignProducts(rule.check_product_ids, params)
220
- && Array.isArray(rule.product_ids)
221
- && rule.product_ids.length;
474
+ // additional discount coupons for API manipualation with
475
+ // PATCH https://api.e-com.plus/v1/applications/<discounts_app_id>/hidden_data.json { COUPON }
476
+ if (!config.discount_rules) {
477
+ config.discount_rules = [];
478
+ }
479
+ Object.keys(config).forEach((configField) => {
480
+ switch (configField) {
481
+ case 'freebies_rules':
482
+ case 'product_kit_discounts':
483
+ case 'discount_rules':
484
+ return;
485
+ default:
486
+ }
487
+ const configObj = config[configField];
488
+ if (configObj && configObj.discount) {
489
+ config.discount_rules.push({
490
+ ...configObj,
491
+ discount_coupon: configField,
222
492
  });
223
- if (validFreebiesRules) {
224
- let subtotal = 0;
225
- params.items.forEach((item) => {
226
- subtotal += (item.quantity * ecomUtils.price(item));
227
- });
228
-
229
- let bestRule;
230
- let discountValue = 0;
231
- for (let i = 0; i < validFreebiesRules.length; i++) {
232
- const rule = validFreebiesRules[i];
233
- // start calculating discount
234
- let value = 0;
235
- rule.product_ids.forEach((productId) => {
236
- const item = params.items.find((_item) => productId === _item.product_id);
237
- if (item) {
238
- value += ecomUtils.price(item);
239
- }
240
- });
241
- const fixedSubtotal = subtotal - value;
242
- if (!bestRule || value > discountValue || bestRule.min_subtotal < rule.min_subtotal) {
243
- if (!(rule.min_subtotal > fixedSubtotal)) {
244
- bestRule = rule;
245
- discountValue = value;
246
- } else if (!discountValue && subtotal >= rule.min_subtotal) {
247
- // discount not applicable yet but additional freebies are available
248
- bestRule = rule;
249
- }
250
- }
251
- }
252
-
253
- if (bestRule) {
254
- // provide freebie products \o/
255
- response.freebie_product_ids = bestRule.product_ids;
256
- if (discountValue) {
257
- addDiscount(
258
- {
259
- type: 'fixed',
260
- value: discountValue,
261
- },
262
- 'FREEBIES',
263
- bestRule.label,
264
- );
265
- }
266
- }
267
- }
268
493
  }
269
- }
494
+ });
270
495
 
271
496
  const discountRules = getValidDiscountRules(config.discount_rules, params);
272
497
  if (discountRules.length) {
273
498
  const { discountRule, discountMatchEnum } = matchDiscountRule(discountRules, params);
274
499
  if (discountRule) {
275
- if (!checkCampaignProducts(discountRule.product_ids, params)) {
276
- return {
277
- available_extra_discount: response.available_extra_discount,
278
- invalid_coupon_message: params.lang === 'pt_br'
279
- ? 'Nenhum produto da promoção está incluído no carrinho'
280
- : 'No promotion products are included in the cart',
281
- };
500
+ const {
501
+ valid: isValidByItems,
502
+ items: filteredItems,
503
+ } = mapCampaignProducts(discountRule, params);
504
+ if (!isValidByItems) {
505
+ addFreebies();
506
+ response.invalid_coupon_message = params.lang === 'pt_br'
507
+ ? 'Nenhum produto da promoção está incluído no carrinho'
508
+ : 'No promotion products are included in the cart';
509
+ return respondSuccess();
282
510
  }
283
511
 
284
512
  const excludedProducts = discountRule.excluded_product_ids;
@@ -287,64 +515,104 @@ export default async ({ params, application }) => {
287
515
  for (let i = 0; i < params.items.length; i++) {
288
516
  const item = params.items[i];
289
517
  if (item.quantity && excludedProducts.includes(item.product_id)) {
290
- return {
291
- available_extra_discount: response.available_extra_discount,
292
- invalid_coupon_message: params.lang === 'pt_br'
293
- ? `Promoção é inválida para o produto ${item.name}`
294
- : `Invalid promotion for product ${item.name}`,
295
- };
518
+ addFreebies();
519
+ response.invalid_coupon_message = params.lang === 'pt_br'
520
+ ? `Promoção é inválida para o produto ${item.name}`
521
+ : `Invalid promotion for product ${item.name}`;
522
+ return respondSuccess();
296
523
  }
297
524
  }
298
525
  }
299
526
 
300
- let { label } = discountRule;
301
527
  const { discount } = discountRule;
528
+ let { label } = discountRule;
302
529
  if (typeof label !== 'string' || !label) {
303
530
  label = params.discount_coupon || `DISCOUNT ${discountMatchEnum}`;
304
531
  }
305
532
  if (
306
533
  discount.apply_at !== 'freight'
307
- && (!response.available_extra_discount || !response.available_extra_discount.value
308
- || discountRule.default_discount === true || checkOpenPromotion(discountRule))
534
+ && (!response.available_extra_discount || !response.available_extra_discount.value
535
+ || discountRule.default_discount === true || checkOpenPromotion(discountRule))
309
536
  ) {
310
537
  // show current discount rule as available discount to apply
311
538
  response.available_extra_discount = {
312
539
  label: label.substring(0, 50),
540
+ type: discount.type,
313
541
  };
314
- ['min_amount', 'type', 'value'].forEach((field) => {
542
+ ['min_amount', 'value'].forEach((field) => {
315
543
  if (discount[field]) {
316
- response.available_extra_discount[field] = discount[field];
544
+ response.available_extra_discount[field] = Number(discount[field]);
317
545
  }
318
546
  });
319
547
  }
320
548
 
321
549
  // params object follows list payments request schema:
322
550
  // https://apx-mods.e-com.plus/api/v1/apply_discount/schema.json?store_id=100
551
+ let checkAmount;
552
+ if (params.amount) {
553
+ checkAmount = params.amount[discountRule.discount.amount_field || 'total'];
554
+ if (discountRule.discount.amount_field !== 'freight') {
555
+ checkAmount -= getFreebiesPreview().value;
556
+ }
557
+ }
323
558
  if (
324
559
  params.amount && params.amount.total > 0
325
- && !(discountRule.discount.min_amount > params.amount[discountRule.discount.amount_field || 'total'])
560
+ && !(discountRule.discount.min_amount > checkAmount)
326
561
  ) {
327
562
  if (
328
563
  discountRule.cumulative_discount === false
329
- && (response.discount_rule || params.amount.discount)
564
+ && (response.discount_rule || params.amount.discount)
330
565
  ) {
331
- // explain discount can't be applied :(
332
- // https://apx-mods.e-com.plus/api/v1/apply_discount/response_schema.json?store_id=100
333
- return {
334
- invalid_coupon_message: params.lang === 'pt_br'
566
+ if (
567
+ response.discount_rule?.extra_discount
568
+ && !params.amount.discount
569
+ && getDiscountValue(discount) > response.discount_rule.extra_discount.value
570
+ ) {
571
+ // replace discount with new bigger one
572
+ delete response.discount_rule;
573
+ } else {
574
+ // explain discount can't be applied :(
575
+ // https://apx-mods.e-com.plus/api/v1/apply_discount/response_schema.json?store_id=100
576
+ addFreebies();
577
+ response.invalid_coupon_message = params.lang === 'pt_br'
335
578
  ? 'A promoção não pôde ser aplicada porque este desconto não é cumulativo'
336
- : 'This discount is not cumulative',
337
- };
579
+ : 'This discount is not cumulative';
580
+ return respondSuccess();
581
+ }
338
582
  }
339
583
 
340
584
  // we have a discount to apply \o/
341
- if (addDiscount(discountRule.discount, discountMatchEnum)) {
585
+ const discountValue = addDiscount(discountRule.discount, discountMatchEnum);
586
+ if (discountValue) {
587
+ if (filteredItems?.length) {
588
+ pointDiscountToEachItem(discountValue, filteredItems);
589
+ }
342
590
  // add discount label and description if any
343
591
  response.discount_rule.label = label;
344
592
  if (discountRule.description) {
345
593
  response.discount_rule.description = discountRule.description;
346
594
  }
347
595
  if (!checkOpenPromotion(discountRule)) {
596
+ if (discountRule.cumulative_discount !== false) {
597
+ // check for additional same-rule discount on different amount
598
+ const {
599
+ discountRule: secondDiscountRule,
600
+ discountMatchEnum: secondDiscountMatchEnum,
601
+ } = matchDiscountRule(discountRules, params, discountRule.discount.apply_at || 'total');
602
+ if (secondDiscountRule) {
603
+ let _checkAmount = params.amount[secondDiscountRule.discount.amount_field || 'total'];
604
+ if (secondDiscountRule.discount.amount_field !== 'freight') {
605
+ _checkAmount -= getFreebiesPreview().value;
606
+ }
607
+ if (
608
+ secondDiscountRule.cumulative_discount !== false
609
+ && !(secondDiscountRule.discount.min_amount > _checkAmount)
610
+ ) {
611
+ addDiscount(secondDiscountRule.discount, secondDiscountMatchEnum + '-2');
612
+ }
613
+ }
614
+ }
615
+
348
616
  // check for additional open discount
349
617
  const {
350
618
  discountRule: openDiscountRule,
@@ -352,78 +620,47 @@ export default async ({ params, application }) => {
352
620
  } = matchDiscountRule(discountRules, {});
353
621
  if (
354
622
  openDiscountRule
355
- && openDiscountRule.cumulative_discount !== false
356
- && openDiscountRule.discount.min_amount
623
+ && openDiscountRule.cumulative_discount !== false
624
+ && openDiscountRule.discount.min_amount
357
625
  ) {
358
- let checkAmount = params.amount[openDiscountRule.discount.amount_field || 'total'];
359
- if (checkAmount) {
626
+ let _checkAmount = params.amount[openDiscountRule.discount.amount_field || 'total'];
627
+ if (_checkAmount) {
360
628
  // subtract current discount to validate cumulative open discount min amount
361
629
  if (response.discount_rule) {
362
- checkAmount -= response.discount_rule.extra_discount.value;
630
+ _checkAmount -= response.discount_rule.extra_discount.value;
363
631
  }
364
- if (openDiscountRule.discount.min_amount <= checkAmount) {
632
+ if (openDiscountRule.discount.amount_field !== 'freight') {
633
+ _checkAmount -= getFreebiesPreview().value;
634
+ }
635
+ if (openDiscountRule.discount.min_amount <= _checkAmount) {
365
636
  addDiscount(openDiscountRule.discount, openDiscountMatchEnum);
366
637
  }
367
638
  }
368
639
  }
369
640
  }
370
641
 
371
- const { customer } = params;
372
- if (
373
- customer && (customer._id || customer.doc_number)
374
- && (discountRule.usage_limit > 0 || discountRule.total_usage_limit > 0)
375
- ) {
376
- // list orders to check discount usage limits
377
- // eslint-disable-next-line prefer-template
378
- const endpoint = 'orders?fields=status'
379
- + `&extra_discount.app.label${(discountRule.case_insensitive ? '%=' : '=')}`
380
- + encodeURIComponent(label);
381
- const usageLimits = [{
382
- // limit by customer
383
- query: customer.doc_number
384
- ? `&buyers.doc_number=${customer.doc_number}`
385
- : `&buyers._id=${customer._id}`,
386
- max: discountRule.usage_limit,
387
- }, {
388
- // total limit
389
- query: '',
390
- max: discountRule.total_usage_limit,
391
- }];
392
- for (let i = 0; i < usageLimits.length; i++) {
393
- const { query, max } = usageLimits[i];
394
- if (max) {
395
- let countOrders;
396
- try {
397
- // send Store API request to list orders with filters
398
- // eslint-disable-next-line no-await-in-loop
399
- const { data } = await api.get(`${endpoint}${query}`);
400
- countOrders = data.result
401
- .filter(({ status }) => status !== 'cancelled')
402
- .length;
403
- } catch (err) {
404
- return {
405
- error: 'CANT_CHECK_USAGE_LIMITS',
406
- message: err.message,
407
- };
408
- }
409
-
410
- if (countOrders >= max) {
411
- // limit reached
412
- return {
413
- invalid_coupon_message: params.lang === 'pt_br'
414
- ? 'A promoção não pôde ser aplicada porque já atingiu o limite de usos'
415
- : 'The promotion could not be applied because it has already reached the usage limit',
416
- };
417
- }
418
- }
642
+ try {
643
+ const isAvailable = await checkUsageLimit(discountRule, label);
644
+ if (!isAvailable) {
645
+ delete response.discount_rule;
646
+ response.invalid_coupon_message = params.lang === 'pt_br'
647
+ ? 'A promoção não pôde ser aplicada porque já atingiu o limite de usos'
648
+ : 'The promotion could not be applied because it has already reached the usage limit';
419
649
  }
650
+ addFreebies();
651
+ return respondSuccess();
652
+ } catch (err) {
653
+ return {
654
+ error: 'CANT_CHECK_USAGE_LIMITS',
655
+ message: err.message,
656
+ };
420
657
  }
421
- return respondSuccess();
422
658
  }
423
659
  }
424
660
  }
425
661
  }
426
662
 
663
+ addFreebies();
427
664
  // response with no error nor discount applied
428
665
  return respondSuccess();
429
666
  };
@@ -1,3 +1,4 @@
1
+ /* eslint-disable default-param-last, no-use-before-define */
1
2
  import ecomUtils from '@ecomplus/utils';
2
3
 
3
4
  const validateDateRange = (rule) => {
@@ -15,6 +16,19 @@ const validateDateRange = (rule) => {
15
16
  };
16
17
 
17
18
  const validateCustomerId = (rule, params) => {
19
+ if (
20
+ typeof rule.customer_ids === 'object'
21
+ && rule.customer_ids
22
+ && !Array.isArray(rule.customer_ids)
23
+ ) {
24
+ const customerIds = [];
25
+ Object.keys(rule.customer_ids).forEach((key) => {
26
+ if (rule.customer_ids[key]) {
27
+ customerIds.push(rule.customer_ids[key]);
28
+ }
29
+ });
30
+ rule.customer_ids = customerIds;
31
+ }
18
32
  if (
19
33
  Array.isArray(rule.customer_ids)
20
34
  && rule.customer_ids.length
@@ -26,27 +40,74 @@ const validateCustomerId = (rule, params) => {
26
40
  return true;
27
41
  };
28
42
 
43
+ const matchFreebieRule = (rule, params = {}) => {
44
+ const coupon = params.discount_coupon;
45
+ const utm = params.utm && params.utm.campaign;
46
+ if (rule.domain && rule.domain !== params.domain) {
47
+ if (params.domain !== `${rule.domain}.skip-open`) {
48
+ return false;
49
+ }
50
+ }
51
+ if (rule.freebie_coupon && rule.freebie_utm) {
52
+ return coupon?.toUpperCase() === rule.freebie_coupon?.toUpperCase()
53
+ || (utm?.toUpperCase() === rule.freebie_utm?.toUpperCase());
54
+ }
55
+ if (rule.freebie_coupon) {
56
+ return coupon?.toUpperCase() === rule.freebie_coupon?.toUpperCase();
57
+ }
58
+ if (rule.freebie_utm) {
59
+ return (utm?.toUpperCase() === rule.freebie_utm?.toUpperCase());
60
+ }
61
+ return true;
62
+ };
63
+
29
64
  const checkOpenPromotion = (rule) => {
30
65
  return !rule.discount_coupon && !rule.utm_campaign
31
66
  && (!Array.isArray(rule.customer_ids) || !rule.customer_ids.length);
32
67
  };
33
68
 
34
- const getValidDiscountRules = (discountRules, params, items) => {
69
+ const getValidDiscountRules = (discountRules, params, itemsForKit) => {
35
70
  if (Array.isArray(discountRules) && discountRules.length) {
36
71
  // validate rules objects
37
72
  return discountRules.filter((rule) => {
38
73
  if (!rule || !validateCustomerId(rule, params)) {
39
74
  return false;
40
75
  }
41
-
42
- if (Array.isArray(rule.product_ids) && Array.isArray(items)) {
76
+ const isKitDiscount = Array.isArray(itemsForKit)
77
+ && (Array.isArray(rule.product_ids) || Array.isArray(rule.category_ids));
78
+ if (rule.domain && rule.domain !== params.domain) {
79
+ if (params.domain === `${rule.domain}.skip-open`) {
80
+ if (!isKitDiscount && checkOpenPromotion(rule)) return false;
81
+ } else {
82
+ return false;
83
+ }
84
+ }
85
+ if (isKitDiscount) {
43
86
  const checkProductId = (item) => {
44
- return (!rule.product_ids.length || rule.product_ids.indexOf(item.product_id) > -1);
87
+ if (
88
+ !(rule.product_ids && rule.product_ids.length)
89
+ && Array.isArray(rule.category_ids)
90
+ && rule.category_ids.length
91
+ ) {
92
+ if (Array.isArray(item.categories)) {
93
+ for (let i = 0; i < item.categories.length; i++) {
94
+ const category = item.categories[i];
95
+ if (rule.category_ids.indexOf(category._id) > -1) {
96
+ return true;
97
+ }
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+ return (
103
+ !(rule.product_ids && rule.product_ids.length)
104
+ || rule.product_ids.indexOf(item.product_id) > -1
105
+ );
45
106
  };
46
107
  // set/add discount value from lowest item price
47
108
  let value;
48
109
  if (rule.discount_lowest_price) {
49
- items.forEach((item) => {
110
+ itemsForKit.forEach((item) => {
50
111
  const price = ecomUtils.price(item);
51
112
  if (price > 0 && checkProductId(item) && (!value || value > price)) {
52
113
  value = price;
@@ -54,7 +115,7 @@ const getValidDiscountRules = (discountRules, params, items) => {
54
115
  });
55
116
  } else if (rule.discount_kit_subtotal) {
56
117
  value = 0;
57
- items.forEach((item) => {
118
+ itemsForKit.forEach((item) => {
58
119
  const price = ecomUtils.price(item);
59
120
  if (price > 0 && checkProductId(item)) {
60
121
  value += price * item.quantity;
@@ -71,6 +132,7 @@ const getValidDiscountRules = (discountRules, params, items) => {
71
132
  value = Math.min(value, rule.discount.value);
72
133
  }
73
134
  }
135
+ rule.originalDiscount = rule.discount;
74
136
  rule.discount = {
75
137
  ...rule.discount,
76
138
  type: 'fixed',
@@ -81,21 +143,31 @@ const getValidDiscountRules = (discountRules, params, items) => {
81
143
  if (!rule.discount || !rule.discount.value) {
82
144
  return false;
83
145
  }
84
-
85
146
  return validateDateRange(rule);
86
147
  });
87
148
  }
88
-
89
149
  // returns array anyway
90
150
  return [];
91
151
  };
92
152
 
93
- const matchDiscountRule = (discountRules, params = {}) => {
153
+ const matchDiscountRule = (_discountRules, params = {}, skipApplyAt) => {
154
+ const validItemsDiscountRules = _discountRules.filter((rule) => {
155
+ return mapCampaignProducts(rule, params).valid;
156
+ });
157
+ const discountRules = validItemsDiscountRules.length
158
+ ? validItemsDiscountRules
159
+ : _discountRules;
160
+ const filteredRules = skipApplyAt
161
+ ? discountRules.filter((rule) => {
162
+ const applyAt = (rule.discount && rule.discount.apply_at) || 'total';
163
+ return applyAt !== skipApplyAt;
164
+ })
165
+ : discountRules;
94
166
  // try to match a promotion
95
167
  if (params.discount_coupon) {
96
168
  // match only by discount coupon
97
169
  return {
98
- discountRule: discountRules.find((rule) => {
170
+ discountRule: filteredRules.find((rule) => {
99
171
  return rule.case_insensitive
100
172
  ? typeof rule.discount_coupon === 'string'
101
173
  && rule.discount_coupon.toUpperCase() === params.discount_coupon.toUpperCase()
@@ -104,10 +176,9 @@ const matchDiscountRule = (discountRules, params = {}) => {
104
176
  discountMatchEnum: 'COUPON',
105
177
  };
106
178
  }
107
-
108
179
  // try to match by UTM campaign first
109
180
  if (params.utm && params.utm.campaign) {
110
- const discountRule = discountRules.find((rule) => {
181
+ const discountRule = filteredRules.find((rule) => {
111
182
  return rule.case_insensitive
112
183
  ? typeof rule.utm_campaign === 'string'
113
184
  && rule.utm_campaign.toUpperCase() === params.utm.campaign.toUpperCase()
@@ -120,11 +191,12 @@ const matchDiscountRule = (discountRules, params = {}) => {
120
191
  };
121
192
  }
122
193
  }
123
-
124
194
  // then try to match by customer
125
195
  if (params.customer && params.customer._id) {
126
- const discountRule = discountRules.find((rule) => Array.isArray(rule.customer_ids)
127
- && rule.customer_ids.indexOf(params.customer._id) > -1);
196
+ const discountRule = filteredRules.find((rule) => {
197
+ return Array.isArray(rule.customer_ids)
198
+ && rule.customer_ids.indexOf(params.customer._id) > -1;
199
+ });
128
200
  if (discountRule) {
129
201
  return {
130
202
  discountRule,
@@ -132,31 +204,59 @@ const matchDiscountRule = (discountRules, params = {}) => {
132
204
  };
133
205
  }
134
206
  }
135
-
207
+ // then try to match by domain
208
+ if (params.domain) {
209
+ const discountRule = filteredRules.find((rule) => {
210
+ return rule.domain === params.domain || params.domain === `${rule.domain}.skip-open`;
211
+ });
212
+ if (discountRule) {
213
+ return {
214
+ discountRule,
215
+ discountMatchEnum: 'DOMAIN',
216
+ };
217
+ }
218
+ }
136
219
  // last try to match by open promotions
137
220
  return {
138
- discountRule: discountRules.find(checkOpenPromotion),
221
+ discountRule: filteredRules.find(checkOpenPromotion),
139
222
  discountMatchEnum: 'OPEN',
140
223
  };
141
224
  };
142
225
 
143
- const checkCampaignProducts = (campaignProducts, params) => {
144
- if (Array.isArray(campaignProducts) && campaignProducts.length) {
145
- // must check at least one campaign product on cart
146
- let hasProductMatch;
147
- if (params.items && params.items.length) {
148
- for (let i = 0; i < campaignProducts.length; i++) {
149
- if (params.items.find((item) => item.quantity && item.product_id === campaignProducts[i])) {
150
- hasProductMatch = true;
151
- break;
226
+ const mapCampaignProducts = (rule, params) => {
227
+ if (Array.isArray(rule.product_ids) && rule.product_ids.length) {
228
+ const items = params.items?.filter((item) => {
229
+ return item.quantity && rule.product_ids.includes(item.product_id);
230
+ }) || [];
231
+ return { valid: items.length, items };
232
+ }
233
+ if (Array.isArray(rule.category_ids) && rule.category_ids.length) {
234
+ let discountValue = 0;
235
+ const items = params.items?.filter((item) => {
236
+ const isValidItem = item.quantity && item.categories?.some((category) => {
237
+ return rule.category_ids.includes(category._id);
238
+ });
239
+ if (isValidItem) {
240
+ const price = ecomUtils.price(item);
241
+ if (price > 0) {
242
+ discountValue += (price * item.quantity);
152
243
  }
153
244
  }
245
+ return isValidItem;
246
+ }) || [];
247
+ // direct "fix" rule discount value limiting by category items
248
+ if (rule.discount?.value) {
249
+ if (rule.discount.type === 'percentage') {
250
+ discountValue *= rule.discount.value / 100;
251
+ } else {
252
+ discountValue = Math.min(discountValue, rule.discount.value);
253
+ }
254
+ rule.discount.type = 'fixed';
255
+ rule.discount.value = discountValue;
154
256
  }
155
- if (!hasProductMatch) {
156
- return false;
157
- }
257
+ return { valid: items.length, items };
158
258
  }
159
- return true;
259
+ return { valid: true, items: [] };
160
260
  };
161
261
 
162
262
  export {
@@ -165,5 +265,6 @@ export {
165
265
  checkOpenPromotion,
166
266
  getValidDiscountRules,
167
267
  matchDiscountRule,
168
- checkCampaignProducts,
268
+ matchFreebieRule,
269
+ mapCampaignProducts,
169
270
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cloudcommerce/app-discounts",
3
3
  "type": "module",
4
- "version": "2.29.9",
4
+ "version": "2.29.11",
5
5
  "description": "e-com.plus Cloud Commerce app for complex discount rules",
6
6
  "main": "lib/discounts.js",
7
7
  "files": [
@@ -23,10 +23,10 @@
23
23
  "homepage": "https://github.com/ecomplus/cloud-commerce/tree/main/packages/apps/discounts#readme",
24
24
  "dependencies": {
25
25
  "@ecomplus/utils": "1.5.0-rc.6",
26
- "@cloudcommerce/api": "2.29.9"
26
+ "@cloudcommerce/api": "2.29.11"
27
27
  },
28
28
  "devDependencies": {
29
- "@cloudcommerce/types": "2.29.9"
29
+ "@cloudcommerce/types": "2.29.11"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "bash ../../../scripts/build-lib.sh"