import { Fyo } from 'fyo'; import { DocValueMap } from 'fyo/core/types'; import { Doc } from 'fyo/model/doc'; import { CurrenciesMap, DefaultMap, FiltersMap, FormulaMap, HiddenMap, } from 'fyo/model/types'; import { DEFAULT_CURRENCY } from 'fyo/utils/consts'; import { ValidationError } from 'fyo/utils/errors'; import { Transactional } from 'models/Transactional/Transactional'; import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers'; import { InventorySettings } from 'models/inventory/InventorySettings'; import { StockTransfer } from 'models/inventory/StockTransfer'; import { validateBatch } from 'models/inventory/helpers'; import { ModelNameEnum } from 'models/types'; import { Money } from 'pesa'; import { FieldTypeEnum, Schema } from 'schemas/types'; import { getIsNullOrUndef, joinMapLists, safeParseFloat } from 'utils'; import { Defaults } from '../Defaults/Defaults'; import { InvoiceItem } from '../InvoiceItem/InvoiceItem'; import { Item } from '../Item/Item'; import { Party } from '../Party/Party'; import { Payment } from '../Payment/Payment'; import { Tax } from '../Tax/Tax'; import { TaxSummary } from '../TaxSummary/TaxSummary'; export abstract class Invoice extends Transactional { _taxes: Record = {}; taxes?: TaxSummary[]; items?: InvoiceItem[]; party?: string; account?: string; currency?: string; netTotal?: Money; grandTotal?: Money; baseGrandTotal?: Money; outstandingAmount?: Money; exchangeRate?: number; setDiscountAmount?: boolean; discountAmount?: Money; discountPercent?: number; discountAfterTax?: boolean; stockNotTransferred?: number; submitted?: boolean; cancelled?: boolean; makeAutoPayment?: boolean; get isSales() { return this.schemaName === 'SalesInvoice'; } get enableDiscounting() { return !!this.fyo.singles?.AccountingSettings?.enableDiscounting; } get isMultiCurrency() { if (!this.currency) { return false; } return this.fyo.singles.SystemSettings!.currency !== this.currency; } get companyCurrency() { return this.fyo.singles.SystemSettings?.currency ?? DEFAULT_CURRENCY; } get stockTransferSchemaName() { return this.isSales ? ModelNameEnum.Shipment : ModelNameEnum.PurchaseReceipt; } get hasLinkedTransfers() { if (!this.submitted) { return false; } return this.getStockTransferred() > 0; } get hasLinkedPayments() { if (!this.submitted) { return false; } return !this.baseGrandTotal?.eq(this.outstandingAmount!); } get autoPaymentAccount(): string | null { const fieldname = this.isSales ? 'salesPaymentAccount' : 'purchasePaymentAccount'; const value = this.fyo.singles.Defaults?.[fieldname]; if (typeof value === 'string' && value.length) { return value; } return null; } constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { super(schema, data, fyo); this._setGetCurrencies(); } async validate() { await super.validate(); if ( this.enableDiscounting && !this.fyo.singles?.AccountingSettings?.discountAccount ) { throw new ValidationError(this.fyo.t`Discount Account is not set.`); } await validateBatch(this); } async afterSubmit() { await super.afterSubmit(); // update outstanding amounts await this.fyo.db.update(this.schemaName, { name: this.name as string, outstandingAmount: this.baseGrandTotal!, }); const party = (await this.fyo.doc.getDoc('Party', this.party!)) as Party; await party.updateOutstandingAmount(); if (this.makeAutoPayment && this.autoPaymentAccount) { const payment = this.getPayment(); await payment?.sync(); await payment?.submit(); await this.load(); } } async afterCancel() { await super.afterCancel(); await this._cancelPayments(); await this._updatePartyOutStanding(); } async _cancelPayments() { const paymentIds = await this.getPaymentIds(); for (const paymentId of paymentIds) { const paymentDoc = (await this.fyo.doc.getDoc( 'Payment', paymentId )) as Payment; await paymentDoc.cancel(); } } async _updatePartyOutStanding() { const partyDoc = (await this.fyo.doc.getDoc( ModelNameEnum.Party, this.party! )) as Party; await partyDoc.updateOutstandingAmount(); } async afterDelete() { await super.afterDelete(); const paymentIds = await this.getPaymentIds(); for (const name of paymentIds) { const paymentDoc = await this.fyo.doc.getDoc(ModelNameEnum.Payment, name); await paymentDoc.delete(); } } async getPaymentIds() { const payments = (await this.fyo.db.getAll('PaymentFor', { fields: ['parent'], filters: { referenceType: this.schemaName, referenceName: this.name! }, orderBy: 'name', })) as { parent: string }[]; if (payments.length != 0) { return [...new Set(payments.map(({ parent }) => parent))]; } return []; } async getExchangeRate() { if (!this.currency) { return 1.0; } const currency = await this.fyo.getValue( ModelNameEnum.SystemSettings, 'currency' ); if (this.currency === currency) { return 1.0; } const exchangeRate = await getExchangeRate({ fromCurrency: this.currency!, toCurrency: currency as string, }); return safeParseFloat(exchangeRate.toFixed(2)); } async getTaxSummary() { const taxes: Record< string, { account: string; rate: number; amount: Money; } > = {}; type TaxDetail = { account: string; rate: number }; for (const item of this.items ?? []) { if (!item.tax) { continue; } const tax = await this.getTax(item.tax!); for (const { account, rate } of (tax.details ?? []) as TaxDetail[]) { taxes[account] ??= { account, rate, amount: this.fyo.pesa(0), }; let amount = item.amount!; if (this.enableDiscounting && !this.discountAfterTax) { amount = item.itemDiscountedTotal!; } const taxAmount = amount.mul(rate / 100); taxes[account].amount = taxes[account].amount.add(taxAmount); } } type Summary = typeof taxes[string] & { idx: number }; const taxArr: Summary[] = []; let idx = 0; for (const account in taxes) { const tax = taxes[account]; if (tax.amount.isZero()) { continue; } taxArr.push({ ...tax, idx, }); idx += 1; } return taxArr; } async getTax(tax: string) { if (!this._taxes![tax]) { this._taxes[tax] = await this.fyo.doc.getDoc('Tax', tax); } return this._taxes[tax]; } getTotalDiscount() { if (!this.enableDiscounting) { return this.fyo.pesa(0); } const itemDiscountAmount = this.getItemDiscountAmount(); const invoiceDiscountAmount = this.getInvoiceDiscountAmount(); return itemDiscountAmount.add(invoiceDiscountAmount); } async getGrandTotal() { const totalDiscount = this.getTotalDiscount(); return ((this.taxes ?? []) as Doc[]) .map((doc) => doc.amount as Money) .reduce((a, b) => a.add(b), this.netTotal!) .sub(totalDiscount); } getInvoiceDiscountAmount() { if (!this.enableDiscounting) { return this.fyo.pesa(0); } if (this.setDiscountAmount) { return this.discountAmount ?? this.fyo.pesa(0); } let totalItemAmounts = this.fyo.pesa(0); for (const item of this.items ?? []) { if (this.discountAfterTax) { totalItemAmounts = totalItemAmounts.add(item.itemTaxedTotal!); } else { totalItemAmounts = totalItemAmounts.add(item.itemDiscountedTotal!); } } return totalItemAmounts.percent(this.discountPercent ?? 0); } getItemDiscountAmount() { if (!this.enableDiscounting) { return this.fyo.pesa(0); } if (!this?.items?.length) { return this.fyo.pesa(0); } let discountAmount = this.fyo.pesa(0); for (const item of this.items) { if (item.setItemDiscountAmount) { discountAmount = discountAmount.add( item.itemDiscountAmount ?? this.fyo.pesa(0) ); } else if (!this.discountAfterTax) { discountAmount = discountAmount.add( (item.amount ?? this.fyo.pesa(0)).mul( (item.itemDiscountPercent ?? 0) / 100 ) ); } else if (this.discountAfterTax) { discountAmount = discountAmount.add( (item.itemTaxedTotal ?? this.fyo.pesa(0)).mul( (item.itemDiscountPercent ?? 0) / 100 ) ); } } return discountAmount; } formulas: FormulaMap = { account: { formula: async () => { return (await this.fyo.getValue( 'Party', this.party!, 'defaultAccount' )) as string; }, dependsOn: ['party'], }, currency: { formula: async () => { const currency = (await this.fyo.getValue( 'Party', this.party!, 'currency' )) as string; if (!getIsNullOrUndef(currency)) { return currency; } return this.fyo.singles.SystemSettings!.currency as string; }, dependsOn: ['party'], }, exchangeRate: { formula: async () => { if ( this.currency === (this.fyo.singles.SystemSettings?.currency ?? DEFAULT_CURRENCY) ) { return 1; } if (this.exchangeRate && this.exchangeRate !== 1) { return this.exchangeRate; } return await this.getExchangeRate(); }, }, netTotal: { formula: async () => this.getSum('items', 'amount', false) }, taxes: { formula: async () => await this.getTaxSummary() }, grandTotal: { formula: async () => await this.getGrandTotal() }, baseGrandTotal: { formula: async () => (this.grandTotal as Money).mul(this.exchangeRate! ?? 1), }, outstandingAmount: { formula: async () => { if (this.submitted) { return; } return this.baseGrandTotal!; }, }, stockNotTransferred: { formula: async () => { if (this.submitted) { return; } return this.getStockNotTransferred(); }, dependsOn: ['items'], }, makeAutoPayment: { formula: () => !!this.autoPaymentAccount, dependsOn: [], }, }; getStockTransferred() { return (this.items ?? []).reduce( (acc, item) => (item.quantity ?? 0) - (item.stockNotTransferred ?? 0) + acc, 0 ); } getTotalQuantity() { return (this.items ?? []).reduce( (acc, item) => acc + (item.quantity ?? 0), 0 ); } getStockNotTransferred() { return (this.items ?? []).reduce( (acc, item) => (item.stockNotTransferred ?? 0) + acc, 0 ); } getItemDiscountedAmounts() { let itemDiscountedAmounts = this.fyo.pesa(0); for (const item of this.items ?? []) { itemDiscountedAmounts = itemDiscountedAmounts.add( item.itemDiscountedTotal ?? item.amount! ); } return itemDiscountedAmounts; } hidden: HiddenMap = { makeAutoPayment: () => { if (this.submitted) { return true; } if (!this.autoPaymentAccount) { return true; } return false; }, setDiscountAmount: () => true || !this.enableDiscounting, discountAmount: () => true || !(this.enableDiscounting && !!this.setDiscountAmount), discountPercent: () => true || !(this.enableDiscounting && !this.setDiscountAmount), discountAfterTax: () => !this.enableDiscounting, taxes: () => !this.taxes?.length, baseGrandTotal: () => this.exchangeRate === 1 || this.baseGrandTotal!.isZero(), grandTotal: () => !this.taxes?.length, entryCurrency: () => !this.isMultiCurrency, currency: () => !this.isMultiCurrency, exchangeRate: () => !this.isMultiCurrency, stockNotTransferred: () => !this.stockNotTransferred, outstandingAmount: () => !!this.outstandingAmount?.isZero() || !this.isSubmitted, terms: () => !(this.terms || !(this.isSubmitted || this.isCancelled)), attachment: () => !(this.attachment || !(this.isSubmitted || this.isCancelled)), }; static defaults: DefaultMap = { makeAutoPayment: (doc) => doc instanceof Invoice && !!doc.autoPaymentAccount, numberSeries: (doc) => getNumberSeries(doc.schemaName, doc.fyo), terms: (doc) => { const defaults = doc.fyo.singles.Defaults as Defaults | undefined; if (doc.schemaName === ModelNameEnum.SalesInvoice) { return defaults?.salesInvoiceTerms ?? ''; } return defaults?.purchaseInvoiceTerms ?? ''; }, date: () => new Date(), }; static filters: FiltersMap = { party: (doc: Doc) => ({ role: ['in', [doc.isSales ? 'Customer' : 'Supplier', 'Both']], }), account: (doc: Doc) => ({ isGroup: false, accountType: doc.isSales ? 'Receivable' : 'Payable', }), numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }), }; static createFilters: FiltersMap = { party: (doc: Doc) => ({ role: doc.isSales ? 'Customer' : 'Supplier', }), }; getCurrencies: CurrenciesMap = { baseGrandTotal: () => this.companyCurrency, outstandingAmount: () => this.companyCurrency, }; _getCurrency() { if (this.exchangeRate === 1) { return this.companyCurrency; } return this.currency ?? DEFAULT_CURRENCY; } _setGetCurrencies() { const currencyFields = this.schema.fields.filter( ({ fieldtype }) => fieldtype === FieldTypeEnum.Currency ); for (const { fieldname } of currencyFields) { this.getCurrencies[fieldname] ??= this._getCurrency.bind(this); } } getPayment(): Payment | null { if (!this.isSubmitted) { return null; } const outstandingAmount = this.outstandingAmount; if (!outstandingAmount) { return null; } if (this.outstandingAmount?.isZero()) { return null; } const accountField = this.isSales ? 'account' : 'paymentAccount'; const data = { party: this.party, date: new Date().toISOString().slice(0, 10), paymentType: this.isSales ? 'Receive' : 'Pay', amount: this.outstandingAmount, [accountField]: this.account, for: [ { referenceType: this.schemaName, referenceName: this.name, amount: this.outstandingAmount, }, ], }; if (this.makeAutoPayment && this.autoPaymentAccount) { const autoPaymentAccount = this.isSales ? 'paymentAccount' : 'account'; data[autoPaymentAccount] = this.autoPaymentAccount; } return this.fyo.doc.getNewDoc(ModelNameEnum.Payment, data) as Payment; } async getStockTransfer(): Promise { if (!this.isSubmitted) { return null; } if (!this.stockNotTransferred) { return null; } const schemaName = this.stockTransferSchemaName; const defaults = (this.fyo.singles.Defaults as Defaults) ?? {}; let terms; if (this.isSales) { terms = defaults.shipmentTerms ?? ''; } else { terms = defaults.purchaseReceiptTerms ?? ''; } const data = { party: this.party, date: new Date().toISOString(), terms, backReference: this.name, }; const location = (this.fyo.singles.InventorySettings as InventorySettings) .defaultLocation ?? null; const transfer = this.fyo.doc.getNewDoc(schemaName, data) as StockTransfer; for (const row of this.items ?? []) { if (!row.item) { continue; } const itemDoc = (await row.loadAndGetLink('item')) as Item; const item = row.item; const quantity = row.stockNotTransferred; const trackItem = itemDoc.trackItem; const batch = row.batch || null; let rate = row.rate as Money; if (this.exchangeRate && this.exchangeRate > 1) { rate = rate.mul(this.exchangeRate); } if (!quantity || !trackItem) { continue; } await transfer.append('items', { item, quantity, location, rate, batch, }); } if (!transfer.items?.length) { return null; } return transfer; } async beforeCancel(): Promise { await super.beforeCancel(); await this._validateStockTransferCancelled(); } async beforeDelete(): Promise { await super.beforeCancel(); await this._validateStockTransferCancelled(); await this._deleteCancelledStockTransfers(); } async _deleteCancelledStockTransfers() { const schemaName = this.stockTransferSchemaName; const transfers = await this._getLinkedStockTransferNames(true); for (const { name } of transfers) { const st = await this.fyo.doc.getDoc(schemaName, name); await st.delete(); } } async _validateStockTransferCancelled() { const schemaName = this.stockTransferSchemaName; const transfers = await this._getLinkedStockTransferNames(false); if (!transfers?.length) { return; } const names = transfers.map(({ name }) => name).join(', '); const label = this.fyo.schemaMap[schemaName]?.label ?? schemaName; throw new ValidationError( this.fyo.t`Cannot cancel ${this.schema.label} ${this .name!} because of the following ${label}: ${names}` ); } async _getLinkedStockTransferNames(cancelled: boolean) { const name = this.name; if (!name) { throw new ValidationError(`Name not found for ${this.schema.label}`); } const schemaName = this.stockTransferSchemaName; const transfers = (await this.fyo.db.getAllRaw(schemaName, { fields: ['name'], filters: { backReference: this.name!, cancelled }, })) as { name: string }[]; return transfers; } async getLinkedPayments() { if (!this.hasLinkedPayments) { return []; } const paymentFors = (await this.fyo.db.getAllRaw('PaymentFor', { fields: ['parent', 'amount'], filters: { referenceName: this.name!, referenceType: this.schemaName }, })) as { parent: string; amount: string }[]; const payments = (await this.fyo.db.getAllRaw('Payment', { fields: ['name', 'date', 'submitted', 'cancelled'], filters: { name: ['in', paymentFors.map((p) => p.parent)] }, })) as { name: string; date: string; submitted: number; cancelled: number; }[]; return joinMapLists(payments, paymentFors, 'name', 'parent') .map((j) => ({ name: j.name, date: new Date(j.date), submitted: !!j.submitted, cancelled: !!j.cancelled, amount: this.fyo.pesa(j.amount), })) .sort((a, b) => a.date.valueOf() - b.date.valueOf()); } async getLinkedStockTransfers() { if (!this.hasLinkedTransfers) { return []; } const schemaName = this.stockTransferSchemaName; const transfers = (await this.fyo.db.getAllRaw(schemaName, { fields: ['name', 'date', 'submitted', 'cancelled'], filters: { backReference: this.name! }, })) as { name: string; date: string; submitted: number; cancelled: number; }[]; const itemSchemaName = schemaName + 'Item'; const transferItems = (await this.fyo.db.getAllRaw(itemSchemaName, { fields: ['parent', 'quantity', 'location', 'amount'], filters: { parent: ['in', transfers.map((t) => t.name)], item: ['in', this.items!.map((i) => i.item!)], }, })) as { parent: string; quantity: number; location: string; amount: string; }[]; return joinMapLists(transfers, transferItems, 'name', 'parent') .map((j) => ({ name: j.name, date: new Date(j.date), submitted: !!j.submitted, cancelled: !!j.cancelled, amount: this.fyo.pesa(j.amount), location: j.location, quantity: j.quantity, })) .sort((a, b) => a.date.valueOf() - b.date.valueOf()); } async addItem(name: string) { return await addItem(name, this); } }