2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 23:30:56 +00:00
books/models/baseModels/Payment/Payment.ts
Mildred Ki'Lya 2bffcda8ff feat: #755 Accounting for taxes on payments
When defining taxes, it is possible to define an additional payment
account that will be used during payments to move taxes from the
original tax account to this new payment tax account. This allows to
account for taxes only when payment is received.

Now payments can reference tax summary objects that will reference the
two accounts to move funds between when the payment is committed. Reuse
some of the Invoice code to generate these tax summary objects.
2023-12-22 10:07:47 +01:00

747 lines
19 KiB
TypeScript

import { Fyo, t } from 'fyo';
import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import {
Action,
ChangeArg,
DefaultMap,
FiltersMap,
FormulaMap,
HiddenMap,
ListViewSettings,
RequiredMap,
ValidationMap,
} from 'fyo/model/types';
import { NotFoundError, ValidationError } from 'fyo/utils/errors';
import {
getDocStatusListColumn,
getLedgerLinkAction,
getNumberSeries,
} from 'models/helpers';
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { QueryFilter } from 'utils/db/types';
import { AccountTypeEnum } from '../Account/types';
import { Invoice } from '../Invoice/Invoice';
import { Party } from '../Party/Party';
import { PaymentFor } from '../PaymentFor/PaymentFor';
import { PaymentMethod, PaymentType } from './types';
import { TaxSummary } from '../TaxSummary/TaxSummary';
type AccountTypeMap = Record<AccountTypeEnum, string[] | undefined>;
export class Payment extends Transactional {
taxes?: TaxSummary[];
party?: string;
amount?: Money;
writeoff?: Money;
paymentType?: PaymentType;
referenceType?: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice;
for?: PaymentFor[];
_accountsMap?: AccountTypeMap;
async change({ changed }: ChangeArg) {
if (changed === 'for') {
this.updateAmountOnReferenceUpdate();
await this.updateDetailsOnReferenceUpdate();
}
if (changed === 'amount') {
this.updateReferenceOnAmountUpdate();
}
}
async updateDetailsOnReferenceUpdate() {
const forReferences = (this.for ?? []) as Doc[];
const { referenceType, referenceName } = forReferences[0] ?? {};
if (
forReferences.length !== 1 ||
this.party ||
this.paymentType ||
!referenceName ||
!referenceType
) {
return;
}
const schemaName = referenceType as string;
const doc = (await this.fyo.doc.getDoc(
schemaName,
referenceName as string
)) as Invoice;
let paymentType: PaymentType;
if (doc.isSales) {
paymentType = 'Receive';
} else {
paymentType = 'Pay';
}
this.party = doc.party as string;
this.paymentType = paymentType;
}
updateAmountOnReferenceUpdate() {
this.amount = this.fyo.pesa(0);
for (const paymentReference of (this.for ?? []) as Doc[]) {
this.amount = this.amount.add(paymentReference.amount as Money);
}
}
updateReferenceOnAmountUpdate() {
const forReferences = (this.for ?? []) as Doc[];
if (forReferences.length !== 1) {
return;
}
forReferences[0].amount = this.amount;
}
async validate() {
await super.validate();
if (this.submitted) {
return;
}
await this.validateFor();
this.validateAccounts();
this.validateTotalReferenceAmount();
await this.validateReferences();
}
async validateFor() {
for (const childDoc of this.for ?? []) {
const referenceName = childDoc.referenceName;
const referenceType = childDoc.referenceType;
const refDoc = (await this.fyo.doc.getDoc(
childDoc.referenceType!,
childDoc.referenceName
)) as Invoice;
if (referenceName && referenceType && !refDoc) {
throw new ValidationError(
t`${referenceType} of type ${
this.fyo.schemaMap?.[referenceType]?.label ?? referenceType
} does not exist`
);
}
if (!refDoc) {
continue;
}
if (refDoc?.party !== this.party) {
throw new ValidationError(
t`${refDoc.name!} party ${refDoc.party!} is different from ${this
.party!}`
);
}
}
}
validateAccounts() {
if (this.paymentAccount !== this.account || !this.account) {
return;
}
throw new this.fyo.errors.ValidationError(
t`To Account and From Account can't be the same: ${
this.account as string
}`
);
}
validateTotalReferenceAmount() {
const forReferences = (this.for ?? []) as Doc[];
if (forReferences.length === 0) {
return;
}
const referenceAmountTotal = forReferences
.map(({ amount }) => amount as Money)
.reduce((a, b) => a.add(b), this.fyo.pesa(0));
if (
(this.amount as Money)
.add((this.writeoff as Money) ?? 0)
.gte(referenceAmountTotal)
) {
return;
}
const writeoff = this.fyo.format(this.writeoff!, 'Currency');
const payment = this.fyo.format(this.amount!, 'Currency');
const refAmount = this.fyo.format(referenceAmountTotal, 'Currency');
if ((this.writeoff as Money).gt(0)) {
throw new ValidationError(
this.fyo.t`Amount: ${payment} and writeoff: ${writeoff}
is less than the total amount allocated to
references: ${refAmount}.`
);
}
throw new ValidationError(
this.fyo.t`Amount: ${payment} is less than the total
amount allocated to references: ${refAmount}.`
);
}
async validateWriteOffAccount() {
if ((this.writeoff as Money).isZero()) {
return;
}
const writeOffAccount = this.fyo.singles.AccountingSettings!
.writeOffAccount as string | null | undefined;
if (!writeOffAccount) {
throw new NotFoundError(
t`Write Off Account not set.
Please set Write Off Account in General Settings`,
false
);
}
const exists = await this.fyo.db.exists(
ModelNameEnum.Account,
writeOffAccount
);
if (exists) {
return;
}
throw new NotFoundError(
t`Write Off Account ${writeOffAccount} does not exist.
Please set Write Off Account in General Settings`,
false
);
}
async getTaxSummary() {
const taxes: Record<
string,
Record<
string,
{
account: string;
from_account: string;
rate: number;
amount: Money;
}
>
> = {};
for (const childDoc of this.for ?? []) {
const referenceName = childDoc.referenceName;
const referenceType = childDoc.referenceType;
const refDoc = (await this.fyo.doc.getDoc(
childDoc.referenceType!,
childDoc.referenceName
)) as Invoice;
if (referenceName && referenceType && !refDoc) {
throw new ValidationError(
t`${referenceType} of type ${
this.fyo.schemaMap?.[referenceType]?.label ?? referenceType
} does not exist`
);
}
if (!refDoc) {
continue;
}
for (const {
details,
taxAmount,
exchangeRate,
} of await refDoc.getTaxItems()) {
const { account, payment_account } = details;
if (!payment_account) {
continue;
}
taxes[payment_account] ??= {};
taxes[payment_account][account] ??= {
account: payment_account,
from_account: account,
rate: details.rate,
amount: this.fyo.pesa(0),
};
taxes[payment_account][account].amount = taxes[payment_account][
account
].amount.add(taxAmount.mul(exchangeRate ?? 1));
}
}
type Summary = typeof taxes[string][string] & { idx: number };
const taxArr: Summary[] = [];
let idx = 0;
for (const payment_account in taxes) {
for (const account in taxes[payment_account]) {
const tax = taxes[payment_account][account];
if (tax.amount.isZero()) {
continue;
}
taxArr.push({
...tax,
idx,
});
idx += 1;
}
}
return taxArr;
}
async getPosting() {
/**
* account : From Account
* paymentAccount : To Account
*
* if Receive
* - account : Debtors, etc
* - paymentAccount : Cash, Bank, etc
*
* if Pay
* - account : Cash, Bank, etc
* - paymentAccount : Creditors, etc
*/
await this.validateWriteOffAccount();
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
const paymentAccount = this.paymentAccount as string;
const account = this.account as string;
const amount = this.amount as Money;
await posting.debit(paymentAccount, amount);
await posting.credit(account, amount);
if (this.taxes) {
if (this.paymentType === 'Receive') {
for (const tax of this.taxes) {
await posting.debit(tax.from_account!, tax.amount!);
await posting.credit(tax.account!, tax.amount!);
}
} else if (this.paymentType === 'Pay') {
for (const tax of this.taxes) {
await posting.credit(tax.from_account!, tax.amount!);
await posting.debit(tax.account!, tax.amount!);
}
}
}
await this.applyWriteOffPosting(posting);
return posting;
}
async applyWriteOffPosting(posting: LedgerPosting) {
const writeoff = this.writeoff as Money;
if (writeoff.isZero()) {
return posting;
}
const account = this.account as string;
const paymentAccount = this.paymentAccount as string;
const writeOffAccount = this.fyo.singles.AccountingSettings!
.writeOffAccount as string;
if (this.paymentType === 'Pay') {
await posting.credit(paymentAccount, writeoff);
await posting.debit(writeOffAccount, writeoff);
} else {
await posting.debit(account, writeoff);
await posting.credit(writeOffAccount, writeoff);
}
}
async validateReferences() {
const forReferences = this.for ?? [];
if (forReferences.length === 0) {
return;
}
for (const row of forReferences) {
this.validateReferenceType(row);
}
await this.validateReferenceOutstanding();
}
validateReferenceType(row: PaymentFor) {
const referenceType = row.referenceType;
if (
![ModelNameEnum.SalesInvoice, ModelNameEnum.PurchaseInvoice].includes(
referenceType!
)
) {
throw new ValidationError(t`Please select a valid reference type.`);
}
}
async validateReferenceOutstanding() {
let outstandingAmount = this.fyo.pesa(0);
for (const row of this.for ?? []) {
const referenceDoc = (await this.fyo.doc.getDoc(
row.referenceType as string,
row.referenceName as string
)) as Invoice;
outstandingAmount = outstandingAmount.add(
referenceDoc.outstandingAmount?.abs() ?? 0
);
}
const amount = this.amount as Money;
if (amount.gt(0) && amount.lte(outstandingAmount)) {
return;
}
let message = this.fyo.t`Payment amount: ${this.fyo.format(
this.amount!,
'Currency'
)} should be less than Outstanding amount: ${this.fyo.format(
outstandingAmount,
'Currency'
)}.`;
if (amount.lte(0)) {
const amt = this.fyo.format(this.amount!, 'Currency');
message = this.fyo.t`Payment amount: ${amt} should be greater than 0.`;
}
throw new ValidationError(message);
}
async afterSubmit() {
await super.afterSubmit();
await this.updateReferenceDocOutstanding();
await this.updatePartyOutstanding();
}
async updateReferenceDocOutstanding() {
for (const row of this.for ?? []) {
const referenceDoc = await this.fyo.doc.getDoc(
row.referenceType!,
row.referenceName
);
const previousOutstandingAmount = referenceDoc.outstandingAmount as Money;
const outstandingAmount = previousOutstandingAmount.sub(row.amount!);
await referenceDoc.setAndSync({ outstandingAmount });
}
}
async afterCancel() {
await super.afterCancel();
await this.revertOutstandingAmount();
}
async revertOutstandingAmount() {
await this._revertReferenceOutstanding();
await this.updatePartyOutstanding();
}
async _revertReferenceOutstanding() {
for (const ref of this.for ?? []) {
const refDoc = await this.fyo.doc.getDoc(
ref.referenceType!,
ref.referenceName
);
const outstandingAmount = (refDoc.outstandingAmount as Money).add(
ref.amount!
);
await refDoc.setAndSync({ outstandingAmount });
}
}
async updatePartyOutstanding() {
const partyDoc = (await this.fyo.doc.getDoc(
ModelNameEnum.Party,
this.party
)) as Party;
await partyDoc.updateOutstandingAmount();
}
static defaults: DefaultMap = {
numberSeries: (doc) => getNumberSeries(doc.schemaName, doc.fyo),
date: () => new Date(),
};
async _getAccountsMap(): Promise<AccountTypeMap> {
if (this._accountsMap) {
return this._accountsMap;
}
const accounts = (await this.fyo.db.getAll(ModelNameEnum.Account, {
fields: ['name', 'accountType'],
filters: {
accountType: [
'in',
[
AccountTypeEnum.Bank,
AccountTypeEnum.Cash,
AccountTypeEnum.Payable,
AccountTypeEnum.Receivable,
],
],
},
})) as { name: string; accountType: AccountTypeEnum }[];
return (this._accountsMap = accounts.reduce((acc, ac) => {
acc[ac.accountType] ??= [];
acc[ac.accountType]!.push(ac.name);
return acc;
}, {} as AccountTypeMap));
}
async _getReferenceAccount() {
const account = await this._getAccountFromParty();
if (!account) {
return await this._getAccountFromFor();
}
return account;
}
async _getAccountFromParty() {
const party = (await this.loadAndGetLink('party')) as Party | null;
if (!party || party.role === 'Both') {
return null;
}
return party.defaultAccount ?? null;
}
async _getAccountFromFor() {
const reference = this?.for?.[0];
if (!reference) {
return null;
}
const refDoc = (await reference.loadAndGetLink(
'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;
}
formulas: FormulaMap = {
account: {
formula: async () => {
const accountsMap = await this._getAccountsMap();
if (this.paymentType === 'Receive') {
return (
(await this._getReferenceAccount()) ??
accountsMap[AccountTypeEnum.Receivable]?.[0] ??
null
);
}
if (this.paymentMethod === 'Cash') {
return accountsMap[AccountTypeEnum.Cash]?.[0] ?? null;
}
if (this.paymentMethod !== 'Cash') {
return accountsMap[AccountTypeEnum.Bank]?.[0] ?? null;
}
return null;
},
dependsOn: ['paymentMethod', 'paymentType', 'party'],
},
paymentAccount: {
formula: async () => {
const accountsMap = await this._getAccountsMap();
if (this.paymentType === 'Pay') {
return (
(await this._getReferenceAccount()) ??
accountsMap[AccountTypeEnum.Payable]?.[0] ??
null
);
}
if (this.paymentMethod === 'Cash') {
return accountsMap[AccountTypeEnum.Cash]?.[0] ?? null;
}
if (this.paymentMethod !== 'Cash') {
return accountsMap[AccountTypeEnum.Bank]?.[0] ?? null;
}
return null;
},
dependsOn: ['paymentMethod', 'paymentType', 'party'],
},
paymentType: {
formula: async () => {
if (!this.party) {
return;
}
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';
}
if (outstanding?.isZero() ?? true) {
return this.paymentType;
}
if (outstanding?.isPositive()) {
return 'Receive';
}
return 'Pay';
},
},
amount: {
formula: () => this.getSum('for', 'amount', false),
dependsOn: ['for'],
},
amountPaid: {
formula: () => this.amount!.sub(this.writeoff!),
dependsOn: ['amount', 'writeoff', 'for'],
},
referenceType: {
formula: () => {
if (this.referenceType) {
return;
}
return this.for![0].referenceType;
},
},
taxes: { formula: async () => await this.getTaxSummary() },
};
validations: ValidationMap = {
amount: (value: DocValue) => {
if ((value as Money).isNegative()) {
throw new ValidationError(
this.fyo.t`Payment amount cannot be less than zero.`
);
}
if (((this.for ?? []) as Doc[]).length === 0) {
return;
}
const amount = (this.getSum('for', 'amount', false) as Money).abs();
if ((value as Money).gt(amount)) {
throw new ValidationError(
this.fyo.t`Payment amount cannot
exceed ${this.fyo.format(amount, 'Currency')}.`
);
} else if ((value as Money).isZero()) {
throw new ValidationError(
this.fyo.t`Payment amount cannot
be ${this.fyo.format(value, 'Currency')}.`
);
}
},
};
required: RequiredMap = {
referenceId: () => this.paymentMethod !== 'Cash',
clearanceDate: () => this.paymentMethod !== 'Cash',
};
hidden: HiddenMap = {
referenceId: () => this.paymentMethod === 'Cash',
clearanceDate: () => this.paymentMethod === 'Cash',
amountPaid: () => this.writeoff?.isZero() ?? true,
attachment: () =>
!(this.attachment || !(this.isSubmitted || this.isCancelled)),
for: () => !!((this.isSubmitted || this.isCancelled) && !this.for?.length),
taxes: () => !this.taxes?.length,
};
static filters: FiltersMap = {
party: (doc: Doc) => {
const paymentType = (doc as Payment).paymentType;
if (paymentType === 'Pay') {
return { role: ['in', ['Supplier', 'Both']] } as QueryFilter;
}
if (paymentType === 'Receive') {
return { role: ['in', ['Customer', 'Both']] } as QueryFilter;
}
return {};
},
numberSeries: () => {
return { referenceType: 'Payment' };
},
account: (doc: Doc) => {
const paymentType = doc.paymentType as PaymentType;
const paymentMethod = doc.paymentMethod as PaymentMethod;
if (paymentType === 'Receive') {
return { accountType: 'Receivable', isGroup: false };
}
if (paymentMethod === 'Cash') {
return { accountType: 'Cash', isGroup: false };
} else {
return { accountType: ['in', ['Bank', 'Cash']], isGroup: false };
}
},
paymentAccount: (doc: Doc) => {
const paymentType = doc.paymentType as PaymentType;
const paymentMethod = doc.paymentMethod as PaymentMethod;
if (paymentType === 'Pay') {
return { accountType: 'Payable', isGroup: false };
}
if (paymentMethod === 'Cash') {
return { accountType: 'Cash', isGroup: false };
} else {
return { accountType: ['in', ['Bank', 'Cash']], isGroup: false };
}
},
};
static getActions(fyo: Fyo): Action[] {
return [getLedgerLinkAction(fyo)];
}
static getListViewSettings(): ListViewSettings {
return {
columns: ['name', getDocStatusListColumn(), 'party', 'date', 'amount'],
};
}
}