mirror of
https://github.com/frappe/books.git
synced 2024-12-22 19:09:01 +00:00
Merge pull request #743 from akshayitzme/invoice-return
feat: invoice return
This commit is contained in:
commit
f5304b1331
@ -185,7 +185,7 @@ export class BespokeQueries {
|
||||
|
||||
static async getReturnBalanceItemsQty(
|
||||
db: DatabaseCore,
|
||||
schemaName: string,
|
||||
schemaName: ModelNameEnum,
|
||||
docName: string
|
||||
): Promise<Record<string, ReturnDocItem> | 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;
|
||||
}
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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<string, Tax> = {};
|
||||
@ -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<Invoice | undefined> {
|
||||
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<void> {
|
||||
await super.beforeCancel();
|
||||
await this._validateStockTransferCancelled();
|
||||
await this._validateHasLinkedReturnInvoices();
|
||||
}
|
||||
|
||||
async beforeDelete(): Promise<void> {
|
||||
|
@ -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: {
|
||||
|
@ -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(
|
||||
|
@ -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'
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
282
models/baseModels/tests/testInvoice.spec.ts
Normal file
282
models/baseModels/tests/testInvoice.spec.ts
Normal file
@ -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);
|
@ -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);
|
||||
|
@ -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 &&
|
||||
|
@ -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",
|
||||
|
@ -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"]
|
||||
|
@ -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": [
|
||||
|
@ -54,6 +54,13 @@
|
||||
"fieldtype": "Float",
|
||||
"readOnly": true,
|
||||
"section": "Outstanding"
|
||||
},
|
||||
{
|
||||
"fieldname": "returnAgainst",
|
||||
"fieldtype": "Link",
|
||||
"target": "PurchaseInvoice",
|
||||
"label": "Return Against",
|
||||
"section": "References"
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
|
@ -54,6 +54,13 @@
|
||||
"fieldtype": "Float",
|
||||
"readOnly": true,
|
||||
"section": "Outstanding"
|
||||
},
|
||||
{
|
||||
"fieldname": "returnAgainst",
|
||||
"fieldtype": "Link",
|
||||
"target": "SalesInvoice",
|
||||
"label": "Return Against",
|
||||
"section": "References"
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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' },
|
||||
|
Loading…
Reference in New Issue
Block a user