@cloudcommerce/app-discounts 2.29.8 → 2.29.10

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;
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,25 +515,24 @@ 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 = {
@@ -320,31 +547,71 @@ export default async ({ params, application }) => {
320
547
 
321
548
  // params object follows list payments request schema:
322
549
  // https://apx-mods.e-com.plus/api/v1/apply_discount/schema.json?store_id=100
550
+ let checkAmount;
551
+ if (params.amount) {
552
+ checkAmount = params.amount[discountRule.discount.amount_field || 'total'];
553
+ if (discountRule.discount.amount_field !== 'freight') {
554
+ checkAmount -= getFreebiesPreview().value;
555
+ }
556
+ }
323
557
  if (
324
558
  params.amount && params.amount.total > 0
325
- && !(discountRule.discount.min_amount > params.amount[discountRule.discount.amount_field || 'total'])
559
+ && !(discountRule.discount.min_amount > checkAmount)
326
560
  ) {
327
561
  if (
328
562
  discountRule.cumulative_discount === false
329
- && (response.discount_rule || params.amount.discount)
563
+ && (response.discount_rule || params.amount.discount)
330
564
  ) {
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'
565
+ if (
566
+ response.discount_rule?.extra_discount
567
+ && !params.amount.discount
568
+ && getDiscountValue(discount) > response.discount_rule.extra_discount.value
569
+ ) {
570
+ // replace discount with new bigger one
571
+ delete response.discount_rule;
572
+ } else {
573
+ // explain discount can't be applied :(
574
+ // https://apx-mods.e-com.plus/api/v1/apply_discount/response_schema.json?store_id=100
575
+ addFreebies();
576
+ response.invalid_coupon_message = params.lang === 'pt_br'
335
577
  ? 'A promoção não pôde ser aplicada porque este desconto não é cumulativo'
336
- : 'This discount is not cumulative',
337
- };
578
+ : 'This discount is not cumulative';
579
+ return respondSuccess();
580
+ }
338
581
  }
339
582
 
340
583
  // we have a discount to apply \o/
341
- if (addDiscount(discountRule.discount, discountMatchEnum)) {
584
+ const discountValue = addDiscount(discountRule.discount, discountMatchEnum);
585
+ if (discountValue) {
586
+ if (filteredItems?.length) {
587
+ pointDiscountToEachItem(discountValue, filteredItems);
588
+ }
342
589
  // add discount label and description if any
343
590
  response.discount_rule.label = label;
344
591
  if (discountRule.description) {
345
592
  response.discount_rule.description = discountRule.description;
346
593
  }
347
594
  if (!checkOpenPromotion(discountRule)) {
595
+ if (discountRule.cumulative_discount !== false) {
596
+ // check for additional same-rule discount on different amount
597
+ const {
598
+ discountRule: secondDiscountRule,
599
+ discountMatchEnum: secondDiscountMatchEnum,
600
+ } = matchDiscountRule(discountRules, params, discountRule.discount.apply_at || 'total');
601
+ if (secondDiscountRule) {
602
+ let _checkAmount = params.amount[secondDiscountRule.discount.amount_field || 'total'];
603
+ if (secondDiscountRule.discount.amount_field !== 'freight') {
604
+ _checkAmount -= getFreebiesPreview().value;
605
+ }
606
+ if (
607
+ secondDiscountRule.cumulative_discount !== false
608
+ && !(secondDiscountRule.discount.min_amount > _checkAmount)
609
+ ) {
610
+ addDiscount(secondDiscountRule.discount, secondDiscountMatchEnum + '-2');
611
+ }
612
+ }
613
+ }
614
+
348
615
  // check for additional open discount
349
616
  const {
350
617
  discountRule: openDiscountRule,
@@ -352,78 +619,47 @@ export default async ({ params, application }) => {
352
619
  } = matchDiscountRule(discountRules, {});
353
620
  if (
354
621
  openDiscountRule
355
- && openDiscountRule.cumulative_discount !== false
356
- && openDiscountRule.discount.min_amount
622
+ && openDiscountRule.cumulative_discount !== false
623
+ && openDiscountRule.discount.min_amount
357
624
  ) {
358
- let checkAmount = params.amount[openDiscountRule.discount.amount_field || 'total'];
359
- if (checkAmount) {
625
+ let _checkAmount = params.amount[openDiscountRule.discount.amount_field || 'total'];
626
+ if (_checkAmount) {
360
627
  // subtract current discount to validate cumulative open discount min amount
361
628
  if (response.discount_rule) {
362
- checkAmount -= response.discount_rule.extra_discount.value;
629
+ _checkAmount -= response.discount_rule.extra_discount.value;
363
630
  }
364
- if (openDiscountRule.discount.min_amount <= checkAmount) {
631
+ if (openDiscountRule.discount.amount_field !== 'freight') {
632
+ _checkAmount -= getFreebiesPreview().value;
633
+ }
634
+ if (openDiscountRule.discount.min_amount <= _checkAmount) {
365
635
  addDiscount(openDiscountRule.discount, openDiscountMatchEnum);
366
636
  }
367
637
  }
368
638
  }
369
639
  }
370
640
 
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
- }
641
+ try {
642
+ const isAvailable = await checkUsageLimit(discountRule, label);
643
+ if (!isAvailable) {
644
+ delete response.discount_rule;
645
+ response.invalid_coupon_message = params.lang === 'pt_br'
646
+ ? 'A promoção não pôde ser aplicada porque já atingiu o limite de usos'
647
+ : 'The promotion could not be applied because it has already reached the usage limit';
419
648
  }
649
+ addFreebies();
650
+ return respondSuccess();
651
+ } catch (err) {
652
+ return {
653
+ error: 'CANT_CHECK_USAGE_LIMITS',
654
+ message: err.message,
655
+ };
420
656
  }
421
- return respondSuccess();
422
657
  }
423
658
  }
424
659
  }
425
660
  }
426
661
 
662
+ addFreebies();
427
663
  // response with no error nor discount applied
428
664
  return respondSuccess();
429
665
  };
@@ -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.8",
4
+ "version": "2.29.10",
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.8"
26
+ "@cloudcommerce/api": "2.29.10"
27
27
  },
28
28
  "devDependencies": {
29
- "@cloudcommerce/types": "2.29.8"
29
+ "@cloudcommerce/types": "2.29.10"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "bash ../../../scripts/build-lib.sh"