mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 14:20:31 +00:00
feat(webapp): invoice tax rate
This commit is contained in:
@@ -4,6 +4,7 @@ import moment from 'moment';
|
||||
import intl from 'react-intl-universal';
|
||||
import { DATATYPES_LENGTH } from '@/constants/dataTypes';
|
||||
import { isBlank } from '@/utils';
|
||||
import { TaxType } from '@/interfaces/TaxRates';
|
||||
|
||||
const getSchema = () =>
|
||||
Yup.object().shape({
|
||||
@@ -35,6 +36,10 @@ const getSchema = () =>
|
||||
.max(DATATYPES_LENGTH.TEXT)
|
||||
.label(intl.get('note')),
|
||||
exchange_rate: Yup.number(),
|
||||
inclusive_exclusive_tax: Yup.string().oneOf([
|
||||
TaxType.Inclusive,
|
||||
TaxType.Exclusive,
|
||||
]),
|
||||
branch_id: Yup.string(),
|
||||
warehouse_id: Yup.string(),
|
||||
project_id: Yup.string(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import classNames from 'classnames';
|
||||
import { Formik, Form } from 'formik';
|
||||
@@ -26,6 +26,7 @@ import withCurrentOrganization from '@/containers/Organization/withCurrentOrgani
|
||||
import { AppToaster } from '@/components';
|
||||
import { compose, orderingLinesIndexes, transactionNumber } from '@/utils';
|
||||
import { useInvoiceFormContext } from './InvoiceFormProvider';
|
||||
import { InvoiceFormActions } from './InvoiceFormActions';
|
||||
import {
|
||||
transformToEditForm,
|
||||
defaultInvoice,
|
||||
@@ -71,7 +72,7 @@ function InvoiceForm({
|
||||
? { ...transformToEditForm(invoice) }
|
||||
: {
|
||||
...defaultInvoice,
|
||||
// If the auto-increment mode is enabled, take the next invoice
|
||||
// If the auto-increment mode is enabled, take the next invoice
|
||||
// number from the settings.
|
||||
...(invoiceAutoIncrementMode && {
|
||||
invoice_no: invoiceNumber,
|
||||
@@ -166,7 +167,11 @@ function InvoiceForm({
|
||||
<Form>
|
||||
<InvoiceFormTopBar />
|
||||
<InvoiceFormHeader />
|
||||
<InvoiceItemsEntriesEditorField />
|
||||
|
||||
<div className={classNames(CLASSES.PAGE_FORM_BODY)}>
|
||||
<InvoiceFormActions />
|
||||
<InvoiceItemsEntriesEditorField />
|
||||
</div>
|
||||
<InvoiceFormFooter />
|
||||
<InvoiceFloatingActions />
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { InclusiveButtonOptions } from './constants';
|
||||
import { Box, FFormGroup, FSelect } from '@/components';
|
||||
import { composeEntriesOnEditInclusiveTax } from './utils';
|
||||
|
||||
/**
|
||||
* Invoice form actions.
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
export function InvoiceFormActions() {
|
||||
return (
|
||||
<InvoiceFormActionsRoot>
|
||||
<InvoiceExclusiveInclusiveSelect />
|
||||
</InvoiceFormActionsRoot>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice exclusive/inclusive select.
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
export function InvoiceExclusiveInclusiveSelect(props) {
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
|
||||
const handleItemSelect = (item) => {
|
||||
const newEntries = composeEntriesOnEditInclusiveTax(
|
||||
item.key,
|
||||
values.entries,
|
||||
);
|
||||
setFieldValue('inclusive_exclusive_tax', item.key);
|
||||
setFieldValue('entries', newEntries);
|
||||
};
|
||||
|
||||
return (
|
||||
<InclusiveFormGroup
|
||||
name={'inclusive_exclusive_tax'}
|
||||
label={'Amounts are'}
|
||||
inline={true}
|
||||
>
|
||||
<InclusiveSelect
|
||||
name={'inclusive_exclusive_tax'}
|
||||
items={InclusiveButtonOptions}
|
||||
textAccessor={'label'}
|
||||
labelAccessor={() => ''}
|
||||
valueAccessor={'key'}
|
||||
popoverProps={{ minimal: true, usePortal: true, inline: false }}
|
||||
buttonProps={{ small: true }}
|
||||
onItemSelect={handleItemSelect}
|
||||
filterable={false}
|
||||
{...props}
|
||||
/>
|
||||
</InclusiveFormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const InclusiveFormGroup = styled(FFormGroup)`
|
||||
margin-bottom: 0;
|
||||
margin-left: auto;
|
||||
|
||||
&.bp3-form-group.bp3-inline label.bp3-label {
|
||||
line-height: 1.25;
|
||||
opacity: 0.6;
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const InclusiveSelect = styled(FSelect)`
|
||||
.bp3-button {
|
||||
padding-right: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const InvoiceFormActionsRoot = styled(Box)`
|
||||
padding-bottom: 12px;
|
||||
display: flex;
|
||||
`;
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useFormikContext } from 'formik';
|
||||
|
||||
import {
|
||||
T,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
TotalLineBorderStyle,
|
||||
TotalLineTextStyle,
|
||||
} from '@/components';
|
||||
import { useInvoiceTotals } from './utils';
|
||||
import { useInvoiceAggregatedTaxRates, useInvoiceTotals } from './utils';
|
||||
|
||||
export function InvoiceFormFooterRight() {
|
||||
// Calculate the total due amount of invoice entries.
|
||||
@@ -20,15 +21,34 @@ export function InvoiceFormFooterRight() {
|
||||
formattedPaymentTotal,
|
||||
} = useInvoiceTotals();
|
||||
|
||||
const {
|
||||
values: { inclusive_exclusive_tax },
|
||||
} = useFormikContext();
|
||||
|
||||
const taxEntries = useInvoiceAggregatedTaxRates();
|
||||
|
||||
return (
|
||||
<InvoiceTotalLines labelColWidth={'180px'} amountColWidth={'180px'}>
|
||||
<TotalLine
|
||||
title={<T id={'invoice_form.label.subtotal'} />}
|
||||
title={
|
||||
<>
|
||||
{inclusive_exclusive_tax === 'inclusive'
|
||||
? 'Subtotal (Tax Inclusive)'
|
||||
: 'Subtotal'}
|
||||
</>
|
||||
}
|
||||
value={formattedSubtotal}
|
||||
borderStyle={TotalLineBorderStyle.None}
|
||||
/>
|
||||
{taxEntries.map((tax, index) => (
|
||||
<TotalLine
|
||||
key={index}
|
||||
title={tax.label}
|
||||
value={tax.taxAmountFormatted}
|
||||
borderStyle={TotalLineBorderStyle.None}
|
||||
/>
|
||||
))}
|
||||
<TotalLine
|
||||
title={<T id={'invoice_form.label.total'} />}
|
||||
title={'Total (USD)'}
|
||||
value={formattedTotal}
|
||||
borderStyle={TotalLineBorderStyle.SingleDark}
|
||||
textStyle={TotalLineTextStyle.Bold}
|
||||
|
||||
@@ -8,7 +8,7 @@ import InvoiceFormHeaderFields from './InvoiceFormHeaderFields';
|
||||
|
||||
import { CLASSES } from '@/constants/classes';
|
||||
import { PageFormBigNumber } from '@/components';
|
||||
import { useInvoiceTotal } from './utils';
|
||||
import { useInvoiceSubtotal } from './utils';
|
||||
|
||||
/**
|
||||
* Invoice form header section.
|
||||
@@ -32,7 +32,7 @@ function InvoiceFormBigTotal() {
|
||||
} = useFormikContext();
|
||||
|
||||
// Calculate the total due amount of invoice entries.
|
||||
const totalDueAmount = useInvoiceTotal();
|
||||
const totalDueAmount = useInvoiceSubtotal();
|
||||
|
||||
return (
|
||||
<PageFormBigNumber
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useEstimate,
|
||||
} from '@/hooks/query';
|
||||
import { useProjects } from '@/containers/Projects/hooks';
|
||||
import { useTaxRates } from '@/hooks/query/taxRates';
|
||||
|
||||
const InvoiceFormContext = createContext();
|
||||
|
||||
@@ -34,10 +35,14 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
|
||||
const isBranchFeatureCan = featureCan(Features.Branches);
|
||||
const isProjectsFeatureCan = featureCan(Features.Projects);
|
||||
|
||||
// Fetch invoice data.
|
||||
const { data: invoice, isLoading: isInvoiceLoading } = useInvoice(invoiceId, {
|
||||
enabled: !!invoiceId,
|
||||
});
|
||||
|
||||
// Fetch tax rates.
|
||||
const { data: taxRates, isLoading: isTaxRatesLoading } = useTaxRates();
|
||||
|
||||
// Fetch project list.
|
||||
const {
|
||||
data: { projects },
|
||||
@@ -113,6 +118,7 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
|
||||
branches,
|
||||
warehouses,
|
||||
projects,
|
||||
taxRates,
|
||||
|
||||
isInvoiceLoading,
|
||||
isItemsLoading,
|
||||
@@ -123,6 +129,7 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
|
||||
isFeatureLoading,
|
||||
isBranchesSuccess,
|
||||
isWarehousesSuccess,
|
||||
isTaxRatesLoading,
|
||||
|
||||
createInvoiceMutate,
|
||||
editInvoiceMutate,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FastField } from 'formik';
|
||||
import { CLASSES } from '@/constants/classes';
|
||||
import ItemsEntriesTable from '@/containers/Entries/ItemsEntriesTable';
|
||||
import { useInvoiceFormContext } from './InvoiceFormProvider';
|
||||
import { entriesFieldShouldUpdate } from './utils';
|
||||
@@ -11,32 +9,33 @@ import { entriesFieldShouldUpdate } from './utils';
|
||||
* Invoice items entries editor field.
|
||||
*/
|
||||
export default function InvoiceItemsEntriesEditorField() {
|
||||
const { items } = useInvoiceFormContext();
|
||||
const { items, taxRates } = useInvoiceFormContext();
|
||||
|
||||
return (
|
||||
<div className={classNames(CLASSES.PAGE_FORM_BODY)}>
|
||||
<FastField
|
||||
name={'entries'}
|
||||
items={items}
|
||||
shouldUpdate={entriesFieldShouldUpdate}
|
||||
>
|
||||
{({
|
||||
form: { values, setFieldValue },
|
||||
field: { value },
|
||||
meta: { error, touched },
|
||||
}) => (
|
||||
<ItemsEntriesTable
|
||||
entries={value}
|
||||
onUpdateData={(entries) => {
|
||||
setFieldValue('entries', entries);
|
||||
}}
|
||||
items={items}
|
||||
errors={error}
|
||||
linesNumber={4}
|
||||
currencyCode={values.currency_code}
|
||||
/>
|
||||
)}
|
||||
</FastField>
|
||||
</div>
|
||||
<FastField
|
||||
name={'entries'}
|
||||
items={items}
|
||||
taxRates={taxRates}
|
||||
shouldUpdate={entriesFieldShouldUpdate}
|
||||
>
|
||||
{({
|
||||
form: { values, setFieldValue },
|
||||
field: { value },
|
||||
meta: { error, touched },
|
||||
}) => (
|
||||
<ItemsEntriesTable
|
||||
value={value}
|
||||
onChange={(entries) => {
|
||||
setFieldValue('entries', entries);
|
||||
}}
|
||||
items={items}
|
||||
taxRates={taxRates}
|
||||
errors={error}
|
||||
linesNumber={4}
|
||||
currencyCode={values.currency_code}
|
||||
isInclusiveTax={values.inclusive_exclusive_tax === 'inclusive'}
|
||||
/>
|
||||
)}
|
||||
</FastField>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
export const InclusiveButtonOptions = [
|
||||
{ key: 'inclusive', label: 'Inclusive of Tax' },
|
||||
{ key: 'exclusive', label: 'Exclusive of Tax' },
|
||||
];
|
||||
@@ -1,27 +1,27 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import moment from 'moment';
|
||||
import * as R from 'ramda';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import { omit, first } from 'lodash';
|
||||
import {
|
||||
compose,
|
||||
transformToForm,
|
||||
repeatValue,
|
||||
transactionNumber,
|
||||
} from '@/utils';
|
||||
import { omit, first, keyBy, sumBy, groupBy } from 'lodash';
|
||||
import { compose, transformToForm, repeatValue } from '@/utils';
|
||||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { formattedAmount, defaultFastFieldShouldUpdate } from '@/utils';
|
||||
import { ERROR } from '@/constants/errors';
|
||||
import { AppToaster } from '@/components';
|
||||
import { useCurrentOrganization } from '@/hooks/state';
|
||||
import { getEntriesTotal } from '@/containers/Entries/utils';
|
||||
import {
|
||||
assignEntriesTaxAmount,
|
||||
getEntriesTotal,
|
||||
} from '@/containers/Entries/utils';
|
||||
import { useInvoiceFormContext } from './InvoiceFormProvider';
|
||||
import {
|
||||
updateItemsEntriesTotal,
|
||||
ensureEntriesHaveEmptyLine,
|
||||
} from '@/containers/Entries/utils';
|
||||
import { TaxType } from '@/interfaces/TaxRates';
|
||||
|
||||
export const MIN_LINES_NUMBER = 1;
|
||||
|
||||
@@ -34,6 +34,9 @@ export const defaultInvoiceEntry = {
|
||||
quantity: '',
|
||||
description: '',
|
||||
amount: '',
|
||||
tax_rate_id: '',
|
||||
tax_rate: '',
|
||||
tax_amount: '',
|
||||
};
|
||||
|
||||
// Default invoice object.
|
||||
@@ -43,6 +46,7 @@ export const defaultInvoice = {
|
||||
due_date: moment().format('YYYY-MM-DD'),
|
||||
delivered: '',
|
||||
invoice_no: '',
|
||||
inclusive_exclusive_tax: 'inclusive',
|
||||
// Holds the invoice number that entered manually only.
|
||||
invoice_no_manually: '',
|
||||
reference_no: '',
|
||||
@@ -114,7 +118,7 @@ export const transformErrors = (errors, { setErrors }) => {
|
||||
*/
|
||||
export const customerNameFieldShouldUpdate = (newProps, oldProps) => {
|
||||
return (
|
||||
newProps.shouldUpdateDeps.items !== oldProps.shouldUpdateDeps.items||
|
||||
newProps.shouldUpdateDeps.items !== oldProps.shouldUpdateDeps.items ||
|
||||
defaultFastFieldShouldUpdate(newProps, oldProps)
|
||||
);
|
||||
};
|
||||
@@ -125,6 +129,7 @@ export const customerNameFieldShouldUpdate = (newProps, oldProps) => {
|
||||
export const entriesFieldShouldUpdate = (newProps, oldProps) => {
|
||||
return (
|
||||
newProps.items !== oldProps.items ||
|
||||
newProps.taxRates !== oldProps.taxRates ||
|
||||
defaultFastFieldShouldUpdate(newProps, oldProps)
|
||||
);
|
||||
};
|
||||
@@ -154,12 +159,17 @@ export function transformValueToRequest(values) {
|
||||
(item) => item.item_id && item.quantity,
|
||||
);
|
||||
return {
|
||||
...omit(values, ['invoice_no', 'invoice_no_manually']),
|
||||
...omit(values, [
|
||||
'invoice_no',
|
||||
'invoice_no_manually',
|
||||
'inclusive_exclusive_tax',
|
||||
]),
|
||||
// The `invoice_no_manually` will be presented just if the auto-increment
|
||||
// is disable, always both attributes hold the same value in manual mode.
|
||||
...(values.invoice_no_manually && {
|
||||
invoice_no: values.invoice_no,
|
||||
}),
|
||||
is_inclusive_tax: values.inclusive_exclusive_tax === 'inclusive',
|
||||
entries: entries.map((entry) => ({ ...omit(entry, ['amount']) })),
|
||||
delivered: false,
|
||||
};
|
||||
@@ -196,7 +206,11 @@ export const useSetPrimaryBranchToForm = () => {
|
||||
}, [isBranchesSuccess, setFieldValue, branches]);
|
||||
};
|
||||
|
||||
export const useInvoiceTotal = () => {
|
||||
/**
|
||||
* Retrieves the invoice subtotal.
|
||||
* @returns {number}
|
||||
*/
|
||||
export const useInvoiceSubtotal = () => {
|
||||
const {
|
||||
values: { entries },
|
||||
} = useFormikContext();
|
||||
@@ -216,10 +230,12 @@ export const useInvoiceTotals = () => {
|
||||
// Retrieves the invoice entries total.
|
||||
const total = React.useMemo(() => getEntriesTotal(entries), [entries]);
|
||||
|
||||
const total_ = useInvoiceTotal();
|
||||
|
||||
// Retrieves the formatted total money.
|
||||
const formattedTotal = React.useMemo(
|
||||
() => formattedAmount(total, currencyCode),
|
||||
[total, currencyCode],
|
||||
() => formattedAmount(total_, currencyCode),
|
||||
[total_, currencyCode],
|
||||
);
|
||||
// Retrieves the formatted subtotal.
|
||||
const formattedSubtotal = React.useMemo(
|
||||
@@ -271,6 +287,9 @@ export const useInvoiceIsForeignCustomer = () => {
|
||||
return isForeignCustomer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets the form state to initial values
|
||||
*/
|
||||
export const resetFormState = ({ initialValues, values, resetForm }) => {
|
||||
resetForm({
|
||||
values: {
|
||||
@@ -281,3 +300,105 @@ export const resetFormState = ({ initialValues, values, resetForm }) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-calcualte the entries tax amount when editing.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const composeEntriesOnEditInclusiveTax = (
|
||||
inclusiveExclusiveTax: string,
|
||||
entries,
|
||||
) => {
|
||||
return R.compose(
|
||||
assignEntriesTaxAmount(inclusiveExclusiveTax === 'inclusive'),
|
||||
)(entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retreives the invoice aggregated tax rates.
|
||||
* @returns {Array}
|
||||
*/
|
||||
export const useInvoiceAggregatedTaxRates = () => {
|
||||
const { values } = useFormikContext();
|
||||
const { taxRates } = useInvoiceFormContext();
|
||||
|
||||
const taxRatesById = useMemo(() => keyBy(taxRates, 'id'), [taxRates]);
|
||||
|
||||
// Calculate the total tax amount of invoice entries.
|
||||
return React.useMemo(() => {
|
||||
const filteredEntries = values.entries.filter((e) => e.tax_rate_id);
|
||||
const groupedTaxRates = groupBy(filteredEntries, 'tax_rate_id');
|
||||
|
||||
return Object.keys(groupedTaxRates).map((taxRateId) => {
|
||||
const taxRate = taxRatesById[taxRateId];
|
||||
const taxRates = groupedTaxRates[taxRateId];
|
||||
const totalTaxAmount = sumBy(taxRates, 'tax_amount');
|
||||
const taxAmountFormatted = formattedAmount(totalTaxAmount, 'USD');
|
||||
|
||||
return {
|
||||
taxRateId,
|
||||
taxRate: taxRate.rate,
|
||||
label: `${taxRate.name} [${taxRate.rate}%]`,
|
||||
taxAmount: totalTaxAmount,
|
||||
taxAmountFormatted,
|
||||
};
|
||||
});
|
||||
}, [values.entries]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retreives the invoice total tax amount.
|
||||
* @returns {number}
|
||||
*/
|
||||
export const useInvoiceTotalTaxAmount = () => {
|
||||
const { values } = useFormikContext();
|
||||
|
||||
return React.useMemo(() => {
|
||||
const filteredEntries = values.entries.filter((entry) => entry.tax_amount);
|
||||
return sumBy(filteredEntries, 'tax_amount');
|
||||
}, [values.entries]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retreives the invoice total.
|
||||
* @returns {number}
|
||||
*/
|
||||
export const useInvoiceTotal = () => {
|
||||
const subtotal = useInvoiceSubtotal();
|
||||
const totalTaxAmount = useInvoiceTotalTaxAmount();
|
||||
const isExclusiveTax = useIsInvoiceTaxExclusive();
|
||||
|
||||
return R.compose(R.when(R.always(isExclusiveTax), R.add(totalTaxAmount)))(
|
||||
subtotal,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retreives the invoice due amount.
|
||||
* @returns {number}
|
||||
*/
|
||||
export const useInvoiceDueAmount = () => {
|
||||
const total = useInvoiceTotal();
|
||||
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detrmines whether the tax is inclusive.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const useIsInvoiceTaxInclusive = () => {
|
||||
const { values } = useFormikContext();
|
||||
|
||||
return values.inclusive_exclusive_tax === TaxType.Inclusive;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detrmines whether the tax is exclusive.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const useIsInvoiceTaxExclusive = () => {
|
||||
const { values } = useFormikContext();
|
||||
|
||||
return values.inclusive_exclusive_tax === TaxType.Exclusive;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user