mirror of
https://github.com/frappe/books.git
synced 2026-08-24 02:24:17 -05:00
Merge pull request #1409 from Gadha2311/loyaltyprogram-expiry
fix: add Loyaltyprogram expiry by checking date
This commit is contained in:
@@ -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;
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+169
-9
@@ -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: `<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';
|
||||
}
|
||||
|
||||
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<string, string | undefined> = {
|
||||
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<M extends ModelsWithItems>(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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Status, UIColors> = {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+18
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
<div
|
||||
class="relative group"
|
||||
:class="{
|
||||
hidden: !fyo.singles.AccountingSettings?.enableLoyaltyProgram,
|
||||
hidden:
|
||||
!fyo.singles.AccountingSettings?.enableLoyaltyProgram ||
|
||||
!loyaltyProgram,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user