2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 15:20:56 +00:00

Merge pull request #743 from akshayitzme/invoice-return

feat: invoice return
This commit is contained in:
Akshay 2023-11-18 15:21:14 +05:30 committed by GitHub
commit f5304b1331
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 661 additions and 38 deletions

View File

@ -185,7 +185,7 @@ export class BespokeQueries {
static async getReturnBalanceItemsQty( static async getReturnBalanceItemsQty(
db: DatabaseCore, db: DatabaseCore,
schemaName: string, schemaName: ModelNameEnum,
docName: string docName: string
): Promise<Record<string, ReturnDocItem> | undefined> { ): Promise<Record<string, ReturnDocItem> | undefined> {
const returnDocNames = ( const returnDocNames = (
@ -200,21 +200,41 @@ export class BespokeQueries {
return; return;
} }
const returnedItems: DocItem[] = await db.knex!(`${schemaName}Item`) const returnedItemsQuery = db.knex!(`${schemaName}Item`)
.select('item', 'batch', 'serialNumber')
.sum({ quantity: 'quantity' }) .sum({ quantity: 'quantity' })
.whereIn('parent', returnDocNames) .whereIn('parent', returnDocNames);
.groupBy('item', 'batch', 'serialNumber');
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) { if (!returnedItems.length) {
return; return;
} }
const docItems = (await docItemsQuery) as DocItem[];
const docItems: DocItem[] = await db.knex!(`${schemaName}Item`)
.select('name', 'item', 'batch', 'serialNumber')
.where('parent', docName)
.groupBy('item', 'batch', 'serialNumber')
.sum({ quantity: 'quantity' });
const docItemsMap = BespokeQueries.#getDocItemMap(docItems); const docItemsMap = BespokeQueries.#getDocItemMap(docItems);
const returnedItemsMap = BespokeQueries.#getDocItemMap(returnedItems); const returnedItemsMap = BespokeQueries.#getDocItemMap(returnedItems);
@ -223,7 +243,6 @@ export class BespokeQueries {
docItemsMap, docItemsMap,
returnedItemsMap returnedItemsMap
); );
return returnBalanceItems; return returnBalanceItems;
} }

View File

@ -16,6 +16,7 @@ export class AccountingSettings extends Doc {
enableInventory?: boolean; enableInventory?: boolean;
enablePriceList?: boolean; enablePriceList?: boolean;
enableFormCustomization?: boolean; enableFormCustomization?: boolean;
enableInvoiceReturns?: boolean;
static filters: FiltersMap = { static filters: FiltersMap = {
writeOffAccount: () => ({ writeOffAccount: () => ({
@ -47,6 +48,9 @@ export class AccountingSettings extends Doc {
enableInventory: () => { enableInventory: () => {
return !!this.enableInventory; return !!this.enableInventory;
}, },
enableInvoiceReturns: () => {
return !!this.enableInvoiceReturns;
},
}; };
override hidden: HiddenMap = { override hidden: HiddenMap = {

View File

@ -25,6 +25,8 @@ import { Party } from '../Party/Party';
import { Payment } from '../Payment/Payment'; import { Payment } from '../Payment/Payment';
import { Tax } from '../Tax/Tax'; import { Tax } from '../Tax/Tax';
import { TaxSummary } from '../TaxSummary/TaxSummary'; import { TaxSummary } from '../TaxSummary/TaxSummary';
import { ReturnDocItem } from 'models/inventory/types';
import { AccountFieldEnum, PaymentTypeEnum } from '../Payment/types';
export abstract class Invoice extends Transactional { export abstract class Invoice extends Transactional {
_taxes: Record<string, Tax> = {}; _taxes: Record<string, Tax> = {};
@ -52,6 +54,9 @@ export abstract class Invoice extends Transactional {
makeAutoPayment?: boolean; makeAutoPayment?: boolean;
makeAutoStockTransfer?: boolean; makeAutoStockTransfer?: boolean;
isReturned?: boolean;
returnAgainst?: string;
get isSales() { get isSales() {
return this.schemaName === 'SalesInvoice'; return this.schemaName === 'SalesInvoice';
} }
@ -118,6 +123,10 @@ export abstract class Invoice extends Transactional {
return null; return null;
} }
get isReturn(): boolean {
return !!this.returnAgainst;
}
constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
super(schema, data, fyo); super(schema, data, fyo);
this._setGetCurrencies(); this._setGetCurrencies();
@ -159,12 +168,15 @@ export abstract class Invoice extends Transactional {
await stockTransfer?.submit(); await stockTransfer?.submit();
await this.load(); await this.load();
} }
await this._updateIsItemsReturned();
} }
async afterCancel() { async afterCancel() {
await super.afterCancel(); await super.afterCancel();
await this._cancelPayments(); await this._cancelPayments();
await this._updatePartyOutStanding(); await this._updatePartyOutStanding();
await this._updateIsItemsReturned();
} }
async _cancelPayments() { async _cancelPayments() {
@ -368,6 +380,134 @@ export abstract class Invoice extends Transactional {
return discountAmount; 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 = { formulas: FormulaMap = {
account: { account: {
formula: async () => { formula: async () => {
@ -518,6 +658,8 @@ export abstract class Invoice extends Transactional {
!(this.attachment || !(this.isSubmitted || this.isCancelled)), !(this.attachment || !(this.isSubmitted || this.isCancelled)),
backReference: () => !this.backReference, backReference: () => !this.backReference,
priceList: () => !this.fyo.singles.AccountingSettings?.enablePriceList, priceList: () => !this.fyo.singles.AccountingSettings?.enablePriceList,
returnAgainst: () =>
(this.isSubmitted || this.isCancelled) && !this.returnAgainst,
}; };
static defaults: DefaultMap = { static defaults: DefaultMap = {
@ -595,12 +737,29 @@ export abstract class Invoice extends Transactional {
return null; 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 = { const data = {
party: this.party, party: this.party,
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
paymentType: this.isSales ? 'Receive' : 'Pay', paymentType,
amount: this.outstandingAmount, amount: this.outstandingAmount?.abs(),
[accountField]: this.account, [accountField]: this.account,
for: [ for: [
{ {
@ -720,6 +879,7 @@ export abstract class Invoice extends Transactional {
async beforeCancel(): Promise<void> { async beforeCancel(): Promise<void> {
await super.beforeCancel(); await super.beforeCancel();
await this._validateStockTransferCancelled(); await this._validateStockTransferCancelled();
await this._validateHasLinkedReturnInvoices();
} }
async beforeDelete(): Promise<void> { async beforeDelete(): Promise<void> {

View File

@ -86,6 +86,10 @@ export abstract class InvoiceItem extends Doc {
return this.parentdoc?.isMultiCurrency ?? false; return this.parentdoc?.isMultiCurrency ?? false;
} }
get isReturn() {
return !!this.parentdoc?.isReturn;
}
constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
super(schema, data, fyo); super(schema, data, fyo);
this._setGetCurrencies(); this._setGetCurrencies();
@ -210,6 +214,15 @@ export abstract class InvoiceItem extends Doc {
const unitDoc = itemDoc.getLink('uom'); const unitDoc = itemDoc.getLink('uom');
let quantity: number = this.quantity ?? 1; let quantity: number = this.quantity ?? 1;
if (this.isReturn && quantity > 0) {
quantity *= -1;
}
if (!this.isReturn && quantity < 0) {
quantity *= -1;
}
if (fieldname === 'transferQuantity') { if (fieldname === 'transferQuantity') {
quantity = this.transferQuantity! * this.unitConversionFactor!; quantity = this.transferQuantity! * this.unitConversionFactor!;
} }
@ -225,6 +238,8 @@ export abstract class InvoiceItem extends Doc {
'transferQuantity', 'transferQuantity',
'transferUnit', 'transferUnit',
'unitConversionFactor', 'unitConversionFactor',
'item',
'isReturn',
], ],
}, },
unitConversionFactor: { unitConversionFactor: {

View File

@ -36,6 +36,7 @@ export class Payment extends Transactional {
amount?: Money; amount?: Money;
writeoff?: Money; writeoff?: Money;
paymentType?: PaymentType; paymentType?: PaymentType;
referenceType?: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice;
for?: PaymentFor[]; for?: PaymentFor[];
_accountsMap?: AccountTypeMap; _accountsMap?: AccountTypeMap;
@ -300,7 +301,7 @@ export class Payment extends Transactional {
)) as Invoice; )) as Invoice;
outstandingAmount = outstandingAmount.add( outstandingAmount = outstandingAmount.add(
referenceDoc.outstandingAmount ?? 0 referenceDoc.outstandingAmount?.abs() ?? 0
); );
} }
@ -438,6 +439,15 @@ export class Payment extends Transactional {
'referenceName' 'referenceName'
)) as Invoice | null; )) 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; return refDoc?.account ?? null;
} }
@ -495,13 +505,21 @@ export class Payment extends Transactional {
} }
const partyDoc = (await this.loadAndGetLink('party')) as Party; 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') { if (partyDoc.role === 'Supplier') {
return 'Pay'; return 'Pay';
} else if (partyDoc.role === 'Customer') { } else if (partyDoc.role === 'Customer') {
return 'Receive'; return 'Receive';
} }
const outstanding = partyDoc.outstandingAmount as Money;
if (outstanding?.isZero() ?? true) { if (outstanding?.isZero() ?? true) {
return this.paymentType; return this.paymentType;
} }
@ -520,6 +538,14 @@ export class Payment extends Transactional {
formula: () => this.amount!.sub(this.writeoff!), formula: () => this.amount!.sub(this.writeoff!),
dependsOn: ['amount', 'writeoff', 'for'], dependsOn: ['amount', 'writeoff', 'for'],
}, },
referenceType: {
formula: () => {
if (this.referenceType) {
return;
}
return this.for![0].referenceType;
},
},
}; };
validations: ValidationMap = { validations: ValidationMap = {
@ -534,7 +560,7 @@ export class Payment extends Transactional {
return; return;
} }
const amount = this.getSum('for', 'amount', false); const amount = (this.getSum('for', 'amount', false) as Money).abs();
if ((value as Money).gt(amount)) { if ((value as Money).gt(amount)) {
throw new ValidationError( throw new ValidationError(

View File

@ -1,2 +1,12 @@
export type PaymentType = 'Receive' | 'Pay'; export type PaymentType = 'Receive' | 'Pay';
export type PaymentMethod = 'Cash' | 'Cheque' | 'Transfer'; export type PaymentMethod = 'Cash' | 'Cheque' | 'Transfer';
export enum PaymentTypeEnum{
Receive = 'Receive',
Pay = 'Pay'
}
export enum AccountFieldEnum{
Account = 'account',
PaymentAccount = 'paymentAccount'
}

View File

@ -12,14 +12,26 @@ export class PurchaseInvoice extends Invoice {
async getPosting() { async getPosting() {
const exchangeRate = this.exchangeRate ?? 1; const exchangeRate = this.exchangeRate ?? 1;
const posting: LedgerPosting = new LedgerPosting(this, this.fyo); 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!) { 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)); await posting.debit(item.account!, item.amount!.mul(exchangeRate));
} }
if (this.taxes) { if (this.taxes) {
for (const tax of 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)); await posting.debit(tax.account!, tax.amount!.mul(exchangeRate));
} }
} }
@ -28,7 +40,11 @@ export class PurchaseInvoice extends Invoice {
const discountAccount = this.fyo.singles.AccountingSettings const discountAccount = this.fyo.singles.AccountingSettings
?.discountAccount as string | undefined; ?.discountAccount as string | undefined;
if (discountAccount && discountAmount.isPositive()) { 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(); await posting.makeRoundOffEntry();

View File

@ -12,14 +12,26 @@ export class SalesInvoice extends Invoice {
async getPosting() { async getPosting() {
const exchangeRate = this.exchangeRate ?? 1; const exchangeRate = this.exchangeRate ?? 1;
const posting: LedgerPosting = new LedgerPosting(this, this.fyo); 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!) { 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)); await posting.credit(item.account!, item.amount!.mul(exchangeRate));
} }
if (this.taxes) { if (this.taxes) {
for (const tax of 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)); await posting.credit(tax.account!, tax.amount!.mul(exchangeRate));
} }
} }
@ -28,7 +40,11 @@ export class SalesInvoice extends Invoice {
const discountAccount = this.fyo.singles.AccountingSettings const discountAccount = this.fyo.singles.AccountingSettings
?.discountAccount as string | undefined; ?.discountAccount as string | undefined;
if (discountAccount && discountAmount.isPositive()) { 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(); await posting.makeRoundOffEntry();

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

View File

@ -1,11 +1,8 @@
import test from 'tape'; import test from 'tape';
import { getDefaultMetaFieldValueMap } from 'backend/helpers';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { getItem } from 'models/inventory/tests/helpers'; import { getItem } from 'models/inventory/tests/helpers';
import { SalesInvoiceItem } from '../SalesInvoiceItem/SalesInvoiceItem';
import { SalesInvoice } from '../SalesInvoice/SalesInvoice'; import { SalesInvoice } from '../SalesInvoice/SalesInvoice';
import { PurchaseInvoiceItem } from '../PurchaseInvoiceItem/PurchaseInvoiceItem';
const fyo = getTestFyo(); const fyo = getTestFyo();
setupTestFyo(fyo, __filename); setupTestFyo(fyo, __filename);

View File

@ -23,6 +23,7 @@ export function getInvoiceActions(
getMakePaymentAction(fyo), getMakePaymentAction(fyo),
getMakeStockTransferAction(fyo, schemaName), getMakeStockTransferAction(fyo, schemaName),
getLedgerLinkAction(fyo), getLedgerLinkAction(fyo),
getMakeReturnDocAction(fyo),
]; ];
} }
@ -97,11 +98,13 @@ export function getMakePaymentAction(fyo: Fyo): Action {
condition: (doc: Doc) => condition: (doc: Doc) =>
doc.isSubmitted && !(doc.outstandingAmount as Money).isZero(), doc.isSubmitted && !(doc.outstandingAmount as Money).isZero(),
action: async (doc, router) => { action: async (doc, router) => {
const schemaName = doc.schema.name;
const payment = (doc as Invoice).getPayment(); const payment = (doc as Invoice).getPayment();
if (!payment) { if (!payment) {
return; return;
} }
await payment?.set('referenceType', schemaName);
const currentRoute = router.currentRoute.value.fullPath; const currentRoute = router.currentRoute.value.fullPath;
payment.once('afterSync', async () => { payment.once('afterSync', async () => {
await payment.submit(); await payment.submit();
@ -109,7 +112,12 @@ export function getMakePaymentAction(fyo: Fyo): Action {
await router.push(currentRoute); 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) { if (doc.schemaName === ModelNameEnum.SalesInvoice) {
hideFields.push('account'); hideFields.push('account');
} else { } else {
@ -166,11 +174,17 @@ export function getMakeReturnDocAction(fyo: Fyo): Action {
label: fyo.t`Return`, label: fyo.t`Return`,
group: fyo.t`Create`, group: fyo.t`Create`,
condition: (doc: Doc) => condition: (doc: Doc) =>
!!fyo.singles.InventorySettings?.enableStockReturns && (!!fyo.singles.AccountingSettings?.enableInvoiceReturns ||
!!fyo.singles.InventorySettings?.enableStockReturns) &&
doc.isSubmitted && doc.isSubmitted &&
!doc.isReturn, !doc.isReturn,
action: async (doc: Doc) => { 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) { if (!returnDoc || !returnDoc.name) {
return; return;
} }
@ -297,6 +311,14 @@ function getSubmittableDocStatus(doc: RenderData | Doc) {
} }
export function getInvoiceStatus(doc: RenderData | Doc): InvoiceStatus { 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 ( if (
doc.submitted && doc.submitted &&
!doc.cancelled && !doc.cancelled &&

View File

@ -86,6 +86,13 @@
"default": false, "default": false,
"section": "Features" "section": "Features"
}, },
{
"fieldname": "enableInvoiceReturns",
"label": "Enable Invoice Returns",
"fieldtype": "Check",
"default": false,
"section": "Features"
},
{ {
"fieldname": "enableFormCustomization", "fieldname": "enableFormCustomization",
"label": "Enable Form Customization", "label": "Enable Form Customization",

View File

@ -181,10 +181,21 @@
"fieldtype": "Attachment", "fieldtype": "Attachment",
"section": "References" "section": "References"
}, },
{
"fieldname": "isReturned",
"fieldtype": "Check",
"hidden": true,
"default": false
},
{ {
"abstract": true, "abstract": true,
"fieldname": "backReference", "fieldname": "backReference",
"section": "References" "section": "References"
},
{
"abstract": true,
"fieldname": "returnAgainst",
"section": "References"
} }
], ],
"keywordFields": ["name", "party"] "keywordFields": ["name", "party"]

View File

@ -155,6 +155,24 @@
"label": "Attachment", "label": "Attachment",
"fieldtype": "Attachment", "fieldtype": "Attachment",
"section": "References" "section": "References"
},
{
"fieldname": "referenceType",
"label": "Type",
"placeholder": "Type",
"fieldtype": "Select",
"options": [
{
"value": "SalesInvoice",
"label": "Sales"
},
{
"value": "PurchaseInvoice",
"label": "Purchase"
}
],
"hidden": true,
"required": true
} }
], ],
"quickEditFields": [ "quickEditFields": [

View File

@ -54,6 +54,13 @@
"fieldtype": "Float", "fieldtype": "Float",
"readOnly": true, "readOnly": true,
"section": "Outstanding" "section": "Outstanding"
},
{
"fieldname": "returnAgainst",
"fieldtype": "Link",
"target": "PurchaseInvoice",
"label": "Return Against",
"section": "References"
} }
], ],
"keywordFields": ["name", "party"] "keywordFields": ["name", "party"]

View File

@ -54,6 +54,13 @@
"fieldtype": "Float", "fieldtype": "Float",
"readOnly": true, "readOnly": true,
"section": "Outstanding" "section": "Outstanding"
},
{
"fieldname": "returnAgainst",
"fieldtype": "Link",
"target": "SalesInvoice",
"label": "Return Against",
"section": "References"
} }
], ],
"keywordFields": ["name", "party"] "keywordFields": ["name", "party"]

View File

@ -92,6 +92,14 @@ function getSubmittableStatus(doc: Doc) {
return 'Cancelled'; return 'Cancelled';
} }
if (doc.returnAgainst && doc.isSubmitted) {
return 'Return';
}
if (doc.isReturned && doc.isSubmitted) {
return 'ReturnIssued';
}
const isInvoice = doc instanceof Invoice; const isInvoice = doc instanceof Invoice;
if ( if (
doc.isSubmitted && doc.isSubmitted &&
@ -113,14 +121,6 @@ function getSubmittableStatus(doc: Doc) {
return 'Paid'; return 'Paid';
} }
if (doc.returnAgainst && doc.isSubmitted) {
return 'Return';
}
if (doc.isReturned && doc.isSubmitted) {
return 'ReturnIssued';
}
if (doc.isSubmitted) { if (doc.isSubmitted) {
return 'Submitted'; return 'Submitted';
} }

View File

@ -1,9 +1,15 @@
import { ModelNameEnum } from 'models/types';
export const routeFilters = { export const routeFilters = {
SalesItems: { for: ['in', ['Sales', 'Both']] }, SalesItems: { for: ['in', ['Sales', 'Both']] },
PurchaseItems: { for: ['in', ['Purchases', 'Both']] }, PurchaseItems: { for: ['in', ['Purchases', 'Both']] },
Items: { for: 'Both' }, Items: { for: 'Both' },
PurchasePayments: { paymentType: 'Pay' }, PurchasePayments: {
SalesPayments: { paymentType: 'Receive' }, referenceType: ModelNameEnum.PurchaseInvoice,
},
SalesPayments: {
referenceType: ModelNameEnum.SalesInvoice,
},
Suppliers: { role: ['in', ['Supplier', 'Both']] }, Suppliers: { role: ['in', ['Supplier', 'Both']] },
Customers: { role: ['in', ['Customer', 'Both']] }, Customers: { role: ['in', ['Customer', 'Both']] },
Party: { role: 'Both' }, Party: { role: 'Both' },