fix: add loyalty point expiry by checking date

This commit is contained in:
Gadha2311
2026-01-31 13:05:25 +05:30
parent 8cefa35b9b
commit 8b64671537
10 changed files with 430 additions and 31 deletions
+96
View File
@@ -0,0 +1,96 @@
import { parentPort } from 'worker_threads';
import { DatabaseManager } from '../backend/database/manager';
import { ModelNameEnum } from '../models/types';
if (parentPort) {
parentPort.postMessage({ type: 'check-loyalty-program-expiry' });
}
export async function checkLoyaltyProgramExpiry() {
const dm = new DatabaseManager();
try {
const currentDate = new Date();
const loyaltyPrograms = (await dm.db?.getAll(ModelNameEnum.LoyaltyProgram, {
fields: ['name', 'toDate', 'status', 'isEnabled'],
filters: {
status: ['!=', 'Expired'],
isEnabled: true,
},
})) as Array<{
name: string;
toDate: string;
status: string;
isEnabled: boolean;
}>;
let expiredCount = 0;
let processedCount = 0;
if (loyaltyPrograms) {
for (const program of loyaltyPrograms) {
processedCount++;
if (program.toDate && new Date(program.toDate) <= currentDate) {
await dm.db?.knex!(ModelNameEnum.LoyaltyProgram)
.where({ name: program.name })
.update({
status: 'Expired',
isEnabled: false,
});
expiredCount++;
}
}
}
const result = {
timestamp: currentDate.toISOString(),
processedPrograms: processedCount,
expiredPrograms: expiredCount,
message: `Loyalty program expiry check completed. ${expiredCount} programs expired out of ${processedCount} processed.`,
};
if (parentPort) {
parentPort.postMessage({
type: 'loyalty-program-expiry-complete',
data: result,
});
}
return result;
} catch (error) {
const errorResult = {
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
message: 'Loyalty program expiry check failed',
};
if (parentPort) {
parentPort.postMessage({
type: 'loyalty-program-expiry-error',
data: errorResult,
});
}
throw error;
} finally {
await dm.call('close');
}
}
checkLoyaltyProgramExpiry().catch((error) => {
const errorResult = {
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
message: 'Loyalty program expiry check failed',
};
if (parentPort) {
parentPort.postMessage({
type: 'loyalty-program-expiry-error',
data: errorResult,
});
}
});
+9
View File
@@ -24,6 +24,15 @@ export async function initScheduler(interval: string) {
},
},
},
{
name: 'checkLoyaltyProgramExpiry',
interval: '0 1 * * *',
worker: {
workerData: {
useTsNode: true,
},
},
},
],
worker: {
argv: ['--require', 'ts-node/register'],
+100 -3
View File
@@ -28,6 +28,8 @@ import {
getReturnLoyaltyPoints,
getItemQtyMap,
getItemVisibility,
validateLoyaltyProgram,
getLoyaltyProgramTier,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
@@ -212,13 +214,49 @@ export abstract class Invoice extends Transactional {
this.party
)) as Party;
if ((this.loyaltyPoints as number) > (partyDoc?.loyaltyPoints || 0)) {
if (this.redeemLoyaltyPoints && (this.loyaltyPoints as number) > 0) {
const currentPoints = partyDoc?.loyaltyPoints || 0;
let pointsToBeEarned = 0;
if (!this.isReturn && this.loyaltyProgram) {
const loyaltyProgramDoc = (await this.fyo.doc.getDoc(
ModelNameEnum.LoyaltyProgram,
this.loyaltyProgram
)) as LoyaltyProgram;
const tier = getLoyaltyProgramTier(
loyaltyProgramDoc,
this?.grandTotal as Money
);
if (tier) {
const collectionFactor = tier.collectionFactor as number;
pointsToBeEarned =
Math.round(this?.grandTotal?.float || 0) * collectionFactor;
}
}
const totalAvailablePoints = currentPoints + pointsToBeEarned;
if ((this.loyaltyPoints as number) > totalAvailablePoints) {
throw new ValidationError(
t`${
this.party as string
} only has ${currentPoints} points (${pointsToBeEarned} will be earned from this transaction)`
);
}
} else if (
(this.loyaltyPoints as number) > (partyDoc?.loyaltyPoints || 0)
) {
throw new ValidationError(
t`${this.party as string} only has ${
partyDoc.loyaltyPoints as number
} points`
);
}
if (this.loyaltyProgram) {
await validateLoyaltyProgram(this, this.loyaltyProgram);
}
}
async afterSubmit() {
@@ -275,6 +313,10 @@ export abstract class Invoice extends Transactional {
if (this.schemaName === ModelNameEnum.SalesInvoice) {
this.updateUsedCountOfCoupons();
}
if (this.loyaltyProgram) {
await this.updateUsedCountOfLoyaltyProgram();
}
}
async afterCancel() {
@@ -284,6 +326,10 @@ export abstract class Invoice extends Transactional {
await this._updateIsItemsReturned();
await this._removeLoyaltyPointEntry();
this.reduceUsedCountOfCoupons();
if (this.loyaltyProgram) {
await this.reduceUsedCountOfLoyaltyProgram();
}
}
async _removeLoyaltyPointEntry() {
@@ -838,6 +884,36 @@ export abstract class Invoice extends Transactional {
});
}
async updateUsedCountOfLoyaltyProgram() {
if (!this.loyaltyProgram) {
return;
}
const loyaltyProgramDoc = await this.fyo.doc.getDoc(
ModelNameEnum.LoyaltyProgram,
this.loyaltyProgram
);
await loyaltyProgramDoc.setAndSync({
used: (loyaltyProgramDoc.used as number) + 1,
});
}
async reduceUsedCountOfLoyaltyProgram() {
if (!this.loyaltyProgram) {
return;
}
const loyaltyProgramDoc = await this.fyo.doc.getDoc(
ModelNameEnum.LoyaltyProgram,
this.loyaltyProgram
);
await loyaltyProgramDoc.setAndSync({
used: (loyaltyProgramDoc.used as number) - 1,
});
}
async updateIsItemsFullyReturned(doc?: Invoice) {
if (!doc?.returnAgainst || doc.schemaName !== ModelNameEnum.SalesInvoice) {
return;
@@ -898,11 +974,32 @@ export abstract class Invoice extends Transactional {
this.loyaltyProgram
)) as LoyaltyProgram;
const expiryDate = this.date as Date;
// Check if loyalty program is enabled
if (!loyaltyProgramDoc.isEnabled) {
return;
}
const invoiceDate = this.date as Date;
const fromDate = loyaltyProgramDoc.fromDate as Date;
const toDate = loyaltyProgramDoc.toDate as Date;
if (fromDate <= expiryDate && toDate >= expiryDate) {
const normalizedInvoiceDate = new Date(invoiceDate);
normalizedInvoiceDate.setHours(0, 0, 0, 0);
const normalizedFromDate = new Date(fromDate);
normalizedFromDate.setHours(0, 0, 0, 0);
const normalizedToDate = new Date(toDate);
normalizedToDate.setHours(0, 0, 0, 0);
if (normalizedToDate.getTime() < normalizedInvoiceDate.getTime()) {
return;
}
if (
normalizedInvoiceDate.getTime() >= normalizedFromDate.getTime() &&
normalizedInvoiceDate.getTime() <= normalizedToDate.getTime()
) {
const party = (await this.loadAndGetLink('party')) as Party;
await createLoyaltyPointEntry(this);
@@ -1,11 +1,38 @@
import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { FiltersMap, ListViewSettings } from 'fyo/model/types';
import { FiltersMap, ListViewSettings, ValidationMap } from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import { CollectionRulesItems } from '../CollectionRulesItems/CollectionRulesItems';
import { AccountRootTypeEnum } from '../Account/types';
import { getLoyaltyProgramStatusColumn } from '../../helpers';
export class LoyaltyProgram extends Doc {
collectionRules?: CollectionRulesItems[];
expiryDuration?: number;
maximumUse?: number;
used?: number;
validations: ValidationMap = {
used: (value: DocValue) => {
const used = value as number;
const maximumUse = this.maximumUse as number;
if (used < 0) {
throw new ValidationError('Used count cannot be negative');
}
if (maximumUse > 0 && used > maximumUse) {
throw new ValidationError('Used count cannot exceed maximum use limit');
}
},
maximumUse: (value: DocValue) => {
const maximumUse = value as number;
if (maximumUse < 0) {
throw new ValidationError('Maximum use cannot be negative');
}
},
};
static filters: FiltersMap = {
expenseAccount: () => ({
@@ -16,7 +43,7 @@ export class LoyaltyProgram extends Doc {
static getListViewSettings(): ListViewSettings {
return {
columns: ['name', 'fromDate', 'toDate', 'expiryDuration'],
columns: ['name', getLoyaltyProgramStatusColumn(), 'fromDate', 'toDate'],
};
}
}
@@ -115,6 +115,15 @@ export class SalesInvoice extends Invoice {
ModelNameEnum.LoyaltyProgram,
this.loyaltyProgram
)) as LoyaltyProgram;
const toDate = loyaltyProgramDoc?.toDate as Date;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (toDate && new Date(toDate).getTime() < today.getTime()) {
throw new ValidationError(
t`Loyalty program has expired and cannot be applied`
);
}
if (!this?.grandTotal) {
return;
@@ -27,10 +27,17 @@ const partyData = {
email: 'john@whoe.com',
};
const today = new Date();
const fromDate = new Date(today);
fromDate.setDate(today.getDate() - 10);
const toDate = new Date(today);
toDate.setDate(today.getDate() + 20);
const loyaltyProgramData = {
name: 'program',
fromDate: new Date('12/10/2024'),
toDate: new Date('12/30/2024'),
fromDate: fromDate,
toDate: toDate,
email: 'sample@gmail.com',
mobile: '1234567890',
expenseAccount: accountData.name,
@@ -118,11 +125,11 @@ async function loyaltyPointEntryDoc(sinvName: string) {
}
}
async function createSalesInvoice() {
async function createSalesInvoice(invoiceDate?: Date) {
const sinvDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
account: 'Debtors',
party: partyData.name,
date: new Date('12/11/2024'),
date: invoiceDate || new Date(),
items: [
{
item: itemData.name,
@@ -189,10 +196,10 @@ test('create Sales Invoice and verify loyalty points are created correctly', asy
});
test('create SINV with future date and verify loyalty points are not created', async (t) => {
const futureDate = new Date(new Date().setDate(new Date().getDate() + 20));
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 30);
const sinvDoc = await createSalesInvoice();
sinvDoc.date = futureDate;
const sinvDoc = await createSalesInvoice(futureDate);
await sinvDoc.sync();
await sinvDoc.submit();
+126 -9
View File
@@ -738,6 +738,64 @@ export function getDocStatusListColumn(): ColumnConfig {
};
}
export function getLoyaltyProgramStatusColumn(): ColumnConfig {
return {
label: t`Status`,
fieldname: 'status',
fieldtype: 'Select',
render(doc) {
const status = getLoyaltyProgramStatus(doc);
const color = loyaltyProgramStatusColor[status] ?? 'gray';
const label = getLoyaltyProgramStatusText(status);
return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
metadata: {
status,
color,
label,
},
};
},
};
}
export function getLoyaltyProgramStatus(doc?: RenderData | Doc): string {
if (!doc) {
return '';
}
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const toDate = doc.toDate as Date;
if (toDate && toDate <= currentDate) {
return 'Expired';
}
return 'Active';
}
export const loyaltyProgramStatusColor: Record<string, string | undefined> = {
Active: 'green',
Disabled: 'gray',
Expired: 'red',
};
export function getLoyaltyProgramStatusText(status: string): string {
switch (status) {
case 'Active':
return t`Active`;
case 'Disabled':
return t`Disabled`;
case 'Expired':
return t`Expired`;
default:
return '';
}
}
type ModelsWithItems = Invoice | StockTransfer | StockMovement;
export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
if (!doc.canEdit) {
@@ -883,6 +941,15 @@ export async function createLoyaltyPointEntry(doc: Invoice) {
if (!loyaltyProgramDoc.isEnabled) {
return;
}
const toDate = loyaltyProgramDoc.toDate as Date;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (toDate && new Date(toDate).getTime() < today.getTime()) {
return;
}
const expiryDate = new Date(Date.now());
expiryDate.setDate(
@@ -951,18 +1018,22 @@ export function getLoyaltyProgramTier(
let loyaltyProgramTier: CollectionRulesItems | undefined;
for (const row of loyaltyProgramData.collectionRules) {
if (isPesa(row.minimumTotalSpent)) {
const minimumSpent = row.minimumTotalSpent;
if (row.minimumTotalSpent !== undefined && row.minimumTotalSpent !== null) {
let minimumSpent: Money;
if (!minimumSpent.lte(grandTotal)) {
continue;
if (isPesa(row.minimumTotalSpent)) {
minimumSpent = row.minimumTotalSpent;
} else {
minimumSpent = new Money(row.minimumTotalSpent as number);
}
if (
!loyaltyProgramTier ||
minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money)
) {
loyaltyProgramTier = row;
if (minimumSpent.lte(grandTotal)) {
if (
!loyaltyProgramTier ||
minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money)
) {
loyaltyProgramTier = row;
}
}
}
}
@@ -1537,6 +1608,52 @@ export async function validateCouponCode(
}
}
export async function validateLoyaltyProgram(
doc: Invoice,
loyaltyProgramName: string
) {
const loyaltyProgram = await doc.fyo.db.getAll(ModelNameEnum.LoyaltyProgram, {
fields: ['fromDate', 'toDate', 'maximumUse', 'used', 'isEnabled'],
filters: { name: loyaltyProgramName },
});
if (!loyaltyProgram[0]?.isEnabled) {
throw new ValidationError(
'Loyalty program cannot be applied as it is not enabled'
);
}
if (
(loyaltyProgram[0]?.maximumUse as number) > 0 &&
(loyaltyProgram[0]?.used as number) >=
(loyaltyProgram[0]?.maximumUse as number)
) {
throw new ValidationError(
'Loyalty program has reached maximum usage limit'
);
}
if (
loyaltyProgram[0].fromDate &&
(doc.date as Date) < (loyaltyProgram[0].fromDate as Date)
) {
throw new ValidationError('Loyalty program is not yet active');
}
const toDate = loyaltyProgram[0].toDate as Date;
if (toDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const normalizedToDate = new Date(toDate);
normalizedToDate.setHours(0, 0, 0, 0);
// Only throw error if toDate is clearly in the past
if (normalizedToDate.getTime() < today.getTime()) {
throw new ValidationError('Loyalty program has expired');
}
}
}
export function removeFreeItems(sinvDoc: SalesInvoice) {
if (!sinvDoc || !sinvDoc.items) {
return;
+20 -2
View File
@@ -51,7 +51,7 @@
"label": "Expiry Duration",
"fieldtype": "Int",
"default": 1,
"required": true
"hidden": true
},
{
"fieldname": "expenseAccount",
@@ -59,6 +59,23 @@
"fieldtype": "Link",
"target": "Account",
"required": true
},
{
"fieldname": "maximumUse",
"label": "Maximum Use",
"fieldtype": "Int",
"default": 0,
"required": true,
"section": "Validity and Usage"
},
{
"fieldname": "used",
"label": "Used",
"fieldtype": "Int",
"default": 0,
"required": true,
"readOnly": true,
"section": "Validity and Usage"
}
],
"quickEditFields": [
@@ -67,7 +84,8 @@
"toDate",
"conversionFactor",
"expenseAccount",
"expiryDuration"
"maximumUse",
"used"
],
"keywordFields": ["name"]
}
+11
View File
@@ -8,6 +8,7 @@ import { Doc } from 'fyo/model/doc';
import { isPesa } from 'fyo/utils';
import { Invoice } from 'models/baseModels/Invoice/Invoice';
import { Party } from 'models/baseModels/Party/Party';
import { LoyaltyProgram } from 'models/baseModels/LoyaltyProgram/LoyaltyProgram';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { getBgTextColorClass } from 'src/utils/colors';
@@ -77,6 +78,7 @@ export default defineComponent({
ReturnIssued: this.t`Return Issued`,
Unpaid: this.t`Unpaid`,
PartlyPaid: this.t`Partly Paid`,
Expired: this.t`Expired`,
}[this.status];
},
color(): UIColors {
@@ -99,6 +101,7 @@ const statusColorMap: Record<Status, UIColors> = {
ReturnIssued: 'gray',
Unpaid: 'red',
PartlyPaid: 'yellow',
Expired: 'red',
};
function getStatus(doc: Doc) {
@@ -110,6 +113,14 @@ function getStatus(doc: Doc) {
return 'NotSaved';
}
if (doc instanceof LoyaltyProgram) {
const currentDate = new Date();
if (doc.toDate && doc.toDate instanceof Date && doc.toDate <= currentDate) {
return 'Expired';
}
return 'Saved';
}
if (doc instanceof Party && doc.outstandingAmount?.isZero() !== true) {
return 'Outstanding';
}
+16 -8
View File
@@ -128,6 +128,22 @@ export default defineComponent({
return;
}
const loyaltyProgramDoc = await this.fyo.db.getAll(
ModelNameEnum.LoyaltyProgram,
{
fields: ['conversionFactor', 'toDate'],
filters: { name: partyData.loyaltyProgram as string },
}
);
const toDate = loyaltyProgramDoc[0]?.toDate as Date;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (toDate && new Date(toDate).getTime() < today.getTime()) {
throw new Error(t`Loyalty program has expired and cannot be applied`);
}
if (this.loyaltyPoints >= newValue) {
this.sinvDoc.loyaltyPoints = newValue;
} else {
@@ -138,14 +154,6 @@ export default defineComponent({
);
}
const loyaltyProgramDoc = await this.fyo.db.getAll(
ModelNameEnum.LoyaltyProgram,
{
fields: ['conversionFactor'],
filters: { name: partyData.loyaltyProgram as string },
}
);
const loyaltyPoint =
newValue * ((loyaltyProgramDoc[0]?.conversionFactor as number) || 0);