diff --git a/backend/database/bespoke.ts b/backend/database/bespoke.ts index 4723f25f..b9b3af74 100644 --- a/backend/database/bespoke.ts +++ b/backend/database/bespoke.ts @@ -185,7 +185,7 @@ export class BespokeQueries { static async getReturnBalanceItemsQty( db: DatabaseCore, - schemaName: string, + schemaName: ModelNameEnum, docName: string ): Promise | undefined> { const returnDocNames = ( @@ -200,21 +200,41 @@ export class BespokeQueries { return; } - const returnedItems: DocItem[] = await db.knex!(`${schemaName}Item`) - .select('item', 'batch', 'serialNumber') + const returnedItemsQuery = db.knex!(`${schemaName}Item`) .sum({ quantity: 'quantity' }) - .whereIn('parent', returnDocNames) - .groupBy('item', 'batch', 'serialNumber'); + .whereIn('parent', returnDocNames); + const docItemsQuery = db.knex!(`${schemaName}Item`) + .where('parent', docName) + .sum({ quantity: 'quantity' }); + + if ( + [ModelNameEnum.SalesInvoice, ModelNameEnum.PurchaseInvoice].includes( + schemaName + ) + ) { + returnedItemsQuery.select('item', 'batch').groupBy('item', 'batch'); + docItemsQuery.select('name', 'item', 'batch').groupBy('item', 'batch'); + } + + if ( + [ModelNameEnum.Shipment, ModelNameEnum.PurchaseReceipt].includes( + schemaName + ) + ) { + returnedItemsQuery + .select('item', 'batch', 'serialNumber') + .groupBy('item', 'batch', 'serialNumber'); + docItemsQuery + .select('name', 'item', 'batch', 'serialNumber') + .groupBy('item', 'batch', 'serialNumber'); + } + + const returnedItems = (await returnedItemsQuery) as DocItem[]; if (!returnedItems.length) { return; } - - const docItems: DocItem[] = await db.knex!(`${schemaName}Item`) - .select('name', 'item', 'batch', 'serialNumber') - .where('parent', docName) - .groupBy('item', 'batch', 'serialNumber') - .sum({ quantity: 'quantity' }); + const docItems = (await docItemsQuery) as DocItem[]; const docItemsMap = BespokeQueries.#getDocItemMap(docItems); const returnedItemsMap = BespokeQueries.#getDocItemMap(returnedItems); @@ -223,7 +243,6 @@ export class BespokeQueries { docItemsMap, returnedItemsMap ); - return returnBalanceItems; } diff --git a/models/baseModels/AccountingSettings/AccountingSettings.ts b/models/baseModels/AccountingSettings/AccountingSettings.ts index a3224bfe..4dc92468 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; enableFormCustomization?: boolean; + enableInvoiceReturns?: boolean; static filters: FiltersMap = { writeOffAccount: () => ({ @@ -47,6 +48,9 @@ export class AccountingSettings extends Doc { enableInventory: () => { return !!this.enableInventory; }, + enableInvoiceReturns: () => { + return !!this.enableInvoiceReturns; + }, }; override hidden: HiddenMap = { diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index 917a1e4d..7c8d451f 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -25,6 +25,8 @@ import { Party } from '../Party/Party'; import { Payment } from '../Payment/Payment'; import { Tax } from '../Tax/Tax'; import { TaxSummary } from '../TaxSummary/TaxSummary'; +import { ReturnDocItem } from 'models/inventory/types'; +import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types'; export abstract class Invoice extends Transactional { _taxes: Record = {}; @@ -52,6 +54,9 @@ export abstract class Invoice extends Transactional { makeAutoPayment?: boolean; makeAutoStockTransfer?: boolean; + isReturned?: boolean; + returnAgainst?: string; + get isSales() { return this.schemaName === 'SalesInvoice'; } @@ -118,6 +123,10 @@ export abstract class Invoice extends Transactional { return null; } + get isReturn(): boolean { + return !!this.returnAgainst; + } + constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { super(schema, data, fyo); this._setGetCurrencies(); @@ -159,12 +168,15 @@ export abstract class Invoice extends Transactional { await stockTransfer?.submit(); await this.load(); } + + await this._updateIsItemsReturned(); } async afterCancel() { await super.afterCancel(); await this._cancelPayments(); await this._updatePartyOutStanding(); + await this._updateIsItemsReturned(); } async _cancelPayments() { @@ -368,6 +380,134 @@ export abstract class Invoice extends Transactional { return discountAmount; } + async getReturnDoc(): Promise { + if (!this.name) { + return; + } + + const docData = this.getValidDict(true, true); + const docItems = docData.items as DocValueMap[]; + + if (!docItems) { + return; + } + + let returnDocItems: DocValueMap[] = []; + + const returnBalanceItemsQty = await this.fyo.db.getReturnBalanceItemsQty( + this.schemaName, + this.name + ); + + for (const item of docItems) { + if (!returnBalanceItemsQty) { + returnDocItems = docItems; + returnDocItems.map((row) => { + row.name = undefined; + (row.quantity as number) *= -1; + return row; + }); + break; + } + + const isItemExist = !!returnDocItems.filter( + (balanceItem) => balanceItem.item === item.item + ).length; + + if (isItemExist) { + continue; + } + + const returnedItem: ReturnDocItem | undefined = + returnBalanceItemsQty[item.item as string]; + + let quantity = returnedItem.quantity; + let serialNumber: string | undefined = + returnedItem.serialNumbers?.join('\n'); + + if ( + item.batch && + returnedItem.batches && + returnedItem.batches[item.batch as string] + ) { + quantity = returnedItem.batches[item.batch as string].quantity; + + if (returnedItem.batches[item.batch as string].serialNumbers) { + serialNumber = + returnedItem.batches[item.batch as string].serialNumbers?.join( + '\n' + ); + } + } + + returnDocItems.push({ + ...item, + serialNumber, + name: undefined, + quantity: quantity, + }); + } + const returnDocData = { + ...docData, + name: undefined, + date: new Date(), + items: returnDocItems, + returnAgainst: docData.name, + } as DocValueMap; + + const newReturnDoc = this.fyo.doc.getNewDoc( + this.schema.name, + returnDocData + ) as Invoice; + + await newReturnDoc.runFormulas(); + return newReturnDoc; + } + + async _updateIsItemsReturned() { + if (!this.isReturn || !this.returnAgainst) { + return; + } + + const returnInvoices = await this.fyo.db.getAll(this.schema.name, { + filters: { + submitted: true, + cancelled: false, + returnAgainst: this.returnAgainst, + }, + }); + + const isReturned = !!returnInvoices.length; + const invoiceDoc = await this.fyo.doc.getDoc( + this.schemaName, + this.returnAgainst + ); + await invoiceDoc.setAndSync({ isReturned }); + await invoiceDoc.submit(); + } + + async _validateHasLinkedReturnInvoices() { + if (!this.name || this.isReturn) { + return; + } + + const returnInvoices = await this.fyo.db.getAll(this.schemaName, { + filters: { + returnAgainst: this.name, + }, + }); + + if (!returnInvoices.length) { + return; + } + + const names = returnInvoices.map(({ name }) => name).join(', '); + throw new ValidationError( + this.fyo + .t`Cannot cancel ${this.name} because of the following ${this.schema.label}: ${names}` + ); + } + formulas: FormulaMap = { account: { formula: async () => { @@ -518,6 +658,8 @@ export abstract class Invoice extends Transactional { !(this.attachment || !(this.isSubmitted || this.isCancelled)), backReference: () => !this.backReference, priceList: () => !this.fyo.singles.AccountingSettings?.enablePriceList, + returnAgainst: () => + (this.isSubmitted || this.isCancelled) && !this.returnAgainst, }; static defaults: DefaultMap = { @@ -595,12 +737,29 @@ export abstract class Invoice extends Transactional { return null; } - const accountField = this.isSales ? 'account' : 'paymentAccount'; + let accountField: AccountFieldEnum = AccountFieldEnum.Account; + let paymentType: PaymentTypeEnum = PaymentTypeEnum.Receive; + + if (this.isSales && this.isReturn) { + accountField = AccountFieldEnum.PaymentAccount; + paymentType = PaymentTypeEnum.Pay; + } + + if (!this.isSales) { + accountField = AccountFieldEnum.PaymentAccount; + paymentType = PaymentTypeEnum.Pay; + + if (this.isReturn) { + accountField = AccountFieldEnum.Account; + paymentType = PaymentTypeEnum.Receive; + } + } + const data = { party: this.party, date: new Date().toISOString().slice(0, 10), - paymentType: this.isSales ? 'Receive' : 'Pay', - amount: this.outstandingAmount, + paymentType, + amount: this.outstandingAmount?.abs(), [accountField]: this.account, for: [ { @@ -720,6 +879,7 @@ export abstract class Invoice extends Transactional { async beforeCancel(): Promise { await super.beforeCancel(); await this._validateStockTransferCancelled(); + await this._validateHasLinkedReturnInvoices(); } async beforeDelete(): Promise { diff --git a/models/baseModels/InvoiceItem/InvoiceItem.ts b/models/baseModels/InvoiceItem/InvoiceItem.ts index d943cc3e..9c39993d 100644 --- a/models/baseModels/InvoiceItem/InvoiceItem.ts +++ b/models/baseModels/InvoiceItem/InvoiceItem.ts @@ -86,6 +86,10 @@ export abstract class InvoiceItem extends Doc { return this.parentdoc?.isMultiCurrency ?? false; } + get isReturn() { + return !!this.parentdoc?.isReturn; + } + constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { super(schema, data, fyo); this._setGetCurrencies(); @@ -210,6 +214,15 @@ export abstract class InvoiceItem extends Doc { const unitDoc = itemDoc.getLink('uom'); let quantity: number = this.quantity ?? 1; + + if (this.isReturn && quantity > 0) { + quantity *= -1; + } + + if (!this.isReturn && quantity < 0) { + quantity *= -1; + } + if (fieldname === 'transferQuantity') { quantity = this.transferQuantity! * this.unitConversionFactor!; } @@ -225,6 +238,8 @@ export abstract class InvoiceItem extends Doc { 'transferQuantity', 'transferUnit', 'unitConversionFactor', + 'item', + 'isReturn', ], }, unitConversionFactor: { diff --git a/models/baseModels/Payment/Payment.ts b/models/baseModels/Payment/Payment.ts index 073ca608..f1b91b1f 100644 --- a/models/baseModels/Payment/Payment.ts +++ b/models/baseModels/Payment/Payment.ts @@ -36,6 +36,7 @@ export class Payment extends Transactional { amount?: Money; writeoff?: Money; paymentType?: PaymentType; + referenceType?: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice; for?: PaymentFor[]; _accountsMap?: AccountTypeMap; @@ -300,7 +301,7 @@ export class Payment extends Transactional { )) as Invoice; outstandingAmount = outstandingAmount.add( - referenceDoc.outstandingAmount ?? 0 + referenceDoc.outstandingAmount?.abs() ?? 0 ); } @@ -438,6 +439,15 @@ export class Payment extends Transactional { 'referenceName' )) as Invoice | null; + if ( + refDoc && + refDoc.schema.name === ModelNameEnum.SalesInvoice && + refDoc.isReturned + ) { + const accountsMap = await this._getAccountsMap(); + return accountsMap[AccountTypeEnum.Cash]?.[0]; + } + return refDoc?.account ?? null; } @@ -495,13 +505,21 @@ export class Payment extends Transactional { } const partyDoc = (await this.loadAndGetLink('party')) as Party; + const outstanding = partyDoc.outstandingAmount as Money; + + if (outstanding.isNegative()) { + if (this.referenceType === ModelNameEnum.SalesInvoice) { + return 'Pay'; + } + return 'Receive'; + } + if (partyDoc.role === 'Supplier') { return 'Pay'; } else if (partyDoc.role === 'Customer') { return 'Receive'; } - const outstanding = partyDoc.outstandingAmount as Money; if (outstanding?.isZero() ?? true) { return this.paymentType; } @@ -520,6 +538,14 @@ export class Payment extends Transactional { formula: () => this.amount!.sub(this.writeoff!), dependsOn: ['amount', 'writeoff', 'for'], }, + referenceType: { + formula: () => { + if (this.referenceType) { + return; + } + return this.for![0].referenceType; + }, + }, }; validations: ValidationMap = { @@ -534,7 +560,7 @@ export class Payment extends Transactional { return; } - const amount = this.getSum('for', 'amount', false); + const amount = (this.getSum('for', 'amount', false) as Money).abs(); if ((value as Money).gt(amount)) { throw new ValidationError( diff --git a/models/baseModels/Payment/types.ts b/models/baseModels/Payment/types.ts index 03108ef7..e51fc16b 100644 --- a/models/baseModels/Payment/types.ts +++ b/models/baseModels/Payment/types.ts @@ -1,2 +1,12 @@ export type PaymentType = 'Receive' | 'Pay'; export type PaymentMethod = 'Cash' | 'Cheque' | 'Transfer'; + +export enum PaymentTypeEnum{ + Receive = 'Receive', + Pay = 'Pay' +} + +export enum AccountFieldEnum{ + Account = 'account', + PaymentAccount = 'paymentAccount' +} diff --git a/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts b/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts index 8da038bf..24403eed 100644 --- a/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts +++ b/models/baseModels/PurchaseInvoice/PurchaseInvoice.ts @@ -12,14 +12,26 @@ export class PurchaseInvoice extends Invoice { async getPosting() { const exchangeRate = this.exchangeRate ?? 1; const posting: LedgerPosting = new LedgerPosting(this, this.fyo); - await posting.credit(this.account!, this.baseGrandTotal!); + if (this.isReturn) { + await posting.debit(this.account!, this.baseGrandTotal!); + } else { + await posting.credit(this.account!, this.baseGrandTotal!); + } for (const item of this.items!) { + if (this.isReturn) { + await posting.credit(item.account!, item.amount!.mul(exchangeRate)); + continue; + } await posting.debit(item.account!, item.amount!.mul(exchangeRate)); } if (this.taxes) { for (const tax of this.taxes) { + if (this.isReturn) { + await posting.credit(tax.account!, tax.amount!.mul(exchangeRate)); + continue; + } await posting.debit(tax.account!, tax.amount!.mul(exchangeRate)); } } @@ -28,7 +40,11 @@ export class PurchaseInvoice extends Invoice { const discountAccount = this.fyo.singles.AccountingSettings ?.discountAccount as string | undefined; if (discountAccount && discountAmount.isPositive()) { - await posting.credit(discountAccount, discountAmount.mul(exchangeRate)); + if (this.isReturn) { + await posting.debit(discountAccount, discountAmount.mul(exchangeRate)); + } else { + await posting.credit(discountAccount, discountAmount.mul(exchangeRate)); + } } await posting.makeRoundOffEntry(); diff --git a/models/baseModels/SalesInvoice/SalesInvoice.ts b/models/baseModels/SalesInvoice/SalesInvoice.ts index 791c7554..e41fac72 100644 --- a/models/baseModels/SalesInvoice/SalesInvoice.ts +++ b/models/baseModels/SalesInvoice/SalesInvoice.ts @@ -12,14 +12,26 @@ export class SalesInvoice extends Invoice { async getPosting() { const exchangeRate = this.exchangeRate ?? 1; const posting: LedgerPosting = new LedgerPosting(this, this.fyo); - await posting.debit(this.account!, this.baseGrandTotal!); + if (this.isReturn) { + await posting.credit(this.account!, this.baseGrandTotal!); + } else { + await posting.debit(this.account!, this.baseGrandTotal!); + } for (const item of this.items!) { + if (this.isReturn) { + await posting.debit(item.account!, item.amount!.mul(exchangeRate)); + continue; + } await posting.credit(item.account!, item.amount!.mul(exchangeRate)); } if (this.taxes) { for (const tax of this.taxes) { + if (this.isReturn) { + await posting.debit(tax.account!, tax.amount!.mul(exchangeRate)); + continue; + } await posting.credit(tax.account!, tax.amount!.mul(exchangeRate)); } } @@ -28,7 +40,11 @@ export class SalesInvoice extends Invoice { const discountAccount = this.fyo.singles.AccountingSettings ?.discountAccount as string | undefined; if (discountAccount && discountAmount.isPositive()) { - await posting.debit(discountAccount, discountAmount.mul(exchangeRate)); + if (this.isReturn) { + await posting.credit(discountAccount, discountAmount.mul(exchangeRate)); + } else { + await posting.debit(discountAccount, discountAmount.mul(exchangeRate)); + } } await posting.makeRoundOffEntry(); diff --git a/models/baseModels/tests/testInvoice.spec.ts b/models/baseModels/tests/testInvoice.spec.ts new file mode 100644 index 00000000..93794b5f --- /dev/null +++ b/models/baseModels/tests/testInvoice.spec.ts @@ -0,0 +1,282 @@ +import test from 'tape'; +import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; +import { ModelNameEnum } from 'models/types'; +import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; +import { Payment } from '../Payment/Payment'; +import { PaymentTypeEnum } from '../Payment/types'; +import { + assertDoesNotThrow, + assertThrows, +} from 'backend/database/tests/helpers'; +import { PurchaseInvoice } from '../PurchaseInvoice/PurchaseInvoice'; + +const fyo = getTestFyo(); +setupTestFyo(fyo, __filename); + +const itemData = { + name: 'Pen', + rate: 100, + unit: 'Unit', + for: 'Both', + trackItem: true, + hasBatch: true, + hasSerialNumber: true, +}; + +const partyData = { + name: 'John Whoe', + email: 'john@whoe.com', +}; + +const batchMap = { + batchOne: { + name: 'PN-AB001', + manufactureDate: '2022-11-03T09:57:04.528', + }, + batchTwo: { + name: 'PN-AB002', + manufactureDate: '2022-10-03T09:57:04.528', + }, +}; + +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` + ); + + for (const batch of Object.values(batchMap)) { + await fyo.doc.getNewDoc(ModelNameEnum.Batch, batch).sync(), + t.ok( + fyo.db.exists(ModelNameEnum.Batch, batch.name), + `batch ${batch.name} exists` + ); + } +}); + +test('create SINV with batch then create payment against it', async (t) => { + const sinvDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { + account: 'Debtors', + party: partyData.name, + items: [ + { + item: itemData.name, + batch: batchMap.batchOne.name, + rate: itemData.rate, + quantity: 2, + }, + ], + }) as SalesInvoice; + + await sinvDoc.sync(); + await sinvDoc.runFormulas(); + await sinvDoc.submit(); + + t.ok( + fyo.db.exists(ModelNameEnum.SalesInvoice, sinvDoc.name), + `${sinvDoc.name} exists` + ); + + const paymentDoc = sinvDoc.getPayment(); + await paymentDoc?.sync(); + await paymentDoc?.submit(); + + t.equals(paymentDoc?.name, 'PAY-1001'); +}); + +test('create SINV return for one qty', async (t) => { + const sinvDoc = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1001' + )) as SalesInvoice; + + let returnDoc = (await sinvDoc?.getReturnDoc()) as SalesInvoice; + + returnDoc.items = []; + returnDoc.append('items', { + item: itemData.name, + batch: batchMap.batchOne.name, + quantity: 1, + rate: itemData.rate, + }); + + await returnDoc.runFormulas(); + await returnDoc.sync(); + await returnDoc.submit(); + + t.ok( + await fyo.db.exists(ModelNameEnum.SalesInvoice, returnDoc.name), + 'SINV return for one qty created' + ); + + t.equals( + returnDoc.outstandingAmount?.float, + itemData.rate, + 'returnDoc outstanding amount matches' + ); + + const returnSinvAles = await fyo.db.getAllRaw( + ModelNameEnum.AccountingLedgerEntry, + { + fields: ['name', 'account', 'credit', 'debit'], + filters: { referenceName: returnDoc.name! }, + } + ); + + for (const ale of returnSinvAles) { + if (ale.account === 'Sales') { + t.equal( + fyo.pesa(ale.debit as string).float, + fyo.pesa(itemData.rate).float, + `return Invoice debited from ${ale.account}` + ); + } + + if (ale.account === 'Debtors') { + t.equal( + fyo.pesa(ale.credit as string).float, + fyo.pesa(itemData.rate).float, + `return Invoice credited to ${ale.account}` + ); + } + } + + await assertThrows( + async () => await sinvDoc.cancel(), + 'can not cancel a SINV when a return invoice is created against it' + ); +}); + +test('create SINV return for balance qty', async (t) => { + const sinvDoc = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1001' + )) as SalesInvoice; + + const returnDoc = (await sinvDoc?.getReturnDoc()) as SalesInvoice; + t.equals( + Object.values(returnDoc.items!)[0].quantity, + -1, + 'return doc has 1 qty left to return' + ); + + await returnDoc.sync(); + + await returnDoc.runFormulas(); + await returnDoc.submit(); + + t.ok( + await fyo.db.exists(ModelNameEnum.SalesInvoice, returnDoc.name), + 'SINV return for one qty created' + ); + + t.equals( + returnDoc.outstandingAmount?.float, + -itemData.rate, + 'return doc outstanding amount matches' + ); +}); + +test('create payment for return invoice', async (t) => { + const returnDoc = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1002' + )) as SalesInvoice; + + t.equals(returnDoc.returnAgainst, 'SINV-1001'); + + const paymentDoc = returnDoc.getPayment() as Payment; + t.equals(paymentDoc.paymentType, PaymentTypeEnum.Pay, 'payment type is pay'); + + t.equals( + paymentDoc.amount?.float, + itemData.rate, + 'payment amount for return invoice matches' + ); + + await paymentDoc.sync(); + + t.ok( + await fyo.db.exists(ModelNameEnum.Payment, paymentDoc.name), + 'payment entry created for return invoice' + ); + + await assertDoesNotThrow( + async () => await returnDoc.cancel(), + 'return invoice cancelled' + ); +}); + +test('creating PINV return when invoice is not paid', async (t) => { + const pinvDoc = fyo.doc.getNewDoc( + ModelNameEnum.PurchaseInvoice + ) as PurchaseInvoice; + + await pinvDoc.set({ + party: partyData.name, + account: 'Creditors', + items: [ + { + item: itemData.name, + batch: batchMap.batchOne.name, + quantity: 2, + rate: itemData.rate, + }, + ], + }); + await pinvDoc.sync(); + await pinvDoc.submit(); + + t.equals(pinvDoc.name, 'PINV-1001', `${pinvDoc.name} is submitted`); + + const returnDoc = (await pinvDoc.getReturnDoc()) as PurchaseInvoice; + await returnDoc.sync(); + await returnDoc.submit(); + + t.equals( + returnDoc?.returnAgainst, + pinvDoc.name, + `return pinv created against ${pinvDoc.name}` + ); + t.equals( + Object.values(returnDoc.items!)[0].quantity, + -2, + 'pinv returned qty matches' + ); + + const returnSinvAles = await fyo.db.getAllRaw( + ModelNameEnum.AccountingLedgerEntry, + { + fields: ['name', 'account', 'credit', 'debit'], + filters: { referenceName: returnDoc.name! }, + } + ); + + for (const ale of returnSinvAles) { + if (ale.account === 'Creditors') { + t.equal( + fyo.pesa(ale.debit as string).float, + returnDoc.outstandingAmount!.float, + `return Invoice debited from ${ale.account}` + ); + } + + if (ale.account === 'Cost of Goods Sold') { + t.equal( + fyo.pesa(ale.credit as string).float, + returnDoc.outstandingAmount!.float, + `return Invoice credited to ${ale.account}` + ); + } + } +}); + +closeTestFyo(fyo, __filename); diff --git a/models/baseModels/tests/testPriceList.spec.ts b/models/baseModels/tests/testPriceList.spec.ts index 91515ab9..ccb7e6f6 100644 --- a/models/baseModels/tests/testPriceList.spec.ts +++ b/models/baseModels/tests/testPriceList.spec.ts @@ -1,11 +1,8 @@ import test from 'tape'; -import { getDefaultMetaFieldValueMap } from 'backend/helpers'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { ModelNameEnum } from 'models/types'; import { getItem } from 'models/inventory/tests/helpers'; -import { SalesInvoiceItem } from '../SalesInvoiceItem/SalesInvoiceItem'; import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; -import { PurchaseInvoiceItem } from '../PurchaseInvoiceItem/PurchaseInvoiceItem'; const fyo = getTestFyo(); setupTestFyo(fyo, __filename); diff --git a/models/helpers.ts b/models/helpers.ts index 4f95ef57..19f32bda 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -23,6 +23,7 @@ export function getInvoiceActions( getMakePaymentAction(fyo), getMakeStockTransferAction(fyo, schemaName), getLedgerLinkAction(fyo), + getMakeReturnDocAction(fyo), ]; } @@ -97,11 +98,13 @@ export function getMakePaymentAction(fyo: Fyo): Action { condition: (doc: Doc) => doc.isSubmitted && !(doc.outstandingAmount as Money).isZero(), action: async (doc, router) => { + const schemaName = doc.schema.name; const payment = (doc as Invoice).getPayment(); if (!payment) { return; } + await payment?.set('referenceType', schemaName); const currentRoute = router.currentRoute.value.fullPath; payment.once('afterSync', async () => { await payment.submit(); @@ -109,7 +112,12 @@ export function getMakePaymentAction(fyo: Fyo): Action { await router.push(currentRoute); }); - const hideFields = ['party', 'paymentType', 'for']; + const hideFields = ['party', 'for']; + + if (!fyo.singles.AccountingSettings?.enableInvoiceReturns) { + hideFields.push('paymentType'); + } + if (doc.schemaName === ModelNameEnum.SalesInvoice) { hideFields.push('account'); } else { @@ -166,11 +174,17 @@ export function getMakeReturnDocAction(fyo: Fyo): Action { label: fyo.t`Return`, group: fyo.t`Create`, condition: (doc: Doc) => - !!fyo.singles.InventorySettings?.enableStockReturns && + (!!fyo.singles.AccountingSettings?.enableInvoiceReturns || + !!fyo.singles.InventorySettings?.enableStockReturns) && doc.isSubmitted && !doc.isReturn, action: async (doc: Doc) => { - const returnDoc = await (doc as StockTransfer)?.getReturnDoc(); + let returnDoc: Invoice | StockTransfer | undefined; + + if (doc instanceof Invoice || doc instanceof StockTransfer) { + returnDoc = await doc.getReturnDoc(); + } + if (!returnDoc || !returnDoc.name) { return; } @@ -297,6 +311,14 @@ function getSubmittableDocStatus(doc: RenderData | Doc) { } export function getInvoiceStatus(doc: RenderData | Doc): InvoiceStatus { + if (doc.submitted && !doc.cancelled && doc.returnAgainst) { + return 'Return'; + } + + if (doc.submitted && !doc.cancelled && doc.isReturned) { + return 'ReturnIssued'; + } + if ( doc.submitted && !doc.cancelled && diff --git a/schemas/app/AccountingSettings.json b/schemas/app/AccountingSettings.json index 454d21ee..46997992 100644 --- a/schemas/app/AccountingSettings.json +++ b/schemas/app/AccountingSettings.json @@ -86,6 +86,13 @@ "default": false, "section": "Features" }, + { + "fieldname": "enableInvoiceReturns", + "label": "Enable Invoice Returns", + "fieldtype": "Check", + "default": false, + "section": "Features" + }, { "fieldname": "enableFormCustomization", "label": "Enable Form Customization", diff --git a/schemas/app/Invoice.json b/schemas/app/Invoice.json index 3e9d8fce..a5ca922f 100644 --- a/schemas/app/Invoice.json +++ b/schemas/app/Invoice.json @@ -181,10 +181,21 @@ "fieldtype": "Attachment", "section": "References" }, + { + "fieldname": "isReturned", + "fieldtype": "Check", + "hidden": true, + "default": false + }, { "abstract": true, "fieldname": "backReference", "section": "References" + }, + { + "abstract": true, + "fieldname": "returnAgainst", + "section": "References" } ], "keywordFields": ["name", "party"] diff --git a/schemas/app/Payment.json b/schemas/app/Payment.json index 71afc80a..9e1de96a 100644 --- a/schemas/app/Payment.json +++ b/schemas/app/Payment.json @@ -155,6 +155,24 @@ "label": "Attachment", "fieldtype": "Attachment", "section": "References" + }, + { + "fieldname": "referenceType", + "label": "Type", + "placeholder": "Type", + "fieldtype": "Select", + "options": [ + { + "value": "SalesInvoice", + "label": "Sales" + }, + { + "value": "PurchaseInvoice", + "label": "Purchase" + } + ], + "hidden": true, + "required": true } ], "quickEditFields": [ diff --git a/schemas/app/PurchaseInvoice.json b/schemas/app/PurchaseInvoice.json index d512f507..fce0eeb6 100644 --- a/schemas/app/PurchaseInvoice.json +++ b/schemas/app/PurchaseInvoice.json @@ -54,6 +54,13 @@ "fieldtype": "Float", "readOnly": true, "section": "Outstanding" + }, + { + "fieldname": "returnAgainst", + "fieldtype": "Link", + "target": "PurchaseInvoice", + "label": "Return Against", + "section": "References" } ], "keywordFields": ["name", "party"] diff --git a/schemas/app/SalesInvoice.json b/schemas/app/SalesInvoice.json index 576e2ff5..dcbfc5de 100644 --- a/schemas/app/SalesInvoice.json +++ b/schemas/app/SalesInvoice.json @@ -54,6 +54,13 @@ "fieldtype": "Float", "readOnly": true, "section": "Outstanding" + }, + { + "fieldname": "returnAgainst", + "fieldtype": "Link", + "target": "SalesInvoice", + "label": "Return Against", + "section": "References" } ], "keywordFields": ["name", "party"] diff --git a/src/components/StatusPill.vue b/src/components/StatusPill.vue index a6cafe24..0c97c805 100644 --- a/src/components/StatusPill.vue +++ b/src/components/StatusPill.vue @@ -92,6 +92,14 @@ function getSubmittableStatus(doc: Doc) { return 'Cancelled'; } + if (doc.returnAgainst && doc.isSubmitted) { + return 'Return'; + } + + if (doc.isReturned && doc.isSubmitted) { + return 'ReturnIssued'; + } + const isInvoice = doc instanceof Invoice; if ( doc.isSubmitted && @@ -113,14 +121,6 @@ function getSubmittableStatus(doc: Doc) { return 'Paid'; } - if (doc.returnAgainst && doc.isSubmitted) { - return 'Return'; - } - - if (doc.isReturned && doc.isSubmitted) { - return 'ReturnIssued'; - } - if (doc.isSubmitted) { return 'Submitted'; } diff --git a/src/utils/filters.ts b/src/utils/filters.ts index 96b521d7..9ae083f5 100644 --- a/src/utils/filters.ts +++ b/src/utils/filters.ts @@ -1,9 +1,15 @@ +import { ModelNameEnum } from 'models/types'; + export const routeFilters = { SalesItems: { for: ['in', ['Sales', 'Both']] }, PurchaseItems: { for: ['in', ['Purchases', 'Both']] }, Items: { for: 'Both' }, - PurchasePayments: { paymentType: 'Pay' }, - SalesPayments: { paymentType: 'Receive' }, + PurchasePayments: { + referenceType: ModelNameEnum.PurchaseInvoice, + }, + SalesPayments: { + referenceType: ModelNameEnum.SalesInvoice, + }, Suppliers: { role: ['in', ['Supplier', 'Both']] }, Customers: { role: ['in', ['Customer', 'Both']] }, Party: { role: 'Both' },