2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +00:00

Merge pull request #418 from 18alantom/add-discounts

feat: add discounts
This commit is contained in:
Alan 2022-07-15 14:34:17 +05:30 committed by GitHub
commit 6154b3aa55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1226 additions and 165 deletions

View File

@ -2,7 +2,7 @@ import {
CannotCommitError,
getDbError,
NotFoundError,
ValueError,
ValueError
} from 'fyo/utils/errors';
import { knex, Knex } from 'knex';
import {
@ -11,12 +11,12 @@ import {
RawValue,
Schema,
SchemaMap,
TargetField,
TargetField
} from '../../schemas/types';
import {
getIsNullOrUndef,
getRandomString,
getValueMapFromList,
getValueMapFromList
} from '../../utils';
import { DatabaseBase, GetAllOptions, QueryFilter } from '../../utils/db/types';
import { getDefaultMetaFieldValueMap, sqliteTypeMap, SYSTEM } from '../helpers';
@ -24,7 +24,7 @@ import {
ColumnDiff,
FieldValueMap,
GetQueryBuilderOptions,
SingleValue,
SingleValue
} from './types';
/**
@ -201,7 +201,7 @@ export default class DatabaseCore extends DatabaseBase {
}
if (fields === undefined) {
fields = schema.fields.map((f) => f.fieldname);
fields = schema.fields.filter((f) => !f.computed).map((f) => f.fieldname);
}
/**
@ -356,7 +356,7 @@ export default class DatabaseCore extends DatabaseBase {
async #removeColumns(schemaName: string, targetColumns: string[]) {
const fields = this.schemaMap[schemaName]?.fields
.filter((f) => f.fieldtype !== FieldTypeEnum.Table)
.filter((f) => f.fieldtype !== FieldTypeEnum.Table && !f.computed)
.map((f) => f.fieldname);
const tableRows = await this.getAll(schemaName, { fields });
this.prestigeTheTable(schemaName, tableRows);
@ -504,7 +504,9 @@ export default class DatabaseCore extends DatabaseBase {
async #getColumnDiff(schemaName: string): Promise<ColumnDiff> {
const tableColumns = await this.#getTableColumns(schemaName);
const validFields = this.schemaMap[schemaName]!.fields;
const validFields = this.schemaMap[schemaName]!.fields.filter(
(f) => !f.computed
);
const diff: ColumnDiff = { added: [], removed: [] };
for (const field of validFields) {
@ -610,7 +612,9 @@ export default class DatabaseCore extends DatabaseBase {
async #createTable(schemaName: string, tableName?: string) {
tableName ??= schemaName;
const fields = this.schemaMap[schemaName]!.fields;
const fields = this.schemaMap[schemaName]!.fields.filter(
(f) => !f.computed
);
return await this.#runCreateTableQuery(tableName, fields);
}
@ -752,9 +756,9 @@ export default class DatabaseCore extends DatabaseBase {
fieldValueMap.name = getRandomString();
}
// Non Table Fields
// Column fields
const fields = this.schemaMap[schemaName]!.fields.filter(
(f) => f.fieldtype !== FieldTypeEnum.Table
(f) => f.fieldtype !== FieldTypeEnum.Table && !f.computed
);
const validMap: FieldValueMap = {};
@ -769,8 +773,9 @@ export default class DatabaseCore extends DatabaseBase {
singleSchemaName: string,
fieldValueMap: FieldValueMap
) {
const fields = this.schemaMap[singleSchemaName]!.fields;
const fields = this.schemaMap[singleSchemaName]!.fields.filter(
(f) => !f.computed
);
for (const field of fields) {
const value = fieldValueMap[field.fieldname] as RawValue | undefined;
if (value === undefined) {
@ -860,8 +865,8 @@ export default class DatabaseCore extends DatabaseBase {
const updateMap = { ...fieldValueMap };
delete updateMap.name;
const schema = this.schemaMap[schemaName] as Schema;
for (const { fieldname, fieldtype } of schema.fields) {
if (fieldtype !== FieldTypeEnum.Table) {
for (const { fieldname, fieldtype, computed } of schema.fields) {
if (fieldtype !== FieldTypeEnum.Table && !computed) {
continue;
}

View File

@ -384,18 +384,27 @@ export class Doc extends Observable<DocValue | Doc[]> {
await validator(value);
}
getValidDict(filterMeta: boolean = false): DocValueMap {
getValidDict(
filterMeta: boolean = false,
filterComputed: boolean = false
): DocValueMap {
let fields = this.schema.fields;
if (filterMeta) {
fields = this.schema.fields.filter((f) => !f.meta);
}
if (filterComputed) {
fields = this.schema.fields.filter((f) => !f.computed);
}
const data: DocValueMap = {};
for (const field of fields) {
let value = this[field.fieldname] as DocValue | DocValueMap[];
if (Array.isArray(value)) {
value = value.map((doc) => (doc as Doc).getValidDict(filterMeta));
value = value.map((doc) =>
(doc as Doc).getValidDict(filterMeta, filterComputed)
);
}
if (isPesa(value)) {
@ -444,7 +453,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
}
if (data && data.name) {
this._syncValues(data);
await this._syncValues(data);
await this.loadLinks();
} else {
throw new NotFoundError(`Not Found: ${this.schemaName} ${this.name}`);
@ -493,15 +502,39 @@ export class Doc extends Observable<DocValue | Doc[]> {
return link;
}
_syncValues(data: DocValueMap) {
async _syncValues(data: DocValueMap) {
this._clearValues();
this._setValuesWithoutChecks(data);
await this._setComputedValuesFromFormulas();
this._dirty = false;
this.trigger('change', {
doc: this,
});
}
async _setComputedValuesFromFormulas() {
for (const field of this.schema.fields) {
await this._setComputedValuesForChildren(field);
if (!field.computed) {
continue;
}
const value = await this._getValueFromFormula(field, this);
this[field.fieldname] = value ?? null;
}
}
async _setComputedValuesForChildren(field: Field) {
if (field.fieldtype !== 'Table') {
return;
}
const childDocs: Doc[] = (this[field.fieldname] as Doc[]) ?? [];
for (const doc of childDocs) {
await doc._setComputedValuesFromFormulas();
}
}
_clearValues() {
for (const { fieldname } of this.schema.fields) {
this[fieldname] = null;
@ -557,6 +590,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
const formulaFields = Object.keys(this.formulas).map(
(fn) => this.fieldMap[fn]
);
changed ||= await this._applyFormulaForFields(
formulaFields,
doc,
@ -577,7 +611,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
continue;
}
const newVal = await this._getValueFromFormula(field, doc);
const newVal = await this._getValueFromFormula(field, doc, fieldname);
const previousVal = doc.get(field.fieldname);
const isSame = areDocValuesEqual(newVal as DocValue, previousVal);
if (newVal === undefined || isSame) {
@ -591,7 +625,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
return changed;
}
async _getValueFromFormula(field: Field, doc: Doc) {
async _getValueFromFormula(field: Field, doc: Doc, fieldname?: string) {
const { formula } = doc.formulas[field.fieldname] ?? {};
if (formula === undefined) {
return;
@ -599,7 +633,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
let value: FormulaReturn;
try {
value = await formula();
value = await formula(fieldname);
} catch {
return;
}
@ -623,9 +657,9 @@ export class Doc extends Observable<DocValue | Doc[]> {
this._setBaseMetaValues();
await this._preSync();
const validDict = this.getValidDict();
const validDict = this.getValidDict(false, true);
const data = await this.fyo.db.insert(this.schemaName, validDict);
this._syncValues(data);
await this._syncValues(data);
this.fyo.telemetry.log(Verb.Created, this.schemaName);
return this;
@ -636,9 +670,9 @@ export class Doc extends Observable<DocValue | Doc[]> {
this._updateModifiedMetaValues();
await this._preSync();
const data = this.getValidDict();
const data = this.getValidDict(false, true);
await this.fyo.db.update(this.schemaName, data);
this._syncValues(data);
await this._syncValues(data);
return this;
}
@ -750,7 +784,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
}
duplicate(): Doc {
const updateMap = this.getValidDict(true);
const updateMap = this.getValidDict(true, true);
for (const field in updateMap) {
const value = updateMap[field];
if (!Array.isArray(value)) {

View File

@ -18,7 +18,7 @@ import { Doc } from './doc';
* - `Required`: Regular function used to decide if a value is mandatory (there are !notnul in the db).
*/
export type FormulaReturn = DocValue | DocValueMap[] | undefined | Doc[];
export type Formula = () => Promise<FormulaReturn> | FormulaReturn;
export type Formula = (fieldname?: string) => Promise<FormulaReturn> | FormulaReturn;
export type FormulaConfig = { dependsOn?: string[]; formula: Formula };
export type Default = () => DocValue;
export type Validation = (value: DocValue) => Promise<void> | void;

View File

@ -1,9 +1,17 @@
import { Doc } from 'fyo/model/doc';
import { FiltersMap, ListsMap, ValidationMap } from 'fyo/model/types';
import {
ChangeArg,
FiltersMap,
ListsMap,
ReadOnlyMap,
ValidationMap
} from 'fyo/model/types';
import { validateEmail } from 'fyo/model/validationFunction';
import { createDiscountAccount } from 'src/setup/setupInstance';
import { getCountryInfo } from 'utils/misc';
export class AccountingSettings extends Doc {
enableDiscounting?: boolean;
static filters: FiltersMap = {
writeOffAccount: () => ({
isGroup: false,
@ -13,6 +21,10 @@ export class AccountingSettings extends Doc {
isGroup: false,
rootType: 'Expense',
}),
discountAccount: () => ({
isGroup: false,
rootType: 'Income',
}),
};
validations: ValidationMap = {
@ -22,4 +34,20 @@ export class AccountingSettings extends Doc {
static lists: ListsMap = {
country: () => Object.keys(getCountryInfo()),
};
readOnly: ReadOnlyMap = {
enableDiscounting: () => {
return !!this.enableDiscounting;
},
};
async change(ch: ChangeArg) {
const discountingEnabled =
ch.changed === 'enableDiscounting' && this.enableDiscounting;
const discountAccountNotSet = !this.discountAccount;
if (discountingEnabled && discountAccountNotSet) {
await createDiscountAccount(this.fyo);
}
}
}

View File

@ -1,11 +1,13 @@
import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { DefaultMap, FiltersMap, FormulaMap } from 'fyo/model/types';
import { DefaultMap, FiltersMap, FormulaMap, HiddenMap } from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import { getExchangeRate } from 'models/helpers';
import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { getIsNullOrUndef } from 'utils';
import { InvoiceItem } from '../InvoiceItem/InvoiceItem';
import { Party } from '../Party/Party';
import { Payment } from '../Payment/Payment';
import { Tax } from '../Tax/Tax';
@ -15,6 +17,7 @@ export abstract class Invoice extends Transactional {
_taxes: Record<string, Tax> = {};
taxes?: TaxSummary[];
items?: InvoiceItem[];
party?: string;
account?: string;
currency?: string;
@ -23,6 +26,10 @@ export abstract class Invoice extends Transactional {
baseGrandTotal?: Money;
outstandingAmount?: Money;
exchangeRate?: number;
setDiscountAmount?: boolean;
discountAmount?: Money;
discountPercent?: number;
discountAfterTax?: boolean;
submitted?: boolean;
cancelled?: boolean;
@ -31,6 +38,20 @@ export abstract class Invoice extends Transactional {
return this.schemaName === 'SalesInvoice';
}
get enableDiscounting() {
return !!this.fyo.singles?.AccountingSettings?.enableDiscounting;
}
async validate() {
await super.validate();
if (
this.enableDiscounting &&
!this.fyo.singles?.AccountingSettings?.discountAccount
) {
throw new ValidationError(this.fyo.t`Discount Account is not set.`);
}
}
async afterSubmit() {
await super.afterSubmit();
@ -123,25 +144,29 @@ export abstract class Invoice extends Transactional {
}
> = {};
for (const row of this.items as Doc[]) {
if (!row.tax) {
type TaxDetail = { account: string; rate: number };
for (const item of this.items ?? []) {
if (!item.tax) {
continue;
}
const tax = await this.getTax(row.tax as string);
for (const d of tax.details as Doc[]) {
const account = d.account as string;
const rate = d.rate as number;
taxes[account] = taxes[account] || {
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),
baseAmount: this.fyo.pesa(0),
};
const amount = (row.amount as Money).mul(rate).div(100);
taxes[account].amount = taxes[account].amount.add(amount);
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);
}
}
@ -162,10 +187,76 @@ export abstract class Invoice extends Transactional {
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!);
.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 = {
@ -215,6 +306,25 @@ export abstract class Invoice extends Transactional {
},
};
getItemDiscountedAmounts() {
let itemDiscountedAmounts = this.fyo.pesa(0);
for (const item of this.items ?? []) {
itemDiscountedAmounts = itemDiscountedAmounts.add(
item.itemDiscountedTotal ?? item.amount!
);
}
return itemDiscountedAmounts;
}
hidden: HiddenMap = {
setDiscountAmount: () => true || !this.enableDiscounting,
discountAmount: () =>
true || !(this.enableDiscounting && !!this.setDiscountAmount),
discountPercent: () =>
true || !(this.enableDiscounting && !this.setDiscountAmount),
discountAfterTax: () => !this.enableDiscounting,
};
static defaults: DefaultMap = {
date: () => new Date().toISOString().slice(0, 10),
};

View File

@ -1,6 +1,11 @@
import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import { FiltersMap, FormulaMap, ValidationMap } from 'fyo/model/types';
import {
FiltersMap,
FormulaMap,
HiddenMap,
ValidationMap
} from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
@ -12,11 +17,40 @@ export abstract class InvoiceItem extends Doc {
baseAmount?: Money;
exchangeRate?: number;
parentdoc?: Invoice;
rate?: Money;
quantity?: number;
tax?: string;
setItemDiscountAmount?: boolean;
itemDiscountAmount?: Money;
itemDiscountPercent?: number;
itemDiscountedTotal?: Money;
itemTaxedTotal?: Money;
get isSales() {
return this.schemaName === 'SalesInvoiceItem';
}
get discountAfterTax() {
return !!this?.parentdoc?.discountAfterTax;
}
get enableDiscounting() {
return !!this.fyo.singles?.AccountingSettings?.enableDiscounting;
}
async getTotalTaxRate(): Promise<number> {
if (!this.tax) {
return 0;
}
const details =
((await this.fyo.getValue('Tax', this.tax, 'details')) as Doc[]) ?? [];
return details.reduce((acc, doc) => {
return (doc.rate as number) + acc;
}, 0);
}
formulas: FormulaMap = {
description: {
formula: async () =>
@ -28,16 +62,51 @@ export abstract class InvoiceItem extends Doc {
dependsOn: ['item'],
},
rate: {
formula: async () => {
formula: async (fieldname) => {
const rate = (await this.fyo.getValue(
'Item',
this.item as string,
'rate'
)) as undefined | Money;
return rate ?? this.fyo.pesa(0);
if (
fieldname !== 'itemTaxedTotal' &&
fieldname !== 'itemDiscountedTotal'
) {
return rate ?? this.fyo.pesa(0);
}
const quantity = this.quantity ?? 0;
const itemDiscountPercent = this.itemDiscountPercent ?? 0;
const itemDiscountAmount = this.itemDiscountAmount ?? this.fyo.pesa(0);
const totalTaxRate = await this.getTotalTaxRate();
const itemTaxedTotal = this.itemTaxedTotal ?? this.fyo.pesa(0);
const itemDiscountedTotal =
this.itemDiscountedTotal ?? this.fyo.pesa(0);
const isItemTaxedTotal = fieldname === 'itemTaxedTotal';
const discountAfterTax = this.discountAfterTax;
const setItemDiscountAmount = !!this.setItemDiscountAmount;
const rateFromTotals = getRate(
quantity,
itemDiscountPercent,
itemDiscountAmount,
totalTaxRate,
itemTaxedTotal,
itemDiscountedTotal,
isItemTaxedTotal,
discountAfterTax,
setItemDiscountAmount
);
return rateFromTotals ?? rate ?? this.fyo.pesa(0);
},
dependsOn: ['item'],
dependsOn: [
'item',
'itemTaxedTotal',
'itemDiscountedTotal',
'setItemDiscountAmount',
],
},
baseRate: {
formula: () =>
@ -98,6 +167,84 @@ export abstract class InvoiceItem extends Doc {
await this.fyo.getValue('Item', this.item as string, 'hsnCode'),
dependsOn: ['item'],
},
itemDiscountedTotal: {
formula: async () => {
const totalTaxRate = await this.getTotalTaxRate();
const rate = this.rate ?? this.fyo.pesa(0);
const quantity = this.quantity ?? 1;
const itemDiscountAmount = this.itemDiscountAmount ?? this.fyo.pesa(0);
const itemDiscountPercent = this.itemDiscountPercent ?? 0;
if (this.setItemDiscountAmount && this.itemDiscountAmount?.isZero()) {
return rate.mul(quantity);
}
if (!this.setItemDiscountAmount && this.itemDiscountPercent === 0) {
return rate.mul(quantity);
}
if (!this.discountAfterTax) {
return getDiscountedTotalBeforeTaxation(
rate,
quantity,
itemDiscountAmount,
itemDiscountPercent,
!!this.setItemDiscountAmount
);
}
return getDiscountedTotalAfterTaxation(
totalTaxRate,
rate,
quantity,
itemDiscountAmount,
itemDiscountPercent,
!!this.setItemDiscountAmount
);
},
dependsOn: [
'itemDiscountAmount',
'itemDiscountPercent',
'itemTaxedTotal',
'setItemDiscountAmount',
'tax',
'rate',
'quantity',
'item',
],
},
itemTaxedTotal: {
formula: async (fieldname) => {
const totalTaxRate = await this.getTotalTaxRate();
const rate = this.rate ?? this.fyo.pesa(0);
const quantity = this.quantity ?? 1;
const itemDiscountAmount = this.itemDiscountAmount ?? this.fyo.pesa(0);
const itemDiscountPercent = this.itemDiscountPercent ?? 0;
if (!this.discountAfterTax) {
return getTaxedTotalAfterDiscounting(
totalTaxRate,
rate,
quantity,
itemDiscountAmount,
itemDiscountPercent,
!!this.setItemDiscountAmount
);
}
return getTaxedTotalBeforeDiscounting(totalTaxRate, rate, quantity);
},
dependsOn: [
'itemDiscountAmount',
'itemDiscountPercent',
'itemDiscountedTotal',
'setItemDiscountAmount',
'tax',
'rate',
'quantity',
'item',
],
},
};
validations: ValidationMap = {
@ -113,6 +260,55 @@ export abstract class InvoiceItem extends Doc {
)}) cannot be less zero.`
);
},
itemDiscountAmount: async (value: DocValue) => {
if ((value as Money).lte(this.amount!)) {
return;
}
throw new ValidationError(
this.fyo.t`Discount Amount (${this.fyo.format(
value,
'Currency'
)}) cannot be greated than Amount (${this.fyo.format(
this.amount!,
'Currency'
)}).`
);
},
itemDiscountPercent: async (value: DocValue) => {
if ((value as number) < 100) {
return;
}
throw new ValidationError(
this.fyo.t`Discount Percent (${
value as number
}) cannot be greater than 100.`
);
},
};
hidden: HiddenMap = {
itemDiscountedTotal: () => {
if (!this.enableDiscounting) {
return true;
}
if (!!this.setItemDiscountAmount && this.itemDiscountAmount?.isZero()) {
return true;
}
if (!this.setItemDiscountAmount && this.itemDiscountPercent === 0) {
return true;
}
return false;
},
setItemDiscountAmount: () => !this.enableDiscounting,
itemDiscountAmount: () =>
!(this.enableDiscounting && !!this.setItemDiscountAmount),
itemDiscountPercent: () =>
!(this.enableDiscounting && !this.setItemDiscountAmount),
};
static filters: FiltersMap = {
@ -136,9 +332,156 @@ export abstract class InvoiceItem extends Doc {
};
},
};
static createFilters: FiltersMap = {
item: (doc: Doc) => {
return { for: doc.isSales ? 'Sales' : 'Purchases' };
},
};
}
function getDiscountedTotalBeforeTaxation(
rate: Money,
quantity: number,
itemDiscountAmount: Money,
itemDiscountPercent: number,
setDiscountAmount: boolean
) {
/**
* If Discount is applied before taxation
* Use different formulas depending on how discount is set
* - if amount : Quantity * Rate - DiscountAmount
* - if percent: Quantity * Rate (1 - DiscountPercent / 100)
*/
const amount = rate.mul(quantity);
if (setDiscountAmount) {
return amount.sub(itemDiscountAmount);
}
return amount.mul(1 - itemDiscountPercent / 100);
}
function getTaxedTotalAfterDiscounting(
totalTaxRate: number,
rate: Money,
quantity: number,
itemDiscountAmount: Money,
itemDiscountPercent: number,
setItemDiscountAmount: boolean
) {
/**
* If Discount is applied before taxation
* Formula: Discounted Total * (1 + TotalTaxRate / 100)
*/
const discountedTotal = getDiscountedTotalBeforeTaxation(
rate,
quantity,
itemDiscountAmount,
itemDiscountPercent,
setItemDiscountAmount
);
return discountedTotal.mul(1 + totalTaxRate / 100);
}
function getDiscountedTotalAfterTaxation(
totalTaxRate: number,
rate: Money,
quantity: number,
itemDiscountAmount: Money,
itemDiscountPercent: number,
setItemDiscountAmount: boolean
) {
/**
* If Discount is applied after taxation
* Use different formulas depending on how discount is set
* - if amount : Taxed Total - Discount Amount
* - if percent: Taxed Total * (1 - Discount Percent / 100)
*/
const taxedTotal = getTaxedTotalBeforeDiscounting(
totalTaxRate,
rate,
quantity
);
if (setItemDiscountAmount) {
return taxedTotal.sub(itemDiscountAmount);
}
return taxedTotal.mul(1 - itemDiscountPercent / 100);
}
function getTaxedTotalBeforeDiscounting(
totalTaxRate: number,
rate: Money,
quantity: number
) {
/**
* If Discount is applied after taxation
* Formula: Rate * Quantity * (1 + Total Tax Rate / 100)
*/
return rate.mul(quantity).mul(1 + totalTaxRate / 100);
}
function getRate(
quantity: number,
itemDiscountPercent: number,
itemDiscountAmount: Money,
totalTaxRate: number,
itemTaxedTotal: Money,
itemDiscountedTotal: Money,
isItemTaxedTotal: boolean,
discountAfterTax: boolean,
setItemDiscountAmount: boolean
) {
const isItemDiscountedTotal = !isItemTaxedTotal;
const discountBeforeTax = !discountAfterTax;
/**
* Rate calculated from itemDiscountedTotal
*/
if (isItemDiscountedTotal && discountBeforeTax && setItemDiscountAmount) {
return itemDiscountedTotal.add(itemDiscountAmount).div(quantity);
}
if (isItemDiscountedTotal && discountBeforeTax && !setItemDiscountAmount) {
return itemDiscountedTotal.div(quantity * (1 - itemDiscountPercent / 100));
}
if (isItemDiscountedTotal && discountAfterTax && setItemDiscountAmount) {
return itemDiscountedTotal
.add(itemDiscountAmount)
.div(quantity * (1 + totalTaxRate / 100));
}
if (isItemDiscountedTotal && discountAfterTax && !setItemDiscountAmount) {
return itemDiscountedTotal.div(
(quantity * (100 - itemDiscountPercent) * (100 + totalTaxRate)) / 100
);
}
/**
* Rate calculated from itemTaxedTotal
*/
if (isItemTaxedTotal && discountAfterTax) {
return itemTaxedTotal.div(quantity * (1 + totalTaxRate / 100));
}
if (isItemTaxedTotal && discountBeforeTax && setItemDiscountAmount) {
return itemTaxedTotal
.div(1 + totalTaxRate / 100)
.add(itemDiscountAmount)
.div(quantity);
}
if (isItemTaxedTotal && discountBeforeTax && !setItemDiscountAmount) {
return itemTaxedTotal.div(
quantity * (1 - itemDiscountPercent / 100) * (1 + totalTaxRate / 100)
);
}
return null;
}

View File

@ -10,14 +10,14 @@ import {
HiddenMap,
ListViewSettings,
RequiredMap,
ValidationMap,
ValidationMap
} from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors';
import {
getDocStatus,
getLedgerLinkAction,
getStatusMap,
statusColor,
statusColor
} from 'models/helpers';
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { Transactional } from 'models/Transactional/Transactional';
@ -366,6 +366,10 @@ export class Payment extends Transactional {
formula: async () => this.getSum('for', 'amount', false),
dependsOn: ['for'],
},
amountPaid: {
formula: async () => this.amount!.sub(this.writeoff!),
dependsOn: ['amount', 'writeoff', 'for'],
},
};
validations: ValidationMap = {
@ -404,6 +408,7 @@ export class Payment extends Transactional {
hidden: HiddenMap = {
referenceId: () => this.paymentMethod === 'Cash',
clearanceDate: () => this.paymentMethod === 'Cash',
amountPaid: () => this.writeoff?.isZero() ?? true,
};
static filters: FiltersMap = {

View File

@ -11,7 +11,6 @@ export class PurchaseInvoice extends Invoice {
async getPosting() {
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
await posting.credit(this.account!, this.baseGrandTotal!);
for (const item of this.items!) {
@ -24,6 +23,13 @@ export class PurchaseInvoice extends Invoice {
}
}
const discountAmount = await this.getTotalDiscount();
const discountAccount = this.fyo.singles.AccountingSettings
?.discountAccount as string | undefined;
if (discountAccount && discountAmount.isPositive()) {
await posting.credit(discountAccount, discountAmount);
}
await posting.makeRoundOffEntry();
return posting;
}

View File

@ -23,6 +23,13 @@ export class SalesInvoice extends Invoice {
}
}
const discountAmount = await this.getTotalDiscount();
const discountAccount = this.fyo.singles.AccountingSettings
?.discountAccount as string | undefined;
if (discountAccount && discountAmount.isPositive()) {
await posting.debit(discountAccount, discountAmount);
}
await posting.makeRoundOffEntry();
return posting;
}

View File

@ -16,15 +16,13 @@
"label": "Write Off Account",
"fieldname": "writeOffAccount",
"fieldtype": "Link",
"target": "Account",
"default": "Write Off"
"target": "Account"
},
{
"label": "Round Off Account",
"fieldname": "roundOffAccount",
"fieldtype": "Link",
"target": "Account",
"default": "Rounded Off"
"target": "Account"
},
{
"fieldname": "country",
@ -36,7 +34,7 @@
},
{
"fieldname": "fullname",
"label": "Name",
"label": "Full Name",
"fieldtype": "Data",
"required": true
},
@ -65,6 +63,18 @@
"fieldtype": "Date",
"required": true
},
{
"fieldname": "enableDiscounting",
"label": "Enable Discounting",
"fieldtype": "Check",
"default": false
},
{
"label": "Discount Account",
"fieldname": "discountAccount",
"fieldtype": "Link",
"target": "Account"
},
{
"fieldname": "setupComplete",
"label": "Setup Complete",

View File

@ -28,6 +28,7 @@
"fieldtype": "Link",
"target": "UOM",
"create": true,
"default": "Unit",
"placeholder": "Unit Type"
},
{

View File

@ -120,6 +120,12 @@
"label": "Write Off",
"fieldtype": "Currency"
},
{
"fieldname": "amountPaid",
"label": "Amount Paid",
"fieldtype": "Currency",
"computed": true
},
{
"fieldname": "for",
"label": "Payment Reference",
@ -141,6 +147,7 @@
"clearanceDate",
"amount",
"writeoff",
"amountPaid",
"for"
],
"keywordFields": ["name", "party", "paymentType"]

View File

@ -53,7 +53,8 @@
"label": "Items",
"fieldtype": "Table",
"target": "PurchaseInvoiceItem",
"required": true
"required": true,
"edit": true
},
{
"fieldname": "netTotal",
@ -92,6 +93,31 @@
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "setDiscountAmount",
"label": "Set Discount Amount",
"fieldtype": "Check",
"default": false
},
{
"fieldname": "discountAmount",
"label": "Discount Amount",
"fieldtype": "Currency",
"readOnly": false
},
{
"fieldname": "discountPercent",
"label": "Discount Percent",
"fieldtype": "Float",
"readOnly": false
},
{
"fieldname": "discountAfterTax",
"label": "Discount After Tax",
"fieldtype": "Check",
"default": false,
"readOnly": false
},
{
"fieldname": "terms",
"label": "Terms",

View File

@ -64,6 +64,38 @@
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "setItemDiscountAmount",
"label": "Set Discount Amount",
"fieldtype": "Check",
"default": false
},
{
"fieldname": "itemDiscountAmount",
"label": "Discount Amount",
"fieldtype": "Currency",
"readOnly": false
},
{
"fieldname": "itemDiscountPercent",
"label": "Discount Percent",
"fieldtype": "Float",
"readOnly": false
},
{
"fieldname": "itemDiscountedTotal",
"label": "Discounted Amount",
"fieldtype": "Currency",
"readOnly": false,
"computed": true
},
{
"fieldname": "itemTaxedTotal",
"label": "Taxed Amount",
"fieldtype": "Currency",
"readOnly": false,
"computed": true
},
{
"fieldname": "hsnCode",
"label": "HSN/SAC",
@ -73,5 +105,20 @@
}
],
"tableFields": ["item", "tax", "quantity", "rate", "amount"],
"keywordFields": ["item", "tax"]
"keywordFields": ["item", "tax"],
"quickEditFields": [
"item",
"account",
"description",
"hsnCode",
"tax",
"quantity",
"rate",
"amount",
"setItemDiscountAmount",
"itemDiscountAmount",
"itemDiscountPercent",
"itemDiscountedTotal",
"itemTaxedTotal"
]
}

View File

@ -52,7 +52,8 @@
"label": "Items",
"fieldtype": "Table",
"target": "SalesInvoiceItem",
"required": true
"required": true,
"edit": true
},
{
"fieldname": "netTotal",
@ -91,6 +92,31 @@
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "setDiscountAmount",
"label": "Set Discount Amount",
"fieldtype": "Check",
"default": false
},
{
"fieldname": "discountAmount",
"label": "Discount Amount",
"fieldtype": "Currency",
"readOnly": false
},
{
"fieldname": "discountPercent",
"label": "Discount Percent",
"fieldtype": "Float",
"readOnly": false
},
{
"fieldname": "discountAfterTax",
"label": "Discount After Tax",
"fieldtype": "Check",
"default": false,
"readOnly": false
},
{
"fieldname": "terms",
"label": "Notes",

View File

@ -14,8 +14,7 @@
{
"fieldname": "description",
"label": "Description",
"fieldtype": "Text",
"hidden": true
"fieldtype": "Text"
},
{
"fieldname": "quantity",
@ -39,12 +38,9 @@
{
"fieldname": "account",
"label": "Account",
"hidden": true,
"fieldtype": "Link",
"target": "Account",
"create": true,
"required": true,
"readOnly": true
"required": true
},
{
"fieldname": "tax",
@ -65,14 +61,60 @@
"fieldtype": "Currency",
"readOnly": true
},
{
"fieldname": "setItemDiscountAmount",
"label": "Set Discount Amount",
"fieldtype": "Check",
"default": false
},
{
"fieldname": "itemDiscountAmount",
"label": "Discount Amount",
"fieldtype": "Currency",
"readOnly": false
},
{
"fieldname": "itemDiscountPercent",
"label": "Discount Percent",
"fieldtype": "Float",
"readOnly": false
},
{
"fieldname": "itemDiscountedTotal",
"label": "Discounted Amount",
"fieldtype": "Currency",
"readOnly": false,
"computed": true
},
{
"fieldname": "itemTaxedTotal",
"label": "Taxed Amount",
"fieldtype": "Currency",
"readOnly": false,
"computed": true
},
{
"fieldname": "hsnCode",
"label": "HSN/SAC",
"fieldtype": "Int",
"placeholder": "HSN/SAC Code",
"hidden": true
"placeholder": "HSN/SAC Code"
}
],
"tableFields": ["item", "tax", "quantity", "rate", "amount"],
"keywordFields": ["item", "tax"]
"keywordFields": ["item", "tax"],
"quickEditFields": [
"item",
"account",
"description",
"hsnCode",
"tax",
"quantity",
"rate",
"amount",
"setItemDiscountAmount",
"itemDiscountAmount",
"itemDiscountPercent",
"itemDiscountedTotal",
"itemTaxedTotal"
]
}

View File

@ -33,6 +33,7 @@ export interface BaseField {
groupBy?: string; // UI Facing used in dropdowns fields
meta?: boolean; // Field is a meta field, i.e. only for the db, not UI
inline?: boolean; // UI Facing config, whether to display doc inline.
computed?: boolean; // Computed values are not stored in the database.
}
export type SelectOption = { value: string; label: string };
@ -50,6 +51,7 @@ export interface TargetField extends BaseField {
fieldtype: FieldTypeEnum.Table | FieldTypeEnum.Link;
target: string; // Name of the table or group of tables to fetch values
create?: boolean; // Whether to show Create in the dropdown
edit?: boolean; // Whether the Table has quick editable columns
}
export interface DynamicLinkField extends BaseField {

View File

@ -6,7 +6,6 @@
flex
justify-center
items-center
h-8
text-sm
"
:class="_class"
@ -35,15 +34,20 @@ export default {
type: Boolean,
default: true,
},
background: {
type: Boolean,
default: true,
},
},
computed: {
_class() {
return {
'opacity-50 cursor-not-allowed pointer-events-none': this.disabled,
'text-white': this.type === 'primary',
'bg-blue-500': this.type === 'primary',
'bg-blue-500': this.type === 'primary' && this.background,
'text-gray-700': this.type !== 'primary',
'bg-gray-200': this.type !== 'primary',
'bg-gray-200': this.type !== 'primary' && this.background,
'h-8': this.background,
'px-3': this.padding && this.icon,
'px-6': this.padding && !this.icon,
};

View File

@ -12,14 +12,8 @@
{{ df.label }}
</div>
<div
class="
flex
items-center
justify-between
focus-within:bg-gray-200
pr-2
rounded
"
class="flex items-center justify-between pr-2 rounded"
:class="isReadOnly ? '' : 'focus-within:bg-gray-200'"
>
<input
ref="input"

View File

@ -62,8 +62,10 @@ export default {
'px-3 py-2': this.size !== 'small',
'px-2 py-1': this.size === 'small',
},
'focus:outline-none focus:bg-gray-200 rounded w-full placeholder-gray-400',
this.isReadOnly ? 'text-gray-800' : 'text-gray-900',
'focus:outline-none rounded w-full placeholder-gray-500',
this.isReadOnly
? 'text-gray-800 focus:bg-transparent'
: 'text-gray-900 focus:bg-gray-200',
];
return this.getInputClassesFromProp(classes);

View File

@ -1,7 +1,7 @@
<template>
<div>
<label class="flex items-center">
<div class="mr-3 text-gray-900 text-sm" v-if="showLabel && !labelRight">
<div class="mr-3 text-gray-600 text-sm" v-if="showLabel && !labelRight">
{{ df.label }}
</div>
<div style="width: 14px; height: 14px; overflow: hidden; cursor: pointer">
@ -61,7 +61,7 @@
@focus="(e) => $emit('focus', e)"
/>
</div>
<div class="ml-3 text-gray-900 text-sm" v-if="showLabel && labelRight">
<div class="ml-3 text-gray-600 text-sm" v-if="showLabel && labelRight">
{{ df.label }}
</div>
</label>

View File

@ -8,7 +8,7 @@
ref="input"
:class="inputClasses"
:type="inputType"
:value="value.round()"
:value="value?.round()"
:placeholder="inputPlaceholder"
:readonly="isReadOnly"
@blur="onBlur"
@ -69,7 +69,7 @@ export default {
},
computed: {
formattedValue() {
return fyo.format(this.value, this.df, this.doc);
return fyo.format(this.value ?? fyo.pesa(0), this.df, this.doc);
},
},
};

View File

@ -38,6 +38,7 @@
v-bind="{ row, tableFields, size, ratio, isNumeric }"
:read-only="isReadOnly"
@remove="removeRow(row)"
:can-edit-row="canEditRow"
/>
</div>
@ -85,6 +86,7 @@ import TableRow from './TableRow.vue';
export default {
name: 'Table',
emits: ['editrow'],
extends: Base,
props: {
value: { type: Array, default: () => [] },
@ -169,8 +171,17 @@ export default {
}
return 2;
},
canEditRow() {
return this.df.edit;
},
ratio() {
return [0.3].concat(this.tableFields.map(() => 1));
const ratio = [0.3].concat(this.tableFields.map(() => 1));
if (this.canEditRow) {
return ratio.concat(0.3);
}
return ratio;
},
tableFields() {
const fields = fyo.schemaMap[this.df.target].tableFields ?? [];

View File

@ -19,6 +19,7 @@
<feather-icon
name="x"
class="w-4 h-4 -ml-1 cursor-pointer"
:button="true"
@click="$emit('remove')"
/>
</span>
@ -39,6 +40,15 @@
@change="(value) => onChange(df, value)"
@new-doc="(doc) => row.set(df.fieldname, doc.name)"
/>
<Button
:icon="true"
:padding="false"
:background="false"
@click="openRowQuickEdit"
v-if="canEditRow"
>
<feather-icon name="edit" class="w-4 h-4 text-gray-600" />
</Button>
<!-- Error Display -->
<div
@ -53,6 +63,7 @@
import { Doc } from 'fyo/model/doc';
import Row from 'src/components/Row.vue';
import { getErrorMessage } from 'src/utils';
import Button from '../Button.vue';
import FormControl from './FormControl.vue';
export default {
@ -64,11 +75,16 @@ export default {
ratio: Array,
isNumeric: Function,
readOnly: Boolean,
canEditRow: {
type: Boolean,
default: false,
},
},
emits: ['remove'],
components: {
Row,
FormControl,
Button,
},
data: () => ({ hovering: false, errors: {} }),
beforeCreate() {
@ -95,6 +111,13 @@ export default {
getErrorString() {
return Object.values(this.errors).filter(Boolean).join(' ');
},
openRowQuickEdit() {
if (!this.row) {
return;
}
this.$parent.$emit('editrow', this.row);
},
},
};
</script>

View File

@ -9,6 +9,8 @@
:class="['resize-none', inputClasses]"
:value="value"
:placeholder="inputPlaceholder"
style="vertical-align: top"
:readonly="isReadOnly"
@blur="(e) => triggerChange(e.target.value)"
@focus="(e) => $emit('focus', e)"
@input="(e) => $emit('input', e)"

View File

@ -1,26 +1,31 @@
<template>
<div class="flex flex-col bg-gray-25">
<!-- Page Header (Title, Buttons, etc) -->
<PageHeader :title="title" :border="false">
<slot name="header" />
</PageHeader>
<div class="flex bg-gray-25">
<div class="flex flex-1 flex-col">
<!-- Page Header (Title, Buttons, etc) -->
<PageHeader :title="title" :border="false">
<slot name="header" />
</PageHeader>
<!-- Invoice Form -->
<div
class="
border
rounded-lg
shadow-lg
flex flex-col
self-center
w-form
h-full
mb-4
bg-white
"
>
<slot name="body" />
<!-- Invoice Form -->
<div
class="
border
rounded-lg
shadow-lg
flex flex-col
self-center
w-form
h-full
mb-4
bg-white
"
>
<slot name="body" />
</div>
</div>
<!-- Invoice Quick Edit -->
<slot name="quickedit" />
</div>
</template>
<script>

View File

@ -16,6 +16,7 @@
</h1>
<div class="flex items-stretch window-no-drag gap-2 ml-auto">
<slot />
<div class="border-r" v-if="showBorder" />
<BackLink v-if="backLink" class="window-no-drag" />
<SearchBar v-if="!hideSearch" />
</div>
@ -33,5 +34,10 @@ export default {
border: { type: Boolean, default: true },
},
components: { SearchBar, BackLink },
computed: {
showBorder() {
return !!this.$slots.default;
},
},
};
</script>

View File

@ -25,6 +25,9 @@ export default {
showHSN() {
return this.doc.items.map((i) => i.hsnCode).every(Boolean);
},
totalDiscount() {
return this.doc.getTotalDiscount();
},
},
};
</script>

View File

@ -104,6 +104,13 @@
<div>{{ t`Subtotal` }}</div>
<div>{{ fyo.format(doc.netTotal, 'Currency') }}</div>
</div>
<div
class="flex pl-2 justify-between py-3 border-b"
v-if="totalDiscount?.float > 0 && !doc.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ `- ${fyo.format(totalDiscount, 'Currency')}` }}</div>
</div>
<div
class="flex pl-2 justify-between py-3"
v-for="tax in doc.taxes"
@ -112,6 +119,13 @@
<div>{{ tax.account }}</div>
<div>{{ fyo.format(tax.amount, 'Currency') }}</div>
</div>
<div
class="flex pl-2 justify-between py-3 border-t"
v-if="totalDiscount?.float > 0 && doc.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ `- ${fyo.format(totalDiscount, 'Currency')}` }}</div>
</div>
<div
class="
flex

View File

@ -85,18 +85,25 @@
</div>
<div class="mt-12">
<div class="flex -mx-3">
<div class="flex justify-end flex-1 p-3 bg-gray-100">
<div class="flex justify-end flex-1 py-3 bg-gray-100 gap-8 pr-6">
<div class="text-right">
<div class="text-gray-800">{{ t`Subtotal` }}</div>
<div class="text-xl mt-2">
{{ fyo.format(doc.netTotal, 'Currency') }}
</div>
</div>
<div
class="ml-8 text-right"
v-for="tax in doc.taxes"
:key="tax.name"
class="text-right"
v-if="totalDiscount?.float > 0 && !doc.discountAfterTax"
>
<div class="text-gray-800">{{ t`Discount` }}</div>
<div class="text-xl mt-2">
{{ `- ${fyo.format(totalDiscount, 'Currency')}` }}
</div>
</div>
<div class="text-right" v-for="tax in doc.taxes" :key="tax.name">
<div class="text-gray-800">
{{ tax.account }}
</div>
@ -104,9 +111,19 @@
{{ fyo.format(tax.amount, 'Currency') }}
</div>
</div>
<div
class="text-right"
v-if="totalDiscount?.float > 0 && !doc.discountAfterTax"
>
<div class="text-gray-800">{{ t`Discount` }}</div>
<div class="text-xl mt-2">
{{ `- ${fyo.format(totalDiscount, 'Currency')}` }}
</div>
</div>
</div>
<div
class="p-3 text-right text-white"
class="py-3 px-4 text-right text-white"
:style="{ backgroundColor: printSettings.color }"
>
<div>

View File

@ -128,6 +128,13 @@
<div>{{ t`Subtotal` }}</div>
<div>{{ fyo.format(doc.netTotal, 'Currency') }}</div>
</div>
<div
class="flex pl-2 justify-between py-1"
v-if="totalDiscount?.float > 0 && !doc.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ `- ${fyo.format(totalDiscount, 'Currency')}` }}</div>
</div>
<div
class="flex pl-2 justify-between py-1"
v-for="tax in doc.taxes"
@ -136,6 +143,13 @@
<div>{{ tax.account }}</div>
<div>{{ fyo.format(tax.amount, 'Currency') }}</div>
</div>
<div
class="flex pl-2 justify-between py-1"
v-if="totalDiscount?.float > 0 && doc.discountAfterTax"
>
<div>{{ t`Discount` }}</div>
<div>{{ `- ${fyo.format(totalDiscount, 'Currency')}` }}</div>
</div>
<div
class="flex pl-2 justify-between py-1 font-semibold"
:style="{ color: printSettings.color }"

View File

@ -32,7 +32,7 @@
text-2xl
focus:outline-none
w-full
placeholder-gray-700
placeholder-gray-500
text-gray-900
rounded-md
p-3

View File

@ -62,10 +62,8 @@
height: getFieldHeight(df),
}"
>
<div class="py-2 pl-4 flex text-gray-600">
<div class="py-1">
{{ df.label }}
</div>
<div class="pl-4 flex text-gray-600">
{{ df.label }}
</div>
<div
@ -190,7 +188,7 @@ export default {
return true;
}
if (this.submitted) {
if (this.submitted || this.doc.parentdoc?.isSubmitted) {
return true;
}

View File

@ -16,7 +16,7 @@
<keep-alive>
<component
:is="Component"
class="w-80 flex-1"
class="w-quick-edit flex-1"
:key="$route.query.schemaName + $route.query.name"
/>
</keep-alive>

View File

@ -10,6 +10,13 @@
>
{{ t`Print` }}
</Button>
<Button
:icon="true"
v-if="!doc?.isSubmitted && doc.enableDiscounting"
@click="toggleInvoiceSettings"
>
<feather-icon name="settings" class="w-4 h-4" />
</Button>
<DropdownWithActions :actions="actions()" />
<Button
v-if="doc?.notInserted || doc?.dirty"
@ -60,7 +67,6 @@
input-class="text-lg font-semibold bg-transparent"
:df="getField('party')"
:value="doc.party"
:placeholder="getField('party').label"
@change="(value) => doc.set('party', value)"
@new-doc="(party) => doc.set('party', party.name)"
:read-only="doc?.submitted"
@ -69,7 +75,6 @@
input-class="bg-gray-100 px-3 py-2 text-base text-right"
:df="getField('date')"
:value="doc.date"
:placeholder="'Date'"
@change="(value) => doc.set('date', value)"
:read-only="doc?.submitted"
/>
@ -86,10 +91,48 @@
input-class="px-3 py-2 text-base bg-transparent"
:df="getField('account')"
:value="doc.account"
:placeholder="'Account'"
@change="(value) => doc.set('account', value)"
:read-only="doc?.submitted"
/>
<!--
<FormControl
v-if="doc.enableDiscounting"
:show-label="true"
:label-right="false"
class="
text-base
bg-gray-100
rounded
flex
items-center
justify-center
w-ful
"
input-class="px-3 py-2 text-base bg-transparent text-right"
:df="getField('setDiscountAmount')"
:value="doc.setDiscountAmount"
@change="(value) => doc.set('setDiscountAmount', value)"
:read-only="doc?.submitted"
/>
<FormControl
v-if="doc.enableDiscounting && !doc.setDiscountAmount"
class="text-base bg-gray-100 rounded"
input-class="px-3 py-2 text-base bg-transparent text-right"
:df="getField('discountPercent')"
:value="doc.discountPercent"
@change="(value) => doc.set('discountPercent', value)"
:read-only="doc?.submitted"
/>
<FormControl
v-if="doc.enableDiscounting && doc.setDiscountAmount"
class="text-base bg-gray-100 rounded"
input-class="px-3 py-2 text-base bg-transparent text-right"
:df="getField('discountAmount')"
:value="doc.discountAmount"
@change="(value) => doc.set('discountAmount', value)"
:read-only="doc?.submitted"
/>
-->
</div>
<hr />
@ -101,6 +144,7 @@
:showHeader="true"
:max-rows-before-overflow="4"
@change="(value) => doc.set('items', value)"
@editrow="toggleQuickEditDoc"
:read-only="doc?.submitted"
/>
</div>
@ -110,16 +154,22 @@
<div v-if="doc.items?.length ?? 0" class="mt-auto">
<hr />
<div class="flex justify-between text-base m-4 gap-12">
<!-- Form Terms-->
<FormControl
class="w-1/2 self-end"
v-if="!doc?.submitted || doc.terms"
:df="getField('terms')"
:value="doc.terms"
input-class="bg-gray-100"
@change="(value) => doc.set('terms', value)"
:read-only="doc?.submitted"
/>
<div class="w-1/2 flex flex-col justify-between">
<!-- Discount Note -->
<p v-if="discountNote?.length" class="text-gray-600 text-sm">
{{ discountNote }}
</p>
<!-- Form Terms-->
<FormControl
v-if="!doc?.submitted || doc.terms"
:df="getField('terms')"
:value="doc.terms"
input-class="bg-gray-100"
class="mt-auto"
@change="(value) => doc.set('terms', value)"
:read-only="doc?.submitted"
/>
</div>
<!-- Totals -->
<div class="w-1/2 gap-2 flex flex-col self-end ml-auto">
@ -130,24 +180,71 @@
</div>
<hr />
<!-- Discount Applied Before Taxes -->
<div
v-if="totalDiscount.float > 0 && !doc.discountAfterTax"
class="flex flex-col gap-2"
>
<div
class="flex justify-between"
v-if="itemDiscountAmount.float > 0"
>
<div>{{ t`Discount` }}</div>
<div>
{{ `- ${fyo.format(itemDiscountAmount, 'Currency')}` }}
</div>
</div>
<div class="flex justify-between" v-if="discountAmount.float > 0">
<div>{{ t`Invoice Discount` }}</div>
<div>{{ `- ${fyo.format(discountAmount, 'Currency')}` }}</div>
</div>
<hr v-if="doc.taxes?.length" />
</div>
<!-- Taxes -->
<div
class="flex justify-between"
v-for="tax in doc.taxes"
:key="tax.name"
v-if="doc.taxes?.length"
class="flex flex-col gap-2 max-h-12 overflow-y-auto"
>
<div>{{ tax.account }}</div>
<div>
{{
fyo.format(tax.amount, {
fieldtype: 'Currency',
currency: doc.currency,
})
}}
<div
class="flex justify-between"
v-for="tax in doc.taxes"
:key="tax.name"
>
<div>{{ tax.account }}</div>
<div>
{{
fyo.format(tax.amount, {
fieldtype: 'Currency',
currency: doc.currency,
})
}}
</div>
</div>
</div>
<hr v-if="doc.taxes?.length" />
<!-- Discount Applied After Taxes -->
<div
v-if="totalDiscount.float > 0 && doc.discountAfterTax"
class="flex flex-col gap-2"
>
<div
class="flex justify-between"
v-if="itemDiscountAmount.float > 0"
>
<div>{{ t`Discount` }}</div>
<div>
{{ `- ${fyo.format(itemDiscountAmount, 'Currency')}` }}
</div>
</div>
<div class="flex justify-between" v-if="discountAmount.float > 0">
<div>{{ t`Invoice Discount` }}</div>
<div>{{ `- ${fyo.format(discountAmount, 'Currency')}` }}</div>
</div>
<hr />
</div>
<!-- Grand Total -->
<div
class="
@ -163,9 +260,9 @@
</div>
<!-- Outstanding Amount -->
<hr v-if="doc.outstandingAmount > 0" />
<hr v-if="doc.outstandingAmount?.float > 0" />
<div
v-if="doc.outstandingAmount > 0"
v-if="doc.outstandingAmount?.float > 0"
class="flex justify-between text-red-600 font-semibold text-base"
>
<div>{{ t`Outstanding Amount` }}</div>
@ -175,6 +272,22 @@
</div>
</div>
</template>
<template #quickedit v-if="quickEditDoc">
<QuickEditForm
class="w-quick-edit"
:name="quickEditDoc.name"
:show-name="false"
:show-save="false"
:source-doc="quickEditDoc"
:source-fields="quickEditFields"
:schema-name="quickEditDoc.schemaName"
:white="true"
:route-back="false"
:load-on-close="false"
@close="toggleQuickEditDoc(null)"
/>
</template>
</FormContainer>
</template>
<script>
@ -190,13 +303,14 @@ import StatusBadge from 'src/components/StatusBadge.vue';
import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc';
import {
docsPath,
getActionsForDocument,
openSettings,
routeTo,
showMessageDialog,
docsPath,
getActionsForDocument,
routeTo,
showMessageDialog
} from 'src/utils/ui';
import { nextTick } from 'vue';
import { handleErrorWithDialog } from '../errorHandling';
import QuickEditForm from './QuickEditForm.vue';
export default {
name: 'InvoiceForm',
@ -208,6 +322,7 @@ export default {
DropdownWithActions,
Table,
FormContainer,
QuickEditForm,
},
provide() {
return {
@ -220,6 +335,8 @@ export default {
return {
chstatus: false,
doc: null,
quickEditDoc: null,
quickEditFields: [],
color: null,
printSettings: null,
companyName: null,
@ -236,6 +353,34 @@ export default {
this.chstatus;
return getDocStatus(this.doc);
},
discountNote() {
const zeroInvoiceDiscount = this.doc?.discountAmount?.isZero();
const zeroItemDiscount = this.itemDiscountAmount?.isZero();
if (zeroInvoiceDiscount && zeroItemDiscount) {
return '';
}
if (!this.doc?.taxes?.length) {
return '';
}
let text = this.t`Discount applied before taxation`;
if (this.doc.discountAfterTax) {
text = this.t`Discount applied after taxation`;
}
return text;
},
totalDiscount() {
return this.discountAmount.add(this.itemDiscountAmount);
},
discountAmount() {
return this.doc?.getInvoiceDiscountAmount();
},
itemDiscountAmount() {
return this.doc.getItemDiscountAmount();
},
},
activated() {
docsPath.value = docsPathMap[this.schemaName];
@ -267,6 +412,27 @@ export default {
},
methods: {
routeTo,
toggleInvoiceSettings() {
if (!this.schemaName) {
return;
}
const fields = ['discountAfterTax'].map((fn) =>
fyo.getField(this.schemaName, fn)
);
this.toggleQuickEditDoc(this.doc, fields);
},
async toggleQuickEditDoc(doc, fields = []) {
if (this.quickEditDoc && doc) {
this.quickEditDoc = null;
this.quickEditFields = [];
await nextTick();
}
this.quickEditDoc = doc;
this.quickEditFields = fields;
},
actions() {
return getActionsForDocument(this.doc);
},
@ -309,9 +475,6 @@ export default {
async handleError(e) {
await handleErrorWithDialog(e, this.doc);
},
openInvoiceSettings() {
openSettings('Invoice');
},
formattedValue(fieldname, doc) {
if (!doc) {
doc = this.doc;

View File

@ -37,7 +37,7 @@
</div>
<!-- Printview Customizer -->
<div class="border-l w-80" v-if="showCustomiser">
<div class="border-l w-quick-edit" v-if="showCustomiser">
<div
class="px-4 flex items-center justify-between h-row-largest border-b"
>

View File

@ -4,7 +4,10 @@
:class="white ? 'bg-white' : 'bg-gray-25'"
>
<!-- Quick edit Tool bar -->
<div class="flex items-center justify-between px-4 border-b h-row-largest">
<div
class="flex items-center justify-between px-4 h-row-largest"
:class="{ 'border-b': showName }"
>
<!-- Close Button and Status Text -->
<div class="flex items-center">
<Button :icon="true" @click="routeToPrevious">
@ -16,7 +19,7 @@
</div>
<!-- Actions, Badge and Status Change Buttons -->
<div class="flex items-stretch gap-2">
<div class="flex items-stretch gap-2" v-if="showSave">
<StatusBadge :status="status" />
<DropdownWithActions :actions="actions" />
<Button
@ -49,7 +52,7 @@
<div
class="px-4 flex-center flex flex-col items-center gap-1.5"
style="height: calc(var(--h-row-mid) * 2 + 1px)"
v-if="doc"
v-if="doc && showName"
>
<FormControl
v-if="imageField"
@ -89,6 +92,7 @@
<script>
import { computed } from '@vue/reactivity';
import { t } from 'fyo';
import { Doc } from 'fyo/model/doc';
import { getDocStatus } from 'models/helpers';
import Button from 'src/components/Button.vue';
import FormControl from 'src/components/Controls/FormControl.vue';
@ -106,6 +110,12 @@ export default {
schemaName: String,
defaults: String,
white: { type: Boolean, default: false },
routeBack: { type: Boolean, default: true },
showName: { type: Boolean, default: true },
showSave: { type: Boolean, default: true },
sourceDoc: { type: Doc, default: null },
loadOnClose: { type: Boolean, default: true },
sourceFields: { type: Array, default: () => [] },
hideFields: { type: Array, default: () => [] },
showFields: { type: Array, default: () => [] },
},
@ -116,6 +126,7 @@ export default {
TwoColumnForm,
DropdownWithActions,
},
emits: ['close'],
provide() {
return {
schemaName: this.schemaName,
@ -145,6 +156,9 @@ export default {
await this.fetchFieldsAndDoc();
},
computed: {
isChild() {
return !!this?.doc?.schema?.isChild;
},
schema() {
return fyo.schemaMap[this.schemaName] ?? null;
},
@ -152,6 +166,10 @@ export default {
return getDocStatus(this.doc);
},
fields() {
if (this.sourceFields?.length) {
return this.sourceFields;
}
if (!this.schema) {
return [];
}
@ -215,8 +233,13 @@ export default {
return;
}
if (readOnly) {
const isManual = this.schema.naming === 'manual';
const isNumberSeries = fyo.getField(this.schemaName, 'numberSeries');
if (readOnly && (!this?.doc[fieldname] || isNumberSeries)) {
this.doc.set(fieldname, t`New ${this.schema.label}`);
}
if (this?.doc[fieldname] && !isManual) {
return;
}
@ -227,6 +250,10 @@ export default {
}, 300);
},
async fetchDoc() {
if (this.sourceDoc) {
return (this.doc = this.sourceDoc);
}
if (!this.schemaName) {
this.$router.back();
}
@ -280,10 +307,15 @@ export default {
}
},
routeToPrevious() {
if (this.doc.dirty && !this.doc.notInserted) {
if (this.loadOnClose && this.doc.dirty && !this.doc.notInserted) {
this.doc.load();
}
this.$router.back();
if (this.routeBack) {
this.$router.back();
} else {
this.$emit('close');
}
},
setTitleSize() {
if (!this.$refs.titleControl) {

View File

@ -95,7 +95,8 @@ export default {
if (
fieldnames.includes('displayPrecision') ||
fieldnames.includes('hideGetStarted') ||
fieldnames.includes('displayPrecision')
fieldnames.includes('displayPrecision') ||
fieldnames.includes('enableDiscounting')
) {
this.showReloadToast();
}

View File

@ -37,17 +37,33 @@ export default {
},
computed: {
fields() {
return [
const fields = [
'fullname',
'companyName',
'country',
'bankName',
'currency',
'writeOffAccount',
'roundOffAccount',
'fiscalYearStart',
'fiscalYearEnd',
'gstin',
].map((fieldname) => fyo.getField('AccountingSettings', fieldname));
'writeOffAccount',
'roundOffAccount',
];
if (!this.doc.enableDiscounting) {
fields.push('enableDiscounting');
}
if (this.doc.enableDiscounting) {
fields.push('discountAccount');
}
if (fyo.singles.SystemSettings.countryCode === 'in') {
fields.push('gstin');
}
return fields.map((fieldname) =>
fyo.getField('AccountingSettings', fieldname)
);
},
},
methods: {

View File

@ -5,8 +5,9 @@ import { createNumberSeries } from 'fyo/model/naming';
import {
DEFAULT_CURRENCY,
DEFAULT_LOCALE,
DEFAULT_SERIES_START,
DEFAULT_SERIES_START
} from 'fyo/utils/consts';
import { AccountRootTypeEnum } from 'models/baseModels/Account/types';
import { AccountingSettings } from 'models/baseModels/AccountingSettings/AccountingSettings';
import { ModelNameEnum } from 'models/types';
import { initializeInstance } from 'src/initFyo';
@ -159,14 +160,65 @@ async function createAccountRecords(
const createCOA = new CreateCOA(chartOfAccounts, fyo);
await createCOA.run();
const parentAccount = await getBankAccountParentName(country, fyo);
const docObject = {
const bankAccountDoc = {
name: bankName,
rootType: 'Asset',
rootType: AccountRootTypeEnum.Asset,
parentAccount,
accountType: 'Bank',
isGroup: false,
};
await checkAndCreateDoc('Account', docObject, fyo);
await checkAndCreateDoc('Account', bankAccountDoc, fyo);
await createDiscountAccount(fyo);
await setDefaultAccounts(fyo);
}
export async function createDiscountAccount(fyo: Fyo) {
const incomeAccountName = fyo.t`Indirect Income`;
const accountExists = await fyo.db.exists(
ModelNameEnum.Account,
incomeAccountName
);
if (!accountExists) {
return;
}
const discountAccountName = fyo.t`Discounts`;
const discountAccountDoc = {
name: discountAccountName,
rootType: AccountRootTypeEnum.Income,
parentAccount: incomeAccountName,
accountType: 'Income Account',
isGroup: false,
};
await checkAndCreateDoc(ModelNameEnum.Account, discountAccountDoc, fyo);
await fyo.singles.AccountingSettings!.setAndSync(
'discountAccount',
discountAccountName
);
}
async function setDefaultAccounts(fyo: Fyo) {
const accountMap: Record<string, string> = {
writeOffAccount: fyo.t`Write Off`,
roundOffAccount: fyo.t`Rounded Off`,
};
for (const key in accountMap) {
const accountName = accountMap[key];
const accountExists = await fyo.db.exists(
ModelNameEnum.Account,
accountName
);
if (!accountExists) {
continue;
}
await fyo.singles.AccountingSettings!.setAndSync(key, accountName);
}
}
async function completeSetup(companyName: string, fyo: Fyo) {

View File

@ -58,6 +58,7 @@ html {
--w-sidebar: 12rem;
--w-desk: calc(100vw - var(--w-sidebar));
--w-desk-fixed: calc(var(--w-app) - var(--w-sidebar));
--w-quick-edit: 22rem;
--w-scrollbar: 0.5rem;
/* Row Heights */
@ -73,6 +74,10 @@ html {
width: 600px;
}
.w-quick-edit {
width: var(--w-quick-edit)
}
.h-form {
height: 800px;
}