diff --git a/jobs/checkLoyaltyProgramExpiry.ts b/jobs/checkLoyaltyProgramExpiry.ts
new file mode 100644
index 00000000..ab5eb70c
--- /dev/null
+++ b/jobs/checkLoyaltyProgramExpiry.ts
@@ -0,0 +1,45 @@
+import { DatabaseManager } from '../backend/database/manager';
+import { ModelNameEnum } from '../models/types';
+
+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', 'maximumUse', 'used'],
+ filters: {
+ status: ['not in', ['Expired']],
+ isEnabled: true,
+ },
+ });
+
+ if (loyaltyPrograms) {
+ for (const program of loyaltyPrograms) {
+ if (program.toDate && new Date(String(program.toDate)) <= currentDate) {
+ await dm.db?.knex!(ModelNameEnum.LoyaltyProgram)
+ .where({ name: program.name })
+ .update({
+ status: 'Expired',
+ isEnabled: false,
+ });
+ }
+ }
+ }
+
+ const result = {
+ timestamp: currentDate.toISOString(),
+ };
+
+ return result;
+ } catch (error) {
+ throw error;
+ } finally {
+ await dm.call('close');
+ }
+}
+
+checkLoyaltyProgramExpiry().catch((error) => {
+ throw error;
+});
diff --git a/main/initSheduler.ts b/main/initSheduler.ts
index 9a4a8551..ea80c2a0 100644
--- a/main/initSheduler.ts
+++ b/main/initSheduler.ts
@@ -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'],
diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts
index 2fa9b2d5..049470af 100644
--- a/models/baseModels/Invoice/Invoice.ts
+++ b/models/baseModels/Invoice/Invoice.ts
@@ -28,6 +28,9 @@ import {
getReturnLoyaltyPoints,
getItemQtyMap,
getItemVisibility,
+ validateLoyaltyProgram,
+ getLoyaltyProgramTier,
+ isLoyaltyProgramExpiredAndMaxed,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
@@ -212,13 +215,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 +314,10 @@ export abstract class Invoice extends Transactional {
if (this.schemaName === ModelNameEnum.SalesInvoice) {
this.updateUsedCountOfCoupons();
}
+
+ if (this.loyaltyProgram) {
+ await this.updateUsedCountOfLoyaltyProgram();
+ }
}
async afterCancel() {
@@ -284,6 +327,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 +885,63 @@ export abstract class Invoice extends Transactional {
});
}
+ async updateUsedCountOfLoyaltyProgram() {
+ if (!this.loyaltyProgram) {
+ return;
+ }
+
+ const loyaltyProgramDoc = await this.fyo.doc.getDoc(
+ ModelNameEnum.LoyaltyProgram,
+ this.loyaltyProgram
+ );
+
+ const maximumUse = loyaltyProgramDoc.maximumUse as number;
+ const used = (loyaltyProgramDoc.used as number) || 0;
+
+ if (this.redeemLoyaltyPoints) {
+ const newUsedCount = used + 1;
+
+ if (maximumUse > 0 && newUsedCount >= maximumUse) {
+ await loyaltyProgramDoc.setAndSync({
+ used: newUsedCount,
+ isEnabled: false,
+ });
+ } else {
+ await loyaltyProgramDoc.setAndSync({
+ used: newUsedCount,
+ });
+ }
+ }
+ }
+
+ async reduceUsedCountOfLoyaltyProgram() {
+ if (!this.loyaltyProgram) {
+ return;
+ }
+
+ const loyaltyProgramDoc = await this.fyo.doc.getDoc(
+ ModelNameEnum.LoyaltyProgram,
+ this.loyaltyProgram
+ );
+
+ const maximumUse = loyaltyProgramDoc.maximumUse as number;
+ const used = (loyaltyProgramDoc.used as number) || 0;
+ const newUsedCount = used - 1;
+
+ if (this.redeemLoyaltyPoints) {
+ if (newUsedCount < maximumUse) {
+ await loyaltyProgramDoc.setAndSync({
+ used: newUsedCount,
+ isEnabled: true,
+ });
+ } else {
+ await loyaltyProgramDoc.setAndSync({
+ used: newUsedCount,
+ });
+ }
+ }
+ }
+
async updateIsItemsFullyReturned(doc?: Invoice) {
if (!doc?.returnAgainst || doc.schemaName !== ModelNameEnum.SalesInvoice) {
return;
@@ -898,11 +1002,27 @@ export abstract class Invoice extends Transactional {
this.loyaltyProgram
)) as LoyaltyProgram;
- const expiryDate = this.date as Date;
+ 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);
@@ -989,7 +1109,13 @@ export abstract class Invoice extends Transactional {
ModelNameEnum.Party,
this.party
);
- return partyDoc?.loyaltyProgram as string;
+ const loyaltyProgramName = partyDoc?.loyaltyProgram as string;
+
+ if (!loyaltyProgramName) {
+ return '';
+ }
+
+ return loyaltyProgramName;
},
dependsOn: ['party', 'name'],
},
@@ -999,6 +1125,17 @@ export abstract class Invoice extends Transactional {
return 0;
}
+ const loyaltyProgramName = this.loyaltyProgram as string;
+ if (loyaltyProgramName) {
+ const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed(
+ this.fyo,
+ loyaltyProgramName
+ );
+ if (isExpiredAndMaxed) {
+ return 0;
+ }
+ }
+
const loyaltyPoints = await this.fyo.getValue(
ModelNameEnum.Party,
this.party,
@@ -1006,7 +1143,7 @@ export abstract class Invoice extends Transactional {
);
return loyaltyPoints || 0;
},
- dependsOn: ['party'],
+ dependsOn: ['party', 'loyaltyProgram'],
},
currency: {
formula: async () => {
@@ -1199,7 +1336,13 @@ export abstract class Invoice extends Transactional {
loyaltyProgram: () => !this.loyaltyProgram,
availableLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn,
loyaltyPoints: () => !this.redeemLoyaltyPoints || this.isReturn,
- redeemLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn,
+ redeemLoyaltyPoints: () => {
+ if (!this.loyaltyProgram || this.isReturn) {
+ return true;
+ }
+
+ return (this.availableLoyaltyPoints ?? 0) <= 0;
+ },
coupons: () => this.isSubmitted && !this.coupons?.length,
priceList: () =>
!this.fyo.singles.AccountingSettings?.enablePriceList ||
diff --git a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts
index 89f73399..20d8db45 100644
--- a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts
+++ b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts
@@ -1,22 +1,42 @@
+import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
-import { FiltersMap, ListViewSettings } from 'fyo/model/types';
+import { 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;
+ status?: 'Active' | 'Expired' | 'Disabled' | 'Maxed';
- static filters: FiltersMap = {
- expenseAccount: () => ({
- rootType: AccountRootTypeEnum.Expense,
- isGroup: false,
- }),
+ 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 getListViewSettings(): ListViewSettings {
return {
- columns: ['name', 'fromDate', 'toDate', 'expiryDuration'],
+ columns: ['name', getLoyaltyProgramStatusColumn(), 'fromDate', 'toDate'],
};
}
}
diff --git a/models/baseModels/Party/Party.ts b/models/baseModels/Party/Party.ts
index bba7181f..e277bd0d 100644
--- a/models/baseModels/Party/Party.ts
+++ b/models/baseModels/Party/Party.ts
@@ -14,6 +14,7 @@ import {
import { Money } from 'pesa';
import { PartyRole } from './types';
import { ModelNameEnum } from 'models/types';
+import { isLoyaltyProgramExpiredAndMaxed } from 'models/helpers';
export class Party extends Doc {
role?: PartyRole;
@@ -66,6 +67,17 @@ export class Party extends Doc {
}
async _getTotalLoyaltyPoints() {
+ const loyaltyProgramName = this.loyaltyProgram as string;
+ if (loyaltyProgramName) {
+ const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed(
+ this.fyo,
+ loyaltyProgramName
+ );
+ if (isExpiredAndMaxed) {
+ return 0;
+ }
+ }
+
const data = (await this.fyo.db.getAll(ModelNameEnum.LoyaltyPointEntry, {
fields: ['name', 'loyaltyPoints', 'expiryDate', 'postingDate'],
filters: {
diff --git a/models/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts
index f4d94a1d..642712dc 100644
--- a/models/baseModels/SalesInvoice/SalesInvoice.ts
+++ b/models/baseModels/SalesInvoice/SalesInvoice.ts
@@ -113,6 +113,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;
diff --git a/models/baseModels/tests/testLoyaltyProgram.spec.ts b/models/baseModels/tests/testLoyaltyProgram.spec.ts
index 2be654b8..8a1b9657 100644
--- a/models/baseModels/tests/testLoyaltyProgram.spec.ts
+++ b/models/baseModels/tests/testLoyaltyProgram.spec.ts
@@ -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();
diff --git a/models/helpers.ts b/models/helpers.ts
index ac458d13..da4af4e4 100644
--- a/models/helpers.ts
+++ b/models/helpers.ts
@@ -743,6 +743,74 @@ 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: `${label}`,
+ 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';
+ }
+
+ const maximumUse = doc.maximumUse as number;
+ const used = doc.used as number;
+
+ if (maximumUse > 0 && used >= maximumUse) {
+ return 'Maxed';
+ }
+
+ return 'Active';
+}
+
+export const loyaltyProgramStatusColor: Record = {
+ Active: 'green',
+ Disabled: 'gray',
+ Expired: 'red',
+ Maxed: 'orange',
+};
+
+export function getLoyaltyProgramStatusText(status: string): string {
+ switch (status) {
+ case 'Active':
+ return t`Active`;
+ case 'Disabled':
+ return t`Disabled`;
+ case 'Expired':
+ return t`Expired`;
+ case 'Maxed':
+ return t`Maxed`;
+ default:
+ return '';
+ }
+}
+
type ModelsWithItems = Invoice | StockTransfer | StockMovement;
export async function addItem(name: string, doc: M) {
if (!doc.canEdit) {
@@ -888,6 +956,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(
@@ -956,18 +1033,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;
+ }
}
}
}
@@ -1542,6 +1623,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;
@@ -1616,3 +1743,36 @@ export function roundFreeItemQty(
): number {
return Math[roundingMethod](quantity);
}
+
+export async function isLoyaltyProgramExpiredAndMaxed(
+ fyo: Fyo,
+ loyaltyProgramName: string
+): Promise {
+ if (!loyaltyProgramName) {
+ return false;
+ }
+
+ const loyaltyProgram = await fyo.db.getAll(ModelNameEnum.LoyaltyProgram, {
+ fields: ['toDate', 'maximumUse', 'used', 'isEnabled'],
+ filters: { name: loyaltyProgramName },
+ });
+
+ if (!loyaltyProgram.length) {
+ return false;
+ }
+
+ const program = loyaltyProgram[0];
+ const currentDate = new Date();
+ currentDate.setHours(0, 0, 0, 0);
+
+ const toDate = program.toDate as Date;
+ const isExpired =
+ toDate && new Date(toDate).getTime() < currentDate.getTime();
+
+ const maximumUse = (program.maximumUse as number) || 0;
+ const used = (program.used as number) || 0;
+ const isMaxed = maximumUse > 0 && used >= maximumUse;
+
+ const result = isExpired || isMaxed;
+ return result;
+}
diff --git a/schemas/app/LoyaltyProgram.json b/schemas/app/LoyaltyProgram.json
index 5c5c1c71..608f97f9 100644
--- a/schemas/app/LoyaltyProgram.json
+++ b/schemas/app/LoyaltyProgram.json
@@ -51,7 +51,7 @@
"label": "Expiry Duration",
"fieldtype": "Int",
"default": 1,
- "required": true
+ "hidden": true
},
{
"fieldname": "expenseAccount",
@@ -59,6 +59,21 @@
"fieldtype": "Link",
"target": "Account",
"required": true
+ },
+ {
+ "fieldname": "maximumUse",
+ "label": "Maximum Use",
+ "fieldtype": "Int",
+ "default": 0,
+ "section": "Validity and Usage"
+ },
+ {
+ "fieldname": "used",
+ "label": "Used",
+ "fieldtype": "Int",
+ "default": 0,
+ "readOnly": true,
+ "section": "Validity and Usage"
}
],
"quickEditFields": [
@@ -67,7 +82,8 @@
"toDate",
"conversionFactor",
"expenseAccount",
- "expiryDuration"
+ "maximumUse",
+ "used"
],
"keywordFields": ["name"]
}
diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json
index f372a543..fcdb4645 100644
--- a/schemas/app/SalesInvoice.json
+++ b/schemas/app/SalesInvoice.json
@@ -82,7 +82,7 @@
"fieldtype": "Link",
"target": "LoyaltyProgram",
"label": "Loyalty Program",
- "section": "References",
+ "section": "Loyalty Points Redemption",
"readOnly": true
},
{
@@ -90,7 +90,7 @@
"fieldtype": "Int",
"label": "Available Loyalty Points",
"readOnly": true,
- "section": "References"
+ "section": "Loyalty Points Redemption"
},
{
"fieldname": "redeemLoyaltyPoints",
diff --git a/src/components/StatusPill.vue b/src/components/StatusPill.vue
index f2d1743a..71baed7d 100644
--- a/src/components/StatusPill.vue
+++ b/src/components/StatusPill.vue
@@ -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,9 @@ export default defineComponent({
ReturnIssued: this.t`Return Issued`,
Unpaid: this.t`Unpaid`,
PartlyPaid: this.t`Partly Paid`,
+ Expired: this.t`Expired`,
+ Active: this.t`Active`,
+ Maxed: this.t`Maxed`,
}[this.status];
},
color(): UIColors {
@@ -99,6 +103,9 @@ const statusColorMap: Record = {
ReturnIssued: 'gray',
Unpaid: 'red',
PartlyPaid: 'yellow',
+ Expired: 'red',
+ Active: 'green',
+ Maxed: 'orange',
};
function getStatus(doc: Doc) {
@@ -110,6 +117,27 @@ function getStatus(doc: Doc) {
return 'NotSaved';
}
+ if (doc instanceof LoyaltyProgram) {
+ const currentDate = new Date();
+ currentDate.setHours(0, 0, 0, 0);
+
+ const maximumUse = doc.maximumUse as number;
+ const used = doc.used as number;
+
+ if (maximumUse > 0 && used >= maximumUse) {
+ return 'Maxed';
+ }
+
+ if (doc.toDate && doc.toDate instanceof Date) {
+ const toDate = new Date(doc.toDate);
+ toDate.setHours(0, 0, 0, 0);
+ if (toDate <= currentDate) {
+ return 'Expired';
+ }
+ }
+ return 'Active';
+ }
+
if (doc instanceof Party && doc.outstandingAmount?.isZero() !== true) {
return 'Outstanding';
}
diff --git a/src/pages/POS/LoyaltyProgramModal.vue b/src/pages/POS/LoyaltyProgramModal.vue
index a4e921d9..d99f3cbc 100644
--- a/src/pages/POS/LoyaltyProgramModal.vue
+++ b/src/pages/POS/LoyaltyProgramModal.vue
@@ -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);
diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue
index 154a85dd..81aaa8b1 100644
--- a/src/pages/POS/POS.vue
+++ b/src/pages/POS/POS.vue
@@ -168,6 +168,7 @@ import {
removeFreeItems,
getItemRateFromPriceList,
getItemVisibility,
+ isLoyaltyProgramExpiredAndMaxed,
} from 'models/helpers';
import { ItemVisibility } from 'src/components/POS/types';
import {
@@ -503,7 +504,21 @@ export default defineComponent({
filters: { name: value },
});
- this.loyaltyProgram = party[0]?.loyaltyProgram as string;
+ const loyaltyProgramName = party[0]?.loyaltyProgram as string;
+
+ if (loyaltyProgramName) {
+ const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed(
+ this.fyo,
+ loyaltyProgramName
+ );
+ if (isExpiredAndMaxed) {
+ this.loyaltyProgram = loyaltyProgramName;
+ this.loyaltyPoints = 0;
+ return;
+ }
+ }
+
+ this.loyaltyProgram = loyaltyProgramName;
this.loyaltyPoints = party[0]?.loyaltyPoints as number;
},
@@ -652,7 +667,8 @@ export default defineComponent({
this.fyo.singles.AccountingSettings?.enablePriceList &&
this.loyaltyPoints &&
this.sinvDoc.party &&
- this.sinvDoc.items?.length
+ this.sinvDoc.items?.length &&
+ this.loyaltyProgram
) {
this.toggleModal('LoyaltyProgram', true);
}
diff --git a/src/pages/POS/POSQuickActions.vue b/src/pages/POS/POSQuickActions.vue
index 51f118b7..54572f48 100644
--- a/src/pages/POS/POSQuickActions.vue
+++ b/src/pages/POS/POSQuickActions.vue
@@ -76,7 +76,9 @@