2
0
mirror of https://github.com/frappe/books.git synced 2025-01-23 07:08:36 +00:00

Merge pull request #930 from AbleKSaju/feat-couponCode

feat: coupon codes
This commit is contained in:
Akshay 2024-10-02 13:42:07 +05:30 committed by GitHub
commit 5b564c2309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1009 additions and 21 deletions

View File

@ -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) {

View File

@ -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.`
);
}
},
};
}

View File

@ -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'],
};
}
}

View File

@ -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<void> {
@ -1259,6 +1322,21 @@ export abstract class Invoice extends Transactional {
}
}
async getPricingRuleDocNames(
item: SalesInvoiceItem,
sinvDoc: SalesInvoice
): Promise<string[]> {
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<ApplicablePricingRules[] | undefined> {
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'],
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: {
item: item.item as string,
unit: item.unit as string,
name: coupon?.coupons as string,
isEnabled: true,
},
})
).map((doc) => doc.parent) as string[];
}
);
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<string | false> =>
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,

View File

@ -4,3 +4,8 @@ export interface ApplicablePricingRules {
applyOnItem: string;
pricingRule: PricingRule;
}
export interface ApplicableCouponCodes {
pricingRule: string;
coupon: string;
}

View File

@ -23,6 +23,8 @@ export class PricingRule extends Doc {
discountPercentage?: number;
discountAmount?: Money;
isCouponCodeBased?: boolean;
forPriceList?: string;
freeItem?: string;

View File

@ -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);

View File

@ -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) => {

View File

@ -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 {

View File

@ -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,

View File

@ -22,6 +22,9 @@ export enum ModelNameEnum {
LoyaltyProgram = 'LoyaltyProgram',
LoyaltyPointEntry = 'LoyaltyPointEntry',
CollectionRulesItems = 'CollectionRulesItems',
CouponCode = 'CouponCode',
AppliedCouponCodes = 'AppliedCouponCodes',
Payment = 'Payment',
PaymentFor = 'PaymentFor',
PriceList = 'PriceList',

View File

@ -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",

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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",

View File

@ -24,6 +24,13 @@
"required": true,
"section": "Default"
},
{
"fieldname": "coupons",
"label": "Coupons",
"fieldtype": "Table",
"target": "AppliedCouponCodes",
"section": "Coupons"
},
{
"fieldname": "backReference",
"label": "Back Reference",

View File

@ -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,

View File

@ -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[],
},
{