@cloudcommerce/app-discounts 0.0.54 → 0.0.57

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,5 +1,5 @@
1
- @cloudcommerce/app-discounts:build: cache hit, replaying output e1cbdb1e09dff4c0
1
+ @cloudcommerce/app-discounts:build: cache hit, replaying output 19577ff5408a53e1
2
2
  @cloudcommerce/app-discounts:build: 
3
- @cloudcommerce/app-discounts:build: > @cloudcommerce/app-discounts@0.0.53 build /home/leo/code/ecomplus/cloud-commerce/packages/apps/discounts
3
+ @cloudcommerce/app-discounts:build: > @cloudcommerce/app-discounts@0.0.56 build /home/leo/code/ecomplus/cloud-commerce/packages/apps/discounts
4
4
  @cloudcommerce/app-discounts:build: > sh ../../../scripts/build-lib.sh
5
5
  @cloudcommerce/app-discounts:build: 
@@ -1,2 +1,2 @@
1
1
  import type { AppModuleBody } from '@cloudcommerce/types';
2
- export declare const applyDiscount: (modBody: AppModuleBody) => Promise<{}>;
2
+ export declare const applyDiscount: (modBody: AppModuleBody) => Promise<any>;
package/lib/discounts.js CHANGED
@@ -1,7 +1,6 @@
1
- import { logger } from 'firebase-functions';
1
+ import * as handleApplyDiscount from '../lib-cjs/apply-discount.cjs';
2
2
 
3
3
  export const applyDiscount = async (modBody) => {
4
- logger.info(modBody);
5
- return {};
4
+ return handleApplyDiscount(modBody);
6
5
  };
7
6
  // # sourceMappingURL=discounts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"discounts.js","sourceRoot":"","sources":["../src/discounts.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,OAAsB,EAAE,EAAE;IAC5D,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACrB,OAAO,EAAE,CAAC;AACZ,CAAC,CAAC"}
1
+ {"version":3,"file":"discounts.js","sourceRoot":"","sources":["../src/discounts.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,mBAAmB,MAAM,+BAA+B,CAAC;AAErE,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,OAAsB,EAAE,EAAE;IAC5D,OAAO,mBAAmB,CAAC,OAAO,CAAC,CAAC;AACtC,CAAC,CAAC"}
@@ -0,0 +1,330 @@
1
+ const ecomUtils = require('@ecomplus/utils');
2
+ const api = require('@cloudcommerce/api');
3
+ const getEnv = require('@cloudcommerce/firebase/lib/env');
4
+
5
+ const {
6
+ validateDateRange,
7
+ validateCustomerId,
8
+ checkOpenPromotion,
9
+ getValidDiscountRules,
10
+ matchDiscountRule,
11
+ checkCampaignProducts,
12
+ } = require('./helpers.cjs');
13
+
14
+ module.exports = async ({ params, application }) => {
15
+ // app configured options
16
+ const config = {
17
+ ...application.data,
18
+ ...application.hidden_data,
19
+ };
20
+
21
+ // setup response object
22
+ // https://apx-mods.e-com.plus/api/v1/apply_discount/response_schema.json?store_id=100
23
+ const response = {};
24
+ const respondSuccess = () => {
25
+ if (response.available_extra_discount && !response.available_extra_discount.value) {
26
+ delete response.available_extra_discount;
27
+ }
28
+ if (
29
+ response.discount_rule
30
+ && (!response.discount_rule.extra_discount || !response.discount_rule.extra_discount.value)
31
+ ) {
32
+ delete response.discount_rule;
33
+ }
34
+ return response;
35
+ };
36
+
37
+ const addDiscount = (discount, flag, label) => {
38
+ let value;
39
+ const maxDiscount = params.amount[discount.apply_at || 'total'];
40
+ if (maxDiscount) {
41
+ // update amount discount and total
42
+ if (discount.type === 'percentage') {
43
+ value = maxDiscount * (discount.value / 100);
44
+ } else {
45
+ value = discount.value;
46
+ }
47
+ if (value > maxDiscount) {
48
+ value = maxDiscount;
49
+ }
50
+ }
51
+
52
+ if (value) {
53
+ if (response.discount_rule) {
54
+ // accumulate discount
55
+ const extraDiscount = response.discount_rule.extra_discount;
56
+ extraDiscount.value += value;
57
+ if (extraDiscount.flags.length < 20) {
58
+ extraDiscount.flags.push(flag);
59
+ }
60
+ } else {
61
+ response.discount_rule = {
62
+ label: label || flag,
63
+ extra_discount: {
64
+ value,
65
+ flags: [flag],
66
+ },
67
+ };
68
+ }
69
+ return true;
70
+ }
71
+ return false;
72
+ };
73
+
74
+ if (params.items && params.items.length) {
75
+ // try product kit discounts first
76
+ if (Array.isArray(config.product_kit_discounts)) {
77
+ config.product_kit_discounts = config.product_kit_discounts.map((kitDiscount) => {
78
+ if (!kitDiscount.product_ids) {
79
+ // kit with any items
80
+ kitDiscount.product_ids = [];
81
+ }
82
+ return kitDiscount;
83
+ });
84
+ }
85
+ const kitDiscounts = getValidDiscountRules(config.product_kit_discounts, params, params.items)
86
+ .sort((a, b) => {
87
+ if (a.min_quantity > b.min_quantity) {
88
+ return -1;
89
+ }
90
+ if (b.min_quantity > a.min_quantity) {
91
+ return 1;
92
+ }
93
+ if (a.discount.min_amount > b.discount.min_amount) {
94
+ return -1;
95
+ }
96
+ if (b.discount.min_amount > a.discount.min_amount) {
97
+ return 1;
98
+ }
99
+ return 0;
100
+ });
101
+ // prevent applying duplicated kit discount for same items
102
+ let discountedItemIds = [];
103
+
104
+ kitDiscounts.forEach((kitDiscount, index) => {
105
+ if (kitDiscount) {
106
+ const productIds = Array.isArray(kitDiscount.product_ids)
107
+ ? kitDiscount.product_ids
108
+ : [];
109
+ let kitItems = productIds.length
110
+ ? params.items.filter((item) => item.quantity && productIds.indexOf(item.product_id) > -1)
111
+ : params.items;
112
+ kitItems = kitItems.filter((item) => discountedItemIds.indexOf(item._id) === -1);
113
+ if (kitDiscount.min_quantity > 0) {
114
+ // check total items quantity
115
+ let totalQuantity = 0;
116
+ kitItems.forEach(({ quantity }) => {
117
+ totalQuantity += quantity;
118
+ });
119
+ if (totalQuantity < kitDiscount.min_quantity) {
120
+ return;
121
+ }
122
+ if (kitDiscount.discount.type === 'fixed' && kitDiscount.cumulative_discount !== false) {
123
+ kitDiscount.discount.value *= Math.floor(totalQuantity / kitDiscount.min_quantity);
124
+ }
125
+ }
126
+
127
+ if (!params.amount || !(kitDiscount.discount.min_amount > params.amount.total)) {
128
+ if (kitDiscount.check_all_items !== false) {
129
+ for (let i = 0; i < productIds.length; i++) {
130
+ const productId = productIds[i];
131
+ if (productId
132
+ && !kitItems.find((item) => item.quantity && item.product_id === productId)) {
133
+ // product not on current cart
134
+ return;
135
+ }
136
+ }
137
+ }
138
+ // apply cumulative discount \o/
139
+ addDiscount(kitDiscount.discount, `KIT-${(index + 1)}`, kitDiscount.label);
140
+ discountedItemIds = discountedItemIds.concat(kitItems.map((item) => item.product_id));
141
+ }
142
+ }
143
+ });
144
+
145
+ // gift products (freebies) campaings
146
+ if (Array.isArray(config.freebies_rules)) {
147
+ const validFreebiesRules = config.freebies_rules.filter((rule) => {
148
+ return validateDateRange(rule)
149
+ && validateCustomerId(rule, params)
150
+ && checkCampaignProducts(rule.check_product_ids, params)
151
+ && Array.isArray(rule.product_ids)
152
+ && rule.product_ids.length;
153
+ });
154
+ if (validFreebiesRules) {
155
+ let subtotal = 0;
156
+ params.items.forEach((item) => {
157
+ subtotal += (item.quantity * ecomUtils.price(item));
158
+ });
159
+
160
+ let bestRule;
161
+ let discountValue = 0;
162
+ for (let i = 0; i < validFreebiesRules.length; i++) {
163
+ const rule = validFreebiesRules[i];
164
+ // start calculating discount
165
+ let value = 0;
166
+ rule.product_ids.forEach((productId) => {
167
+ const item = params.items.find((item) => productId === item.product_id);
168
+ if (item) {
169
+ value += ecomUtils.price(item);
170
+ }
171
+ });
172
+ const fixedSubtotal = subtotal - value;
173
+ if (!bestRule || value > discountValue || bestRule.min_subtotal < rule.min_subtotal) {
174
+ if (!(rule.min_subtotal > fixedSubtotal)) {
175
+ bestRule = rule;
176
+ discountValue = value;
177
+ } else if (!discountValue && subtotal >= rule.min_subtotal) {
178
+ // discount not applicable yet but additional freebies are available
179
+ bestRule = rule;
180
+ }
181
+ }
182
+ }
183
+
184
+ if (bestRule) {
185
+ // provide freebie products \o/
186
+ response.freebie_product_ids = bestRule.product_ids;
187
+ if (discountValue) {
188
+ addDiscount(
189
+ {
190
+ type: 'fixed',
191
+ value: discountValue,
192
+ },
193
+ 'FREEBIES',
194
+ bestRule.label,
195
+ );
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ const discountRules = getValidDiscountRules(config.discount_rules, params);
203
+ if (discountRules.length) {
204
+ const { discountRule, discountMatchEnum } = matchDiscountRule(discountRules, params);
205
+ if (discountRule) {
206
+ if (!checkCampaignProducts(discountRule.product_ids, params)) {
207
+ return {
208
+ available_extra_discount: response.available_extra_discount,
209
+ invalid_coupon_message: params.lang === 'pt_br'
210
+ ? 'Nenhum produto da promoção está incluído no carrinho'
211
+ : 'No promotion products are included in the cart',
212
+ };
213
+ }
214
+
215
+ const excludedProducts = discountRule.excluded_product_ids;
216
+ if (Array.isArray(excludedProducts) && excludedProducts.length && params.items) {
217
+ // must check any excluded product is on cart
218
+ for (let i = 0; i < params.items.length; i++) {
219
+ const item = params.items[i];
220
+ if (item.quantity && excludedProducts.includes(item.product_id)) {
221
+ return {
222
+ available_extra_discount: response.available_extra_discount,
223
+ invalid_coupon_message: params.lang === 'pt_br'
224
+ ? `Promoção é inválida para o produto ${item.name}`
225
+ : `Invalid promotion for product ${item.name}`,
226
+ };
227
+ }
228
+ }
229
+ }
230
+
231
+ let { label } = discountRule;
232
+ const { discount } = discountRule;
233
+ if (typeof label !== 'string' || !label) {
234
+ label = params.discount_coupon || `DISCOUNT ${discountMatchEnum}`;
235
+ }
236
+ if (
237
+ discount.apply_at !== 'freight'
238
+ && (!response.available_extra_discount || !response.available_extra_discount.value
239
+ || discountRule.default_discount === true || checkOpenPromotion(discountRule))
240
+ ) {
241
+ // show current discount rule as available discount to apply
242
+ response.available_extra_discount = {
243
+ label: label.substring(0, 50),
244
+ };
245
+ ['min_amount', 'type', 'value'].forEach((field) => {
246
+ if (discount[field]) {
247
+ response.available_extra_discount[field] = discount[field];
248
+ }
249
+ });
250
+ }
251
+
252
+ // params object follows list payments request schema:
253
+ // https://apx-mods.e-com.plus/api/v1/apply_discount/schema.json?store_id=100
254
+ if (
255
+ params.amount && params.amount.total > 0
256
+ && !(discountRule.discount.min_amount > params.amount[discountRule.discount.amount_field || 'total'])
257
+ ) {
258
+ if (
259
+ discountRule.cumulative_discount === false
260
+ && (response.discount_rule || params.amount.discount)
261
+ ) {
262
+ // explain discount can't be applied :(
263
+ // https://apx-mods.e-com.plus/api/v1/apply_discount/response_schema.json?store_id=100
264
+ return {
265
+ invalid_coupon_message: params.lang === 'pt_br'
266
+ ? 'A promoção não pôde ser aplicada porque este desconto não é cumulativo'
267
+ : 'This discount is not cumulative',
268
+ };
269
+ }
270
+
271
+ // we have a discount to apply \o/
272
+ if (addDiscount(discountRule.discount, discountMatchEnum)) {
273
+ // add discount label and description if any
274
+ response.discount_rule.label = label;
275
+ if (discountRule.description) {
276
+ response.discount_rule.description = discountRule.description;
277
+ }
278
+
279
+ const { customer } = params;
280
+ if (
281
+ customer && customer._id
282
+ && (discountRule.usage_limit > 0 || discountRule.total_usage_limit > 0)
283
+ ) {
284
+ // list orders to check discount usage limits
285
+ // eslint-disable-next-line prefer-template
286
+ const endpoint = 'orders?fields=_id'
287
+ + `&extra_discount.app.label${(discountRule.case_insensitive ? '%=' : '=')}`
288
+ + encodeURIComponent(label);
289
+ const usageLimits = [{
290
+ // limit by customer
291
+ query: `&buyers._id=${customer._id}`,
292
+ max: discountRule.usage_limit,
293
+ }, {
294
+ // total limit
295
+ query: '',
296
+ max: discountRule.total_usage_limit,
297
+ }];
298
+ const { apiAuth } = getEnv();
299
+ for (let i = 0; i < usageLimits.length; i++) {
300
+ const { query, max } = usageLimits[i];
301
+ if (max) {
302
+ let countOrders;
303
+ try {
304
+ // send Store API request to list orders with filters
305
+ const { data } = await api.get(`${endpoint}${query}`, { apiAuth });
306
+ countOrders = data.result.length;
307
+ } catch (e) {
308
+ countOrders = max;
309
+ }
310
+
311
+ if (countOrders >= max) {
312
+ // limit reached
313
+ return {
314
+ invalid_coupon_message: params.lang === 'pt_br'
315
+ ? 'A promoção não pôde ser aplicada porque já atingiu o limite de usos'
316
+ : 'The promotion could not be applied because it has already reached the usage limit',
317
+ };
318
+ }
319
+ }
320
+ }
321
+ }
322
+ return respondSuccess();
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ // response with no error nor discount applied
329
+ return respondSuccess();
330
+ };
@@ -0,0 +1,167 @@
1
+ const ecomUtils = require('@ecomplus/utils');
2
+
3
+ const validateDateRange = (rule) => {
4
+ // filter campaings by date
5
+ const timestamp = Date.now();
6
+ if (rule.date_range) {
7
+ if (rule.date_range.start && new Date(rule.date_range.start).getTime() > timestamp) {
8
+ return false;
9
+ }
10
+ if (rule.date_range.end && new Date(rule.date_range.end).getTime() < timestamp) {
11
+ return false;
12
+ }
13
+ }
14
+ return true;
15
+ };
16
+
17
+ const validateCustomerId = (rule, params) => {
18
+ if (
19
+ Array.isArray(rule.customer_ids)
20
+ && rule.customer_ids.length
21
+ && rule.customer_ids.indexOf(params.customer && params.customer._id) === -1
22
+ ) {
23
+ // unavailable for current customer
24
+ return false;
25
+ }
26
+ return true;
27
+ };
28
+
29
+ const checkOpenPromotion = (rule) => {
30
+ return !rule.discount_coupon && !rule.utm_campaign
31
+ && (!Array.isArray(rule.customer_ids) || !rule.customer_ids.length);
32
+ };
33
+
34
+ const getValidDiscountRules = (discountRules, params, items) => {
35
+ if (Array.isArray(discountRules) && discountRules.length) {
36
+ // validate rules objects
37
+ return discountRules.filter((rule) => {
38
+ if (!rule || !validateCustomerId(rule, params)) {
39
+ return false;
40
+ }
41
+
42
+ if (Array.isArray(rule.product_ids) && Array.isArray(items)) {
43
+ const checkProductId = (item) => {
44
+ return (!rule.product_ids.length || rule.product_ids.indexOf(item.product_id) > -1);
45
+ };
46
+ // set/add discount value from lowest item price
47
+ let value;
48
+ if (rule.discount_lowest_price) {
49
+ items.forEach((item) => {
50
+ const price = ecomUtils.price(item);
51
+ if (price > 0 && checkProductId(item) && (!value || value > price)) {
52
+ value = price;
53
+ }
54
+ });
55
+ } else if (rule.discount_kit_subtotal) {
56
+ value = 0;
57
+ items.forEach((item) => {
58
+ const price = ecomUtils.price(item);
59
+ if (price > 0 && checkProductId(item)) {
60
+ value += price * item.quantity;
61
+ }
62
+ });
63
+ }
64
+ if (value) {
65
+ if (rule.discount && rule.discount.value) {
66
+ if (rule.discount.type === 'percentage') {
67
+ value *= rule.discount.value / 100;
68
+ } else {
69
+ value += rule.discount.value;
70
+ }
71
+ }
72
+ rule.discount = {
73
+ ...rule.discount,
74
+ type: 'fixed',
75
+ value,
76
+ };
77
+ }
78
+ }
79
+ if (!rule.discount || !rule.discount.value) {
80
+ return false;
81
+ }
82
+
83
+ return validateDateRange(rule);
84
+ });
85
+ }
86
+
87
+ // returns array anyway
88
+ return [];
89
+ };
90
+
91
+ const matchDiscountRule = (discountRules, params) => {
92
+ // try to match a promotion
93
+ if (params.discount_coupon) {
94
+ // match only by discount coupon
95
+ return {
96
+ discountRule: discountRules.find((rule) => {
97
+ return rule.case_insensitive
98
+ ? typeof rule.discount_coupon === 'string'
99
+ && rule.discount_coupon.toUpperCase() === params.discount_coupon.toUpperCase()
100
+ : rule.discount_coupon === params.discount_coupon;
101
+ }),
102
+ discountMatchEnum: 'COUPON',
103
+ };
104
+ }
105
+
106
+ // try to match by UTM campaign first
107
+ if (params.utm && params.utm.campaign) {
108
+ const discountRule = discountRules.find((rule) => {
109
+ return rule.case_insensitive
110
+ ? typeof rule.utm_campaign === 'string'
111
+ && rule.utm_campaign.toUpperCase() === params.utm.campaign.toUpperCase()
112
+ : rule.utm_campaign === params.utm.campaign;
113
+ });
114
+ if (discountRule) {
115
+ return {
116
+ discountRule,
117
+ discountMatchEnum: 'UTM',
118
+ };
119
+ }
120
+ }
121
+
122
+ // then try to match by customer
123
+ if (params.customer && params.customer._id) {
124
+ const discountRule = discountRules.find((rule) => Array.isArray(rule.customer_ids)
125
+ && rule.customer_ids.indexOf(params.customer._id) > -1);
126
+ if (discountRule) {
127
+ return {
128
+ discountRule,
129
+ discountMatchEnum: 'CUSTOMER',
130
+ };
131
+ }
132
+ }
133
+
134
+ // last try to match by open promotions
135
+ return {
136
+ discountRule: discountRules.find(checkOpenPromotion),
137
+ discountMatchEnum: 'OPEN',
138
+ };
139
+ };
140
+
141
+ const checkCampaignProducts = (campaignProducts, params) => {
142
+ if (Array.isArray(campaignProducts) && campaignProducts.length) {
143
+ // must check at least one campaign product on cart
144
+ let hasProductMatch;
145
+ if (params.items && params.items.length) {
146
+ for (let i = 0; i < campaignProducts.length; i++) {
147
+ if (params.items.find((item) => item.quantity && item.product_id === campaignProducts[i])) {
148
+ hasProductMatch = true;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ if (!hasProductMatch) {
154
+ return false;
155
+ }
156
+ }
157
+ return true;
158
+ };
159
+
160
+ module.exports = {
161
+ validateDateRange,
162
+ validateCustomerId,
163
+ checkOpenPromotion,
164
+ getValidDiscountRules,
165
+ matchDiscountRule,
166
+ checkCampaignProducts,
167
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cloudcommerce/app-discounts",
3
3
  "type": "module",
4
- "version": "0.0.54",
4
+ "version": "0.0.57",
5
5
  "description": "E-Com Plus Cloud Commerce app for complex discount rules",
6
6
  "main": "lib/discounts.js",
7
7
  "repository": {
@@ -16,13 +16,14 @@
16
16
  },
17
17
  "homepage": "https://github.com/ecomplus/cloud-commerce/tree/main/packages/apps/discounts#readme",
18
18
  "dependencies": {
19
- "@cloudcommerce/api": "0.0.54",
20
- "@cloudcommerce/firebase": "0.0.54",
19
+ "@cloudcommerce/api": "0.0.57",
20
+ "@cloudcommerce/firebase": "0.0.57",
21
+ "@ecomplus/utils": "^1.4.1",
21
22
  "firebase-admin": "^11.0.1",
22
23
  "firebase-functions": "^3.22.0"
23
24
  },
24
25
  "devDependencies": {
25
- "@cloudcommerce/types": "0.0.54",
26
+ "@cloudcommerce/types": "0.0.57",
26
27
  "@firebase/app-types": "^0.7.0"
27
28
  },
28
29
  "scripts": {
package/src/discounts.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  /* eslint-disable import/prefer-default-export */
2
2
  import type { AppModuleBody } from '@cloudcommerce/types';
3
- import { logger } from 'firebase-functions';
3
+ import * as handleApplyDiscount from '../lib-cjs/apply-discount.cjs';
4
4
 
5
5
  export const applyDiscount = async (modBody: AppModuleBody) => {
6
- logger.info(modBody);
7
- return {};
6
+ return handleApplyDiscount(modBody);
8
7
  };