2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 19:09:01 +00:00

incr: abstract away transactional

- add deletion
- fix reversion
This commit is contained in:
18alantom 2022-05-03 18:43:47 +05:30
parent 9a012207f1
commit 20ea214a4b
26 changed files with 738 additions and 531 deletions

View File

@ -234,6 +234,10 @@ export default class DatabaseCore extends DatabaseBase {
options: GetAllOptions = {}
): Promise<FieldValueMap[]> {
const schema = this.schemaMap[schemaName] as Schema;
if (schema === undefined) {
throw new NotFoundError(`schema ${schemaName} not found`);
}
const hasCreated = !!schema.fields.find((f) => f.fieldname === 'created');
const {
fields = ['name', ...(schema.keywordFields ?? [])],

View File

@ -25,6 +25,7 @@ import {
import { setName } from './naming';
import {
Action,
ChangeArg,
CurrenciesMap,
DefaultMap,
EmptyMessageMap,
@ -37,7 +38,7 @@ import {
ReadOnlyMap,
RequiredMap,
TreeViewSettings,
ValidationMap
ValidationMap,
} from './types';
import { validateOptions, validateRequired } from './validationFunction';
@ -106,6 +107,14 @@ export class Doc extends Observable<DocValue | Doc[]> {
return fieldnames.map((f) => this.fieldMap[f]);
}
get isSubmitted() {
return !!this.submitted && !this.cancelled;
}
get isCancelled() {
return !!this.submitted && !!this.cancelled;
}
_setValuesWithoutChecks(data: DocValueMap) {
for (const field of this.schema.fields) {
const fieldname = field.fieldname;
@ -131,7 +140,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
// set value and trigger change
async set(
fieldname: string | DocValueMap,
value?: DocValue | Doc[]
value?: DocValue | Doc[] | DocValueMap[]
): Promise<boolean> {
if (typeof fieldname === 'object') {
return await this.setMultiple(fieldname as DocValueMap);
@ -143,10 +152,9 @@ export class Doc extends Observable<DocValue | Doc[]> {
this._setDirty(true);
if (Array.isArray(value)) {
this[fieldname] = value.map((row, i) => {
row.idx = i;
return row;
});
for (const row of value) {
this.push(fieldname, row);
}
} else {
const field = this.fieldMap[fieldname];
await this._validateField(field, value);
@ -177,7 +185,10 @@ export class Doc extends Observable<DocValue | Doc[]> {
return hasSet;
}
_canSet(fieldname: string, value?: DocValue | Doc[]): boolean {
_canSet(
fieldname: string,
value?: DocValue | Doc[] | DocValueMap[]
): boolean {
if (fieldname === 'numberSeries' && !this.notInserted) {
return false;
}
@ -610,6 +621,10 @@ export class Doc extends Observable<DocValue | Doc[]> {
}
async delete() {
if (this.schema.isSubmittable && !this.isCancelled) {
return;
}
await this.trigger('beforeDelete');
await this.fyo.db.delete(this.schemaName, this.name!);
await this.trigger('afterDelete');
@ -619,7 +634,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
}
async submit() {
if (!this.schema.isSubmittable || this.cancelled) {
if (!this.schema.isSubmittable || this.submitted || this.cancelled) {
return;
}
@ -631,7 +646,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
}
async cancel() {
if (!this.schema.isSubmittable || !this.submitted) {
if (!this.schema.isSubmittable || !this.submitted || this.cancelled) {
return;
}
@ -727,14 +742,24 @@ export class Doc extends Observable<DocValue | Doc[]> {
return doc;
}
/**
* Lifecycle Methods
*
* Abstractish methods that are called using `this.trigger`.
* These are to be overridden if required when subclassing.
*/
async change(ch: ChangeArg) {}
async validate() {}
async beforeSync() {}
async afterSync() {}
async beforeDelete() {}
async afterDelete() {}
async beforeSubmit() {}
async afterSubmit() {}
async beforeRename() {}
async afterRename() {}
async beforeCancel() {}
async afterCancel() {}
async beforeDelete() {}
async afterDelete() {}
formulas: FormulaMap = {};
validations: ValidationMap = {};

View File

@ -35,6 +35,8 @@ export type CurrenciesMap = Record<string, GetCurrency | undefined>;
export type HiddenMap = Record<string, Hidden | undefined>;
export type ReadOnlyMap = Record<string, ReadOnly | undefined>;
export type ChangeArg = { doc: Doc; changed: string };
/**
* Should add this for hidden too
*/

View File

@ -0,0 +1,207 @@
import { Fyo, t } from 'fyo';
import { ValidationError } from 'fyo/utils/errors';
import { Account } from 'models/baseModels/Account/Account';
import { AccountingLedgerEntry } from 'models/baseModels/AccountingLedgerEntry/AccountingLedgerEntry';
import { ModelNameEnum } from 'models/types';
import Money from 'pesa/dist/types/src/money';
import { Transactional } from './Transactional';
import { AccountBalanceChange, TransactionType } from './types';
/**
* # LedgerPosting
*
* Class that maintains a set of AccountingLedgerEntries pertaining to
* a single Transactional doc, example a Payment entry.
*
* For each account touched in a transactional doc a separate ledger entry is
* created.
*
* This is done using the `debit(...)` and `credit(...)` methods which also
* keep track of the change in balance to an account.
*
* Unless `post()` or `postReverese()` is called the entries aren't made.
*/
export class LedgerPosting {
fyo: Fyo;
refDoc: Transactional;
entries: AccountingLedgerEntry[];
entryMap: Record<string, AccountingLedgerEntry>;
reverted: boolean;
accountBalanceChanges: AccountBalanceChange[];
constructor(refDoc: Transactional, fyo: Fyo) {
this.fyo = fyo;
this.refDoc = refDoc;
this.entries = [];
this.entryMap = {};
this.reverted = false;
this.accountBalanceChanges = [];
}
async debit(account: string, amount: Money) {
const ledgerEntry = this._getLedgerEntry(account);
await ledgerEntry.set('debit', ledgerEntry.debit!.add(amount));
await this._updateAccountBalanceChange(account, 'debit', amount);
}
async credit(account: string, amount: Money) {
const ledgerEntry = this._getLedgerEntry(account);
await ledgerEntry.set('credit', ledgerEntry.credit!.add(amount));
await this._updateAccountBalanceChange(account, 'credit', amount);
}
async post() {
this._validateIsEqual();
await this._sync();
}
async postReverse() {
this._validateIsEqual();
await this._syncReverse();
}
validate() {
this._validateIsEqual();
}
async makeRoundOffEntry() {
const { debit, credit } = this._getTotalDebitAndCredit();
const difference = debit.sub(credit);
const absoluteValue = difference.abs();
if (absoluteValue.eq(0)) {
return;
}
const roundOffAccount = await this._getRoundOffAccount();
if (difference.gt(0)) {
this.credit(roundOffAccount, absoluteValue);
} else {
this.debit(roundOffAccount, absoluteValue);
}
}
async _updateAccountBalanceChange(
name: string,
type: TransactionType,
amount: Money
) {
const accountDoc = (await this.fyo.doc.getDoc('Account', name)) as Account;
let change: Money;
if (accountDoc.isDebit) {
change = type === 'debit' ? amount : amount.neg();
} else {
change = type === 'credit' ? amount : amount.neg();
}
this.accountBalanceChanges.push({
name,
change,
});
}
_getLedgerEntry(account: string): AccountingLedgerEntry {
if (this.entryMap[account]) {
return this.entryMap[account];
}
const ledgerEntry = this.fyo.doc.getNewDoc(
ModelNameEnum.AccountingLedgerEntry,
{
account: account,
party: (this.refDoc.party as string) ?? '',
date: this.refDoc.date as string | Date,
referenceType: this.refDoc.schemaName,
referenceName: this.refDoc.name!,
reverted: this.reverted,
debit: this.fyo.pesa(0),
credit: this.fyo.pesa(0),
}
) as AccountingLedgerEntry;
this.entries.push(ledgerEntry);
this.entryMap[account] = ledgerEntry;
return this.entryMap[account];
}
_validateIsEqual() {
const { debit, credit } = this._getTotalDebitAndCredit();
if (debit.eq(credit)) {
return;
}
throw new ValidationError(
t`Total Debit: ${this.fyo.format(
debit,
'Currency'
)} must be equal to Total Credit: ${this.fyo.format(credit, 'Currency')}`
);
}
_getTotalDebitAndCredit() {
let debit = this.fyo.pesa(0);
let credit = this.fyo.pesa(0);
for (const entry of this.entries) {
debit = debit.add(entry.debit!);
credit = credit.add(entry.credit!);
}
return { debit, credit };
}
async _sync() {
await this._syncLedgerEntries();
await this._syncBalanceChanges();
}
async _syncLedgerEntries() {
for (const entry of this.entries) {
await entry.sync();
}
}
async _syncReverse() {
await this._syncReverseLedgerEntries();
for (const entry of this.accountBalanceChanges) {
entry.change = (entry.change as Money).neg();
}
await this._syncBalanceChanges();
}
async _syncBalanceChanges() {
for (const { name, change } of this.accountBalanceChanges) {
const accountDoc = await this.fyo.doc.getDoc(ModelNameEnum.Account, name);
const balance = accountDoc.get('balance') as Money;
await accountDoc.setAndSync('balance', balance.add(change));
}
}
async _syncReverseLedgerEntries() {
const data = (await this.fyo.db.getAll('AccountingLedgerEntry', {
fields: ['name'],
filters: {
referenceType: this.refDoc.schemaName,
referenceName: this.refDoc.name!,
reverted: false,
},
})) as { name: string }[];
for (const { name } of data) {
const doc = (await this.fyo.doc.getDoc(
'AccountingLedgerEntry',
name
)) as AccountingLedgerEntry;
await doc.revert();
}
}
async _getRoundOffAccount() {
return (await this.fyo.getValue(
ModelNameEnum.AccountingSettings,
'roundOffAccount'
)) as string;
}
}

View File

@ -0,0 +1,57 @@
import { Doc } from 'fyo/model/doc';
import { ModelNameEnum } from 'models/types';
import { LedgerPosting } from './LedgerPosting';
/**
* # Transactional
*
* Any model that creates ledger entries on submit should extend the
* `Transactional` model.
*
* Example of transactional models:
* - Invoice
* - Payment
* - JournalEntry
*
* Basically it does the following:
* - `afterSubmit`: create the ledger entries.
* - `afterCancel`: create reverse ledger entries.
* - `afterDelete`: delete the normal and reversed ledger entries.
*/
export abstract class Transactional extends Doc {
isTransactional = true;
abstract getPosting(): Promise<LedgerPosting>;
async validate() {
const posting = await this.getPosting();
posting.validate();
}
async afterSubmit(): Promise<void> {
const posting = await this.getPosting();
await posting.post();
}
async afterCancel(): Promise<void> {
const posting = await this.getPosting();
await posting.postReverse();
}
async afterDelete(): Promise<void> {
const ledgerEntryIds = (await this.fyo.db.getAll(
ModelNameEnum.AccountingLedgerEntry,
{
fields: ['name'],
filters: {
referenceType: this.schemaName,
referenceName: this.name!,
},
}
)) as { name: string }[];
for (const { name } of ledgerEntryIds) {
await this.fyo.db.delete(ModelNameEnum.AccountingLedgerEntry, name);
}
}
}

View File

@ -4,24 +4,22 @@ import Money from 'pesa/dist/types/src/money';
export interface LedgerPostingOptions {
reference: Doc;
party?: string;
date?: string;
description?: string;
}
export interface LedgerEntry {
account: string;
party: string;
date: string;
referenceType: string;
referenceName: string;
description?: string;
reverted: boolean;
debit: Money;
credit: Money;
}
export interface AccountEntry {
export interface AccountBalanceChange {
name: string;
balanceChange: Money;
change: Money;
}
export type TransactionType = 'credit' | 'debit';

View File

@ -7,13 +7,25 @@ import {
TreeViewSettings,
} from 'fyo/model/types';
import { QueryFilter } from 'utils/db/types';
import { AccountRootType, AccountType } from './types';
import { AccountRootType, AccountRootTypeEnum, AccountType } from './types';
export class Account extends Doc {
rootType?: AccountRootType;
accountType?: AccountType;
parentAccount?: string;
get isDebit() {
const debitAccounts = [
AccountRootTypeEnum.Asset,
AccountRootTypeEnum.Expense,
] as AccountRootType[];
return debitAccounts.includes(this.rootType!);
}
get isCredit() {
return !this.isDebit;
}
static defaults: DefaultMap = {
/**
* NestedSet indices are actually not used
@ -55,7 +67,7 @@ export class Account extends Doc {
}
static filters: FiltersMap = {
parentAccount: (doc: Account) => {
parentAccount: (doc: Doc) => {
const filter: QueryFilter = {
isGroup: true,
};

View File

@ -1,7 +1,44 @@
import { Doc } from 'fyo/model/doc';
import { ListViewSettings } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import Money from 'pesa/dist/types/src/money';
export class AccountingLedgerEntry extends Doc {
date?: string | Date;
account?: string;
party?: string;
debit?: Money;
credit?: Money;
referenceType?: string;
referenceName?: string;
balance?: Money;
reverted?: boolean;
async revert() {
if (this.reverted) {
return;
}
await this.set('reverted', true);
const revertedEntry = this.fyo.doc.getNewDoc(
ModelNameEnum.AccountingLedgerEntry,
{
account: this.account,
party: this.party,
date: new Date(),
referenceType: this.referenceType,
referenceName: this.referenceName,
debit: this.credit,
credit: this.debit,
reverted: true,
reverts: this.name,
}
);
await this.sync();
await revertedEntry.sync();
}
static getListViewSettings(): ListViewSettings {
return {
columns: ['account', 'party', 'debit', 'credit', 'balance'],

View File

@ -2,7 +2,7 @@ import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { DefaultMap, FiltersMap, FormulaMap } from 'fyo/model/types';
import { getExchangeRate } from 'models/helpers';
import { LedgerPosting } from 'models/ledgerPosting/ledgerPosting';
import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types';
import Money from 'pesa/dist/types/src/money';
import { getIsNullOrUndef } from 'utils';
@ -11,7 +11,7 @@ import { Payment } from '../Payment/Payment';
import { Tax } from '../Tax/Tax';
import { TaxSummary } from '../TaxSummary/TaxSummary';
export abstract class Invoice extends Doc {
export abstract class Invoice extends Transactional {
_taxes: Record<string, Tax> = {};
taxes?: TaxSummary[];
@ -26,34 +26,12 @@ export abstract class Invoice extends Doc {
submitted?: boolean;
cancelled?: boolean;
abstract getPosting(): Promise<LedgerPosting>;
get isSales() {
return this.schemaName === 'SalesInvoice';
}
async getPayments() {
const payments = await this.fyo.db.getAll('PaymentFor', {
fields: ['parent'],
filters: { referenceName: this.name! },
orderBy: 'name',
});
if (payments.length != 0) {
return payments;
}
return [];
}
async beforeSync() {
const entries = await this.getPosting();
await entries.validateEntries();
}
async afterSubmit() {
// post ledger entries
const entries = await this.getPosting();
await entries.post();
await super.afterSubmit();
// update outstanding amounts
await this.fyo.db.update(this.schemaName, {
@ -65,29 +43,48 @@ export abstract class Invoice extends Doc {
await party.updateOutstandingAmount();
}
async afterCancel() {
const paymentRefList = await this.getPayments();
for (const paymentFor of paymentRefList) {
const paymentReference = paymentFor.parent;
const payment = (await this.fyo.doc.getDoc(
async beforeCancel() {
await super.beforeCancel();
const paymentIds = await this.getPaymentIds();
for (const paymentId of paymentIds) {
const paymentDoc = (await this.fyo.doc.getDoc(
'Payment',
paymentReference as string
paymentId
)) as Payment;
const paymentEntries = await payment.getPosting();
for (const entry of paymentEntries) {
await entry.postReverse();
}
// To set the payment status as unsubmitted.
await this.fyo.db.update('Payment', {
name: paymentReference,
submitted: false,
cancelled: true,
});
await paymentDoc.cancel();
}
const entries = await this.getPosting();
await entries.postReverse();
}
async afterCancel() {
await super.afterCancel();
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) {
await this.fyo.db.delete(ModelNameEnum.AccountingLedgerEntry, name);
}
}
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() {

View File

@ -8,14 +8,15 @@ import {
} from 'fyo/model/types';
import { DateTime } from 'luxon';
import { getLedgerLinkAction } from 'models/helpers';
import { Transactional } from 'models/Transactional/Transactional';
import Money from 'pesa/dist/types/src/money';
import { LedgerPosting } from '../../ledgerPosting/ledgerPosting';
import { LedgerPosting } from '../../Transactional/LedgerPosting';
export class JournalEntry extends Doc {
export class JournalEntry extends Transactional {
accounts: Doc[] = [];
getPosting() {
const entries = new LedgerPosting({ reference: this }, this.fyo);
async getPosting() {
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
for (const row of this.accounts) {
const debit = row.debit as Money;
@ -23,25 +24,13 @@ export class JournalEntry extends Doc {
const account = row.account as string;
if (!debit.isZero()) {
entries.debit(account, debit);
await posting.debit(account, debit);
} else if (!credit.isZero()) {
entries.credit(account, credit);
await posting.credit(account, credit);
}
}
return entries;
}
async beforeSync() {
this.getPosting().validateEntries();
}
async beforeSubmit() {
await this.getPosting().post();
}
async afterCancel() {
await this.getPosting().postReverse();
return posting;
}
static defaults: DefaultMap = {

View File

@ -15,39 +15,55 @@ import { PartyRole } from './types';
export class Party extends Doc {
async updateOutstandingAmount() {
/**
* If Role === "Both" then outstanding Amount
* will be the amount to be paid to the party.
*/
const role = this.role as PartyRole;
switch (role) {
case 'Customer':
await this._updateOutstandingAmount('SalesInvoice');
break;
case 'Supplier':
await this._updateOutstandingAmount('PurchaseInvoice');
break;
case 'Both':
await this._updateOutstandingAmount('SalesInvoice');
await this._updateOutstandingAmount('PurchaseInvoice');
break;
let outstandingAmount = this.fyo.pesa(0);
if (role === 'Customer' || role === 'Both') {
const outstandingReceive = await this._getTotalOutstandingAmount(
'SalesInvoice'
);
outstandingAmount = outstandingAmount.add(outstandingReceive);
}
if (role === 'Supplier') {
const outstandingPay = await this._getTotalOutstandingAmount(
'PurchaseInvoice'
);
outstandingAmount = outstandingAmount.add(outstandingPay);
}
if (role === 'Both') {
const outstandingPay = await this._getTotalOutstandingAmount(
'PurchaseInvoice'
);
outstandingAmount = outstandingAmount.sub(outstandingPay);
}
await this.setAndSync({ outstandingAmount });
}
async _updateOutstandingAmount(
async _getTotalOutstandingAmount(
schemaName: 'SalesInvoice' | 'PurchaseInvoice'
) {
const outstandingAmounts = (
await this.fyo.db.getAllRaw(schemaName, {
fields: ['outstandingAmount', 'party'],
filters: { submitted: true },
})
).filter(({ party }) => party === this.name);
const outstandingAmounts = await this.fyo.db.getAllRaw(schemaName, {
fields: ['outstandingAmount'],
filters: {
submitted: true,
cancelled: false,
party: this.name as string,
},
});
const totalOutstanding = outstandingAmounts
return outstandingAmounts
.map(({ outstandingAmount }) =>
this.fyo.pesa(outstandingAmount as number)
)
.reduce((a, b) => a.add(b), this.fyo.pesa(0));
await this.set('outstandingAmount', totalOutstanding);
await this.sync();
}
formulas: FormulaMap = {

View File

@ -3,6 +3,7 @@ import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import {
Action,
ChangeArg,
DefaultMap,
FiltersMap,
FormulaMap,
@ -13,26 +14,29 @@ import {
} from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import { getLedgerLinkAction } from 'models/helpers';
import { LedgerPosting } from 'models/ledgerPosting/ledgerPosting';
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types';
import Money from 'pesa/dist/types/src/money';
import { getIsNullOrUndef } from 'utils';
import { Invoice } from '../Invoice/Invoice';
import { Party } from '../Party/Party';
import { PaymentFor } from '../PaymentFor/PaymentFor';
import { PaymentMethod, PaymentType } from './types';
export class Payment extends Doc {
export class Payment extends Transactional {
party?: string;
amount?: Money;
writeoff?: Money;
paymentType?: PaymentType;
async change({ changed }: { changed: string }) {
switch (changed) {
case 'for': {
this.updateAmountOnReferenceUpdate();
await this.updateDetailsOnReferenceUpdate();
}
case 'amount': {
this.updateReferenceOnAmountUpdate();
}
async change({ changed }: ChangeArg) {
if (changed === 'for') {
this.updateAmountOnReferenceUpdate();
await this.updateDetailsOnReferenceUpdate();
}
if (changed === 'amount') {
this.updateReferenceOnAmountUpdate();
}
}
@ -51,20 +55,19 @@ export class Payment extends Doc {
}
const schemaName = referenceType as string;
const doc = await this.fyo.doc.getDoc(schemaName, referenceName as string);
const doc = (await this.fyo.doc.getDoc(
schemaName,
referenceName as string
)) as Invoice;
let party;
let paymentType: PaymentType;
if (schemaName === 'SalesInvoice') {
party = doc.customer;
if (doc.isSales) {
paymentType = 'Receive';
} else {
party = doc.supplier;
paymentType = 'Pay';
}
this.party = party as string;
this.party = doc.party as string;
this.paymentType = paymentType;
}
@ -87,21 +90,26 @@ export class Payment extends Doc {
}
async validate() {
await super.validate();
this.validateAccounts();
this.validateReferenceAmount();
this.validateWriteOff();
this.validateTotalReferenceAmount();
this.validateWriteOffAccount();
await this.validateReferences();
}
validateAccounts() {
if (this.paymentAccount !== this.account || !this.account) {
return;
}
throw new this.fyo.errors.ValidationError(
`To Account and From Account can't be the same: ${this.account}`
t`To Account and From Account can't be the same: ${
this.account as string
}`
);
}
validateReferenceAmount() {
validateTotalReferenceAmount() {
const forReferences = (this.for ?? []) as Doc[];
if (forReferences.length === 0) {
return;
@ -137,7 +145,7 @@ export class Payment extends Doc {
);
}
validateWriteOff() {
validateWriteOffAccount() {
if ((this.writeoff as Money).isZero()) {
return;
}
@ -155,131 +163,134 @@ export class Payment extends Doc {
const paymentAccount = this.paymentAccount as string;
const amount = this.amount as Money;
const writeoff = this.writeoff as Money;
const entries = new LedgerPosting(
{
reference: this,
party: this.party!,
},
this.fyo
);
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
await entries.debit(paymentAccount as string, amount.sub(writeoff));
await entries.credit(account as string, amount.sub(writeoff));
await posting.debit(paymentAccount as string, amount.sub(writeoff));
await posting.credit(account as string, amount.sub(writeoff));
this.applyWriteOffPosting(posting);
return posting;
}
async applyWriteOffPosting(posting: LedgerPosting) {
const writeoff = this.writeoff as Money;
if (writeoff.isZero()) {
return [entries];
return posting;
}
const writeoffEntry = new LedgerPosting(
{
reference: this,
party: this.party!,
},
this.fyo
);
const account = this.account as string;
const writeOffAccount = this.fyo.singles.AccountingSettings!
.writeOffAccount as string;
if (this.paymentType === 'Pay') {
await writeoffEntry.debit(account, writeoff);
await writeoffEntry.credit(writeOffAccount, writeoff);
await posting.credit(account, writeoff);
await posting.debit(writeOffAccount, writeoff);
} else {
await writeoffEntry.debit(writeOffAccount, writeoff);
await writeoffEntry.credit(account, writeoff);
await posting.debit(account, writeoff);
await posting.credit(writeOffAccount, writeoff);
}
return [entries, writeoffEntry];
}
async beforeSubmit() {
const forReferences = (this.for ?? []) as Doc[];
async validateReferences() {
const forReferences = (this.for ?? []) as PaymentFor[];
if (forReferences.length === 0) {
return;
}
for (const row of forReferences) {
if (
!['SalesInvoice', 'PurchaseInvoice'].includes(
row.referenceType as string
)
) {
continue;
}
const referenceDoc = await this.fyo.doc.getDoc(
row.referenceType as string,
row.referenceName as string
);
let outstandingAmount = referenceDoc.outstandingAmount as Money;
const baseGrandTotal = referenceDoc.baseGrandTotal as Money;
const amount = this.amount as Money;
if (getIsNullOrUndef(outstandingAmount)) {
outstandingAmount = baseGrandTotal;
}
if (amount.lte(0) || amount.gt(outstandingAmount)) {
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);
} else {
// update outstanding amounts in invoice and party
const newOutstanding = outstandingAmount.sub(amount);
await referenceDoc.set('outstandingAmount', newOutstanding);
await referenceDoc.sync();
const party = (await this.fyo.doc.getDoc(
'Party',
this.party!
)) as Party;
await party.updateOutstandingAmount();
}
this.validateReferenceType(row);
await this.validateReferenceOutstanding(row);
}
}
async afterSubmit() {
const entryList = await this.getPosting();
for (const entry of entryList) {
await entry.post();
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(row: PaymentFor) {
const referenceDoc = await this.fyo.doc.getDoc(
row.referenceType as string,
row.referenceName as string
);
const outstandingAmount = referenceDoc.outstandingAmount as Money;
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 beforeSubmit() {
await this.updateReferenceDocOutstanding();
await this.updatePartyOutstanding();
}
async updateReferenceDocOutstanding() {
for (const row of (this.for ?? []) as PaymentFor[]) {
const referenceDoc = await this.fyo.doc.getDoc(
row.referenceType!,
row.referenceName!
);
const previousOutstandingAmount = referenceDoc.outstandingAmount as Money;
const outstandingAmount = previousOutstandingAmount.sub(this.amount!);
await referenceDoc.setAndSync({ outstandingAmount });
}
}
async afterCancel() {
this.updateReferenceOutstandingAmount();
const entryList = await this.getPosting();
for (const entry of entryList) {
await entry.postReverse();
await super.afterCancel();
this.revertOutstandingAmount();
}
async revertOutstandingAmount() {
await this._revertReferenceOutstanding();
await this.updatePartyOutstanding();
}
async _revertReferenceOutstanding() {
for (const ref of this.for as PaymentFor[]) {
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 updateReferenceOutstandingAmount() {
await (this.for as Doc[]).forEach(
async ({ amount, referenceType, referenceName }) => {
const refDoc = await this.fyo.doc.getDoc(
referenceType as string,
referenceName as string
);
refDoc.setMultiple({
outstandingAmount: (refDoc.outstandingAmount as Money).add(
amount as Money
),
});
refDoc.sync();
}
);
async updatePartyOutstanding() {
const partyDoc = (await this.fyo.doc.getDoc(
ModelNameEnum.Party,
this.party!
)) as Party;
await partyDoc.updateOutstandingAmount();
}
static defaults: DefaultMap = { date: () => new Date().toISOString() };
@ -334,11 +345,12 @@ export class Payment extends Doc {
required: RequiredMap = {
referenceId: () => this.paymentMethod !== 'Cash',
clearanceDate: () => this.paymentMethod === 'Cash',
clearanceDate: () => this.paymentMethod !== 'Cash',
};
hidden: HiddenMap = {
referenceId: () => this.paymentMethod !== 'Cash',
referenceId: () => this.paymentMethod === 'Cash',
clearanceDate: () => this.paymentMethod === 'Cash',
};
static filters: FiltersMap = {

View File

@ -1,9 +1,41 @@
import { Doc } from 'fyo/model/doc';
import { FiltersMap, FormulaMap } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import Money from 'pesa/dist/types/src/money';
import { PartyRoleEnum } from '../Party/types';
import { Payment } from '../Payment/Payment';
export class PaymentFor extends Doc {
parentdoc?: Payment | undefined;
referenceType?: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice;
referenceName?: string;
amount?: Money;
formulas: FormulaMap = {
referenceType: {
formula: async () => {
if (this.referenceType) {
return;
}
const party = this.parentdoc!.party;
if (party === undefined) {
return ModelNameEnum.SalesInvoice;
}
const role = await this.fyo.getValue(
ModelNameEnum.Party,
party,
'role'
);
if (role === PartyRoleEnum.Supplier) {
return ModelNameEnum.PurchaseInvoice;
}
return ModelNameEnum.SalesInvoice;
},
},
amount: {
formula: async () => {
if (!this.referenceName) {
@ -27,8 +59,19 @@ export class PaymentFor extends Doc {
};
static filters: FiltersMap = {
referenceName: () => ({
outstandingAmount: ['>', 0],
}),
referenceName: (doc) => {
const baseFilters = {
outstandingAmount: ['>', 0],
submitted: true,
cancelled: false,
};
const party = doc?.parentdoc?.party as undefined | string;
if (party === undefined) {
return baseFilters;
}
return { ...baseFilters, party };
},
};
}

View File

@ -1,6 +1,6 @@
import { Fyo } from 'fyo';
import { Action, ListViewSettings } from 'fyo/model/types';
import { LedgerPosting } from 'models/ledgerPosting/ledgerPosting';
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { ModelNameEnum } from 'models/types';
import { getInvoiceActions, getTransactionStatusColumn } from '../../helpers';
import { Invoice } from '../Invoice/Invoice';
@ -10,28 +10,22 @@ export class PurchaseInvoice extends Invoice {
items?: PurchaseInvoiceItem[];
async getPosting() {
const entries: LedgerPosting = new LedgerPosting(
{
reference: this,
party: this.party,
},
this.fyo
);
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
await entries.credit(this.account!, this.baseGrandTotal!);
await posting.credit(this.account!, this.baseGrandTotal!);
for (const item of this.items!) {
await entries.debit(item.account!, item.baseAmount!);
await posting.debit(item.account!, item.baseAmount!);
}
if (this.taxes) {
for (const tax of this.taxes) {
await entries.debit(tax.account!, tax.baseAmount!);
await posting.debit(tax.account!, tax.baseAmount!);
}
}
entries.makeRoundOffEntry();
return entries;
await posting.makeRoundOffEntry();
return posting;
}
static getActions(fyo: Fyo): Action[] {

View File

@ -1,6 +1,6 @@
import { Fyo } from 'fyo';
import { Action, ListViewSettings } from 'fyo/model/types';
import { LedgerPosting } from 'models/ledgerPosting/ledgerPosting';
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { ModelNameEnum } from 'models/types';
import { getInvoiceActions, getTransactionStatusColumn } from '../../helpers';
import { Invoice } from '../Invoice/Invoice';
@ -10,26 +10,21 @@ export class SalesInvoice extends Invoice {
items?: SalesInvoiceItem[];
async getPosting() {
const entries: LedgerPosting = new LedgerPosting(
{
reference: this,
party: this.party,
},
this.fyo
);
await entries.debit(this.account!, this.baseGrandTotal!);
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
await posting.debit(this.account!, this.baseGrandTotal!);
for (const item of this.items!) {
await entries.credit(item.account!, item.baseAmount!);
await posting.credit(item.account!, item.baseAmount!);
}
if (this.taxes) {
for (const tax of this.taxes!) {
await entries.credit(tax.account!, tax.baseAmount!);
await posting.credit(tax.account!, tax.baseAmount!);
}
}
entries.makeRoundOffEntry();
return entries;
await posting.makeRoundOffEntry();
return posting;
}
static getActions(fyo: Fyo): Action[] {

View File

@ -7,26 +7,6 @@ import Money from 'pesa/dist/types/src/money';
import { Router } from 'vue-router';
import { InvoiceStatus, ModelNameEnum } from './types';
export function getLedgerLinkAction(fyo: Fyo): Action {
return {
label: fyo.t`Ledger Entries`,
condition: (doc: Doc) => !!doc.submitted,
action: async (doc: Doc, router: Router) => {
router.push({
name: 'Report',
params: {
reportName: 'general-ledger',
defaultFilters: {
// @ts-ignore
referenceType: doc.schemaName,
referenceName: doc.name,
},
},
});
},
};
}
export function getInvoiceActions(
schemaName: ModelNameEnum.PurchaseInvoice | ModelNameEnum.SalesInvoice,
fyo: Fyo
@ -35,14 +15,15 @@ export function getInvoiceActions(
{
label: fyo.t`Make Payment`,
condition: (doc: Doc) =>
(doc.submitted as boolean) && (doc.outstandingAmount as Money).gt(0),
doc.isSubmitted && !(doc.outstandingAmount as Money).isZero(),
action: async function makePayment(doc: Doc) {
const payment = await fyo.doc.getNewDoc('Payment');
payment.once('afterSync', async () => {
await payment.submit();
});
const isSales = schemaName === 'SalesInvoice';
const party = isSales ? doc.customer : doc.supplier;
const party = doc.party as string;
const paymentType = isSales ? 'Receive' : 'Pay';
const hideAccountField = isSales ? 'account' : 'paymentAccount';
@ -71,6 +52,26 @@ export function getInvoiceActions(
];
}
export function getLedgerLinkAction(fyo: Fyo): Action {
return {
label: fyo.t`Ledger Entries`,
condition: (doc: Doc) => doc.isSubmitted,
action: async (doc: Doc, router: Router) => {
router.push({
name: 'Report',
params: {
reportName: 'general-ledger',
defaultFilters: {
// @ts-ignore
referenceType: doc.schemaName,
referenceName: doc.name,
},
},
});
},
};
}
export function getTransactionStatusColumn(): ColumnConfig {
const statusMap = {
Unpaid: t`Unpaid`,

View File

@ -1,209 +0,0 @@
import { Fyo } from 'fyo';
import { Doc } from 'fyo/model/doc';
import { ValidationError } from 'fyo/utils/errors';
import Money from 'pesa/dist/types/src/money';
import {
AccountEntry,
LedgerEntry,
LedgerPostingOptions,
TransactionType,
} from './types';
export class LedgerPosting {
reference: Doc;
party?: string;
date?: string;
description?: string;
entries: LedgerEntry[];
entryMap: Record<string, LedgerEntry>;
reverted: boolean;
accountEntries: AccountEntry[];
fyo: Fyo;
constructor(
{ reference, party, date, description }: LedgerPostingOptions,
fyo: Fyo
) {
this.reference = reference;
this.party = party;
this.date = date;
this.description = description ?? '';
this.entries = [];
this.entryMap = {};
this.reverted = false;
// To change balance while entering ledger entries
this.accountEntries = [];
this.fyo = fyo;
}
async debit(
account: string,
amount: Money,
referenceType?: string,
referenceName?: string
) {
const entry = this.getEntry(account, referenceType, referenceName);
entry.debit = entry.debit.add(amount);
await this.setAccountBalanceChange(account, 'debit', amount);
}
async credit(
account: string,
amount: Money,
referenceType?: string,
referenceName?: string
) {
const entry = this.getEntry(account, referenceType, referenceName);
entry.credit = entry.credit.add(amount);
await this.setAccountBalanceChange(account, 'credit', amount);
}
async setAccountBalanceChange(
accountName: string,
type: TransactionType,
amount: Money
) {
const debitAccounts = ['Asset', 'Expense'];
const accountDoc = await this.fyo.doc.getDoc('Account', accountName);
const rootType = accountDoc.rootType as string;
if (debitAccounts.indexOf(rootType) === -1) {
const change = type == 'credit' ? amount : amount.neg();
this.accountEntries.push({
name: accountName,
balanceChange: change,
});
} else {
const change = type == 'debit' ? amount : amount.neg();
this.accountEntries.push({
name: accountName,
balanceChange: change,
});
}
}
getEntry(account: string, referenceType?: string, referenceName?: string) {
if (!this.entryMap[account]) {
const entry: LedgerEntry = {
account: account,
party: this.party ?? '',
date: this.date ?? (this.reference.date as string),
referenceType: referenceType ?? this.reference.schemaName,
referenceName: referenceName ?? this.reference.name!,
description: this.description,
reverted: this.reverted,
debit: this.fyo.pesa(0),
credit: this.fyo.pesa(0),
};
this.entries.push(entry);
this.entryMap[account] = entry;
}
return this.entryMap[account];
}
async post() {
this.validateEntries();
await this.insertEntries();
}
async postReverse() {
this.validateEntries();
const data = await this.fyo.db.getAll('AccountingLedgerEntry', {
fields: ['name'],
filters: {
referenceName: this.reference.name!,
reverted: false,
},
});
for (const entry of data) {
const entryDoc = await this.fyo.doc.getDoc(
'AccountingLedgerEntry',
entry.name as string
);
entryDoc.reverted = true;
await entryDoc.sync();
}
let temp;
for (const entry of this.entries) {
temp = entry.debit;
entry.debit = entry.credit;
entry.credit = temp;
entry.reverted = true;
}
for (const entry of this.accountEntries) {
entry.balanceChange = (entry.balanceChange as Money).neg();
}
await this.insertEntries();
}
makeRoundOffEntry() {
const { debit, credit } = this.getTotalDebitAndCredit();
const difference = debit.sub(credit);
const absoluteValue = difference.abs();
const allowance = 0.5;
if (absoluteValue.eq(0)) {
return;
}
const roundOffAccount = this.getRoundOffAccount();
if (absoluteValue.lte(allowance)) {
if (difference.gt(0)) {
this.credit(roundOffAccount, absoluteValue);
} else {
this.debit(roundOffAccount, absoluteValue);
}
}
}
validateEntries() {
const { debit, credit } = this.getTotalDebitAndCredit();
if (debit.neq(credit)) {
throw new ValidationError(
`Total Debit: ${this.fyo.format(
debit,
'Currency'
)} must be equal to Total Credit: ${this.fyo.format(
credit,
'Currency'
)}`
);
}
}
getTotalDebitAndCredit() {
let debit = this.fyo.pesa(0);
let credit = this.fyo.pesa(0);
for (const entry of this.entries) {
debit = debit.add(entry.debit);
credit = credit.add(entry.credit);
}
return { debit, credit };
}
async insertEntries() {
for (const entry of this.entries) {
const entryDoc = this.fyo.doc.getNewDoc('AccountingLedgerEntry');
Object.assign(entryDoc, entry);
await entryDoc.sync();
}
for (const entry of this.accountEntries) {
const entryDoc = await this.fyo.doc.getDoc('Account', entry.name);
const balance = entryDoc.get('balance') as Money;
entryDoc.balance = balance.add(entry.balanceChange);
await entryDoc.sync();
}
}
getRoundOffAccount() {
return this.fyo.singles.AccountingSettings!.roundOffAccount as string;
}
}

View File

@ -1,6 +1,6 @@
{
"name": "AccountingLedgerEntry",
"label": "Ledger Entry",
"label": "Accounting Ledger Entry",
"isSingle": false,
"isChild": false,
"naming": "autoincrement",
@ -8,52 +8,68 @@
{
"fieldname": "date",
"label": "Date",
"fieldtype": "Date"
"fieldtype": "Date",
"readOnly": true
},
{
"fieldname": "account",
"label": "Account",
"fieldtype": "Link",
"target": "Account",
"required": true
"required": true,
"readOnly": true
},
{
"fieldname": "party",
"label": "Party",
"fieldtype": "Link",
"target": "Party"
"target": "Party",
"readOnly": true
},
{
"fieldname": "debit",
"label": "Debit",
"fieldtype": "Currency"
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "credit",
"label": "Credit",
"fieldtype": "Currency"
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "referenceType",
"label": "Ref. Type",
"fieldtype": "Data"
"fieldtype": "Data",
"readOnly": true
},
{
"fieldname": "referenceName",
"label": "Ref. Name",
"fieldtype": "DynamicLink",
"references": "referenceType"
"references": "referenceType",
"readOnly": true
},
{
"fieldname": "balance",
"label": "Balance",
"fieldtype": "Currency"
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "reverted",
"label": "Reverted",
"fieldtype": "Check",
"default": false
"default": false,
"readOnly": true
},
{
"fieldname": "reverts",
"label": "Reverts",
"fieldtype": "Link",
"target": "AccountingLedgerEntry",
"readOnly": true
}
],
"quickEditFields": [
@ -64,7 +80,9 @@
"credit",
"referenceType",
"referenceName",
"balance"
"balance",
"reverted",
"reverts"
],
"keywordFields": ["account", "party", "referenceName"]
}

View File

@ -12,11 +12,11 @@
"options": [
{
"value": "SalesInvoice",
"label": "Sales Invoice"
"label": "Sales"
},
{
"value": "PurchaseInvoice",
"label": "Purchase Invoice"
"label": "Purchase"
}
],
"required": true

View File

@ -57,7 +57,7 @@
:class="inputClasses"
:checked="value"
:readonly="isReadOnly"
@change="(e) => triggerChange(e.target.checked)"
@change="(e) => !isReadOnly && triggerChange(e.target.checked)"
@focus="(e) => $emit('focus', e)"
/>
</div>

View File

@ -6,7 +6,7 @@ export default {
extends: Link,
created() {
const watchKey = `doc.${this.df.references}`;
this.targetWatcher = this.$watch(watchKey, function(newTarget, oldTarget) {
this.targetWatcher = this.$watch(watchKey, function (newTarget, oldTarget) {
if (oldTarget && newTarget !== oldTarget) {
this.triggerChange('');
}
@ -16,13 +16,13 @@ export default {
this.targetWatcher();
},
methods: {
getTarget() {
if (!this.doc) {
throw new Error('You must provide `doc` for DynamicLink to work.');
getTargetSchemaName() {
if (!this.doc || !this.df.references) {
return null;
}
return this.doc[this.df.references];
}
}
},
},
};
</script>

View File

@ -27,8 +27,15 @@ export default {
},
},
methods: {
getTargetSchemaName() {
return this.df.target;
},
async getSuggestions(keyword = '') {
const schemaName = this.df.target;
const schemaName = this.getTargetSchemaName();
if (!schemaName) {
return [];
}
const schema = fyo.schemaMap[schemaName];
const filters = await this.getFilters(keyword);

View File

@ -101,6 +101,7 @@ export default {
],
filters: {
party: this.doc.name,
cancelled: false,
},
limit: 3,
orderBy: 'created',

View File

@ -11,7 +11,7 @@
>
{{ t`Print` }}
</Button>
<DropdownWithActions :actions="actions" />
<DropdownWithActions :actions="actions()" />
<Button
v-if="doc?.notInserted || doc?.dirty"
type="primary"
@ -245,8 +245,8 @@ export default {
Button,
FormControl,
DropdownWithActions,
Table
},
Table,
},
provide() {
return {
schemaName: this.schemaName,
@ -267,9 +267,6 @@ export default {
address() {
return this.printSettings && this.printSettings.getLink('address');
},
actions() {
return getActionsForDocument(this.doc);
},
},
async mounted() {
try {
@ -301,6 +298,9 @@ export default {
},
methods: {
routeTo,
actions() {
return getActionsForDocument(this.doc);
},
getField(fieldname) {
return fyo.getField(this.schemaName, fieldname);
},
@ -310,8 +310,8 @@ export default {
submit() {
const message =
this.schemaName === ModelNameEnum.SalesInvoice
? this.t`Are you sure you want to submit this Sales Invoice?`
: this.t`Are you sure you want to submit this Purchase Invoice?`;
? this.t`Submit Sales Invoice?`
: this.t`Submit Purchase Invoice?`;
showMessageDialog({
message,
buttons: [

View File

@ -161,9 +161,9 @@ import PageHeader from 'src/components/PageHeader';
import StatusBadge from 'src/components/StatusBadge';
import { fyo } from 'src/initFyo';
import {
getActionsForDocument,
routeTo,
showMessageDialog,
getActionsForDocument,
routeTo,
showMessageDialog
} from 'src/utils/ui';
import { handleErrorWithDialog } from '../errorHandling';
@ -242,7 +242,7 @@ export default {
},
async submit() {
showMessageDialog({
message: this.t`Are you sure you want to submit this Journal Entry?`,
message: this.t`Submit Journal Entry?`,
buttons: [
{
label: this.t`Yes`,

View File

@ -66,9 +66,6 @@ export async function openQuickEdit({
showFields,
hideFields,
defaults: stringifyCircular(defaults),
/*
lastRoute: currentRoute,
*/
},
});
}
@ -142,12 +139,16 @@ export async function routeTo(route: string | RouteLocationRaw) {
}
export function deleteDocWithPrompt(doc: Doc) {
const schemaLabel = fyo.schemaMap[doc.schemaName]!.label;
let detail = t`This action is permanent.`;
if (doc.isTransactional) {
detail = t`This action is permanent and will delete all ledger entries.`;
}
return new Promise((resolve) => {
showMessageDialog({
message: t`Are you sure you want to delete ${
doc.schemaName
} ${doc.name!}?`,
detail: t`This action is permanent`,
message: t`Delete ${schemaLabel} ${doc.name!}?`,
detail,
buttons: [
{
label: t`Delete`,
@ -204,10 +205,9 @@ export async function cancelDocWithPrompt(doc: Doc) {
}
return new Promise((resolve) => {
const schemaLabel = fyo.schemaMap[doc.schemaName]!.label;
showMessageDialog({
message: t`Are you sure you want to cancel ${
doc.schemaName
} ${doc.name!}?`,
message: t`Cancel ${schemaLabel} ${doc.name!}?`,
detail,
buttons: [
{
@ -276,7 +276,8 @@ function getDeleteAction(doc: Doc): Action {
template: '<span class="text-red-700">{{ t`Delete` }}</span>',
},
condition: (doc: Doc) =>
!doc.notInserted && !doc.submitted && !doc.schema.isSingle,
(!doc.notInserted && !doc.schema.isSubmittable && !doc.schema.isSingle) ||
doc.isCancelled,
action: () =>
deleteDocWithPrompt(doc).then((res) => {
if (res) {