From d40de4d22bd1d71f43f21182d23cacb4da73289f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 8 Oct 2023 16:07:18 +0200 Subject: [PATCH] feat: integrate tax rates to bills (#260) --- .../src/api/controllers/Purchases/Bills.ts | 21 +++ ..._add_tax_amount_withheld_to_bills_table.js | 10 ++ packages/server/src/interfaces/Bill.ts | 12 +- packages/server/src/loaders/eventEmitter.ts | 8 +- packages/server/src/models/Bill.ts | 156 ++++++++++++++---- .../BillPayments/BillPaymentGLEntries.ts | 4 +- .../Purchases/Bills/BillDTOTransformer.ts | 20 ++- .../services/Purchases/Bills/BillGLEntries.ts | 95 +++++++++-- .../src/services/Purchases/Bills/GetBill.ts | 3 +- .../Bills/PurchaseInvoiceTransformer.ts | 119 +++++++++++-- .../Sales/Invoices/InvoiceGLEntries.ts | 3 +- .../SaleInvoiceTaxEntryTransformer.ts | 4 +- .../Sales/Invoices/SaleInvoiceTransformer.ts | 2 +- .../BillTaxRateValidateSubscriber.ts | 89 ++++++++++ .../WriteBillTaxTransactionsSubscriber.ts | 87 ++++++++++ .../subscribers/Bills/WriteJournalEntries.ts | 87 ---------- .../src/components/DrawersContainer.tsx | 2 - .../webapp/src/components/Forms/Select.tsx | 4 + .../src/constants/InclusiveTaxOptions.ts | 7 + .../Drawers/BillDrawer/BillDetailHeader.tsx | 2 +- .../BillDrawer/BillDetailTableFooter.tsx | 16 +- .../containers/Entries/EntriesActionBar.tsx | 16 ++ .../webapp/src/containers/Entries/utils.tsx | 36 +++- .../Purchases/Bills/BillForm/BillForm.tsx | 6 +- .../Bills/BillForm/BillFormEntriesActions.tsx | 58 +++++++ .../Bills/BillForm/BillFormFooterRight.tsx | 35 +++- .../Bills/BillForm/BillFormProvider.tsx | 11 +- .../Bills/BillForm/BillItemsEntriesEditor.tsx | 59 +++---- .../Purchases/Bills/BillForm/utils.tsx | 106 +++++++++++- .../Bills/BillsLanding/components.tsx | 20 +-- .../InvoiceForm/InvoiceFormActions.tsx | 27 +-- .../InvoiceForm/InvoiceFormFooterRight.tsx | 3 +- .../Sales/Invoices/InvoiceForm/utils.tsx | 43 ++--- packages/webapp/src/style/objects/form.scss | 5 +- 34 files changed, 894 insertions(+), 282 deletions(-) create mode 100644 packages/server/src/database/migrations/20231004012644_add_tax_amount_withheld_to_bills_table.js create mode 100644 packages/server/src/services/TaxRates/subscribers/BillTaxRateValidateSubscriber.ts create mode 100644 packages/server/src/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber.ts delete mode 100644 packages/server/src/subscribers/Bills/WriteJournalEntries.ts create mode 100644 packages/webapp/src/constants/InclusiveTaxOptions.ts create mode 100644 packages/webapp/src/containers/Entries/EntriesActionBar.tsx create mode 100644 packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormEntriesActions.tsx diff --git a/packages/server/src/api/controllers/Purchases/Bills.ts b/packages/server/src/api/controllers/Purchases/Bills.ts index 2bfe230e3..61fc5cf70 100644 --- a/packages/server/src/api/controllers/Purchases/Bills.ts +++ b/packages/server/src/api/controllers/Purchases/Bills.ts @@ -115,6 +115,8 @@ export default class BillsController extends BaseController { check('note').optional().trim().escape(), check('open').default(false).isBoolean().toBoolean(), + check('is_inclusive_tax').default(false).isBoolean().toBoolean(), + check('entries').isArray({ min: 1 }), check('entries.*.index').exists().isNumeric().toInt(), @@ -137,6 +139,15 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), + check('entries.*.tax_code') + .optional({ nullable: true }) + .trim() + .escape() + .isString(), + check('entries.*.tax_rate_id') + .optional({ nullable: true }) + .isNumeric() + .toInt(), ]; } @@ -542,6 +553,16 @@ export default class BillsController extends BaseController { ], }); } + if (error.errorType === 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND') { + return res.boom.badRequest(null, { + errors: [{ type: 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND', code: 1800 }], + }); + } + if (error.errorType === 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND') { + return res.boom.badRequest(null, { + errors: [{ type: 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND', code: 1900 }], + }); + } } next(error); } diff --git a/packages/server/src/database/migrations/20231004012644_add_tax_amount_withheld_to_bills_table.js b/packages/server/src/database/migrations/20231004012644_add_tax_amount_withheld_to_bills_table.js new file mode 100644 index 000000000..f61655877 --- /dev/null +++ b/packages/server/src/database/migrations/20231004012644_add_tax_amount_withheld_to_bills_table.js @@ -0,0 +1,10 @@ +exports.up = (knex) => { + return knex.schema.table('bills', (table) => { + table.boolean('is_inclusive_tax').defaultTo(false); + table.decimal('tax_amount_withheld'); + }); +}; + +exports.down = (knex) => { + return knex.schema.table('bills', () => {}); +}; diff --git a/packages/server/src/interfaces/Bill.ts b/packages/server/src/interfaces/Bill.ts index fa94c3e41..8276a4623 100644 --- a/packages/server/src/interfaces/Bill.ts +++ b/packages/server/src/interfaces/Bill.ts @@ -2,6 +2,7 @@ import { Knex } from 'knex'; import { IDynamicListFilterDTO } from './DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IBillLandedCost } from './LandedCost'; + export interface IBillDTO { vendorId: number; billNumber: string; @@ -15,10 +16,10 @@ export interface IBillDTO { exchangeRate?: number; open: boolean; entries: IItemEntryDTO[]; - branchId?: number; warehouseId?: number; projectId?: number; + isInclusiveTax?: boolean; } export interface IBillEditDTO { @@ -80,6 +81,15 @@ export interface IBill { localAmount?: number; locatedLandedCosts?: IBillLandedCost[]; + + amountLocal: number; + subtotal: number; + subtotalLocal: number; + subtotalExcludingTax: number; + taxAmountWithheld: number; + taxAmountWithheldLocal: number; + total: number; + totalLocal: number; } export interface IBillsFilter extends IDynamicListFilterDTO { diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index df5724e83..47589de5f 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -81,6 +81,8 @@ import { ProjectBillableBillSubscriber } from '@/services/Projects/Projects/Proj import { SyncActualTimeTaskSubscriber } from '@/services/Projects/Times/SyncActualTimeTaskSubscriber'; import { SaleInvoiceTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber'; import { WriteInvoiceTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber'; +import { BillTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/BillTaxRateValidateSubscriber'; +import { WriteBillTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber'; export default () => { return new EventPublisher(); @@ -188,8 +190,12 @@ export const susbcribers = () => { ProjectBillableExpensesSubscriber, ProjectBillableBillSubscriber, - // Tax Rates + // Tax Rates - Sale Invoice SaleInvoiceTaxRateValidateSubscriber, WriteInvoiceTaxTransactionsSubscriber, + + // Tax Rates - Bills + BillTaxRateValidateSubscriber, + WriteBillTaxTransactionsSubscriber, ]; }; diff --git a/packages/server/src/models/Bill.ts b/packages/server/src/models/Bill.ts index b651671c1..17dd07fa4 100644 --- a/packages/server/src/models/Bill.ts +++ b/packages/server/src/models/Bill.ts @@ -13,6 +13,109 @@ export default class Bill extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + public amount: number; + public paymentAmount: number; + public landedCostAmount: number; + public allocatedCostAmount: number; + public isInclusiveTax: boolean; + public taxAmountWithheld: number; + public exchangeRate: number; + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'balance', + 'dueAmount', + 'isOpen', + 'isPartiallyPaid', + 'isFullyPaid', + 'isPaid', + 'remainingDays', + 'overdueDays', + 'isOverdue', + 'unallocatedCostAmount', + 'localAmount', + 'localAllocatedCostAmount', + 'billableAmount', + 'amountLocal', + 'subtotal', + 'subtotalLocal', + 'subtotalExludingTax', + 'taxAmountWithheldLocal', + 'total', + 'totalLocal', + ]; + } + + /** + * Invoice amount in base currency. + * @returns {number} + */ + get amountLocal() { + return this.amount * this.exchangeRate; + } + + /** + * Subtotal. (Tax inclusive) if the tax inclusive is enabled. + * @returns {number} + */ + get subtotal() { + return this.amount; + } + + /** + * Subtotal in base currency. (Tax inclusive) if the tax inclusive is enabled. + * @returns {number} + */ + get subtotalLocal() { + return this.amountLocal; + } + + /** + * Sale invoice amount excluding tax. + * @returns {number} + */ + get subtotalExcludingTax() { + return this.isInclusiveTax + ? this.subtotal - this.taxAmountWithheld + : this.subtotal; + } + + /** + * Tax amount withheld in base currency. + * @returns {number} + */ + get taxAmountWithheldLocal() { + return this.taxAmountWithheld * this.exchangeRate; + } + + /** + * Invoice total. (Tax included) + * @returns {number} + */ + get total() { + return this.isInclusiveTax + ? this.subtotal + : this.subtotal + this.taxAmountWithheld; + } + + /** + * Invoice total in local currency. (Tax included) + * @returns {number} + */ + get totalLocal() { + return this.total * this.exchangeRate; + } + /** * Table name */ @@ -158,40 +261,13 @@ export default class Bill extends mixin(TenantModel, [ }; } - /** - * Timestamps columns. - */ - get timestamps() { - return ['createdAt', 'updatedAt']; - } - - /** - * Virtual attributes. - */ - static get virtualAttributes() { - return [ - 'balance', - 'dueAmount', - 'isOpen', - 'isPartiallyPaid', - 'isFullyPaid', - 'isPaid', - 'remainingDays', - 'overdueDays', - 'isOverdue', - 'unallocatedCostAmount', - 'localAmount', - 'localAllocatedCostAmount', - 'billableAmount', - ]; - } - /** * Invoice amount in organization base currency. + * @deprecated * @returns {number} */ get localAmount() { - return this.amount * this.exchangeRate; + return this.amountLocal; } /** @@ -231,7 +307,7 @@ export default class Bill extends mixin(TenantModel, [ * @return {number} */ get dueAmount() { - return Math.max(this.amount - this.balance, 0); + return Math.max(this.total - this.balance, 0); } /** @@ -247,7 +323,7 @@ export default class Bill extends mixin(TenantModel, [ * @return {boolean} */ get isPartiallyPaid() { - return this.dueAmount !== this.amount && this.dueAmount > 0; + return this.dueAmount !== this.total && this.dueAmount > 0; } /** @@ -308,7 +384,7 @@ export default class Bill extends mixin(TenantModel, [ * Retrieves the calculated amount which have not been invoiced. */ get billableAmount() { - return Math.max(this.amount - this.invoicedAmount, 0); + return Math.max(this.total - this.invoicedAmount, 0); } /** @@ -326,6 +402,7 @@ export default class Bill extends mixin(TenantModel, [ const ItemEntry = require('models/ItemEntry'); const BillLandedCost = require('models/BillLandedCost'); const Branch = require('models/Branch'); + const TaxRateTransaction = require('models/TaxRateTransaction'); return { vendor: { @@ -373,6 +450,21 @@ export default class Bill extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Bill may has associated tax rate transactions. + */ + taxes: { + relation: Model.HasManyRelation, + modelClass: TaxRateTransaction.default, + join: { + from: 'bills.id', + to: 'tax_rate_transactions.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'Bill'); + }, + }, }; } diff --git a/packages/server/src/services/Purchases/BillPayments/BillPaymentGLEntries.ts b/packages/server/src/services/Purchases/BillPayments/BillPaymentGLEntries.ts index e2f51d309..57f4966d7 100644 --- a/packages/server/src/services/Purchases/BillPayments/BillPaymentGLEntries.ts +++ b/packages/server/src/services/Purchases/BillPayments/BillPaymentGLEntries.ts @@ -203,8 +203,8 @@ export class BillPaymentGLEntries { /** * Retrieves the payment GL payable entry. - * @param {IBillPayment} billPayment - * @param {number} APAccountId + * @param {IBillPayment} billPayment + * @param {number} APAccountId * @returns {ILedgerEntry} */ private getPaymentGLPayableEntry = ( diff --git a/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts b/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts index 046e0177e..fed2c249e 100644 --- a/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts @@ -14,6 +14,7 @@ import { import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { ItemEntriesTaxTransactions } from '@/services/TaxRates/ItemEntriesTaxTransactions'; @Service() export class BillDTOTransformer { @@ -23,6 +24,9 @@ export class BillDTOTransformer { @Inject() private warehouseDTOTransform: WarehouseTransactionDTOTransform; + @Inject() + private taxDTOTransformer: ItemEntriesTaxTransactions; + @Inject() private tenancy: HasTenancyService; @@ -73,14 +77,24 @@ export class BillDTOTransformer { const billNumber = billDTO.billNumber || oldBill?.billNumber; const initialEntries = billDTO.entries.map((entry) => ({ - reference_type: 'Bill', + referenceType: 'Bill', + isInclusiveTax: billDTO.isInclusiveTax, ...omit(entry, ['amount']), })); - const entries = await composeAsync( + const asyncEntries = await composeAsync( + // Associate tax rate from tax id to entries. + this.taxDTOTransformer.assocTaxRateFromTaxIdToEntries(tenantId), + // Associate tax rate id from tax code to entries. + this.taxDTOTransformer.assocTaxRateIdFromCodeToEntries(tenantId), // Sets the default cost account to the bill entries. this.setBillEntriesDefaultAccounts(tenantId) )(initialEntries); + const entries = R.compose( + // Remove tax code from entries. + R.map(R.omit(['taxCode'])) + )(asyncEntries); + const initialDTO = { ...formatDateFields(omit(billDTO, ['open', 'entries']), [ 'billDate', @@ -100,6 +114,8 @@ export class BillDTOTransformer { userId: authorizedUser.id, }; return R.compose( + // Associates tax amount withheld to the model. + this.taxDTOTransformer.assocTaxAmountWithheldFromEntries, this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) )(initialDTO); diff --git a/packages/server/src/services/Purchases/Bills/BillGLEntries.ts b/packages/server/src/services/Purchases/Bills/BillGLEntries.ts index ebfbc8da8..9c1352e9a 100644 --- a/packages/server/src/services/Purchases/Bills/BillGLEntries.ts +++ b/packages/server/src/services/Purchases/Bills/BillGLEntries.ts @@ -7,6 +7,7 @@ import { AccountNormal, IBill, IItemEntry, ILedgerEntry } from '@/interfaces'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import Ledger from '@/services/Accounting/Ledger'; import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; +import ItemsEntriesService from '@/services/Items/ItemsEntriesService'; @Service() export class BillGLEntries { @@ -16,6 +17,9 @@ export class BillGLEntries { @Inject() private ledgerStorage: LedgerStorageService; + @Inject() + private itemsEntriesService: ItemsEntriesService; + /** * Creates bill GL entries. * @param {number} tenantId - @@ -43,8 +47,16 @@ export class BillGLEntries { {}, trx ); - const billLedger = this.getBillLedger(bill, APAccount.id); - + // Find or create tax payable account. + const taxPayableAccount = await accountRepository.findOrCreateTaxPayable( + {}, + trx + ); + const billLedger = this.getBillLedger( + bill, + APAccount.id, + taxPayableAccount.id + ); // Commit the GL enties on the storage. await this.ledgerStorage.commit(tenantId, billLedger, trx); }; @@ -83,7 +95,7 @@ export class BillGLEntries { /** * Retrieves the bill common entry. - * @param {IBill} bill + * @param {IBill} bill * @returns {ILedgerEntry} */ private getBillCommonEntry = (bill: IBill) => { @@ -119,7 +131,7 @@ export class BillGLEntries { (bill: IBill, entry: IItemEntry, index: number): ILedgerEntry => { const commonJournalMeta = this.getBillCommonEntry(bill); - const localAmount = bill.exchangeRate * entry.amount; + const localAmount = bill.exchangeRate * entry.amountExludingTax; const landedCostAmount = sumBy(entry.allocatedCostEntries, 'cost'); return { @@ -173,7 +185,7 @@ export class BillGLEntries { return { ...commonJournalMeta, - credit: bill.localAmount, + credit: bill.totalLocal, accountId: payableAccountId, contactId: bill.vendorId, accountNormal: AccountNormal.CREDIT, @@ -182,15 +194,62 @@ export class BillGLEntries { }; }; + /** + * Retrieves the bill tax GL entry. + * @param {IBill} bill - + * @param {number} taxPayableAccountId - + * @param {IItemEntry} entry - + * @param {number} index - + * @returns {ILedgerEntry} + */ + private getBillTaxEntry = R.curry( + ( + bill: IBill, + taxPayableAccountId: number, + entry: IItemEntry, + index: number + ): ILedgerEntry => { + const commonJournalMeta = this.getBillCommonEntry(bill); + + return { + ...commonJournalMeta, + debit: entry.taxAmount, + index, + indexGroup: 30, + accountId: taxPayableAccountId, + accountNormal: AccountNormal.CREDIT, + taxRateId: entry.taxRateId, + taxRate: entry.taxRate, + }; + } + ); + + /** + * Retrieves the bill tax GL entries. + * @param {IBill} bill + * @param {number} taxPayableAccountId + * @returns {ILedgerEntry[]} + */ + private getBillTaxEntries = (bill: IBill, taxPayableAccountId: number) => { + // Retrieves the non-zero tax entries. + const nonZeroTaxEntries = this.itemsEntriesService.getNonZeroEntries( + bill.entries + ); + const transformTaxEntry = this.getBillTaxEntry(bill, taxPayableAccountId); + + return nonZeroTaxEntries.map(transformTaxEntry); + }; + /** * Retrieves the given bill GL entries. - * @param {IBill} bill - * @param {number} payableAccountId + * @param {IBill} bill + * @param {number} payableAccountId * @returns {ILedgerEntry[]} */ private getBillGLEntries = ( bill: IBill, - payableAccountId: number + payableAccountId: number, + taxPayableAccountId: number ): ILedgerEntry[] => { const payableEntry = this.getBillPayableEntry(payableAccountId, bill); @@ -201,18 +260,28 @@ export class BillGLEntries { const landedCostEntries = bill.locatedLandedCosts.map( landedCostTransformer ); + const taxEntries = this.getBillTaxEntries(bill, taxPayableAccountId); + // Allocate cost entries journal entries. - return [payableEntry, ...itemsEntries, ...landedCostEntries]; + return [payableEntry, ...itemsEntries, ...landedCostEntries, ...taxEntries]; }; /** * Retrieves the given bill ledger. - * @param {IBill} bill - * @param {number} payableAccountId + * @param {IBill} bill + * @param {number} payableAccountId * @returns {Ledger} */ - private getBillLedger = (bill: IBill, payableAccountId: number) => { - const entries = this.getBillGLEntries(bill, payableAccountId); + private getBillLedger = ( + bill: IBill, + payableAccountId: number, + taxPayableAccountId: number + ) => { + const entries = this.getBillGLEntries( + bill, + payableAccountId, + taxPayableAccountId + ); return new Ledger(entries); }; diff --git a/packages/server/src/services/Purchases/Bills/GetBill.ts b/packages/server/src/services/Purchases/Bills/GetBill.ts index fb653c25c..03f6a8361 100644 --- a/packages/server/src/services/Purchases/Bills/GetBill.ts +++ b/packages/server/src/services/Purchases/Bills/GetBill.ts @@ -28,7 +28,8 @@ export class GetBill { .findById(billId) .withGraphFetched('vendor') .withGraphFetched('entries.item') - .withGraphFetched('branch'); + .withGraphFetched('branch') + .withGraphFetched('taxes.taxRate'); // Validates the bill existance. this.validators.validateBillExistance(bill); diff --git a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts index 7918a3643..07d3a78d6 100644 --- a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts @@ -1,27 +1,42 @@ import { IBill } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; +import { SaleInvoiceTaxEntryTransformer } from '@/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer'; import { formatNumber } from 'utils'; export class PurchaseInvoiceTransformer extends Transformer { /** - * Include these attributes to sale invoice object. + * Include these attributes to sale bill object. * @returns {Array} */ public includeAttributes = (): string[] => { return [ 'formattedBillDate', 'formattedDueDate', - 'formattedAmount', 'formattedPaymentAmount', 'formattedBalance', 'formattedDueAmount', 'formattedExchangeRate', + 'subtotalFormatted', + 'subtotalLocalFormatted', + 'subtotalExcludingTaxFormatted', + 'taxAmountWithheldLocalFormatted', + 'totalFormatted', + 'totalLocalFormatted', + 'taxes', ]; }; /** - * Retrieve formatted invoice date. - * @param {IBill} invoice + * Excluded attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['amount', 'amountLocal', 'localAmount']; + }; + + /** + * Retrieve formatted bill date. + * @param {IBill} bill * @returns {String} */ protected formattedBillDate = (bill: IBill): string => { @@ -29,8 +44,8 @@ export class PurchaseInvoiceTransformer extends Transformer { }; /** - * Retrieve formatted invoice date. - * @param {IBill} invoice + * Retrieve formatted bill date. + * @param {IBill} bill * @returns {String} */ protected formattedDueDate = (bill: IBill): string => { @@ -39,7 +54,7 @@ export class PurchaseInvoiceTransformer extends Transformer { /** * Retrieve formatted bill amount. - * @param {IBill} invoice + * @param {IBill} bill * @returns {string} */ protected formattedAmount = (bill): string => { @@ -48,7 +63,7 @@ export class PurchaseInvoiceTransformer extends Transformer { /** * Retrieve formatted bill amount. - * @param {IBill} invoice + * @param {IBill} bill * @returns {string} */ protected formattedPaymentAmount = (bill): string => { @@ -59,7 +74,7 @@ export class PurchaseInvoiceTransformer extends Transformer { /** * Retrieve formatted bill amount. - * @param {IBill} invoice + * @param {IBill} bill * @returns {string} */ protected formattedDueAmount = (bill): string => { @@ -77,10 +92,90 @@ export class PurchaseInvoiceTransformer extends Transformer { /** * Retrieve the formatted exchange rate. - * @param {ISaleInvoice} invoice + * @param {IBill} bill * @returns {string} */ - protected formattedExchangeRate = (invoice): string => { - return formatNumber(invoice.exchangeRate, { money: false }); + protected formattedExchangeRate = (bill): string => { + return formatNumber(bill.exchangeRate, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieves the formatted subtotal. + * @param {IBill} bill + * @returns {string} + */ + protected subtotalFormatted = (bill): string => { + return formatNumber(bill.subtotal, { + currencyCode: bill.currencyCode, + }); + }; + + /** + * Retrieves the local subtotal formatted. + * @param {IBill} bill + * @returns {string} + */ + protected subtotalLocalFormatted = (bill): string => { + return formatNumber(bill.subtotalLocal, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieves the formatted subtotal tax excluded. + * @param {IBill} bill + * @returns {string} + */ + protected subtotalExcludingTaxFormatted = (bill): string => { + return formatNumber(bill.subtotalExludingTax, { + currencyCode: bill.currencyCode, + }); + }; + + /** + * Retrieves the local formatted tax amount withheld + * @param {IBill} bill + * @returns {string} + */ + protected taxAmountWithheldLocalFormatted = (bill): string => { + return formatNumber(bill.taxAmountWithheldLocal, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieves the total formatted. + * @param {IBill} bill + * @returns {string} + */ + protected totalFormatted = (bill): string => { + return formatNumber(bill.total, { + currencyCode: bill.currencyCode, + }); + }; + + /** + * Retrieves the local total formatted. + * @param {IBill} bill + * @returns {string} + */ + protected totalLocalFormatted = (bill): string => { + return formatNumber(bill.totalLocal, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieve the taxes lines of bill. + * @param {Bill} bill + */ + protected taxes = (bill) => { + return this.item(bill.taxes, new SaleInvoiceTaxEntryTransformer(), { + subtotal: bill.subtotal, + isInclusiveTax: bill.isInclusiveTax, + currencyCode: bill.currencyCode, + }); }; } diff --git a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts index d816672c2..7b714e70e 100644 --- a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts +++ b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts @@ -218,7 +218,8 @@ export class SaleInvoiceGLEntries { ...commonEntry, credit: entry.taxAmount, accountId: taxPayableAccountId, - index: index + 3, + index: index + 1, + indexGroup: 30, accountNormal: AccountNormal.CREDIT, taxRateId: entry.taxRateId, taxRate: entry.taxRate, diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts index 6f028423d..ae4e9c87e 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts @@ -62,8 +62,8 @@ export class SaleInvoiceTaxEntryTransformer extends Transformer { const taxRate = this.taxRate(taxEntry); return this.options.isInclusiveTax - ? getInclusiveTaxAmount(this.options.amount, taxRate) - : getExlusiveTaxAmount(this.options.amount, taxRate); + ? getInclusiveTaxAmount(this.options.subtotal, taxRate) + : getExlusiveTaxAmount(this.options.subtotal, taxRate); }; /** diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts index ffb2d8391..979b312a9 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts @@ -171,7 +171,7 @@ export class SaleInvoiceTransformer extends Transformer { */ protected taxes = (invoice) => { return this.item(invoice.taxes, new SaleInvoiceTaxEntryTransformer(), { - amount: invoice.amount, + subtotal: invoice.subtotal, isInclusiveTax: invoice.isInclusiveTax, currencyCode: invoice.currencyCode, }); diff --git a/packages/server/src/services/TaxRates/subscribers/BillTaxRateValidateSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/BillTaxRateValidateSubscriber.ts new file mode 100644 index 000000000..9850e0648 --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/BillTaxRateValidateSubscriber.ts @@ -0,0 +1,89 @@ +import { Inject, Service } from 'typedi'; +import { IBillCreatingPayload, IBillEditingPayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { CommandTaxRatesValidators } from '../CommandTaxRatesValidators'; + +@Service() +export class BillTaxRateValidateSubscriber { + @Inject() + private taxRateDTOValidator: CommandTaxRatesValidators; + + /** + * Attaches events with handlers. + */ + public attach(bus) { + bus.subscribe( + events.bill.onCreating, + this.validateBillEntriesTaxCodeExistanceOnCreating + ); + bus.subscribe( + events.bill.onCreating, + this.validateBillEntriesTaxIdExistanceOnCreating + ); + bus.subscribe( + events.bill.onEditing, + this.validateBillEntriesTaxCodeExistanceOnEditing + ); + bus.subscribe( + events.bill.onEditing, + this.validateBillEntriesTaxIdExistanceOnEditing + ); + return bus; + } + + /** + * Validate bill entries tax rate code existance when creating. + * @param {IBillCreatingPayload} + */ + private validateBillEntriesTaxCodeExistanceOnCreating = async ({ + billDTO, + tenantId, + }: IBillCreatingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + billDTO.entries + ); + }; + + /** + * Validate the tax rate id existance when creating. + * @param {IBillCreatingPayload} + */ + private validateBillEntriesTaxIdExistanceOnCreating = async ({ + billDTO, + tenantId, + }: IBillCreatingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCodeId( + tenantId, + billDTO.entries + ); + }; + + /** + * Validate bill entries tax rate code existance when editing. + * @param {IBillEditingPayload} + */ + private validateBillEntriesTaxCodeExistanceOnEditing = async ({ + tenantId, + billDTO, + }: IBillEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + billDTO.entries + ); + }; + + /** + * Validates the bill entries tax rate id existance when editing. + * @param {ISaleInvoiceEditingPayload} payload - + */ + private validateBillEntriesTaxIdExistanceOnEditing = async ({ + tenantId, + billDTO, + }: IBillEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCodeId( + tenantId, + billDTO.entries + ); + }; +} diff --git a/packages/server/src/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber.ts new file mode 100644 index 000000000..fc74c0686 --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber.ts @@ -0,0 +1,87 @@ +import { Inject, Service } from 'typedi'; +import { + IBIllEventDeletedPayload, + IBillCreatedPayload, + IBillEditedPayload, + ISaleInvoiceCreatedPayload, + ISaleInvoiceDeletedPayload, + ISaleInvoiceEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { WriteTaxTransactionsItemEntries } from '../WriteTaxTransactionsItemEntries'; + +@Service() +export class WriteBillTaxTransactionsSubscriber { + @Inject() + private writeTaxTransactions: WriteTaxTransactionsItemEntries; + + /** + * Attaches events with handlers. + */ + public attach(bus) { + bus.subscribe( + events.bill.onCreated, + this.writeInvoiceTaxTransactionsOnCreated + ); + bus.subscribe( + events.bill.onEdited, + this.rewriteInvoiceTaxTransactionsOnEdited + ); + bus.subscribe( + events.bill.onDeleted, + this.removeInvoiceTaxTransactionsOnDeleted + ); + return bus; + } + + /** + * Writes the bill tax transactions on invoice created. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private writeInvoiceTaxTransactionsOnCreated = async ({ + tenantId, + bill, + trx, + }: IBillCreatedPayload) => { + await this.writeTaxTransactions.writeTaxTransactionsFromItemEntries( + tenantId, + bill.entries, + trx + ); + }; + + /** + * Rewrites the bill tax transactions on invoice edited. + * @param {IBillEditedPayload} payload - + */ + private rewriteInvoiceTaxTransactionsOnEdited = async ({ + tenantId, + bill, + trx, + }: IBillEditedPayload) => { + await this.writeTaxTransactions.rewriteTaxRateTransactionsFromItemEntries( + tenantId, + bill.entries, + 'Bill', + bill.id, + trx + ); + }; + + /** + * Removes the invoice tax transactions on invoice deleted. + * @param {IBIllEventDeletedPayload} + */ + private removeInvoiceTaxTransactionsOnDeleted = async ({ + tenantId, + oldBill, + trx, + }: IBIllEventDeletedPayload) => { + await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries( + tenantId, + oldBill.id, + 'Bill', + trx + ); + }; +} diff --git a/packages/server/src/subscribers/Bills/WriteJournalEntries.ts b/packages/server/src/subscribers/Bills/WriteJournalEntries.ts deleted file mode 100644 index 50e4ea155..000000000 --- a/packages/server/src/subscribers/Bills/WriteJournalEntries.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Inject, Service } from 'typedi'; -import events from '@/subscribers/events'; -import TenancyService from '@/services/Tenancy/TenancyService'; -import BillsService from '@/services/Purchases/Bills'; -import { - IBillCreatedPayload, - IBillEditedPayload, - IBIllEventDeletedPayload, -} from '@/interfaces'; - -@Service() -export default class BillWriteGLEntriesSubscriber { - @Inject() - tenancy: TenancyService; - - @Inject() - billsService: BillsService; - - /** - * Attaches events with handles. - */ - public attach(bus) { - bus.subscribe( - events.bill.onCreated, - this.handlerWriteJournalEntriesOnCreate - ); - bus.subscribe( - events.bill.onEdited, - this.handleOverwriteJournalEntriesOnEdit - ); - bus.subscribe(events.bill.onDeleted, this.handlerDeleteJournalEntries); - } - - /** - * Handles writing journal entries once bill created. - * @param {IBillCreatedPayload} payload - - */ - private handlerWriteJournalEntriesOnCreate = async ({ - tenantId, - billId, - bill, - trx, - }: IBillCreatedPayload) => { - // Can't continue if the bill is not opened yet. - if (!bill.openedAt) return null; - - await this.billsService.recordJournalTransactions( - tenantId, - billId, - false, - trx - ); - }; - - /** - * Handles the overwriting journal entries once bill edited. - * @param {IBillEditedPayload} payload - - */ - private handleOverwriteJournalEntriesOnEdit = async ({ - tenantId, - billId, - bill, - trx, - }: IBillEditedPayload) => { - // Can't continue if the bill is not opened yet. - if (!bill.openedAt) return null; - - await this.billsService.recordJournalTransactions( - tenantId, - billId, - true, - trx - ); - }; - - /** - * Handles revert journal entries on bill deleted. - * @param {IBIllEventDeletedPayload} payload - - */ - private handlerDeleteJournalEntries = async ({ - tenantId, - billId, - trx, - }: IBIllEventDeletedPayload) => { - await this.billsService.revertJournalEntries(tenantId, billId, trx); - }; -} diff --git a/packages/webapp/src/components/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index ef96dc608..c8c04f63a 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import AccountDrawer from '@/containers/Drawers/AccountDrawer'; import ManualJournalDrawer from '@/containers/Drawers/ManualJournalDrawer'; import ExpenseDrawer from '@/containers/Drawers/ExpenseDrawer'; diff --git a/packages/webapp/src/components/Forms/Select.tsx b/packages/webapp/src/components/Forms/Select.tsx index 205f23f85..cae52b1b8 100644 --- a/packages/webapp/src/components/Forms/Select.tsx +++ b/packages/webapp/src/components/Forms/Select.tsx @@ -26,6 +26,10 @@ const SelectButton = styled(Button)` position: relative; padding-right: 30px; + &.bp4-small{ + padding-right: 24px; + } + &:not(.is-selected):not([class*='bp4-intent-']):not(.bp4-minimal) { color: #5c7080; } diff --git a/packages/webapp/src/constants/InclusiveTaxOptions.ts b/packages/webapp/src/constants/InclusiveTaxOptions.ts new file mode 100644 index 000000000..b5e16100a --- /dev/null +++ b/packages/webapp/src/constants/InclusiveTaxOptions.ts @@ -0,0 +1,7 @@ + +import { TaxType } from '@/interfaces/TaxRates'; + +export const InclusiveTaxOptions = [ + { key: TaxType.Inclusive, label: 'Inclusive of Tax' }, + { key: TaxType.Exclusive, label: 'Exclusive of Tax' }, +]; diff --git a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailHeader.tsx b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailHeader.tsx index b8fa7a78f..34e0418a3 100644 --- a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailHeader.tsx @@ -30,7 +30,7 @@ export default function BillDetailHeader() { -

{bill.formatted_amount}

+

{bill.total_formatted}

diff --git a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx index e3b44c1ce..17c29c1a4 100644 --- a/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/BillDrawer/BillDetailTableFooter.tsx @@ -1,11 +1,8 @@ // @ts-nocheck -import React from 'react'; import styled from 'styled-components'; - import { TotalLineBorderStyle, TotalLineTextStyle, - FormatNumber, T, TotalLines, TotalLine, @@ -23,12 +20,20 @@ export function BillDetailTableFooter() { } - value={} + value={bill.subtotal_formatted} borderStyle={TotalLineBorderStyle.SingleDark} /> + {bill.taxes.map((taxRate) => ( + + ))} } - value={bill.formatted_amount} + value={bill.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> @@ -39,6 +44,7 @@ export function BillDetailTableFooter() { } value={bill.formatted_due_amount} + textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Entries/EntriesActionBar.tsx b/packages/webapp/src/containers/Entries/EntriesActionBar.tsx new file mode 100644 index 000000000..921e63c10 --- /dev/null +++ b/packages/webapp/src/containers/Entries/EntriesActionBar.tsx @@ -0,0 +1,16 @@ +import { Box } from '@/components'; +import styled from 'styled-components'; + +export const EntriesActionsBar = styled(Box)` + padding-bottom: 12px; + display: flex; + + .bp4-form-group { + margin-bottom: 0; + + label.bp4-label { + opacity: 0.6; + margin-right: 8px; + } + } +`; diff --git a/packages/webapp/src/containers/Entries/utils.tsx b/packages/webapp/src/containers/Entries/utils.tsx index 51ff04412..bfc62138e 100644 --- a/packages/webapp/src/containers/Entries/utils.tsx +++ b/packages/webapp/src/containers/Entries/utils.tsx @@ -1,8 +1,7 @@ // @ts-nocheck import React, { useCallback } from 'react'; import * as R from 'ramda'; -import { sumBy, isEmpty, last, keyBy } from 'lodash'; - +import { sumBy, isEmpty, last, keyBy, groupBy } from 'lodash'; import { useItem } from '@/hooks/query'; import { toSafeNumber, @@ -12,6 +11,7 @@ import { updateAutoAddNewLine, orderingLinesIndexes, updateTableRow, + formattedAmount, } from '@/utils'; import { useItemEntriesTableContext } from './ItemEntriesTableProvider'; @@ -116,6 +116,11 @@ export function useFetchItemRow({ landedCost, itemType, notifyNewRow }) { ? item.purchase_description : item.sell_description; + const taxRateId = + itemType === ITEM_TYPE.PURCHASABLE + ? item.purchase_tax_rate_id + : item.sell_tax_rate_id; + // Detarmines whether the landed cost checkbox should be disabled. const landedCostDisabled = isLandedCostDisabled(item); @@ -130,6 +135,7 @@ export function useFetchItemRow({ landedCost, itemType, notifyNewRow }) { landed_cost_disabled: landedCostDisabled, } : {}), + taxRateId, }; setItemRow(null); saveInvoke(notifyNewRow, newRow, rowIndex); @@ -266,3 +272,29 @@ export const useComposeRowsOnRemoveTableRow = () => { [minLinesNumber, defaultEntry, localValue], ); }; + +/** + * Retrieves the aggregate tax rates from the given item entries. + */ +export const aggregateItemEntriesTaxRates = R.curry((taxRates, entries) => { + const taxRatesById = keyBy(taxRates, 'id'); + + // Calculate the total tax amount of invoice entries. + const filteredEntries = 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, + }; + }); +}); diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillForm.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillForm.tsx index 38aa0fb5e..57e9bda18 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillForm.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillForm.tsx @@ -26,6 +26,7 @@ import { handleErrors, } from './utils'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; +import { BillFormEntriesActions } from './BillFormEntriesActions'; /** * Bill form. @@ -126,7 +127,10 @@ function BillForm({
- +
+ + +
diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormEntriesActions.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormEntriesActions.tsx new file mode 100644 index 000000000..acc7e9948 --- /dev/null +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormEntriesActions.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { useFormikContext } from 'formik'; +import { FFormGroup, FSelect } from '@/components'; +import { InclusiveTaxOptions } from '@/constants/InclusiveTaxOptions'; + +import { composeEntriesOnEditInclusiveTax } from './utils'; +import { EntriesActionsBar } from '@/containers/Entries/EntriesActionBar'; + +export function BillFormEntriesActions() { + return ( + + + + ); +} + +/** + * Bill exclusive/inclusive select. + * @returns {React.ReactNode} + */ +export function BillExclusiveInclusiveSelect(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 ( + + ''} + valueAccessor={'key'} + popoverProps={{ minimal: true, usePortal: true, inline: false }} + buttonProps={{ small: true }} + onItemSelect={handleItemSelect} + filterable={false} + {...props} + /> + + ); +} + +const InclusiveFormGroup = styled(FFormGroup)` + margin-left: auto; +`; diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx index 825cb8eff..0657299d6 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormFooterRight.tsx @@ -1,15 +1,14 @@ // @ts-nocheck -import React from 'react'; import styled from 'styled-components'; - import { - T, TotalLines, TotalLine, TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useBillTotals } from './utils'; +import { useBillAggregatedTaxRates, useBillTotals } from './utils'; +import { useFormikContext } from 'formik'; +import { TaxType } from '@/interfaces/TaxRates'; export function BillFormFooterRight() { const { @@ -19,26 +18,46 @@ export function BillFormFooterRight() { formattedPaymentTotal, } = useBillTotals(); + const { + values: { inclusive_exclusive_tax, currency_code }, + } = useFormikContext(); + + const taxEntries = useBillAggregatedTaxRates(); + return ( } + title={ + <> + {inclusive_exclusive_tax === TaxType.Inclusive + ? 'Subtotal (Tax Inclusive)' + : 'Subtotal'} + + } value={formattedSubtotal} borderStyle={TotalLineBorderStyle.None} /> + {taxEntries.map((tax, index) => ( + + ))} } + title={`Total (${currency_code})`} value={formattedTotal} borderStyle={TotalLineBorderStyle.SingleDark} textStyle={TotalLineTextStyle.Bold} /> } + title={'Paid Amount'} value={formattedPaymentTotal} borderStyle={TotalLineBorderStyle.None} /> } + title={'Due Amount'} value={formattedDueTotal} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormProvider.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormProvider.tsx index 7c717a9b1..d960891f0 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormProvider.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillFormProvider.tsx @@ -15,6 +15,7 @@ import { useCreateBill, useEditBill, } from '@/hooks/query'; +import { useTaxRates } from '@/hooks/query/taxRates'; const BillFormContext = createContext(); @@ -83,6 +84,9 @@ function BillFormProvider({ billId, ...props }) { isSuccess: isBranchesSuccess, } = useBranches({}, { enabled: isBranchFeatureCan }); + // Fetch tax rates. + const { data: taxRates, isLoading: isTaxRatesLoading } = useTaxRates(); + // Fetches the projects list. const { data: { projects }, @@ -103,7 +107,10 @@ function BillFormProvider({ billId, ...props }) { // Determines whether the warehouse and branches are loading. const isFeatureLoading = - isWarehouesLoading || isBranchesLoading || isProjectsLoading; + isWarehouesLoading || + isBranchesLoading || + isProjectsLoading || + isTaxRatesLoading; const provider = { accounts, @@ -113,6 +120,7 @@ function BillFormProvider({ billId, ...props }) { warehouses, branches, projects, + taxRates, submitPayload, isNewMode, @@ -124,6 +132,7 @@ function BillFormProvider({ billId, ...props }) { isFeatureLoading, isBranchesSuccess, isWarehousesSuccess, + isTaxRatesLoading, createBillMutate, editBillMutate, diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.tsx index 5d46a0e03..a07e43fcb 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.tsx @@ -1,46 +1,41 @@ // @ts-nocheck -import React from 'react'; -import classNames from 'classnames'; import ItemsEntriesTable from '@/containers/Entries/ItemsEntriesTable'; import { FastField } from 'formik'; -import { CLASSES } from '@/constants/classes'; import { useBillFormContext } from './BillFormProvider'; import { entriesFieldShouldUpdate } from './utils'; import { ITEM_TYPE } from '@/containers/Entries/utils'; /** - * Bill form body. + * Bill form body. */ export default function BillFormBody({ defaultBill }) { - const { items } = useBillFormContext(); + const { items, taxRates } = useBillFormContext(); return ( -
- - {({ - form: { values, setFieldValue }, - field: { value }, - meta: { error, touched }, - }) => ( - { - setFieldValue('entries', entries); - }} - items={items} - errors={error} - linesNumber={4} - currencyCode={values.currency_code} - itemType={ITEM_TYPE.PURCHASABLE} - landedCost={true} - enableTaxRates={false} - /> - )} - -
+ + {({ + form: { values, setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + { + setFieldValue('entries', entries); + }} + items={items} + errors={error} + linesNumber={4} + currencyCode={values.currency_code} + itemType={ITEM_TYPE.PURCHASABLE} + taxRates={taxRates} + landedCost={true} + /> + )} + ); } diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx index 7e7cce017..2d82bfea5 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx @@ -3,7 +3,7 @@ import React from 'react'; import moment from 'moment'; import intl from 'react-intl-universal'; import * as R from 'ramda'; -import { first } from 'lodash'; +import { first, chain } from 'lodash'; import { Intent } from '@blueprintjs/core'; import { useFormikContext } from 'formik'; import { AppToaster } from '@/components'; @@ -17,6 +17,8 @@ import { import { updateItemsEntriesTotal, ensureEntriesHaveEmptyLine, + assignEntriesTaxAmount, + aggregateItemEntriesTaxRates, } from '@/containers/Entries/utils'; import { useCurrentOrganization } from '@/hooks/state'; import { @@ -24,6 +26,7 @@ import { getEntriesTotal, } from '@/containers/Entries/utils'; import { useBillFormContext } from './BillFormProvider'; +import { TaxType } from '@/interfaces/TaxRates'; export const MIN_LINES_NUMBER = 1; @@ -37,6 +40,9 @@ export const defaultBillEntry = { description: '', amount: '', landed_cost: false, + tax_rate_id: '', + tax_rate: '', + tax_amount: '', }; // Default bill. @@ -46,6 +52,7 @@ export const defaultBill = { bill_date: moment(new Date()).format('YYYY-MM-DD'), due_date: moment(new Date()).format('YYYY-MM-DD'), reference_no: '', + inclusive_exclusive_tax: TaxType.Inclusive, note: '', open: '', branch_id: '', @@ -82,6 +89,9 @@ export const transformToEditForm = (bill) => { return { ...transformToForm(bill, defaultBill), + inclusive_exclusive_tax: bill.is_inclusive_tax + ? TaxType.Inclusive + : TaxType.Exclusive, entries, }; }; @@ -228,11 +238,12 @@ export const useSetPrimaryWarehouseToForm = () => { */ export const useBillTotals = () => { const { - values: { entries, currency_code: currencyCode }, + values: { currency_code: currencyCode }, } = useFormikContext(); - // Retrieves the bili entries total. - const total = React.useMemo(() => getEntriesTotal(entries), [entries]); + // Retrieves the bill subtotal. + const subtotal = useBillSubtotal(); + const total = useBillTotal(); // Retrieves the formatted total money. const formattedTotal = React.useMemo( @@ -241,8 +252,8 @@ export const useBillTotals = () => { ); // Retrieves the formatted subtotal. const formattedSubtotal = React.useMemo( - () => formattedAmount(total, currencyCode, { money: false }), - [total, currencyCode], + () => formattedAmount(subtotal, currencyCode, { money: false }), + [subtotal, currencyCode], ); // Retrieves the payment total. const paymentTotal = React.useMemo(() => 0, []); @@ -288,3 +299,86 @@ export const useBillIsForeignCustomer = () => { ); return isForeignCustomer; }; + +/** + * Re-calculates the entries tax amount when editing. + * @returns {string} + */ +export const composeEntriesOnEditInclusiveTax = ( + inclusiveExclusiveTax: string, + entries, +) => { + return R.compose( + assignEntriesTaxAmount(inclusiveExclusiveTax === 'inclusive'), + )(entries); +}; + +/** + * Retreives the bill aggregated tax rates. + * @returns {Array} + */ +export const useBillAggregatedTaxRates = () => { + const { values } = useFormikContext(); + const { taxRates } = useBillFormContext(); + + const aggregateTaxRates = React.useMemo( + () => aggregateItemEntriesTaxRates(taxRates), + [taxRates], + ); + // Calculate the total tax amount of bill entries. + return React.useMemo(() => { + return aggregateTaxRates(values.entries); + }, [aggregateTaxRates, values.entries]); +}; + +/** + * Retrieves the bill subtotal. + * @returns {number} + */ +export const useBillSubtotal = () => { + const { + values: { entries }, + } = useFormikContext(); + + // Calculate the total due amount of bill entries. + return React.useMemo(() => getEntriesTotal(entries), [entries]); +}; + +/** + * Retreives the bill total tax amount. + * @returns {number} + */ +export const useBillTotalTaxAmount = () => { + const { values } = useFormikContext(); + + return React.useMemo(() => { + return chain(values.entries) + .filter((entry) => entry.tax_amount) + .sumBy('tax_amount') + .value(); + }, [values.entries]); +}; + +/** + * Detarmines whether the tax is exclusive. + * @returns {boolean} + */ +export const useIsBillTaxExclusive = () => { + const { values } = useFormikContext(); + + return values.inclusive_exclusive_tax === TaxType.Exclusive; +}; + +/** + * Retreives the bill total. + * @returns {number} + */ +export const useBillTotal = () => { + const subtotal = useBillSubtotal(); + const totalTaxAmount = useBillTotalTaxAmount(); + const isExclusiveTax = useIsBillTaxExclusive(); + + return R.compose(R.when(R.always(isExclusiveTax), R.add(totalTaxAmount)))( + subtotal, + ); +}; diff --git a/packages/webapp/src/containers/Purchases/Bills/BillsLanding/components.tsx b/packages/webapp/src/containers/Purchases/Bills/BillsLanding/components.tsx index 7facf19ee..167f145aa 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillsLanding/components.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillsLanding/components.tsx @@ -9,20 +9,18 @@ import { Tag, ProgressBar, } from '@blueprintjs/core'; - +import clsx from 'classnames'; import { FormatDateCell, FormattedMessage as T, Icon, If, Choose, - Money, Can, } from '@/components'; import { formattedAmount, safeCallback, - isBlank, calculateStatus, } from '@/utils'; import { @@ -30,6 +28,7 @@ import { PaymentMadeAction, AbilitySubject, } from '@/constants/abilityOption'; +import { CLASSES } from '@/constants'; /** * Actions menu. @@ -101,17 +100,6 @@ export function ActionsMenu({ ); } -/** - * Amount accessor. - */ -export function AmountAccessor(bill) { - return !isBlank(bill.amount) ? ( - - ) : ( - '' - ); -} - /** * Status accessor. */ @@ -198,11 +186,11 @@ export function useBillsTableColumns() { { id: 'amount', Header: intl.get('amount'), - accessor: AmountAccessor, + accessor: 'total_formatted', width: 120, - className: 'amount', align: 'right', clickable: true, + className: clsx(CLASSES.FONT_BOLD), }, { id: 'status', diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx index bf21e0fad..7f8d74c39 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx @@ -3,7 +3,8 @@ import React from 'react'; import styled from 'styled-components'; import { useFormikContext } from 'formik'; import { InclusiveButtonOptions } from './constants'; -import { Box, FFormGroup, FSelect } from '@/components'; +import { FFormGroup, FSelect } from '@/components'; +import { EntriesActionsBar } from '@/containers/Entries/EntriesActionBar'; import { composeEntriesOnEditInclusiveTax } from './utils'; /** @@ -12,9 +13,9 @@ import { composeEntriesOnEditInclusiveTax } from './utils'; */ export function InvoiceFormActions() { return ( - + - + ); } @@ -40,7 +41,7 @@ export function InvoiceExclusiveInclusiveSelect(props) { label={'Amounts are'} inline={true} > - - {inclusive_exclusive_tax === 'inclusive' + {inclusive_exclusive_tax === TaxType.Inclusive ? 'Subtotal (Tax Inclusive)' : 'Subtotal'} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index d227dba44..4186cbc63 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -1,18 +1,23 @@ // @ts-nocheck -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; +import { useFormikContext } from 'formik'; import intl from 'react-intl-universal'; import moment from 'moment'; import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; -import { omit, first, keyBy, sumBy, groupBy } from 'lodash'; -import { compose, transformToForm, repeatValue } from '@/utils'; -import { useFormikContext } from 'formik'; - -import { formattedAmount, defaultFastFieldShouldUpdate } from '@/utils'; +import { omit, first, sumBy } from 'lodash'; +import { + compose, + transformToForm, + repeatValue, + formattedAmount, + defaultFastFieldShouldUpdate, +} from '@/utils'; import { ERROR } from '@/constants/errors'; import { AppToaster } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; import { + aggregateItemEntriesTaxRates, assignEntriesTaxAmount, getEntriesTotal, } from '@/containers/Entries/utils'; @@ -327,28 +332,14 @@ export const useInvoiceAggregatedTaxRates = () => { const { values } = useFormikContext(); const { taxRates } = useInvoiceFormContext(); - const taxRatesById = useMemo(() => keyBy(taxRates, 'id'), [taxRates]); - + const aggregateTaxRates = React.useMemo( + () => aggregateItemEntriesTaxRates(taxRates), + [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]); + return aggregateTaxRates(values.entries); + }, [aggregateTaxRates, values.entries]); }; /** diff --git a/packages/webapp/src/style/objects/form.scss b/packages/webapp/src/style/objects/form.scss index c56b838f9..e08d42af4 100644 --- a/packages/webapp/src/style/objects/form.scss +++ b/packages/webapp/src/style/objects/form.scss @@ -21,12 +21,9 @@ label.bp4-label { .required { color: red; } - .bp4-form-group.bp4-inline & { margin: 0 10px 0 0; - line-height: 1.6; - padding-top: calc(0.3rem + 1px); - padding-bottom: calc(0.3rem + 1px); + line-height: 30px; } }