diff --git a/models/baseModels/AccountingSettings/AccountingSettings.ts b/models/baseModels/AccountingSettings/AccountingSettings.ts index a600f35b1..86ce363cf 100644 --- a/models/baseModels/AccountingSettings/AccountingSettings.ts +++ b/models/baseModels/AccountingSettings/AccountingSettings.ts @@ -16,6 +16,7 @@ export class AccountingSettings extends Doc { enableInventory?: boolean; enablePriceList?: boolean; enableLead?: boolean; + enableCouponCode?: boolean; enableFormCustomization?: boolean; enableInvoiceReturns?: boolean; enableLoyaltyProgram?: boolean; @@ -67,6 +68,8 @@ export class AccountingSettings extends Doc { gstin: () => this.fyo.singles.SystemSettings?.countryCode !== 'in', enablePricingRule: () => !this.fyo.singles.AccountingSettings?.enableDiscounting, + enableCouponCode: () => + !this.fyo.singles.AccountingSettings?.enablePricingRule, }; async change(ch: ChangeArg) { diff --git a/models/baseModels/AppliedCouponCodes/AppliedCouponCodes.ts b/models/baseModels/AppliedCouponCodes/AppliedCouponCodes.ts new file mode 100644 index 000000000..f7158c1ba --- /dev/null +++ b/models/baseModels/AppliedCouponCodes/AppliedCouponCodes.ts @@ -0,0 +1,107 @@ +import { DocValue } from 'fyo/core/types'; +import { ValidationMap } from 'fyo/model/types'; +import { ValidationError } from 'fyo/utils/errors'; +import { ModelNameEnum } from 'models/types'; +import { Money } from 'pesa'; +import { InvoiceItem } from '../InvoiceItem/InvoiceItem'; +import { getApplicableCouponCodesName } from 'models/helpers'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; + +export class AppliedCouponCodes extends InvoiceItem { + coupons?: string; + + validations: ValidationMap = { + coupons: async (value: DocValue) => { + if (!value) { + return; + } + + const coupon = await this.fyo.db.getAll(ModelNameEnum.CouponCode, { + fields: [ + 'minAmount', + 'maxAmount', + 'pricingRule', + 'validFrom', + 'validTo', + 'maximumUse', + 'used', + 'isEnabled', + ], + filters: { name: value as string }, + }); + + if (!coupon[0].isEnabled) { + throw new ValidationError( + 'Coupon code cannot be applied as it is not enabled' + ); + } + + if ((coupon[0]?.maximumUse as number) <= (coupon[0]?.used as number)) { + throw new ValidationError( + 'Coupon code has been used maximum number of times' + ); + } + + const applicableCouponCodesNames = await getApplicableCouponCodesName( + value as string, + this.parentdoc as SalesInvoice + ); + + if (!applicableCouponCodesNames?.length) { + throw new ValidationError( + this.fyo.t`Coupon ${ + value as string + } is not applicable for applied items.` + ); + } + + const couponExist = this.parentdoc?.coupons?.some( + (coupon) => coupon?.coupons === value + ); + + if (couponExist) { + throw new ValidationError( + this.fyo.t`${value as string} already applied.` + ); + } + + if ( + (coupon[0].minAmount as Money).gte( + this.parentdoc?.grandTotal as Money + ) && + !(coupon[0].minAmount as Money).isZero() + ) { + throw new ValidationError( + this.fyo.t`The Grand Total must exceed ${ + (coupon[0].minAmount as Money).float + } to apply the coupon ${value as string}.` + ); + } + + if ( + (coupon[0].maxAmount as Money).lte( + this.parentdoc?.grandTotal as Money + ) && + !(coupon[0].maxAmount as Money).isZero() + ) { + throw new ValidationError( + this.fyo.t`The Grand Total must be less than ${ + (coupon[0].maxAmount as Money).float + } to apply this coupon.` + ); + } + + if ((coupon[0].validFrom as Date) > (this.parentdoc?.date as Date)) { + throw new ValidationError( + this.fyo.t`Valid From Date should be less than Valid To Date.` + ); + } + + if ((coupon[0].validTo as Date) < (this.parentdoc?.date as Date)) { + throw new ValidationError( + this.fyo.t`Valid To Date should be greater than Valid From Date.` + ); + } + }, + }; +} diff --git a/models/baseModels/CouponCode/CouponCode.ts b/models/baseModels/CouponCode/CouponCode.ts new file mode 100644 index 000000000..7f26efdc2 --- /dev/null +++ b/models/baseModels/CouponCode/CouponCode.ts @@ -0,0 +1,192 @@ +import { DocValue } from 'fyo/core/types'; +import { Doc } from 'fyo/model/doc'; +import { + FiltersMap, + FormulaMap, + ListViewSettings, + ValidationMap, +} from 'fyo/model/types'; +import { ValidationError } from 'fyo/utils/errors'; +import { t } from 'fyo'; +import { Money } from 'pesa'; +import { ModelNameEnum } from 'models/types'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { ApplicableCouponCodes } from '../Invoice/types'; + +export class CouponCode extends Doc { + name?: string; + couponName?: string; + pricingRule?: string; + + validFrom?: Date; + validTo?: Date; + + minAmount?: Money; + maxAmount?: Money; + + removeUnusedCoupons(coupons: ApplicableCouponCodes[], sinvDoc: SalesInvoice) { + if (!coupons.length) { + sinvDoc.coupons = []; + + return; + } + + sinvDoc.coupons = sinvDoc.coupons!.filter((coupon) => { + return coupons.find((c: ApplicableCouponCodes) => + coupon?.coupons?.includes(c?.coupon) + ); + }); + } + + formulas: FormulaMap = { + name: { + formula: () => { + return this.couponName?.replace(/\s+/g, '').toUpperCase().slice(0, 8); + }, + dependsOn: ['couponName'], + }, + }; + + async pricingRuleData() { + return await this.fyo.db.getAll(ModelNameEnum.PricingRule, { + fields: ['minAmount', 'maxAmount', 'validFrom', 'validTo'], + filters: { + name: this.pricingRule as string, + }, + }); + } + + validations: ValidationMap = { + minAmount: async (value: DocValue) => { + if (!value || !this.maxAmount || !this.pricingRule) { + return; + } + + const [pricingRuleData] = await this.pricingRuleData(); + + if ( + (pricingRuleData?.minAmount as Money).isZero() && + (pricingRuleData.maxAmount as Money).isZero() + ) { + return; + } + + const { minAmount } = pricingRuleData; + + if ((value as Money).isZero() && this.maxAmount.isZero()) { + return; + } + + if ((value as Money).lt(minAmount as Money)) { + throw new ValidationError( + t`Minimum Amount should be greather than the Pricing Rule's Minimum Amount.` + ); + } + + if ((value as Money).gte(this.maxAmount)) { + throw new ValidationError( + t`Minimum Amount should be less than the Maximum Amount.` + ); + } + }, + maxAmount: async (value: DocValue) => { + if (!this.minAmount || !value || !this.pricingRule) { + return; + } + + const [pricingRuleData] = await this.pricingRuleData(); + + if ( + (pricingRuleData?.minAmount as Money).isZero() && + (pricingRuleData.maxAmount as Money).isZero() + ) { + return; + } + + const { maxAmount } = pricingRuleData; + + if (this.minAmount.isZero() && (value as Money).isZero()) { + return; + } + + if ((value as Money).gt(maxAmount as Money)) { + throw new ValidationError( + t`Maximum Amount should be lesser than Pricing Rule's Maximum Amount` + ); + } + + if ((value as Money).lte(this.minAmount)) { + throw new ValidationError( + t`Maximum Amount should be greater than the Minimum Amount.` + ); + } + }, + validFrom: async (value: DocValue) => { + if (!value || !this.validTo || !this.pricingRule) { + return; + } + + const [pricingRuleData] = await this.pricingRuleData(); + + if (!pricingRuleData?.validFrom && !pricingRuleData.validTo) { + return; + } + + const { validFrom } = pricingRuleData; + if ( + validFrom && + (value as Date).toISOString() < (validFrom as Date).toISOString() + ) { + throw new ValidationError( + t`Valid From Date should be greather than Pricing Rule's Valid From Date.` + ); + } + + if ((value as Date).toISOString() >= this.validTo.toISOString()) { + throw new ValidationError( + t`Valid From Date should be less than Valid To Date.` + ); + } + }, + validTo: async (value: DocValue) => { + if (!this.validFrom || !value || !this.pricingRule) { + return; + } + + const [pricingRuleData] = await this.pricingRuleData(); + + if (!pricingRuleData?.validFrom && !pricingRuleData.validTo) { + return; + } + + const { validTo } = pricingRuleData; + + if ( + validTo && + (value as Date).toISOString() > (validTo as Date).toISOString() + ) { + throw new ValidationError( + t`Valid To Date should be lesser than Pricing Rule's Valid To Date.` + ); + } + + if ((value as Date).toISOString() <= this.validFrom.toISOString()) { + throw new ValidationError( + t`Valid To Date should be greater than Valid From Date.` + ); + } + }, + }; + + static filters: FiltersMap = { + pricingRule: () => ({ + isCouponCodeBased: true, + }), + }; + + static getListViewSettings(): ListViewSettings { + return { + columns: ['name', 'couponName', 'pricingRule', 'maximumUse', 'used'], + }; + } +} diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index 6cccedef4..28cb2e2f8 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -13,10 +13,12 @@ import { ValidationError } from 'fyo/utils/errors'; import { Transactional } from 'models/Transactional/Transactional'; import { addItem, + canApplyCouponCode, canApplyPricingRule, createLoyaltyPointEntry, filterPricingRules, getAddedLPWithGrandTotal, + getApplicableCouponCodesName, getExchangeRate, getNumberSeries, getPricingRulesConflicts, @@ -39,9 +41,14 @@ import { TaxSummary } from '../TaxSummary/TaxSummary'; import { ReturnDocItem } from 'models/inventory/types'; import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types'; import { PricingRule } from '../PricingRule/PricingRule'; -import { ApplicablePricingRules } from './types'; +import { ApplicableCouponCodes, ApplicablePricingRules } from './types'; import { PricingRuleDetail } from '../PricingRuleDetail/PricingRuleDetail'; import { LoyaltyProgram } from '../LoyaltyProgram/LoyaltyProgram'; +import { AppliedCouponCodes } from '../AppliedCouponCodes/AppliedCouponCodes'; +import { CouponCode } from '../CouponCode/CouponCode'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { SalesInvoiceItem } from '../SalesInvoiceItem/SalesInvoiceItem'; +import { PriceListItem } from '../PriceList/PriceListItem'; export type TaxDetail = { account: string; @@ -61,6 +68,7 @@ export abstract class Invoice extends Transactional { taxes?: TaxSummary[]; items?: InvoiceItem[]; + coupons?: AppliedCouponCodes[]; party?: string; account?: string; currency?: string; @@ -187,6 +195,9 @@ export abstract class Invoice extends Transactional { if (this.isReturn) { await this._removeLoyaltyPointEntry(); + this.reduceUsedCountOfCoupons(); + + return; } if (this.isQuote) { @@ -228,6 +239,10 @@ export abstract class Invoice extends Transactional { await this._updateIsItemsReturned(); await this._createLoyaltyPointEntry(); + + if (this.schemaName === ModelNameEnum.SalesInvoice) { + this.updateUsedCountOfCoupons(); + } } async afterCancel() { @@ -236,12 +251,10 @@ export abstract class Invoice extends Transactional { await this._updatePartyOutStanding(); await this._updateIsItemsReturned(); await this._removeLoyaltyPointEntry(); + this.reduceUsedCountOfCoupons(); } async _removeLoyaltyPointEntry() { - if (!this.loyaltyProgram) { - return; - } await removeLoyaltyPoint(this); } @@ -545,6 +558,31 @@ export abstract class Invoice extends Transactional { return newReturnDoc; } + updateUsedCountOfCoupons() { + this.coupons?.map(async (coupon) => { + const couponDoc = await this.fyo.doc.getDoc( + ModelNameEnum.CouponCode, + coupon.coupons + ); + + await couponDoc.setAndSync({ used: (couponDoc.used as number) + 1 }); + }); + } + reduceUsedCountOfCoupons() { + if (!this.coupons?.length) { + return; + } + + this.coupons?.map(async (coupon) => { + const couponDoc = await this.fyo.doc.getDoc( + ModelNameEnum.CouponCode, + coupon.coupons + ); + + await couponDoc.setAndSync({ used: (couponDoc.used as number) - 1 }); + }); + } + async _updateIsItemsReturned() { if (!this.isReturn || !this.returnAgainst || this.isQuote) { return; @@ -727,7 +765,7 @@ export abstract class Invoice extends Transactional { await this.appendPricingRuleDetail(pricingRule); return !!pricingRule; }, - dependsOn: ['items'], + dependsOn: ['items', 'coupons'], }, }; @@ -1035,6 +1073,31 @@ export abstract class Invoice extends Transactional { } else { this.clearFreeItems(); } + + if (!this.coupons?.length) { + return; + } + + const applicableCouponCodes = await Promise.all( + this.coupons?.map(async (coupon) => { + return await getApplicableCouponCodesName( + coupon.coupons as string, + this as SalesInvoice + ); + }) + ); + + const flattedApplicableCouponCodes = applicableCouponCodes?.flat(); + + const couponCodeDoc = (await this.fyo.doc.getDoc( + ModelNameEnum.CouponCode, + this.coupons[0].coupons + )) as CouponCode; + + couponCodeDoc.removeUnusedCoupons( + flattedApplicableCouponCodes as ApplicableCouponCodes[], + this as SalesInvoice + ); } async beforeCancel(): Promise { @@ -1259,6 +1322,21 @@ export abstract class Invoice extends Transactional { } } + async getPricingRuleDocNames( + item: SalesInvoiceItem, + sinvDoc: SalesInvoice + ): Promise { + const docs = (await sinvDoc.fyo.db.getAll(ModelNameEnum.PricingRuleItem, { + fields: ['parent'], + filters: { + item: item.item as string, + unit: item.unit as string, + }, + })) as PriceListItem[]; + + return docs.map((doc) => doc.parent) as string[]; + } + async getPricingRule(): Promise { if (!this.isSales || !this.items) { return; @@ -1288,15 +1366,49 @@ export abstract class Invoice extends Transactional { continue; } - const pricingRuleDocNames = ( - await this.fyo.db.getAll(ModelNameEnum.PricingRuleItem, { - fields: ['parent'], - filters: { - item: item.item as string, - unit: item.unit as string, - }, - }) - ).map((doc) => doc.parent) as string[]; + const pricingRuleDocNames = await this.getPricingRuleDocNames( + item, + this as SalesInvoice + ); + + if (!pricingRuleDocNames.length) { + continue; + } + + if (this.coupons?.length) { + for (const coupon of this.coupons) { + const couponCodeDatas = await this.fyo.db.getAll( + ModelNameEnum.CouponCode, + { + fields: ['*'], + filters: { + name: coupon?.coupons as string, + isEnabled: true, + }, + } + ); + + const couponPricingRuleDocNames = couponCodeDatas + .map((doc) => doc.pricingRule) + .filter((val) => + pricingRuleDocNames.includes(val as string) + ) as string[]; + + if (!couponPricingRuleDocNames.length) { + continue; + } + + const filtered = canApplyCouponCode( + couponCodeDatas[0] as CouponCode, + this.grandTotal as Money, + this.date as Date + ); + + if (filtered) { + pricingRuleDocNames.push(...couponPricingRuleDocNames); + } + } + } const pricingRuleDocsForItem = (await this.fyo.db.getAll( ModelNameEnum.PricingRule, @@ -1311,6 +1423,50 @@ export abstract class Invoice extends Transactional { } )) as PricingRule[]; + if (pricingRuleDocsForItem[0].isCouponCodeBased) { + if (!this.coupons?.length) { + continue; + } + + const data = await Promise.allSettled( + this.coupons?.map(async (val) => { + if (!val.coupons) { + return false; + } + + const [pricingRule] = ( + await this.fyo.db.getAll(ModelNameEnum.CouponCode, { + fields: ['pricingRule'], + filters: { + name: val?.coupons, + }, + }) + ).map((doc) => doc.pricingRule); + + if (!pricingRule) { + return false; + } + + if (pricingRuleDocsForItem[0].name === pricingRule) { + return pricingRule; + } + + return false; + }) + ); + + const fulfilledData = data + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + .map((result) => result.value as string); + + if (!fulfilledData[0] && !fulfilledData.filter((val) => val).length) { + continue; + } + } + const filtered = filterPricingRules( pricingRuleDocsForItem, this.date as Date, diff --git a/models/baseModels/Invoice/types.ts b/models/baseModels/Invoice/types.ts index 50362b213..80e1d8f89 100644 --- a/models/baseModels/Invoice/types.ts +++ b/models/baseModels/Invoice/types.ts @@ -4,3 +4,8 @@ export interface ApplicablePricingRules { applyOnItem: string; pricingRule: PricingRule; } + +export interface ApplicableCouponCodes { + pricingRule: string; + coupon: string; +} diff --git a/models/baseModels/PricingRule/PricingRule.ts b/models/baseModels/PricingRule/PricingRule.ts index 31888d6f7..88b6f207f 100644 --- a/models/baseModels/PricingRule/PricingRule.ts +++ b/models/baseModels/PricingRule/PricingRule.ts @@ -23,6 +23,8 @@ export class PricingRule extends Doc { discountPercentage?: number; discountAmount?: Money; + isCouponCodeBased?: boolean; + forPriceList?: string; freeItem?: string; diff --git a/models/baseModels/tests/testCouponCodes.spec.ts b/models/baseModels/tests/testCouponCodes.spec.ts new file mode 100644 index 000000000..a7d26956a --- /dev/null +++ b/models/baseModels/tests/testCouponCodes.spec.ts @@ -0,0 +1,298 @@ +import test from 'tape'; +import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; +import { ModelNameEnum } from 'models/types'; +import { getItem } from 'models/inventory/tests/helpers'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { PricingRule } from '../PricingRule/PricingRule'; +import { assertThrows } from 'backend/database/tests/helpers'; + +const fyo = getTestFyo(); +setupTestFyo(fyo, __filename); + +const itemMap = { + Jacket: { + name: 'Jacket', + rate: 1000, + unit: 'Unit', + }, + Cap: { + name: 'Cap', + rate: 100, + unit: 'Unit', + }, +}; + +const partyMap = { + partyOne: { + name: 'Daisy', + email: 'daisy@alien.com', + account: 'Debtors', + }, +}; + +const pricingRuleMap = [ + { + name: 'PRLE-1001', + title: 'JKT PDR Offer', + isCouponCodeBased: true, + appliedItems: [{ item: itemMap.Jacket.name }], + discountType: 'Price Discount', + priceDiscountType: 'rate', + discountRate: 800, + minQuantity: 4, + maxQuantity: 6, + minAmount: fyo.pesa(4000), + maxAmount: fyo.pesa(6000), + validFrom: '2024-02-01', + validTo: '2024-02-29', + priority: '1', + }, + { + name: 'PRLE-1002', + title: 'CAP PDR Offer', + appliedItems: [{ item: itemMap.Cap.name }], + discountType: 'Product Discount', + freeItem: 'Cap', + freeItemQuantity: 1, + freeItemUnit: 'Unit', + freeItemRate: 0, + minQuantity: 4, + maxQuantity: 6, + minAmount: 200, + maxAmount: 1000, + validFrom: '2024-02-01', + validTo: '2024-02-29', + priority: '1', + }, +]; +const couponCodesMap = [ + { + name: 'COUPON1', + isEnabled: true, + couponName: 'coupon1', + pricingRule: pricingRuleMap[0].name, + maximumUse: 5, + used: 0, + minAmount: fyo.pesa(4000), + maxAmount: fyo.pesa(6000), + validFrom: '2024-02-01', + validTo: '2024-02-29', + }, + { + name: 'COUPON2', + couponName: 'coupon2', + pricingRule: pricingRuleMap[1].name, + maximumUse: 1, + used: 0, + minAmount: 200, + maxAmount: 1000, + validFrom: '2024-02-01', + validTo: '2024-02-29', + }, +]; + +test(' Coupon Codes: create dummy item, party, pricing rules, coupon codes', async (t) => { + // Create Items + for (const { name, rate } of Object.values(itemMap)) { + const item = getItem(name, rate, false); + + await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync(); + + t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `Item: ${name} exists`); + } + + // Create Party + await fyo.doc.getNewDoc(ModelNameEnum.Party, partyMap.partyOne).sync(); + + t.ok( + await fyo.db.exists(ModelNameEnum.Party, partyMap.partyOne.name), + `Party: ${partyMap.partyOne.name} exists` + ); + + // Create Pricing Rules + for (const pricingRule of Object.values(pricingRuleMap)) { + await fyo.doc.getNewDoc(ModelNameEnum.PricingRule, pricingRule).sync(); + + t.ok( + await fyo.db.exists(ModelNameEnum.PricingRule, pricingRule.name), + `Pricing Rule: ${pricingRule.name} exists` + ); + } + + await fyo.singles.AccountingSettings?.set('enablePricingRule', true); + + t.ok(fyo.singles.AccountingSettings?.enablePricingRule); + + // Create Coupon Codes + for (const couponCodes of Object.values(couponCodesMap)) { + await fyo.doc.getNewDoc(ModelNameEnum.CouponCode, couponCodes).sync(); + + t.ok( + await fyo.db.exists(ModelNameEnum.CouponCode, couponCodes.name), + `Coupoon Code: ${couponCodes.name} exists` + ); + } + + await fyo.singles.AccountingSettings?.set('enableCouponCode', true); + + t.ok(fyo.singles.AccountingSettings?.enableCouponCode); +}); + +test('disabled coupon codes is not applied', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-01-20T18:30:00.000Z', + party: partyMap.partyOne.name, + account: partyMap.partyOne.account, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + + await sinv.append('coupons', { + coupons: couponCodesMap[0].name, + }); + + await sinv.runFormulas(); + + t.equal(sinv.pricingRuleDetail?.length, undefined); +}); + +test('Coupon code not created: coupons min amount must be lesser than coupons max.', async (t) => { + couponCodesMap[0].minAmount = fyo.pesa(7000); + + const ccodeDoc = fyo.doc.getNewDoc( + ModelNameEnum.CouponCode, + couponCodesMap[0] + ) as PricingRule; + + await assertThrows( + async () => await ccodeDoc.sync(), + 'Minimum Amount should be less than the Maximum Amount' + ); +}); + +test('Coupon code not created: pricing rules max amount is lower than the coupons min.', async (t) => { + couponCodesMap[0].minAmount = fyo.pesa(3000); + + const ccodeDoc = fyo.doc.getNewDoc( + ModelNameEnum.CouponCode, + couponCodesMap[0] + ) as PricingRule; + + await assertThrows( + async () => await ccodeDoc.sync(), + "Minimum Amount should be greather than the Pricing Rule's Minimum Amount" + ); +}); + +test('coupon code not created: pricing rules max amount is lower than the coupons max.', async (t) => { + couponCodesMap[0].maxAmount = fyo.pesa(7000); + + const ccodeDoc = fyo.doc.getNewDoc( + ModelNameEnum.CouponCode, + couponCodesMap[0] + ) as PricingRule; + + await assertThrows( + async () => await ccodeDoc.sync(), + "Maximum Amount should be lesser than Pricing Rule's Maximum Amount" + ); +}); + +test("coupon code is not applied when coupon's validfrom date < coupon's validTo date", async (t) => { + couponCodesMap[0].minAmount = fyo.pesa(4000); + couponCodesMap[0].maxAmount = fyo.pesa(6000); + couponCodesMap[0].validTo = '2024-01-10'; + + const ccodeDoc = fyo.doc.getNewDoc( + ModelNameEnum.CouponCode, + couponCodesMap[0] + ) as PricingRule; + + await assertThrows( + async () => await ccodeDoc.sync(), + 'Valid From Date should be less than Valid To Date' + ); +}); + +test("coupon code is not applied when coupon's validFrom date < pricing rule's validFrom date", async (t) => { + couponCodesMap[0].validFrom = '2024-01-01'; + + const ccodeDoc = fyo.doc.getNewDoc( + ModelNameEnum.CouponCode, + couponCodesMap[0] + ) as PricingRule; + + await assertThrows( + async () => await ccodeDoc.sync(), + "Valid From Date should be greather than Pricing Rule's Valid From Date" + ); +}); + +test("coupon code is not applied when coupon's validTo date > pricing rule's validTo date", async (t) => { + couponCodesMap[0].validFrom = '2024-02-01'; + couponCodesMap[0].validTo = '2024-03-01'; + + const ccodeDoc = fyo.doc.getNewDoc( + ModelNameEnum.CouponCode, + couponCodesMap[0] + ) as PricingRule; + + await assertThrows( + async () => await ccodeDoc.sync(), + "Valid To Date should be lesser than Pricing Rule's Valid To Date" + ); +}); + +test('apply coupon code', async (t) => { + couponCodesMap[0].name = 'COUPON1'; + couponCodesMap[0].validTo = '2024-02-29'; + + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-10', + party: partyMap.partyOne.name, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Jacket.name, + quantity: 5, + rate: itemMap.Jacket.rate, + }); + + await sinv.append('coupons', { coupons: couponCodesMap[0].name }); + await sinv.runFormulas(); + + t.equal(sinv.pricingRuleDetail?.length, 1); + + t.equal( + sinv.pricingRuleDetail![0].referenceName, + pricingRuleMap[0].name, + 'Pricing Rule is applied' + ); +}); + +test('Coupon not applied: incorrect items added.', async (t) => { + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + date: '2024-02-10', + party: partyMap.partyOne.name, + account: partyMap.partyOne.account, + }) as SalesInvoice; + + await sinv.append('items', { + item: itemMap.Cap.name, + quantity: 5, + rate: itemMap.Cap.rate, + }); + + await sinv.append('coupons', { coupons: couponCodesMap[0].name }); + + await sinv.runFormulas(); + await sinv.sync(); + + t.equal(sinv.coupons?.length, 0, 'coupon code is not applied'); +}); + +closeTestFyo(fyo, __filename); diff --git a/models/baseModels/tests/testPricingRule.spec.ts b/models/baseModels/tests/testPricingRule.spec.ts index dfffab3cc..515c6e993 100644 --- a/models/baseModels/tests/testPricingRule.spec.ts +++ b/models/baseModels/tests/testPricingRule.spec.ts @@ -2,10 +2,6 @@ import test from 'tape'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { ModelNameEnum } from 'models/types'; import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; -import { - assertDoesNotThrow, - assertThrows, -} from 'backend/database/tests/helpers'; import { getItem } from 'models/inventory/tests/helpers'; import { PricingRule } from '../PricingRule/PricingRule'; @@ -104,7 +100,7 @@ test('disabled pricing rule is not applied', async (t) => { await sinv.append('items', { item: itemMap.Jacket.name, quantity: 5 }); await sinv.runFormulas(); - t.equal(sinv.pricingRuleDetail?.length, undefined); + t.equal(sinv.pricingRuleDetail?.length, 0); }); test('pricing rule is applied when filtered by min and max qty', async (t) => { diff --git a/models/helpers.ts b/models/helpers.ts index 167096d35..141ecc9c8 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -28,6 +28,8 @@ import { LoyaltyProgram } from './baseModels/LoyaltyProgram/LoyaltyProgram'; import { CollectionRulesItems } from './baseModels/CollectionRulesItems/CollectionRulesItems'; import { isPesa } from 'fyo/utils'; import { Party } from './baseModels/Party/Party'; +import { CouponCode } from './baseModels/CouponCode/CouponCode'; +import { SalesInvoice } from './baseModels/SalesInvoice/SalesInvoice'; export function getQuoteActions( fyo: Fyo, @@ -584,8 +586,6 @@ export async function getExchangeRate({ }; exchangeRate = data.rates[toCurrency]; } catch (error) { - // eslint-disable-next-line no-console - console.error(error); exchangeRate ??= 1; } @@ -762,6 +762,10 @@ export function getLoyaltyProgramTier( } export async function removeLoyaltyPoint(doc: Doc) { + if (!doc.loyaltyProgram) { + return; + } + const data = (await doc.fyo.db.getAll(ModelNameEnum.LoyaltyPointEntry, { fields: ['name', 'loyaltyPoints', 'expiryDate'], filters: { @@ -916,6 +920,7 @@ export function canApplyPricingRule( ) { return false; } + if ( pricingRuleDoc.validTo && new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() > @@ -925,6 +930,82 @@ export function canApplyPricingRule( } return true; } + +export function canApplyCouponCode( + couponCodeData: CouponCode, + amount: Money, + sinvDate: Date +): boolean { + // Filter by Amount + if ( + !couponCodeData.minAmount?.isZero() && + amount.lte(couponCodeData.minAmount as Money) + ) { + return false; + } + + if ( + !couponCodeData.maxAmount?.isZero() && + amount.gte(couponCodeData.maxAmount as Money) + ) { + return false; + } + + // Filter by Validity + if ( + couponCodeData.validFrom && + new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() < + couponCodeData.validFrom.toISOString() + ) { + return false; + } + + if ( + couponCodeData.validTo && + new Date(sinvDate.setHours(0, 0, 0, 0)).toISOString() > + couponCodeData.validTo.toISOString() + ) { + return false; + } + + return true; +} + +export async function getApplicableCouponCodesName( + couponName: string, + sinvDoc: SalesInvoice +) { + const couponCodeDatas = (await sinvDoc.fyo.db.getAll( + ModelNameEnum.CouponCode, + { + fields: ['*'], + filters: { + name: couponName, + isEnabled: true, + }, + } + )) as CouponCode[]; + + if (!couponCodeDatas || couponCodeDatas.length === 0) { + return []; + } + + const applicablePricingRules = await getPricingRule(sinvDoc); + + if (!applicablePricingRules?.length) { + return []; + } + + return applicablePricingRules + ?.filter( + (rule) => rule?.pricingRule?.name === couponCodeDatas[0].pricingRule + ) + .map((rule) => ({ + pricingRule: rule.pricingRule.name, + coupon: couponCodeDatas[0].name, + })); +} + export function getPricingRulesConflicts( pricingRules: PricingRule[] ): undefined | boolean { diff --git a/models/index.ts b/models/index.ts index ace9901e7..ed4973b3e 100644 --- a/models/index.ts +++ b/models/index.ts @@ -13,6 +13,8 @@ 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 { AppliedCouponCodes } from './baseModels/AppliedCouponCodes/AppliedCouponCodes'; +import { CouponCode } from './baseModels/CouponCode/CouponCode'; import { Payment } from './baseModels/Payment/Payment'; import { PaymentFor } from './baseModels/PaymentFor/PaymentFor'; import { PriceList } from './baseModels/PriceList/PriceList'; @@ -64,6 +66,7 @@ export const models = { LoyaltyProgram, LoyaltyPointEntry, CollectionRulesItems, + CouponCode, Payment, PaymentFor, PrintSettings, @@ -75,6 +78,7 @@ export const models = { PurchaseInvoiceItem, SalesInvoice, SalesInvoiceItem, + AppliedCouponCodes, SalesQuote, SalesQuoteItem, SerialNumber, diff --git a/models/types.ts b/models/types.ts index 9b127d909..2420b8feb 100644 --- a/models/types.ts +++ b/models/types.ts @@ -22,6 +22,9 @@ export enum ModelNameEnum { LoyaltyProgram = 'LoyaltyProgram', LoyaltyPointEntry = 'LoyaltyPointEntry', CollectionRulesItems = 'CollectionRulesItems', + CouponCode = 'CouponCode', + + AppliedCouponCodes = 'AppliedCouponCodes', Payment = 'Payment', PaymentFor = 'PaymentFor', PriceList = 'PriceList', diff --git a/schemas/app/AccountingSettings.json b/schemas/app/AccountingSettings.json index 80fd83127..4493af67a 100644 --- a/schemas/app/AccountingSettings.json +++ b/schemas/app/AccountingSettings.json @@ -121,6 +121,13 @@ "default": false, "section": "Features" }, + { + "fieldname": "enableCouponCode", + "label": "Enable Coupon Code", + "fieldtype": "Check", + "default": false, + "section": "Features" + }, { "fieldname": "fiscalYearStart", "label": "Fiscal Year Start Date", diff --git a/schemas/app/AppliedCouponCodes.json b/schemas/app/AppliedCouponCodes.json new file mode 100644 index 000000000..a5acdace0 --- /dev/null +++ b/schemas/app/AppliedCouponCodes.json @@ -0,0 +1,15 @@ +{ + "name": "AppliedCouponCodes", + "label": "Applied Coupon Codes", + "isChild": true, + "fields": [ + { + "fieldname": "coupons", + "label": "Coupons", + "fieldtype": "Link", + "target": "CouponCode" + } + ], + "tableFields": ["coupons"], + "quickEditFields": ["coupons"] +} diff --git a/schemas/app/CouponCode.json b/schemas/app/CouponCode.json new file mode 100644 index 000000000..6a59d77c8 --- /dev/null +++ b/schemas/app/CouponCode.json @@ -0,0 +1,89 @@ +{ + "name": "CouponCode", + "label": "Coupon Code", + "naming": "manual", + + "fields": [ + { + "fieldname": "name", + "label": "Coupon Code", + "fieldtype": "Data", + "required": true + }, + { + "fieldname": "couponName", + "label": "Name", + "fieldtype": "Data", + "required": true, + "placeholder": "Coupon Name", + "section": "Default" + }, + { + "fieldname": "isEnabled", + "label": "Is Enabled", + "fieldtype": "Check", + "default": true, + "required": true + }, + { + "fieldname": "pricingRule", + "label": "Pricing Rule", + "fieldtype": "Link", + "target": "PricingRule", + "required": true + }, + { + "fieldname": "minAmount", + "label": "Min Amount", + "fieldtype": "Currency", + "section": "Amount" + }, + { + "fieldname": "maxAmount", + "label": "Max Amount", + "fieldtype": "Currency", + "section": "Amount" + }, + { + "fieldname": "validFrom", + "label": "Valid From", + "fieldtype": "Date", + "required": true, + "section": "Validity and Usage" + }, + { + "fieldname": "validTo", + "label": "Valid To", + "fieldtype": "Date", + "required": true, + "section": "Validity and Usage" + }, + { + "fieldname": "maximumUse", + "label": "Maximum Use", + "fieldtype": "Int", + "default": 0, + "required": true, + "section": "Validity and Usage" + }, + { + "fieldname": "used", + "label": "Used", + "fieldtype": "Int", + "default": 0, + "required": true, + "readOnly": true, + "section": "Validity and Usage" + } + ], + "quickEditFields": [ + "name", + "couponCode", + "pricingRule", + "validFrom", + "validTo", + "maximumUse", + "used" + ], + "keywordFields": ["name"] +} diff --git a/schemas/app/PricingRule.json b/schemas/app/PricingRule.json index 9d474c77b..cefc335f3 100644 --- a/schemas/app/PricingRule.json +++ b/schemas/app/PricingRule.json @@ -54,6 +54,18 @@ } ] }, + { + "fieldname": "isCouponCodeBased", + "label": "Is Coupon Code Based", + "fieldtype": "Check", + "default": false + }, + { + "fieldname": "isMultiple", + "label": "Is Multiple", + "fieldtype": "Check", + "default": false + }, { "fieldname": "priceDiscountType", "label": "Price Discount Type", diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json index b196f2b8e..2086377f7 100644 --- a/schemas/app/SalesInvoice.json +++ b/schemas/app/SalesInvoice.json @@ -24,6 +24,13 @@ "required": true, "section": "Default" }, + { + "fieldname": "coupons", + "label": "Coupons", + "fieldtype": "Table", + "target": "AppliedCouponCodes", + "section": "Coupons" + }, { "fieldname": "backReference", "label": "Back Reference", diff --git a/schemas/schemas.ts b/schemas/schemas.ts index aef9ff6df..f3d56c23c 100644 --- a/schemas/schemas.ts +++ b/schemas/schemas.ts @@ -19,6 +19,8 @@ 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 CouponCode from './app/CouponCode.json'; +import AppliedCouponCodes from './app/AppliedCouponCodes.json'; import Payment from './app/Payment.json'; import PaymentFor from './app/PaymentFor.json'; import PriceList from './app/PriceList.json'; @@ -128,6 +130,8 @@ export const appSchemas: Schema[] | SchemaStub[] = [ SalesInvoiceItem as SchemaStub, PurchaseInvoiceItem as SchemaStub, SalesQuoteItem as SchemaStub, + CouponCode as Schema, + AppliedCouponCodes as Schema, PriceList as Schema, PriceListItem as SchemaStub, diff --git a/src/utils/sidebarConfig.ts b/src/utils/sidebarConfig.ts index 647383817..c040569fa 100644 --- a/src/utils/sidebarConfig.ts +++ b/src/utils/sidebarConfig.ts @@ -216,6 +216,13 @@ function getCompleteSidebar(): SidebarConfig { schemaName: 'Lead', hidden: () => !fyo.singles.AccountingSettings?.enableLead, }, + { + label: t`Coupon Code`, + name: 'coupon-code', + route: `/list/CouponCode`, + schemaName: 'CouponCode', + hidden: () => !fyo.singles.AccountingSettings?.enableCouponCode, + }, ] as SidebarItem[], }, {