From 4eb705cea98884255745ae7222bb8e2a264e625a Mon Sep 17 00:00:00 2001 From: Wisley Alves <33531578+wisley7l@users.noreply.github.com> Date: Fri, 6 Oct 2023 20:01:05 -0300 Subject: [PATCH] fix(loyalty points): Add points after some days gap with cron to prevent fast cancellations (#250) Updating with https://github.com/ecomplus/app-loyalty-points/commit/8942e05cd03b801e5546490d5aee1957fcf1576e * fix(loyalty points): Updating with https://github.com/ecomplus/app-loyalty-points * chore(pnpm lock): Update file * fix(pnpm lock): Back to main version * chore(pnpm lock): Add @ecomplus/utils to the loyalty points app [skip ci] --- packages/apps/loyalty-points/package.json | 3 +- .../src/functions-lib/cron-add-points.ts | 104 ++++++++++++++++++ .../handle-loyalty-points-event.ts | 92 +++++++++++----- .../src/loyalty-create-transaction.ts | 15 ++- .../src/loyalty-list-payments.ts | 11 +- .../src/loyalty-points-events.ts | 13 ++- pnpm-lock.yaml | 3 + 7 files changed, 208 insertions(+), 33 deletions(-) create mode 100644 packages/apps/loyalty-points/src/functions-lib/cron-add-points.ts diff --git a/packages/apps/loyalty-points/package.json b/packages/apps/loyalty-points/package.json index 1971b1fb3..52e3493e2 100644 --- a/packages/apps/loyalty-points/package.json +++ b/packages/apps/loyalty-points/package.json @@ -25,7 +25,8 @@ }, "dependencies": { "@cloudcommerce/api": "workspace:*", - "@cloudcommerce/firebase": "workspace:*" + "@cloudcommerce/firebase": "workspace:*", + "@ecomplus/utils": "1.5.0-rc.5" }, "devDependencies": { "@cloudcommerce/test-base": "workspace:*", diff --git a/packages/apps/loyalty-points/src/functions-lib/cron-add-points.ts b/packages/apps/loyalty-points/src/functions-lib/cron-add-points.ts new file mode 100644 index 000000000..f05eda411 --- /dev/null +++ b/packages/apps/loyalty-points/src/functions-lib/cron-add-points.ts @@ -0,0 +1,104 @@ +import type { CustomerSet, Orders } from '@cloudcommerce/types'; +import logger from 'firebase-functions/logger'; +import { getFirestore } from 'firebase-admin/firestore'; +import api from '@cloudcommerce/api'; +import { Endpoint } from '@cloudcommerce/api/types'; + +const addPoints = async () => { + const d = new Date(); + // double checking paid orders after 5 days + d.setDate(d.getDate() - 5); + const db = getFirestore(); + const snapshot = await db.collection('pointsToAdd') + .where('queuedAt', '<=', d) + .orderBy('queuedAt') + .get(); + const { docs } = snapshot; + logger.info(`${docs.length} points to add`); + + for (let i = 0; i < docs.length; i++) { + const { customerId, pointEntries } = docs[i].data(); + const orderId = docs[i].ref.id; + let order: Orders | undefined; + try { + // eslint-disable-next-line no-await-in-loop + order = (await api.get(`orders/${orderId}`)).data; + } catch (error: any) { + const status = error.response?.status; + if (status > 400 && status < 500) { + logger.warn(`failed reading order ${orderId}`, { + status, + response: error.response?.data, + }); + } else { + throw error; + } + } + + if (order && Array.isArray(pointEntries) && pointEntries.length) { + const currentStatus = order.financial_status && order.financial_status.current; + if (currentStatus === 'paid') { + const tryAddPoints = (data: CustomerSet) => { + logger.info(`POST ${JSON.stringify(data)} ${customerId}`); + return api.post(`customers/${customerId}/loyalty_points_entries`, data); + }; + + const pointsEndpoint = `/customers/${customerId}/loyalty_points_entries`; + + for (let ii = 0; ii < pointEntries.length; ii++) { + const pointsEntry = pointEntries[ii]; + try { + // eslint-disable-next-line no-await-in-loop + await tryAddPoints(pointsEntry); + } catch (err: any) { + const status = err.response?.status; + if (status === 403) { + // delete older points entry and retry + + const findUrl = `${pointsEndpoint}` + + `?valid_thru<=${(new Date().toISOString())}&sort=active_points&limit=1`; + try { + // eslint-disable-next-line no-await-in-loop + await api.get(findUrl as Endpoint) + .then(({ data }) => { + const pointsList = data.result; + if (pointsList.length) { + const endpoint = `${pointsEndpoint}/${pointsList[0]._id}`; + return api.delete(endpoint as Endpoint) + .then(() => { + return tryAddPoints(pointsEntry); + }); + } + return null; + }); + } catch (error: any) { + logger.warn(`failed cleaning/adding points retry to ${orderId}`, { + customerId, + pointsEntry, + url: error.config?.url, + method: error.config?.method, + status: error.response?.status, + response: error.response?.data, + }); + } + } else if (status > 400 && status < 500) { + logger.warn(`failed adding points to ${orderId}`, { + customerId, + pointsEndpoint, + pointsEntry, + status, + response: err.response.data, + }); + } else { + throw err; + } + } + } + } + } + // eslint-disable-next-line no-await-in-loop + await docs[i].ref.delete(); + } +}; + +export default addPoints; diff --git a/packages/apps/loyalty-points/src/functions-lib/handle-loyalty-points-event.ts b/packages/apps/loyalty-points/src/functions-lib/handle-loyalty-points-event.ts index d0daf4003..3e0c97d37 100644 --- a/packages/apps/loyalty-points/src/functions-lib/handle-loyalty-points-event.ts +++ b/packages/apps/loyalty-points/src/functions-lib/handle-loyalty-points-event.ts @@ -1,7 +1,8 @@ import type { Orders, Customers, ResourceId } from '@cloudcommerce/types'; import api from '@cloudcommerce/api'; -import { getFirestore } from 'firebase-admin/firestore'; +import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import logger from 'firebase-functions/logger'; +import ecomUtils from '@ecomplus/utils'; import getProgramId from './get-program-id'; const ECHO_SUCCESS = 'SUCCESS'; @@ -97,36 +98,77 @@ const handleLoyaltyPointsEvent = async ( const hasEarnedPoints = haveEarnedPoints(pointsList, orderId); if (isPaid && !hasEarnedPoints) { - for (let i = 0; i < programRules.length; i++) { - const rule = programRules[i]; - if (amount.subtotal) { - if (!rule.min_subtotal_to_earn || rule.min_subtotal_to_earn <= amount.subtotal) { - const pointsValue = ((rule.earn_percentage || 1) / 100) - * (amount.subtotal - (amount.discount || 0)); + const docRef = getFirestore().doc(`pointsToAdd/${orderId}`); + const docSnapshot = await docRef.get(); + if (!docSnapshot.exists) { + const pointEntries: any[] = []; + + for (let i = 0; i < programRules.length; i++) { + const rule = programRules[i]; + if (rule.earn_percentage === 0) { + continue; + } + if ( + !rule.min_subtotal_to_earn + || (amount?.subtotal && rule.min_subtotal_to_earn <= amount.subtotal) + ) { + let subtotal = amount.subtotal as number; - let validThru: string | undefined; - if (rule.expiration_days > 0) { - const d = new Date(); - d.setDate(d.getDate() + rule.expiration_days); - validThru = d.toISOString(); - } + if (Array.isArray(rule.category_ids) && rule.category_ids.length) { + if (!order.items || !order.items.length) { + continue; + } - const data = { - name: rule.name, - program_id: getProgramId(rule, i), - earned_points: pointsValue, - active_points: pointsValue, - ratio: rule.ratio || 1, - valid_thru: validThru, - order_id: orderId, - } as any; // TODO: set the correct type + // eslint-disable-next-line no-await-in-loop + const { data: { result } } = await api.get('search/v1', { + limit: order.items.length, + params: { + _id: order.items.map((item) => item.product_id), + 'categories._id': rule.category_ids, + }, + }); - // eslint-disable-next-line no-await-in-loop - await api.post(`customers/${customerId}/loyalty_points_entries`, data); + // eslint-disable-next-line no-await-in-loop + order.items.forEach((item) => { + if (!result.find(({ _id }) => _id === item.product_id)) { + subtotal -= (ecomUtils.price(item) * item.quantity); + } + }); + } - return responsePubSub(ECHO_SUCCESS); + const pointsValue = ((rule.earn_percentage || 1) / 100) + * (subtotal - (amount.discount || 0)); + + if (pointsValue > 0) { + let validThru: string | undefined; + if (rule.expiration_days > 0) { + const d = new Date(); + d.setDate(d.getDate() + 2 + rule.expiration_days); + validThru = d.toISOString(); + } + const data = { + name: rule.name, + program_id: getProgramId(rule, i), + earned_points: pointsValue, + active_points: pointsValue, + ratio: rule.ratio || 1, + order_id: orderId, + }; + if (validThru) { + Object.assign(data, { valid_thru: validThru }); + } + pointEntries.push(data); + } } } + + await docRef.set({ + customerId, + pointEntries, + queuedAt: Timestamp.now(), + }); + + return responsePubSub(ECHO_SUCCESS); } } diff --git a/packages/apps/loyalty-points/src/loyalty-create-transaction.ts b/packages/apps/loyalty-points/src/loyalty-create-transaction.ts index bd92e01ee..955bfc92d 100644 --- a/packages/apps/loyalty-points/src/loyalty-create-transaction.ts +++ b/packages/apps/loyalty-points/src/loyalty-create-transaction.ts @@ -76,7 +76,7 @@ export default async (appData: AppModuleBody) => { if (pointsApplied && Array.isArray(programsRules) && programsRules.length) { // for (const programId in pointsApplied) { Object.keys(pointsApplied).forEach((programId) => { - const pointsValue = pointsApplied[programId]; + let pointsValue = pointsApplied[programId]; if (pointsValue > 0) { const programRule = programsRules.find((programRuleFound, index) => { if (programRuleFound) { @@ -87,14 +87,21 @@ export default async (appData: AppModuleBody) => { }); if (programRule) { + let maxPoints = programRule.max_points; const ratio = programRule.ratio || 1; + if (!maxPoints && programRule.max_amount_percentage && params.amount) { + maxPoints = Math.round( + ((programRule.max_amount_percentage * params.amount.total) / 100) / ratio, + ); + } + if (maxPoints < pointsValue) { + pointsValue = maxPoints; + } transaction.loyalty_points = { name: programRule.name, program_id: programRule.program_id, ratio, - points_value: programRule.max_points < pointsValue - ? programRule.max_points - : pointsValue, + points_value: pointsValue, }; transaction.amount = pointsValue * ratio; } diff --git a/packages/apps/loyalty-points/src/loyalty-list-payments.ts b/packages/apps/loyalty-points/src/loyalty-list-payments.ts index 8984f1d5b..81245022d 100644 --- a/packages/apps/loyalty-points/src/loyalty-list-payments.ts +++ b/packages/apps/loyalty-points/src/loyalty-list-payments.ts @@ -36,11 +36,18 @@ export default (data: AppModuleBody) => { if (Array.isArray(appData.programs_rules)) { const pointsPrograms = {}; appData.programs_rules.forEach((programRule, index) => { + let maxPoints = programRule.max_points; + const ratio = programRule.ratio || 1; + if (!maxPoints && programRule.max_amount_percentage && params.amount) { + maxPoints = Math.round( + ((programRule.max_amount_percentage * params.amount.total) / 100) / ratio, + ); + } const programId = getProgramId(programRule, index); pointsPrograms[programId] = { name: programRule.name, - ratio: programRule.ratio || 1, - max_points: programRule.max_points, + ratio, + max_points: maxPoints, min_subtotal_to_earn: programRule.min_subtotal_to_earn, earn_percentage: programRule.earn_percentage, }; diff --git a/packages/apps/loyalty-points/src/loyalty-points-events.ts b/packages/apps/loyalty-points/src/loyalty-points-events.ts index f4d2e55c3..7eb5e8c76 100644 --- a/packages/apps/loyalty-points/src/loyalty-points-events.ts +++ b/packages/apps/loyalty-points/src/loyalty-points-events.ts @@ -6,7 +6,12 @@ import { ApiEventHandler, } from '@cloudcommerce/firebase/lib/helpers/pubsub'; import logger from 'firebase-functions/logger'; +import config from '@cloudcommerce/firebase/lib/config'; +import functions from 'firebase-functions/v1'; import handleLoyaltyPointsEvent from './functions-lib/handle-loyalty-points-event'; +import addPoints from './functions-lib/cron-add-points'; + +const { httpsFunctionOptions: { region } } = config.get(); const handleApiEvent: ApiEventHandler = async ({ evName, @@ -37,5 +42,11 @@ export const loyaltypoints = { onStoreEvent: createAppEventsFunction( 'loyaltyPoints', handleApiEvent, - ) as any, + ), + + cronAddPoints: functions.region(region).pubsub + .schedule('28 * * * *') + .onRun(() => { + return addPoints; + }), }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 537c313e8..fa8c61fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: '@cloudcommerce/firebase': specifier: workspace:* version: link:../../firebase + '@ecomplus/utils': + specifier: 1.5.0-rc.5 + version: 1.5.0-rc.5 devDependencies: '@cloudcommerce/test-base': specifier: workspace:*