diff --git a/README.md b/README.md index 89eda037..08231962 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ If you want to contribute code then you can fork this repo, make changes and rai | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | French | [DeepL](https://www.deepl.com/), [mael-chouteau](https://github.com/mael-chouteau), [joandreux](https://github.com/joandreux) | | German | [DeepL](https://www.deepl.com/), [barredterra](https://github.com/barredterra), [promexio](https://github.com/promexio), [C2H6-383](https://github.com/C2H6-383) | -| Portuguese | [DeepL](https://www.deepl.com/) | +| Portuguese | [DeepL](https://www.deepl.com/), [Valdir Amaral](https://github.com/valdir-amaral) | | Arabic | [taha2002](https://github.com/taha2002), [Faridget](https://github.com/faridget) | | Catalan | Dídac E. Jiménez | | Dutch | [FastAct](https://github.com/FastAct) | @@ -158,9 +158,10 @@ If you want to contribute code then you can fork this repo, make changes and rai | Gujarati | [dhruvilxcode](https://github.com/dhruvilxcode), [4silvertooth](https://github.com/4silvertooth) | | Hindi | [bnsinghgit](https://github.com/bnsinghgit) | | Korean | [Isaac-Kwon](https://github.com/Isaac-Kwon) | -| Simplified Chinese | [wcxu21](https://github.com/wcxu21), [wolone](https://github.com/wolone) | -| Swedish | [papplo](https://github.com/papplo) | +| Simplified Chinese | [wcxu21](https://github.com/wcxu21), [wolone](https://github.com/wolone), [Ji Qu](https://github.com/winkidney) | +| Swedish | [papplo](https://github.com/papplo), [Crims-on](https://github.com/Crims-on) | | Turkish | Eyuq, [XTechnology-TR](https://github.com/XTechnology-TR) | +| Danish | [Tummas Joensen](https://github.com/slang123) | ## License diff --git a/colors.json b/colors.json index 428e9f44..31d3ebca 100644 --- a/colors.json +++ b/colors.json @@ -11,6 +11,9 @@ "600": "#7C7C7C", "700": "#525252", "800": "#383838", + "850": "#282828", + "875": "#212121", + "890": "#1C1C1C", "900": "#171717" }, "red": { diff --git a/main.ts b/main.ts index 5727fd15..23140d29 100644 --- a/main.ts +++ b/main.ts @@ -102,13 +102,6 @@ export class Main { resizable: true, }; - if (!this.isMac) { - options.titleBarOverlay = { - color: '#FFFFFF', - height: 26, - }; - } - if (this.isDevelopment || this.isLinux) { Object.assign(options, { icon: this.icon }); } diff --git a/main/preload.ts b/main/preload.ts index e0a700e9..c4fc72d0 100644 --- a/main/preload.ts +++ b/main/preload.ts @@ -27,6 +27,42 @@ const ipc = { return ipcRenderer.send(IPC_MESSAGES.RELOAD_MAIN_WINDOW); }, + minimizeWindow() { + return ipcRenderer.send(IPC_MESSAGES.MINIMIZE_MAIN_WINDOW); + }, + + toggleMaximize() { + return ipcRenderer.send(IPC_MESSAGES.MAXIMIZE_MAIN_WINDOW); + }, + + isMaximized() { + return new Promise((resolve, reject) => { + ipcRenderer.send(IPC_MESSAGES.ISMAXIMIZED_MAIN_WINDOW); + ipcRenderer.once( + IPC_MESSAGES.ISMAXIMIZED_RESULT, + (_event, isMaximized) => { + resolve(isMaximized); + } + ); + }); + }, + + isFullscreen() { + return new Promise((resolve, reject) => { + ipcRenderer.send(IPC_MESSAGES.ISFULLSCREEN_MAIN_WINDOW); + ipcRenderer.once( + IPC_MESSAGES.ISFULLSCREEN_RESULT, + (_event, isFullscreen) => { + resolve(isFullscreen); + } + ); + }); + }, + + closeWindow() { + return ipcRenderer.send(IPC_MESSAGES.CLOSE_MAIN_WINDOW); + }, + async getCreds() { return (await ipcRenderer.invoke(IPC_ACTIONS.GET_CREDS)) as Creds; }, diff --git a/main/registerIpcMainMessageListeners.ts b/main/registerIpcMainMessageListeners.ts index 65c42039..909bc38c 100644 --- a/main/registerIpcMainMessageListeners.ts +++ b/main/registerIpcMainMessageListeners.ts @@ -21,6 +21,30 @@ export default function registerIpcMainMessageListeners(main: Main) { main.mainWindow!.reload(); }); + ipcMain.on(IPC_MESSAGES.MINIMIZE_MAIN_WINDOW, () => { + main.mainWindow!.minimize(); + }); + + ipcMain.on(IPC_MESSAGES.MAXIMIZE_MAIN_WINDOW, () => { + main.mainWindow!.isMaximized() + ? main.mainWindow!.unmaximize() + : main.mainWindow!.maximize(); + }); + + ipcMain.on(IPC_MESSAGES.ISMAXIMIZED_MAIN_WINDOW, (event) => { + const isMaximized = main.mainWindow?.isMaximized() ?? false; + event.sender.send(IPC_MESSAGES.ISMAXIMIZED_RESULT, isMaximized); + }); + + ipcMain.on(IPC_MESSAGES.ISFULLSCREEN_MAIN_WINDOW, (event) => { + const isFullscreen = main.mainWindow?.isFullScreen() ?? false; + event.sender.send(IPC_MESSAGES.ISFULLSCREEN_RESULT, isFullscreen); + }); + + ipcMain.on(IPC_MESSAGES.CLOSE_MAIN_WINDOW, () => { + main.mainWindow!.close(); + }); + ipcMain.on(IPC_MESSAGES.OPEN_EXTERNAL, (_, link: string) => { shell.openExternal(link).catch((err) => emitMainProcessError(err)); }); diff --git a/models/baseModels/AccountingSettings/AccountingSettings.ts b/models/baseModels/AccountingSettings/AccountingSettings.ts index 8e32ad4e..a600f35b 100644 --- a/models/baseModels/AccountingSettings/AccountingSettings.ts +++ b/models/baseModels/AccountingSettings/AccountingSettings.ts @@ -18,6 +18,7 @@ export class AccountingSettings extends Doc { enableLead?: boolean; enableFormCustomization?: boolean; enableInvoiceReturns?: boolean; + enableLoyaltyProgram?: boolean; enablePricingRule?: boolean; static filters: FiltersMap = { @@ -56,6 +57,9 @@ export class AccountingSettings extends Doc { enableInvoiceReturns: () => { return !!this.enableInvoiceReturns; }, + enableLoyaltyProgram: () => { + return !!this.enableLoyaltyProgram; + }, }; override hidden: HiddenMap = { diff --git a/models/baseModels/CollectionRulesItems/CollectionRulesItems.ts b/models/baseModels/CollectionRulesItems/CollectionRulesItems.ts new file mode 100644 index 00000000..c468d818 --- /dev/null +++ b/models/baseModels/CollectionRulesItems/CollectionRulesItems.ts @@ -0,0 +1,8 @@ +import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; + +export class CollectionRulesItems extends Doc { + tierName?: string; + collectionFactor?: number; + minimumTotalSpent?: Money; +} diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index db98d386..6cccedef 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -1,4 +1,4 @@ -import { Fyo } from 'fyo'; +import { Fyo, t } from 'fyo'; import { DocValueMap } from 'fyo/core/types'; import { Doc } from 'fyo/model/doc'; import { @@ -14,10 +14,13 @@ import { Transactional } from 'models/Transactional/Transactional'; import { addItem, canApplyPricingRule, + createLoyaltyPointEntry, filterPricingRules, + getAddedLPWithGrandTotal, getExchangeRate, getNumberSeries, getPricingRulesConflicts, + removeLoyaltyPoint, roundFreeItemQty, } from 'models/helpers'; import { StockTransfer } from 'models/inventory/StockTransfer'; @@ -38,6 +41,7 @@ import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types'; import { PricingRule } from '../PricingRule/PricingRule'; import { ApplicablePricingRules } from './types'; import { PricingRuleDetail } from '../PricingRuleDetail/PricingRuleDetail'; +import { LoyaltyProgram } from '../LoyaltyProgram/LoyaltyProgram'; export type TaxDetail = { account: string; @@ -69,8 +73,10 @@ export abstract class Invoice extends Transactional { setDiscountAmount?: boolean; discountAmount?: Money; discountPercent?: number; + loyaltyPoints?: number; discountAfterTax?: boolean; stockNotTransferred?: number; + loyaltyProgram?: string; backReference?: string; submitted?: boolean; @@ -179,17 +185,31 @@ export abstract class Invoice extends Transactional { async afterSubmit() { await super.afterSubmit(); + if (this.isReturn) { + await this._removeLoyaltyPointEntry(); + } + if (this.isQuote) { return; } + let lpAddedBaseGrandTotal: Money | undefined; + + if (this.redeemLoyaltyPoints) { + lpAddedBaseGrandTotal = await this.getLPAddedBaseGrandTotal(); + } + // update outstanding amounts await this.fyo.db.update(this.schemaName, { name: this.name as string, - outstandingAmount: this.baseGrandTotal!, + outstandingAmount: lpAddedBaseGrandTotal! || this.baseGrandTotal!, }); - const party = (await this.fyo.doc.getDoc('Party', this.party)) as Party; + const party = (await this.fyo.doc.getDoc( + ModelNameEnum.Party, + this.party + )) as Party; + await party.updateOutstandingAmount(); if (this.makeAutoPayment && this.autoPaymentAccount) { @@ -207,6 +227,7 @@ export abstract class Invoice extends Transactional { } await this._updateIsItemsReturned(); + await this._createLoyaltyPointEntry(); } async afterCancel() { @@ -214,6 +235,14 @@ export abstract class Invoice extends Transactional { await this._cancelPayments(); await this._updatePartyOutStanding(); await this._updateIsItemsReturned(); + await this._removeLoyaltyPointEntry(); + } + + async _removeLoyaltyPointEntry() { + if (!this.loyaltyProgram) { + return; + } + await removeLoyaltyPoint(this); } async _cancelPayments() { @@ -538,6 +567,28 @@ export abstract class Invoice extends Transactional { await invoiceDoc.submit(); } + async _createLoyaltyPointEntry() { + if (!this.loyaltyProgram) { + return; + } + + const loyaltyProgramDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + this.loyaltyProgram + )) as LoyaltyProgram; + + const expiryDate = this.date as Date; + const fromDate = loyaltyProgramDoc.fromDate as Date; + const toDate = loyaltyProgramDoc.toDate as Date; + + if (fromDate <= expiryDate && toDate >= expiryDate) { + const party = (await this.loadAndGetLink('party')) as Party; + + await createLoyaltyPointEntry(this); + await party.updateLoyaltyPoints(); + } + } + async _validateHasLinkedReturnInvoices() { if (!this.name || this.isReturn || this.isQuote) { return; @@ -560,6 +611,16 @@ export abstract class Invoice extends Transactional { ); } + async getLPAddedBaseGrandTotal() { + const totalLotaltyAmount = await getAddedLPWithGrandTotal( + this.fyo, + this.loyaltyProgram as string, + this.loyaltyPoints as number + ); + + return totalLotaltyAmount.sub(this.baseGrandTotal as Money).abs(); + } + formulas: FormulaMap = { account: { formula: async () => { @@ -571,6 +632,16 @@ export abstract class Invoice extends Transactional { }, dependsOn: ['party'], }, + loyaltyProgram: { + formula: async () => { + const partyDoc = await this.fyo.doc.getDoc( + ModelNameEnum.Party, + this.party + ); + return partyDoc?.loyaltyProgram as string; + }, + dependsOn: ['party', 'name'], + }, currency: { formula: async () => { const currency = (await this.fyo.getValue( @@ -611,12 +682,15 @@ export abstract class Invoice extends Transactional { dependsOn: ['grandTotal', 'exchangeRate'], }, outstandingAmount: { - formula: () => { + formula: async () => { if (this.submitted) { return; } + if (this.redeemLoyaltyPoints) { + return await this.getLPAddedBaseGrandTotal(); + } - return this.baseGrandTotal!; + return this.baseGrandTotal; }, }, stockNotTransferred: { @@ -726,6 +800,9 @@ export abstract class Invoice extends Transactional { !(this.attachment || !(this.isSubmitted || this.isCancelled)), backReference: () => !this.backReference, quote: () => !this.quote, + loyaltyProgram: () => !this.loyaltyProgram, + loyaltyPoints: () => !this.redeemLoyaltyPoints || this.isReturn, + redeemLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn, priceList: () => !this.fyo.singles.AccountingSettings?.enablePriceList || (!this.canEdit && !this.priceList), @@ -1121,19 +1198,15 @@ export abstract class Invoice extends Transactional { return; } - if (!this.pricingRuleDetail?.length || !this.pricingRuleDetail.length) { - return; - } - this.items = this.items.filter((item) => !item.isFreeItem); for (const item of this.items) { - const pricingRuleDetailForItem = this.pricingRuleDetail.filter( + const pricingRuleDetailForItem = this.pricingRuleDetail?.filter( (doc) => doc.referenceItem === item.item ); - if (!pricingRuleDetailForItem.length) { - return; + if (!pricingRuleDetailForItem?.length) { + continue; } const pricingRuleDoc = (await this.fyo.doc.getDoc( @@ -1179,6 +1252,7 @@ export abstract class Invoice extends Transactional { item: pricingRuleDoc.freeItem as string, quantity: freeItemQty, isFreeItem: true, + pricingRule: pricingRuleDoc.title, rate: pricingRuleDoc.freeItemRate, unit: pricingRuleDoc.freeItemUnit, }); @@ -1189,6 +1263,7 @@ export abstract class Invoice extends Transactional { if (!this.isSales || !this.items) { return; } + const pricingRules: ApplicablePricingRules[] = []; for (const item of this.items) { @@ -1196,6 +1271,23 @@ export abstract class Invoice extends Transactional { continue; } + const duplicatePricingRule = this.pricingRuleDetail?.filter( + (pricingrule: PricingRuleDetail) => + pricingrule.referenceItem == item.item + ); + + if (duplicatePricingRule && duplicatePricingRule?.length >= 2) { + const { showToast } = await import('src/utils/interactive'); + const message = t`Pricing Rule '${ + duplicatePricingRule[0]?.referenceName as string + }' is already applied to item '${ + item.item as string + }' in another batch.`; + showToast({ type: 'error', message }); + + continue; + } + const pricingRuleDocNames = ( await this.fyo.db.getAll(ModelNameEnum.PricingRuleItem, { fields: ['parent'], @@ -1230,10 +1322,7 @@ export abstract class Invoice extends Transactional { continue; } - const isPricingRuleHasConflicts = getPricingRulesConflicts( - filtered, - item.item as string - ); + const isPricingRuleHasConflicts = getPricingRulesConflicts(filtered); if (isPricingRuleHasConflicts) { continue; diff --git a/models/baseModels/LoyaltyPointEntry/LoyaltyPointEntry.ts b/models/baseModels/LoyaltyPointEntry/LoyaltyPointEntry.ts new file mode 100644 index 00000000..4a71954d --- /dev/null +++ b/models/baseModels/LoyaltyPointEntry/LoyaltyPointEntry.ts @@ -0,0 +1,21 @@ +import { Doc } from 'fyo/model/doc'; +import { ListViewSettings } from 'fyo/model/types'; + +export class LoyaltyPointEntry extends Doc { + loyaltyProgram?: string; + customer?: string; + invoice?: string; + purchaseAmount?: number; + expiryDate?: Date; + + static override getListViewSettings(): ListViewSettings { + return { + columns: [ + 'loyaltyProgram', + 'customer', + 'purchaseAmount', + 'loyaltyPoints', + ], + }; + } +} diff --git a/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts new file mode 100644 index 00000000..4768c8e3 --- /dev/null +++ b/models/baseModels/LoyaltyProgram/LoyaltyProgram.ts @@ -0,0 +1,22 @@ +import { Doc } from 'fyo/model/doc'; +import { FiltersMap, ListViewSettings } from 'fyo/model/types'; +import { CollectionRulesItems } from '../CollectionRulesItems/CollectionRulesItems'; +import { AccountRootTypeEnum } from '../Account/types'; + +export class LoyaltyProgram extends Doc { + collectionRules?: CollectionRulesItems[]; + expiryDuration?: number; + + static filters: FiltersMap = { + expenseAccount: () => ({ + rootType: AccountRootTypeEnum.Liability, + isGroup: false, + }), + }; + + static getListViewSettings(): ListViewSettings { + return { + columns: ['name', 'fromDate', 'toDate', 'expiryDuration'], + }; + } +} diff --git a/models/baseModels/Party/Party.ts b/models/baseModels/Party/Party.ts index 082ef6a6..bba7181f 100644 --- a/models/baseModels/Party/Party.ts +++ b/models/baseModels/Party/Party.ts @@ -20,6 +20,7 @@ export class Party extends Doc { party?: string; fromLead?: string; defaultAccount?: string; + loyaltyPoints?: number; outstandingAmount?: Money; async updateOutstandingAmount() { /** @@ -54,6 +55,40 @@ export class Party extends Doc { await this.setAndSync({ outstandingAmount }); } + async updateLoyaltyPoints() { + let loyaltyPoints = 0; + + if (this.role === 'Customer' || this.role === 'Both') { + loyaltyPoints = await this._getTotalLoyaltyPoints(); + } + + await this.setAndSync({ loyaltyPoints }); + } + + async _getTotalLoyaltyPoints() { + const data = (await this.fyo.db.getAll(ModelNameEnum.LoyaltyPointEntry, { + fields: ['name', 'loyaltyPoints', 'expiryDate', 'postingDate'], + filters: { + customer: this.name as string, + }, + })) as { + name: string; + loyaltyPoints: number; + expiryDate: Date; + postingDate: Date; + }[]; + + const totalLoyaltyPoints = data.reduce((total, entry) => { + if (entry.expiryDate > entry.postingDate) { + return total + entry.loyaltyPoints; + } + + return total; + }, 0); + + return totalLoyaltyPoints; + } + async _getTotalOutstandingAmount( schemaName: 'SalesInvoice' | 'PurchaseInvoice' ) { diff --git a/models/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts index e41fac72..a56f7744 100644 --- a/models/baseModels/SalesInvoice/SalesInvoice.ts +++ b/models/baseModels/SalesInvoice/SalesInvoice.ts @@ -1,10 +1,18 @@ -import { Fyo } from 'fyo'; -import { Action, ListViewSettings } from 'fyo/model/types'; +import { Fyo, t } from 'fyo'; +import { Action, ListViewSettings, ValidationMap } from 'fyo/model/types'; import { LedgerPosting } from 'models/Transactional/LedgerPosting'; import { ModelNameEnum } from 'models/types'; -import { getInvoiceActions, getTransactionStatusColumn } from '../../helpers'; +import { + getAddedLPWithGrandTotal, + getInvoiceActions, + getTransactionStatusColumn, +} from '../../helpers'; import { Invoice } from '../Invoice/Invoice'; import { SalesInvoiceItem } from '../SalesInvoiceItem/SalesInvoiceItem'; +import { LoyaltyProgram } from '../LoyaltyProgram/LoyaltyProgram'; +import { DocValue } from 'fyo/core/types'; +import { Party } from '../Party/Party'; +import { ValidationError } from 'fyo/utils/errors'; export class SalesInvoice extends Invoice { items?: SalesInvoiceItem[]; @@ -26,6 +34,26 @@ export class SalesInvoice extends Invoice { await posting.credit(item.account!, item.amount!.mul(exchangeRate)); } + if (this.redeemLoyaltyPoints) { + const loyaltyProgramDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + this.loyaltyProgram + )) as LoyaltyProgram; + + const totalAmount = await getAddedLPWithGrandTotal( + this.fyo, + this.loyaltyProgram as string, + this.loyaltyPoints as number + ); + + await posting.debit( + loyaltyProgramDoc.expenseAccount as string, + totalAmount + ); + + await posting.credit(this.account!, totalAmount); + } + if (this.taxes) { for (const tax of this.taxes) { if (this.isReturn) { @@ -51,6 +79,50 @@ export class SalesInvoice extends Invoice { return posting; } + validations: ValidationMap = { + loyaltyPoints: async (value: DocValue) => { + if (!this.redeemLoyaltyPoints) { + return; + } + + const partyDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.Party, + this.party + )) as Party; + + if ((value as number) <= 0) { + throw new ValidationError(t`Points must be greather than 0`); + } + + if ((value as number) > (partyDoc?.loyaltyPoints || 0)) { + throw new ValidationError( + t`${this.party as string} only has ${ + partyDoc.loyaltyPoints as number + } points` + ); + } + + const loyaltyProgramDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + this.loyaltyProgram + )) as LoyaltyProgram; + + if (!this?.grandTotal) { + return; + } + + const loyaltyPoint = + ((value as number) || 0) * + ((loyaltyProgramDoc?.conversionFactor as number) || 0); + + if (this.grandTotal?.lt(loyaltyPoint)) { + throw new ValidationError( + t`no need ${value as number} points to purchase this item` + ); + } + }, + }; + static getListViewSettings(): ListViewSettings { return { columns: [ diff --git a/models/baseModels/tests/testLoyaltyProgram.spec.ts b/models/baseModels/tests/testLoyaltyProgram.spec.ts new file mode 100644 index 00000000..c3076fd9 --- /dev/null +++ b/models/baseModels/tests/testLoyaltyProgram.spec.ts @@ -0,0 +1,260 @@ +import test from 'tape'; +import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; +import { ModelNameEnum } from 'models/types'; +import { Party } from '../Party/Party'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { getLoyaltyProgramTier } from 'models/helpers'; +import { CollectionRulesItems } from '../CollectionRulesItems/CollectionRulesItems'; + +const fyo = getTestFyo(); +setupTestFyo(fyo, __filename); + +const accountData = { + name: 'Loyalty Point Redemption', + rootType: 'Liability', + parentAccount: 'Accounts Payable', + isGroup: false, +}; + +const itemData = { + name: 'Pen', + rate: 4000, + for: 'Both', +}; + +const partyData = { + name: 'John Whoe', + email: 'john@whoe.com', +}; + +const loyaltyProgramData = { + name: 'program', + fromDate: new Date(Date.now()), + toDate: new Date(Date.now()), + email: 'sample@gmail.com', + mobile: '1234567890', + expenseAccount: accountData.name, +}; + +const collectionRulesData = [ + { + tierName: 'Silver', + collectionFactor: 0.5, + minimumTotalSpent: 2000, + }, + { tierName: 'Gold', collectionFactor: 0.5, minimumTotalSpent: 3000 }, +]; + +test('create test docs', async (t) => { + await fyo.doc.getNewDoc(ModelNameEnum.Item, itemData).sync(); + + t.ok( + fyo.db.exists(ModelNameEnum.Item, itemData.name), + `dummy item ${itemData.name} exists` + ); + + await fyo.doc.getNewDoc(ModelNameEnum.Party, partyData).sync(); + + t.ok( + fyo.db.exists(ModelNameEnum.Party, partyData.name), + `dummy party ${partyData.name} exists` + ); + + await fyo.doc.getNewDoc(ModelNameEnum.Account, accountData).sync(); + + t.ok( + fyo.db.exists(ModelNameEnum.Account, accountData.name), + `dummy account ${accountData.name} exists` + ); +}); + +test('create a Loyalty Program document', async (t) => { + const loyaltyProgramDoc = fyo.doc.getNewDoc( + ModelNameEnum.LoyaltyProgram, + loyaltyProgramData + ); + + await loyaltyProgramDoc.append('collectionRules', collectionRulesData[0]); + + await loyaltyProgramDoc.append('collectionRules', collectionRulesData[1]); + + await loyaltyProgramDoc.sync(); + + t.ok( + fyo.db.exists(ModelNameEnum.LoyaltyProgram, loyaltyProgramData.name), + `Loyalty Program '${loyaltyProgramData.name}' exists ` + ); + + const partyDoc = (await fyo.doc.getDoc( + ModelNameEnum.Party, + partyData.name + )) as Party; + + await partyDoc.setAndSync('loyaltyProgram', loyaltyProgramData.name); + + t.equals( + partyDoc.loyaltyProgram, + loyaltyProgramData.name, + `Loyalty Program '${loyaltyProgramData.name}' successfully added to Party '${partyData.name}'` + ); +}); + +async function loyaltyPointEntryDoc(sinvName: string) { + const loyaltyPointEntryData = (await fyo.db.getAll( + ModelNameEnum.LoyaltyPointEntry, + { + fields: ['name', 'customer', 'loyaltyPoints', 'loyaltyProgramTier'], + filters: { invoice: sinvName! }, + } + )) as { + name?: string; + customer?: string; + loyaltyPoints?: number; + loyaltyProgramTier?: string; + }[]; + + if (loyaltyPointEntryData) { + return loyaltyPointEntryData[0]; + } +} + +async function createSalesInvoice() { + const sinvDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + account: 'Debtors', + party: partyData.name, + items: [ + { + item: itemData.name, + rate: itemData.rate, + quantity: 1, + }, + ], + }) as SalesInvoice; + + return sinvDoc; +} + +test('create Sales Invoice and verify loyalty points are created correctly', async (t) => { + const sinvDoc = await createSalesInvoice(); + + await sinvDoc.sync(); + await sinvDoc.submit(); + + t.ok( + fyo.db.exists(ModelNameEnum.SalesInvoice, sinvDoc.name), + `Sales Invoice '${sinvDoc.name}' exists` + ); + + t.ok( + fyo.db.exists(ModelNameEnum.SalesInvoice, sinvDoc.loyaltyProgram), + `Loyalty Program '${sinvDoc.loyaltyProgram}' should be linked to Sales Invoice '${sinvDoc.name}'` + ); + + t.equals( + sinvDoc.loyaltyProgram, + loyaltyProgramData.name, + `Loyalty Program '${sinvDoc.loyaltyProgram}' linked to Sales Invoice'` + ); + + const loyaltyPointEntryData = await loyaltyPointEntryDoc( + sinvDoc.name as string + ); + + const loyaltyProgramDoc = (await fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + sinvDoc.loyaltyProgram + )) as Party; + + const selectedTier: CollectionRulesItems | undefined = getLoyaltyProgramTier( + loyaltyProgramDoc, + fyo.pesa(itemData.rate) + ); + + t.equals( + loyaltyPointEntryData?.loyaltyProgramTier, + selectedTier?.tierName, + `tier name ${loyaltyPointEntryData?.loyaltyProgramTier} matches.'` + ); + + const tierData = collectionRulesData.find((rule) => { + return rule.tierName === loyaltyPointEntryData?.loyaltyProgramTier; + }); + + t.equals( + loyaltyPointEntryData?.loyaltyPoints, + itemData.rate * (tierData?.collectionFactor as number), + `calculation of ${loyaltyPointEntryData?.loyaltyPoints} loyalty Point is correct'` + ); +}); + +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 sinvDoc = await createSalesInvoice(); + sinvDoc.date = futureDate; + + await sinvDoc.sync(); + await sinvDoc.submit(); + + t.ok( + fyo.db.exists(ModelNameEnum.SalesInvoice, sinvDoc.name), + `Sales Invoice '${sinvDoc.name}' exists` + ); + + const loyaltyPointEntryData = await loyaltyPointEntryDoc( + sinvDoc.name as string + ); + t.equals( + loyaltyPointEntryData, + undefined, + 'Loyalty points should not be created for a future-dated Sales Invoice' + ); +}); + +test('redeem loyalty points and verify a new loyalty point entry doc is created', async (t) => { + const sinvDoc = await createSalesInvoice(); + + sinvDoc.redeemLoyaltyPoints = true; + sinvDoc.loyaltyPoints = 1000; + + await sinvDoc.sync(); + await sinvDoc.submit(); + + t.ok( + fyo.db.exists(ModelNameEnum.SalesInvoice, sinvDoc.name), + `Sales Invoice '${sinvDoc.name}' exists` + ); + + const loyaltyPointEntryData = await loyaltyPointEntryDoc( + sinvDoc.name as string + ); + + t.ok( + await fyo.db.exists( + ModelNameEnum.LoyaltyPointEntry, + loyaltyPointEntryData?.name + ), + `Negative Loyalty Point Entry '${loyaltyPointEntryData?.name}' should be created against Sales Invoice '${sinvDoc.name}'` + ); + + t.equals( + loyaltyPointEntryData?.loyaltyPoints, + -1000, + `redeemed loyalty point matches the loyalty points used` + ); + + const partyDoc = (await fyo.doc.getDoc( + ModelNameEnum.Party, + partyData.name + )) as Party; + + const totalPoints = await partyDoc._getTotalLoyaltyPoints(); + + t.equals( + totalPoints, + itemData.rate * collectionRulesData[1].collectionFactor + -1000, + `Customer '${partyData.name}' should have a total of ${totalPoints} loyalty points after redemption` + ); +}); + +closeTestFyo(fyo, __filename); diff --git a/models/helpers.ts b/models/helpers.ts index eb634cf2..167096d3 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -23,8 +23,11 @@ import { StockTransfer } from './inventory/StockTransfer'; import { InvoiceStatus, ModelNameEnum } from './types'; import { Lead } from './baseModels/Lead/Lead'; import { PricingRule } from './baseModels/PricingRule/PricingRule'; -import { ValidationError } from 'fyo/utils/errors'; import { ApplicablePricingRules } from './baseModels/Invoice/types'; +import { LoyaltyProgram } from './baseModels/LoyaltyProgram/LoyaltyProgram'; +import { CollectionRulesItems } from './baseModels/CollectionRulesItems/CollectionRulesItems'; +import { isPesa } from 'fyo/utils'; +import { Party } from './baseModels/Party/Party'; export function getQuoteActions( fyo: Fyo, @@ -663,15 +666,139 @@ export async function addItem(name: string, doc: M) { await item.set('item', name); } +export async function createLoyaltyPointEntry(doc: Invoice) { + const loyaltyProgramDoc = (await doc.fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + doc?.loyaltyProgram + )) as LoyaltyProgram; + + if (!loyaltyProgramDoc.isEnabled) { + return; + } + const expiryDate = new Date(Date.now()); + + expiryDate.setDate( + expiryDate.getDate() + (loyaltyProgramDoc.expiryDuration || 0) + ); + + let loyaltyProgramTier; + let loyaltyPoint: number; + + if (doc.redeemLoyaltyPoints) { + loyaltyPoint = -(doc.loyaltyPoints || 0); + } else { + loyaltyProgramTier = getLoyaltyProgramTier( + loyaltyProgramDoc, + doc?.grandTotal as Money + ) as CollectionRulesItems; + + if (!loyaltyProgramTier) { + return; + } + + const collectionFactor = loyaltyProgramTier.collectionFactor as number; + loyaltyPoint = Math.round(doc?.grandTotal?.float || 0) * collectionFactor; + } + + const newLoyaltyPointEntry = doc.fyo.doc.getNewDoc( + ModelNameEnum.LoyaltyPointEntry, + { + loyaltyProgram: doc.loyaltyProgram, + customer: doc.party, + invoice: doc.name, + postingDate: doc.date, + purchaseAmount: doc.grandTotal, + expiryDate: expiryDate, + loyaltyProgramTier: loyaltyProgramTier?.tierName, + loyaltyPoints: loyaltyPoint, + } + ); + + return await newLoyaltyPointEntry.sync(); +} + +export async function getAddedLPWithGrandTotal( + fyo: Fyo, + loyaltyProgram: string, + loyaltyPoints: number +) { + const loyaltyProgramDoc = (await fyo.doc.getDoc( + ModelNameEnum.LoyaltyProgram, + loyaltyProgram + )) as LoyaltyProgram; + + const conversionFactor = loyaltyProgramDoc.conversionFactor as number; + + return fyo.pesa((loyaltyPoints || 0) * conversionFactor); +} + +export function getLoyaltyProgramTier( + loyaltyProgramData: LoyaltyProgram, + grandTotal: Money +): CollectionRulesItems | undefined { + if (!loyaltyProgramData.collectionRules) { + return; + } + + let loyaltyProgramTier: CollectionRulesItems | undefined; + + for (const row of loyaltyProgramData.collectionRules) { + if (isPesa(row.minimumTotalSpent)) { + const minimumSpent = row.minimumTotalSpent; + + if (!minimumSpent.lte(grandTotal)) { + continue; + } + + if ( + !loyaltyProgramTier || + minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money) + ) { + loyaltyProgramTier = row; + } + } + } + return loyaltyProgramTier; +} + +export async function removeLoyaltyPoint(doc: Doc) { + const data = (await doc.fyo.db.getAll(ModelNameEnum.LoyaltyPointEntry, { + fields: ['name', 'loyaltyPoints', 'expiryDate'], + filters: { + loyaltyProgram: doc.loyaltyProgram as string, + invoice: doc.isReturn + ? (doc.returnAgainst as string) + : (doc.name as string), + }, + })) as { name: string; loyaltyPoints: number; expiryDate: Date }[]; + + if (!data.length) { + return; + } + + const loyalityPointEntryDoc = await doc.fyo.doc.getDoc( + ModelNameEnum.LoyaltyPointEntry, + data[0].name + ); + + const party = (await doc.fyo.doc.getDoc( + ModelNameEnum.Party, + doc.party as string + )) as Party; + + await loyalityPointEntryDoc.delete(); + await party.updateLoyaltyPoints(); +} + export async function getPricingRule( doc: Invoice -): Promise { +): Promise { if ( !doc.fyo.singles.AccountingSettings?.enablePricingRule || !doc.isSales || !doc.items ) { - return null; + return; } const pricingRules: ApplicablePricingRules[] = []; @@ -715,10 +842,7 @@ export async function getPricingRule( continue; } - const isPricingRuleHasConflicts = getPricingRulesConflicts( - filtered, - item.item as string - ); + const isPricingRuleHasConflicts = getPricingRulesConflicts(filtered); if (isPricingRuleHasConflicts) { continue; @@ -729,7 +853,6 @@ export async function getPricingRule( pricingRule: filtered[0], }); } - return pricingRules; } @@ -802,11 +925,9 @@ export function canApplyPricingRule( } return true; } - export function getPricingRulesConflicts( - pricingRules: PricingRule[], - item: string -): string[] | undefined { + pricingRules: PricingRule[] +): undefined | boolean { const pricingRuleDocs = Array.from(pricingRules); const firstPricingRule = pricingRuleDocs.shift(); @@ -827,13 +948,7 @@ export function getPricingRulesConflicts( return; } - throw new ValidationError( - t`Pricing Rules ${ - firstPricingRule.name as string - }, ${conflictingPricingRuleNames.join( - ', ' - )} has the same Priority for the Item ${item}.` - ); + return true; } export function roundFreeItemQty( diff --git a/models/index.ts b/models/index.ts index dd940dc9..ace9901e 100644 --- a/models/index.ts +++ b/models/index.ts @@ -9,6 +9,9 @@ import { JournalEntry } from './baseModels/JournalEntry/JournalEntry'; import { JournalEntryAccount } from './baseModels/JournalEntryAccount/JournalEntryAccount'; import { Misc } from './baseModels/Misc'; import { Party } from './baseModels/Party/Party'; +import { LoyaltyProgram } from './baseModels/LoyaltyProgram/LoyaltyProgram'; +import { LoyaltyPointEntry } from './baseModels/LoyaltyPointEntry/LoyaltyPointEntry'; +import { CollectionRulesItems } from './baseModels/CollectionRulesItems/CollectionRulesItems'; import { Lead } from './baseModels/Lead/Lead'; import { Payment } from './baseModels/Payment/Payment'; import { PaymentFor } from './baseModels/PaymentFor/PaymentFor'; @@ -58,6 +61,9 @@ export const models = { Misc, Lead, Party, + LoyaltyProgram, + LoyaltyPointEntry, + CollectionRulesItems, Payment, PaymentFor, PrintSettings, diff --git a/models/regionalModels/in/Party.ts b/models/regionalModels/in/Party.ts index cd560cd5..0d64a9d0 100644 --- a/models/regionalModels/in/Party.ts +++ b/models/regionalModels/in/Party.ts @@ -1,11 +1,13 @@ import { HiddenMap } from 'fyo/model/types'; import { Party as BaseParty } from 'models/baseModels/Party/Party'; import { GSTType } from './types'; +import { PartyRole } from 'models/baseModels/Party/types'; export class Party extends BaseParty { gstin?: string; - fromLead?: string; + role?: PartyRole; gstType?: GSTType; + loyaltyProgram?: string; // eslint-disable-next-line @typescript-eslint/require-await async beforeSync() { @@ -19,6 +21,12 @@ export class Party extends BaseParty { hidden: HiddenMap = { gstin: () => (this.gstType as GSTType) !== 'Registered Regular', - fromLead: () => !this.fromLead, + loyaltyProgram: () => { + if (!this.fyo.singles.AccountingSettings?.enableLoyaltyProgram) { + return true; + } + return this.role === 'Supplier'; + }, + loyaltyPoints: () => !this.loyaltyProgram || this.role === 'Supplier', }; } diff --git a/models/types.ts b/models/types.ts index 04ba0c73..9b127d90 100644 --- a/models/types.ts +++ b/models/types.ts @@ -19,6 +19,9 @@ export enum ModelNameEnum { NumberSeries = 'NumberSeries', Lead = 'Lead', Party = 'Party', + LoyaltyProgram = 'LoyaltyProgram', + LoyaltyPointEntry = 'LoyaltyPointEntry', + CollectionRulesItems = 'CollectionRulesItems', Payment = 'Payment', PaymentFor = 'PaymentFor', PriceList = 'PriceList', diff --git a/schemas/app/AccountingSettings.json b/schemas/app/AccountingSettings.json index eda1a88b..80fd8312 100644 --- a/schemas/app/AccountingSettings.json +++ b/schemas/app/AccountingSettings.json @@ -114,6 +114,13 @@ "default": false, "section": "Features" }, + { + "fieldname": "enableLoyaltyProgram", + "label": "Enable Loyalty Program", + "fieldtype": "Check", + "default": false, + "section": "Features" + }, { "fieldname": "fiscalYearStart", "label": "Fiscal Year Start Date", diff --git a/schemas/app/CollectionRulesItems.json b/schemas/app/CollectionRulesItems.json new file mode 100644 index 00000000..6ae4c5c9 --- /dev/null +++ b/schemas/app/CollectionRulesItems.json @@ -0,0 +1,23 @@ +{ + "name": "CollectionRulesItems", + "label": "Collection Rules", + "isChild": true, + "fields": [ + { + "fieldname": "tierName", + "label": "Tier Name", + "fieldtype": "Data" + }, + { + "fieldname": "collectionFactor", + "label": "Collection Factor (=1 LP)", + "fieldtype": "Float" + }, + { + "fieldname": "minimumTotalSpent", + "label": "Minimum Total Spent", + "fieldtype": "Currency" + } + ], + "tableFields": ["tierName", "collectionFactor", "minimumTotalSpent"] +} diff --git a/schemas/app/LoyaltyPointEntry.json b/schemas/app/LoyaltyPointEntry.json new file mode 100644 index 00000000..e2ac634f --- /dev/null +++ b/schemas/app/LoyaltyPointEntry.json @@ -0,0 +1,70 @@ +{ + "name": "LoyaltyPointEntry", + "label": "Loyalty Point Entry", + "create": false, + "naming": "random", + "fields": [ + { + "label": "Entry No.", + "fieldname": "name", + "fieldtype": "Data", + "required": true, + "readOnly": true, + "section": "Default" + }, + { + "fieldname": "loyaltyProgram", + "readOnly": true, + "label": "Loyalty Program", + "fieldtype": "Data", + "required": true + }, + { + "fieldname": "loyaltyProgramTier", + "readOnly": true, + "label": "Loyalty Program Tier", + "fieldtype": "Data" + }, + { + "fieldname": "customer", + "readOnly": true, + "label": "Customer", + "fieldtype": "Link", + "target": "Party", + "required": true + }, + { + "fieldname": "invoice", + "readOnly": true, + "label": "Invoice", + "fieldtype": "Link", + "target": "SalesInvoice", + "required": true + }, + { + "fieldname": "loyaltyPoints", + "readOnly": true, + "label": "Loyalty Points", + "fieldtype": "Int" + }, + { + "fieldname": "purchaseAmount", + "readOnly": true, + "label": "Purchase Amount", + "fieldtype": "Currency", + "required": true + }, + { + "fieldname": "expiryDate", + "readOnly": true, + "label": "Expiry Date", + "fieldtype": "Date" + }, + { + "fieldname": "postingDate", + "readOnly": true, + "label": "Posting Date", + "fieldtype": "Date" + } + ] +} diff --git a/schemas/app/LoyaltyProgram.json b/schemas/app/LoyaltyProgram.json new file mode 100644 index 00000000..a53e5eca --- /dev/null +++ b/schemas/app/LoyaltyProgram.json @@ -0,0 +1,72 @@ +{ + "name": "LoyaltyProgram", + "label": "Loyalty Program", + "naming": "manual", + + "fields": [ + { + "fieldname": "name", + "label": "Name", + "fieldtype": "Data", + "required": true, + "placeholder": "Name", + "section": "Default" + }, + { + "fieldname": "fromDate", + "label": "From Date", + "fieldtype": "Date", + "required": true + }, + { + "fieldname": "toDate", + "label": "To Date", + "fieldtype": "Date", + "required": true + }, + { + "fieldname": "isEnabled", + "label": "Is Enabled", + "fieldtype": "Check", + "default": true, + "required": true + }, + { + "fieldname": "collectionRules", + "label": "Collection Rules", + "fieldtype": "Table", + "target": "CollectionRulesItems", + "required": false + }, + { + "fieldname": "conversionFactor", + "label": "Conversion Factor", + "fieldtype": "Float", + "default": 1, + "required": true + }, + { + "fieldname": "expiryDuration", + "label": "Expiry Duration", + "fieldtype": "Int", + "default": 1, + "required": true + }, + { + "fieldname": "expenseAccount", + "label": "Expense Account", + "fieldtype": "Link", + "target": "Account", + "required": true + } + ], + "quickEditFields": [ + "name", + "fromDate", + "toDate", + "conversionFactor", + "expenseAccount", + "expiryDuration" + ], + "keywordFields": ["name"] +} diff --git a/schemas/app/Party.json b/schemas/app/Party.json index f6ccf25b..6dca775b 100644 --- a/schemas/app/Party.json +++ b/schemas/app/Party.json @@ -86,6 +86,23 @@ "readOnly": true, "section": "References" }, + + { + "fieldname": "loyaltyProgram", + "label": "Loyalty Program", + "fieldtype": "Link", + "target": "LoyaltyProgram", + "create": true, + "section": "Loyalty Program" + }, + { + "fieldname": "loyaltyPoints", + "label": "Loyalty Points", + "fieldtype": "Int", + "readOnly": true, + "default": 0, + "section": "Loyalty Program" + }, { "fieldname": "taxId", "label": "Tax ID", @@ -104,6 +121,7 @@ "phone", "address", "defaultAccount", + "loyaltyProgram", "currency", "role", "taxId" diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json index 9b8de0fc..b196f2b8 100644 --- a/schemas/app/SalesInvoice.json +++ b/schemas/app/SalesInvoice.json @@ -70,6 +70,27 @@ "label": "Return Against", "section": "References" }, + { + "fieldname": "loyaltyProgram", + "fieldtype": "Link", + "target": "LoyaltyProgram", + "label": "Loyalty Program", + "section": "References", + "readOnly": true + }, + { + "fieldname": "redeemLoyaltyPoints", + "fieldtype": "Check", + "default": false, + "label": "Redeem Loyalty Points", + "section": "Loyalty Points Redemption" + }, + { + "fieldname": "loyaltyPoints", + "fieldtype": "Int", + "label": "Loyalty Points", + "section": "Loyalty Points Redemption" + }, { "fieldname": "isPOS", "fieldtype": "Check", diff --git a/schemas/app/SalesInvoiceItem.json b/schemas/app/SalesInvoiceItem.json index 682f6957..fb524586 100644 --- a/schemas/app/SalesInvoiceItem.json +++ b/schemas/app/SalesInvoiceItem.json @@ -8,6 +8,11 @@ "fieldtype": "Check", "default": false, "hidden": true + }, + { + "fieldname": "pricingRule", + "fieldtype": "Data", + "hidden": true } ] } diff --git a/schemas/core/SystemSettings.json b/schemas/core/SystemSettings.json index e5570c90..39a37601 100644 --- a/schemas/core/SystemSettings.json +++ b/schemas/core/SystemSettings.json @@ -117,6 +117,14 @@ "fieldtype": "Data", "readOnly": true, "hidden": true + }, + { + "fieldname": "darkMode", + "label": "Dark mode", + "fieldtype": "Check", + "default": false, + "description": "Sets the theme of the app.", + "section": "Theme" } ], "quickEditFields": [ diff --git a/schemas/schemas.ts b/schemas/schemas.ts index 822e1372..aef9ff6d 100644 --- a/schemas/schemas.ts +++ b/schemas/schemas.ts @@ -16,6 +16,9 @@ import Misc from './app/Misc.json'; import NumberSeries from './app/NumberSeries.json'; import Party from './app/Party.json'; import Lead from './app/Lead.json'; +import LoyaltyProgram from './app/LoyaltyProgram.json'; +import LoyaltyPointEntry from './app/LoyaltyPointEntry.json'; +import CollectionRulesItems from './app/CollectionRulesItems.json'; import Payment from './app/Payment.json'; import PaymentFor from './app/PaymentFor.json'; import PriceList from './app/PriceList.json'; @@ -106,6 +109,10 @@ export const appSchemas: Schema[] | SchemaStub[] = [ UOM as Schema, UOMConversionItem as Schema, + LoyaltyProgram as Schema, + LoyaltyPointEntry as Schema, + CollectionRulesItems as Schema, + Payment as Schema, PaymentFor as Schema, diff --git a/src/App.vue b/src/App.vue index e9360b13..01e5e11b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,14 @@