diff --git a/packages/server/src/api/controllers/FinancialStatements.ts b/packages/server/src/api/controllers/FinancialStatements.ts index cabbb1235..15b7900f8 100644 --- a/packages/server/src/api/controllers/FinancialStatements.ts +++ b/packages/server/src/api/controllers/FinancialStatements.ts @@ -20,6 +20,7 @@ import InventoryDetailsController from './FinancialStatements/InventoryDetails'; import TransactionsByReferenceController from './FinancialStatements/TransactionsByReference'; import CashflowAccountTransactions from './FinancialStatements/CashflowAccountTransactions'; import ProjectProfitabilityController from './FinancialStatements/ProjectProfitabilitySummary'; +import SalesTaxLiabilitySummary from './FinancialStatements/SalesTaxLiabilitySummary'; @Service() export default class FinancialStatementsService { @@ -68,40 +69,44 @@ export default class FinancialStatementsService { ); router.use( '/customer-balance-summary', - Container.get(CustomerBalanceSummaryController).router(), + Container.get(CustomerBalanceSummaryController).router() ); router.use( '/vendor-balance-summary', - Container.get(VendorBalanceSummaryController).router(), + Container.get(VendorBalanceSummaryController).router() ); router.use( '/transactions-by-customers', - Container.get(TransactionsByCustomers).router(), + Container.get(TransactionsByCustomers).router() ); router.use( '/transactions-by-vendors', - Container.get(TransactionsByVendors).router(), + Container.get(TransactionsByVendors).router() ); router.use( '/cash-flow', - Container.get(CashFlowStatementController).router(), + Container.get(CashFlowStatementController).router() ); router.use( '/inventory-item-details', - Container.get(InventoryDetailsController).router(), + Container.get(InventoryDetailsController).router() ); router.use( '/transactions-by-reference', - Container.get(TransactionsByReferenceController).router(), + Container.get(TransactionsByReferenceController).router() ); router.use( '/cashflow-account-transactions', - Container.get(CashflowAccountTransactions).router(), + Container.get(CashflowAccountTransactions).router() ); router.use( '/project-profitability-summary', - Container.get(ProjectProfitabilityController).router(), - ) + Container.get(ProjectProfitabilityController).router() + ); + router.use( + '/sales-tax-liability-summary', + Container.get(SalesTaxLiabilitySummary).router() + ); return router; } } diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts new file mode 100644 index 000000000..56c837288 --- /dev/null +++ b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts @@ -0,0 +1,90 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { query } from 'express-validator'; +import { Inject } from 'typedi'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import BaseFinancialReportController from '../BaseFinancialReportController'; +import { AbilitySubject, ReportsAction } from '@/interfaces'; +import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { SalesTaxLiabilitySummaryService } from '@/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService'; + +export default class SalesTaxLiabilitySummary extends BaseFinancialReportController { + @Inject() + private salesTaxLiabilitySummaryService: SalesTaxLiabilitySummaryService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + CheckPolicies( + ReportsAction.READ_SALES_TAX_LIABILITY_SUMMARY, + AbilitySubject.Report + ), + this.validationSchema, + asyncMiddleware(this.salesTaxLiabilitySummary.bind(this)) + ); + return router; + } + + /** + * Validation schema. + */ + get validationSchema() { + return [ + query('from_date').optional().isISO8601(), + query('to_date').optional().isISO8601(), + ]; + } + + /* + * Retrieves the sales tax liability summary. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + async salesTaxLiabilitySummary( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const filter = this.matchedQueryData(req); + + try { + const accept = this.accepts(req); + const acceptType = accept.types(['json', 'application/json+table']); + + switch (acceptType) { + case 'application/json+table': + const salesTaxLiabilityTable = + await this.salesTaxLiabilitySummaryService.salesTaxLiabilitySummaryTable( + tenantId, + filter + ); + + return res.status(200).send({ + table: salesTaxLiabilityTable.table, + query: salesTaxLiabilityTable.query, + meta: salesTaxLiabilityTable.meta, + }); + case 'json': + default: + const salesTaxLiability = + await this.salesTaxLiabilitySummaryService.salesTaxLiability( + tenantId, + filter + ); + return res.status(200).send({ + data: salesTaxLiability.data, + query: salesTaxLiability.query, + meta: salesTaxLiability.meta, + }); + } + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index c9975b710..d90b94d8d 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -169,8 +169,9 @@ export default class SaleInvoicesController extends BaseController { check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('project_id').optional({ nullable: true }).isNumeric().toInt(), - check('entries').exists().isArray({ min: 1 }), + check('is_inclusive_tax').optional().isBoolean().toBoolean(), + check('entries').exists().isArray({ min: 1 }), check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), @@ -183,6 +184,15 @@ export default class SaleInvoicesController extends BaseController { .optional({ nullable: true }) .trim() .escape(), + check('entries.*.tax_code') + .optional({ nullable: true }) + .trim() + .escape() + .isString(), + check('entries.*.tax_rate_id') + .optional({ nullable: true }) + .isNumeric() + .toInt(), check('entries.*.warehouse_id') .optional({ nullable: true }) .isNumeric() @@ -756,6 +766,16 @@ export default class SaleInvoicesController 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: 5000 }], + }); + } + 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: 5100 }], + }); + } } next(error); } diff --git a/packages/server/src/api/controllers/TaxRates/TaxRates.ts b/packages/server/src/api/controllers/TaxRates/TaxRates.ts new file mode 100644 index 000000000..f60c7e0e2 --- /dev/null +++ b/packages/server/src/api/controllers/TaxRates/TaxRates.ts @@ -0,0 +1,278 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response } from 'express'; +import { body, param } from 'express-validator'; +import BaseController from '@/api/controllers/BaseController'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import { TaxRatesApplication } from '@/services/TaxRates/TaxRatesApplication'; +import CheckAbilities from '@/api/middleware/CheckPolicies'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '@/services/TaxRates/constants'; +import { AbilitySubject, TaxRateAction } from '@/interfaces'; + +@Service() +export class TaxRatesController extends BaseController { + @Inject() + private taxRatesApplication: TaxRatesApplication; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/', + CheckAbilities(TaxRateAction.CREATE, AbilitySubject.TaxRate), + this.taxRateValidationSchema, + this.validationResult, + asyncMiddleware(this.createTaxRate.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id', + CheckAbilities(TaxRateAction.EDIT, AbilitySubject.TaxRate), + [param('id').exists().toInt(), ...this.taxRateValidationSchema], + this.validationResult, + asyncMiddleware(this.editTaxRate.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/active', + CheckAbilities(TaxRateAction.EDIT, AbilitySubject.TaxRate), + [param('id').exists().toInt()], + this.validationResult, + asyncMiddleware(this.activateTaxRate.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/inactive', + CheckAbilities(TaxRateAction.EDIT, AbilitySubject.TaxRate), + [param('id').exists().toInt()], + this.validationResult, + asyncMiddleware(this.inactivateTaxRate.bind(this)), + this.handleServiceErrors + ); + router.delete( + '/:id', + CheckAbilities(TaxRateAction.DELETE, AbilitySubject.TaxRate), + [param('id').exists().toInt()], + this.validationResult, + asyncMiddleware(this.deleteTaxRate.bind(this)), + this.handleServiceErrors + ); + router.get( + '/:id', + CheckAbilities(TaxRateAction.VIEW, AbilitySubject.TaxRate), + [param('id').exists().toInt()], + this.validationResult, + asyncMiddleware(this.getTaxRate.bind(this)), + this.handleServiceErrors + ); + router.get( + '/', + CheckAbilities(TaxRateAction.VIEW, AbilitySubject.TaxRate), + this.validationResult, + asyncMiddleware(this.getTaxRates.bind(this)), + this.handleServiceErrors + ); + return router; + } + + /** + * Tax rate validation schema. + */ + private get taxRateValidationSchema() { + return [ + body('name').exists(), + body('code').exists().isString(), + body('rate').exists().isNumeric().toFloat(), + body('description').optional().trim().isString(), + body('is_non_recoverable').optional().isBoolean().default(false), + body('is_compound').optional().isBoolean().default(false), + body('active').optional().isBoolean().default(false), + ]; + } + + /** + * Creates a new tax rate. + * @param {Request} req - + * @param {Response} res - + */ + public async createTaxRate(req: Request, res: Response, next) { + const { tenantId } = req; + const createTaxRateDTO = this.matchedBodyData(req); + + try { + const taxRate = await this.taxRatesApplication.createTaxRate( + tenantId, + createTaxRateDTO + ); + return res.status(200).send({ + data: taxRate, + }); + } catch (error) { + next(error); + } + } + + /** + * Edits the given tax rate. + * @param {Request} req - + * @param {Response} res - + */ + public async editTaxRate(req: Request, res: Response, next) { + const { tenantId } = req; + const editTaxRateDTO = this.matchedBodyData(req); + const { id: taxRateId } = req.params; + + try { + const taxRate = await this.taxRatesApplication.editTaxRate( + tenantId, + taxRateId, + editTaxRateDTO + ); + return res.status(200).send({ + data: taxRate, + }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given tax rate. + * @param {Request} req - + * @param {Response} res - + */ + public async deleteTaxRate(req: Request, res: Response, next) { + const { tenantId } = req; + const { id: taxRateId } = req.params; + + try { + await this.taxRatesApplication.deleteTaxRate(tenantId, taxRateId); + + return res.status(200).send({ + code: 200, + message: 'The tax rate has been deleted successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the given tax rate. + * @param {Request} req - + * @param {Response} res - + */ + public async getTaxRate(req: Request, res: Response, next) { + const { tenantId } = req; + const { id: taxRateId } = req.params; + + try { + const taxRate = await this.taxRatesApplication.getTaxRate( + tenantId, + taxRateId + ); + return res.status(200).send({ data: taxRate }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the tax rates list. + * @param {Request} req - + * @param {Response} res - + */ + public async getTaxRates(req: Request, res: Response, next) { + const { tenantId } = req; + + try { + const taxRates = await this.taxRatesApplication.getTaxRates(tenantId); + + return res.status(200).send({ data: taxRates }); + } catch (error) { + next(error); + } + } + + /** + * Inactivates the given tax rate. + * @param req + * @param res + * @param next + * @returns + */ + public async inactivateTaxRate(req: Request, res: Response, next) { + const { tenantId } = req; + const { id: taxRateId } = req.params; + + try { + await this.taxRatesApplication.inactivateTaxRate(tenantId, taxRateId); + + return res.status(200).send({ + id: taxRateId, + message: 'The given tax rate has been inactivated successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Inactivates the given tax rate. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + public async activateTaxRate(req: Request, res: Response, next) { + const { tenantId } = req; + const { id: taxRateId } = req.params; + + try { + await this.taxRatesApplication.activateTaxRate(tenantId, taxRateId); + + return res.status(200).send({ + id: taxRateId, + message: 'The given tax rate has been activated successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handleServiceErrors(error: Error, req: Request, res: Response, next) { + if (error instanceof ServiceError) { + if (error.errorType === ERRORS.TAX_CODE_NOT_UNIQUE) { + return res.boom.badRequest(null, { + errors: [{ type: ERRORS.TAX_CODE_NOT_UNIQUE, code: 100 }], + }); + } + if (error.errorType === ERRORS.TAX_RATE_NOT_FOUND) { + return res.boom.badRequest(null, { + errors: [{ type: ERRORS.TAX_RATE_NOT_FOUND, code: 200 }], + }); + } + if (error.errorType === ERRORS.TAX_RATE_ALREADY_INACTIVE) { + return res.boom.badRequest(null, { + errors: [{ type: ERRORS.TAX_RATE_ALREADY_INACTIVE, code: 300 }], + }); + } + if (error.errorType === ERRORS.TAX_RATE_ALREADY_ACTIVE) { + return res.boom.badRequest(null, { + errors: [{ type: ERRORS.TAX_RATE_ALREADY_ACTIVE, code: 400 }], + }); + } + } + next(error); + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 727a9e0ba..6a41c8304 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -55,6 +55,7 @@ import { InventoryItemsCostController } from './controllers/Inventory/Inventorty import { ProjectsController } from './controllers/Projects/Projects'; import { ProjectTasksController } from './controllers/Projects/Tasks'; import { ProjectTimesController } from './controllers/Projects/Times'; +import { TaxRatesController } from './controllers/TaxRates/TaxRates'; export default () => { const app = Router(); @@ -129,6 +130,7 @@ export default () => { ); dashboard.use('/warehouses', Container.get(WarehousesController).router()); dashboard.use('/projects', Container.get(ProjectsController).router()); + dashboard.use('/tax-rates', Container.get(TaxRatesController).router()); dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTimesController).router()); diff --git a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js new file mode 100644 index 000000000..881a9db31 --- /dev/null +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -0,0 +1,52 @@ +exports.up = (knex) => { + return knex.schema + .createTable('tax_rates', (table) => { + table.increments(); + table.string('name'); + table.string('code'); + table.decimal('rate'); + table.string('description'); + table.boolean('is_non_recoverable').defaultTo(false); + table.boolean('is_compound').defaultTo(false); + table.boolean('active').defaultTo(false); + table.date('deleted_at'); + table.timestamps(); + }) + .table('items_entries', (table) => { + table.boolean('is_inclusive_tax').defaultTo(false); + table + .integer('tax_rate_id') + .unsigned() + .references('id') + .inTable('tax_rates'); + table.decimal('tax_rate').unsigned(); + }) + .table('sales_invoices', (table) => { + table.boolean('is_inclusive_tax').defaultTo(false); + table.decimal('tax_amount_withheld'); + }) + .createTable('tax_rate_transactions', (table) => { + table.increments('id'); + table + .integer('tax_rate_id') + .unsigned() + .references('id') + .inTable('tax_rates'); + table.string('reference_type'); + table.integer('reference_id'); + table.decimal('rate').unsigned(); + table.integer('tax_account_id').unsigned(); + }) + .table('accounts_transactions', (table) => { + table + .integer('tax_rate_id') + .unsigned() + .references('id') + .inTable('tax_rates'); + table.decimal('tax_rate').unsigned(); + }); +}; + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('tax_rates'); +}; diff --git a/packages/server/src/database/seeds/core/20230912121909_seed_tax_rates.ts b/packages/server/src/database/seeds/core/20230912121909_seed_tax_rates.ts new file mode 100644 index 000000000..10856e740 --- /dev/null +++ b/packages/server/src/database/seeds/core/20230912121909_seed_tax_rates.ts @@ -0,0 +1,14 @@ +import { TenantSeeder } from '@/lib/Seeder/TenantSeeder'; +import { InitialTaxRates } from '../data/TaxRates'; + +export default class SeedTaxRates extends TenantSeeder { + /** + * Seeds initial tax rates to the organization. + */ + up(knex) { + return knex('tax_rates').then(async () => { + // Inserts seed entries. + return knex('tax_rates').insert(InitialTaxRates); + }); + } +} diff --git a/packages/server/src/database/seeds/core/20230912121909_update_tax_payable_account.ts b/packages/server/src/database/seeds/core/20230912121909_update_tax_payable_account.ts new file mode 100644 index 000000000..469eef10e --- /dev/null +++ b/packages/server/src/database/seeds/core/20230912121909_update_tax_payable_account.ts @@ -0,0 +1,16 @@ +import { TenantSeeder } from '@/lib/Seeder/TenantSeeder'; +import { InitialTaxRates } from '../data/TaxRates'; + +export default class UpdateTaxPayableAccount extends TenantSeeder { + /** + * Seeds initial tax rates to the organization. + */ + up(knex) { + return knex('accounts').then(async () => { + // Inserts seed entries. + return knex('accounts').where('slug', 'tax-payable').update({ + account_type: 'tax-payable', + }); + }); + } +} diff --git a/packages/server/src/database/seeds/data/TaxRates.ts b/packages/server/src/database/seeds/data/TaxRates.ts new file mode 100644 index 000000000..592b60565 --- /dev/null +++ b/packages/server/src/database/seeds/data/TaxRates.ts @@ -0,0 +1,30 @@ +export const InitialTaxRates = [ + { + name: 'Tax Exempt', + code: 'TAX-EXEMPT', + description: 'Exempts goods or services from taxes.', + rate: 0, + active: 1, + }, + { + name: 'Tax on Purchases', + code: 'TAX-PURCHASES', + description: 'Fee added to the cost when you buy items.', + rate: 0, + active: 1, + }, + { + name: 'Tax on Sales', + code: 'TAX-SALES', + description: 'Fee added to the cost when you sell items.', + rate: 0, + active: 1, + }, + { + name: 'Sales Tax on Imports', + code: 'TAX-IMPORTS', + description: 'Fee added to the cost when you sale to another country.', + rate: 0, + active: 1, + }, +]; diff --git a/packages/server/src/database/seeds/data/accounts.js b/packages/server/src/database/seeds/data/accounts.js index 00fada5f5..a5f7182ba 100644 --- a/packages/server/src/database/seeds/data/accounts.js +++ b/packages/server/src/database/seeds/data/accounts.js @@ -1,7 +1,17 @@ +export const TaxPayableAccount = { + name: 'Tax Payable', + slug: 'tax-payable', + account_type: 'tax-payable', + code: '20006', + description: '', + active: 1, + index: 1, + predefined: 1, +}; export default [ { - name:'Bank Account', + name: 'Bank Account', slug: 'bank-account', account_type: 'bank', code: '10001', @@ -11,7 +21,7 @@ export default [ predefined: 1, }, { - name:'Saving Bank Account', + name: 'Saving Bank Account', slug: 'saving-bank-account', account_type: 'bank', code: '10002', @@ -21,7 +31,7 @@ export default [ predefined: 0, }, { - name:'Undeposited Funds', + name: 'Undeposited Funds', slug: 'undeposited-funds', account_type: 'cash', code: '10003', @@ -31,7 +41,7 @@ export default [ predefined: 1, }, { - name:'Petty Cash', + name: 'Petty Cash', slug: 'petty-cash', account_type: 'cash', code: '10004', @@ -41,7 +51,7 @@ export default [ predefined: 1, }, { - name:'Computer Equipment', + name: 'Computer Equipment', slug: 'computer-equipment', code: '10005', account_type: 'fixed-asset', @@ -52,7 +62,7 @@ export default [ description: '', }, { - name:'Office Equipment', + name: 'Office Equipment', slug: 'office-equipment', code: '10006', account_type: 'fixed-asset', @@ -63,7 +73,7 @@ export default [ description: '', }, { - name:'Accounts Receivable (A/R)', + name: 'Accounts Receivable (A/R)', slug: 'accounts-receivable', account_type: 'accounts-receivable', code: '10007', @@ -73,7 +83,7 @@ export default [ predefined: 1, }, { - name:'Inventory Asset', + name: 'Inventory Asset', slug: 'inventory-asset', code: '10008', account_type: 'inventory', @@ -81,12 +91,13 @@ export default [ parent_account_id: null, index: 1, active: 1, - description:'An account that holds valuation of products or goods that available for sale.', + description: + 'An account that holds valuation of products or goods that available for sale.', }, // Libilities { - name:'Accounts Payable (A/P)', + name: 'Accounts Payable (A/P)', slug: 'accounts-payable', account_type: 'accounts-payable', parent_account_id: null, @@ -97,38 +108,39 @@ export default [ predefined: 1, }, { - name:'Owner A Drawings', + name: 'Owner A Drawings', slug: 'owner-drawings', account_type: 'other-current-liability', parent_account_id: null, code: '20002', - description:'Withdrawals by the owners.', + description: 'Withdrawals by the owners.', active: 1, index: 1, predefined: 0, }, { - name:'Loan', + name: 'Loan', slug: 'owner-drawings', account_type: 'other-current-liability', code: '20003', - description:'Money that has been borrowed from a creditor.', + description: 'Money that has been borrowed from a creditor.', active: 1, index: 1, predefined: 0, }, { - name:'Opening Balance Liabilities', + name: 'Opening Balance Liabilities', slug: 'opening-balance-liabilities', account_type: 'other-current-liability', code: '20004', - description:'This account will hold the difference in the debits and credits entered during the opening balance..', + description: + 'This account will hold the difference in the debits and credits entered during the opening balance..', active: 1, index: 1, predefined: 0, }, { - name:'Revenue Received in Advance', + name: 'Revenue Received in Advance', slug: 'revenue-received-in-advance', account_type: 'other-current-liability', parent_account_id: null, @@ -138,34 +150,27 @@ export default [ index: 1, predefined: 0, }, - { - name:'Sales Tax Payable', - slug: 'owner-drawings', - account_type: 'other-current-liability', - code: '20006', - description: '', - active: 1, - index: 1, - predefined: 1, - }, + TaxPayableAccount, // Equity { - name:'Retained Earnings', + name: 'Retained Earnings', slug: 'retained-earnings', account_type: 'equity', code: '30001', - description:'Retained earnings tracks net income from previous fiscal years.', + description: + 'Retained earnings tracks net income from previous fiscal years.', active: 1, index: 1, predefined: 1, }, { - name:'Opening Balance Equity', + name: 'Opening Balance Equity', slug: 'opening-balance-equity', account_type: 'equity', code: '30002', - description:'When you enter opening balances to the accounts, the amounts enter in Opening balance equity. This ensures that you have a correct trial balance sheet for your company, without even specific the second credit or debit entry.', + description: + 'When you enter opening balances to the accounts, the amounts enter in Opening balance equity. This ensures that you have a correct trial balance sheet for your company, without even specific the second credit or debit entry.', active: 1, index: 1, predefined: 1, @@ -181,11 +186,12 @@ export default [ predefined: 1, }, { - name:`Drawings`, + name: `Drawings`, slug: 'drawings', account_type: 'equity', code: '30003', - description:'Goods purchased with the intention of selling these to customers', + description: + 'Goods purchased with the intention of selling these to customers', active: 1, index: 1, predefined: 1, @@ -193,7 +199,7 @@ export default [ // Expenses { - name:'Other Expenses', + name: 'Other Expenses', slug: 'other-expenses', account_type: 'other-expense', parent_account_id: null, @@ -204,18 +210,18 @@ export default [ predefined: 1, }, { - name:'Cost of Goods Sold', + name: 'Cost of Goods Sold', slug: 'cost-of-goods-sold', account_type: 'cost-of-goods-sold', parent_account_id: null, code: '40002', - description:'Tracks the direct cost of the goods sold.', + description: 'Tracks the direct cost of the goods sold.', active: 1, index: 1, predefined: 1, }, { - name:'Office expenses', + name: 'Office expenses', slug: 'office-expenses', account_type: 'expense', parent_account_id: null, @@ -226,7 +232,7 @@ export default [ predefined: 0, }, { - name:'Rent', + name: 'Rent', slug: 'rent', account_type: 'expense', parent_account_id: null, @@ -237,29 +243,30 @@ export default [ predefined: 0, }, { - name:'Exchange Gain or Loss', + name: 'Exchange Gain or Loss', slug: 'exchange-grain-loss', account_type: 'other-expense', parent_account_id: null, code: '40005', - description:'Tracks the gain and losses of the exchange differences.', + description: 'Tracks the gain and losses of the exchange differences.', active: 1, index: 1, predefined: 1, }, { - name:'Bank Fees and Charges', + name: 'Bank Fees and Charges', slug: 'bank-fees-and-charges', account_type: 'expense', parent_account_id: null, code: '40006', - description: 'Any bank fees levied is recorded into the bank fees and charges account. A bank account maintenance fee, transaction charges, a late payment fee are some examples.', + description: + 'Any bank fees levied is recorded into the bank fees and charges account. A bank account maintenance fee, transaction charges, a late payment fee are some examples.', active: 1, index: 1, predefined: 0, }, { - name:'Depreciation Expense', + name: 'Depreciation Expense', slug: 'depreciation-expense', account_type: 'expense', parent_account_id: null, @@ -272,7 +279,7 @@ export default [ // Income { - name:'Sales of Product Income', + name: 'Sales of Product Income', slug: 'sales-of-product-income', account_type: 'income', predefined: 1, @@ -283,7 +290,7 @@ export default [ description: '', }, { - name:'Sales of Service Income', + name: 'Sales of Service Income', slug: 'sales-of-service-income', account_type: 'income', predefined: 0, @@ -294,7 +301,7 @@ export default [ description: '', }, { - name:'Uncategorized Income', + name: 'Uncategorized Income', slug: 'uncategorized-income', account_type: 'income', parent_account_id: null, @@ -305,14 +312,15 @@ export default [ predefined: 1, }, { - name:'Other Income', + name: 'Other Income', slug: 'other-income', account_type: 'other-income', parent_account_id: null, code: '50004', - description:'The income activities are not associated to the core business.', + description: + 'The income activities are not associated to the core business.', active: 1, index: 1, predefined: 0, - } -]; \ No newline at end of file + }, +]; diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 3d3ce47a7..2239448c1 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -77,6 +77,9 @@ export interface IAccountTransaction { projectId?: number; account?: IAccount; + + taxRateId?: number; + taxRate?: number; } export interface IAccountResponse extends IAccount {} @@ -150,3 +153,11 @@ export enum AccountAction { VIEW = 'View', TransactionsLocking = 'TransactionsLocking', } + + +export enum TaxRateAction { + CREATE = 'Create', + EDIT = 'Edit', + DELETE = 'Delete', + VIEW = 'View', +} \ No newline at end of file diff --git a/packages/server/src/interfaces/FinancialStatements.ts b/packages/server/src/interfaces/FinancialStatements.ts index ca39183e0..fb1d77452 100644 --- a/packages/server/src/interfaces/FinancialStatements.ts +++ b/packages/server/src/interfaces/FinancialStatements.ts @@ -37,6 +37,7 @@ export enum ReportsAction { READ_INVENTORY_ITEM_DETAILS = 'read-inventory-item-details', READ_CASHFLOW_ACCOUNT_TRANSACTION = 'read-cashflow-account-transactions', READ_PROJECT_PROFITABILITY_SUMMARY = 'read-project-profitability-summary', + READ_SALES_TAX_LIABILITY_SUMMARY = 'read-sales-tax-liability-summary', } export interface IFinancialSheetBranchesQuery { diff --git a/packages/server/src/interfaces/ItemEntry.ts b/packages/server/src/interfaces/ItemEntry.ts index 135f52e21..575c3ae44 100644 --- a/packages/server/src/interfaces/ItemEntry.ts +++ b/packages/server/src/interfaces/ItemEntry.ts @@ -18,6 +18,11 @@ export interface IItemEntry { rate: number; amount: number; + total: number; + amountInclusingTax: number; + amountExludingTax: number; + discountAmount: number; + landedCost: number; allocatedCostAmount: number; unallocatedCostAmount: number; @@ -32,6 +37,10 @@ export interface IItemEntry { projectRefType?: ProjectLinkRefType; projectRefInvoicedAmount?: number; + taxRateId: number | null; + taxRate: number; + taxAmount: number; + item?: IItem; allocatedCostEntries?: IBillLandedCostEntry[]; @@ -46,6 +55,9 @@ export interface IItemEntryDTO { projectRefId?: number; projectRefType?: ProjectLinkRefType; projectRefInvoicedAmount?: number; + + taxRateId?: number; + taxCode?: string; } export enum ProjectLinkRefType { diff --git a/packages/server/src/interfaces/Ledger.ts b/packages/server/src/interfaces/Ledger.ts index 8af6ac8b8..0f6379676 100644 --- a/packages/server/src/interfaces/Ledger.ts +++ b/packages/server/src/interfaces/Ledger.ts @@ -48,6 +48,9 @@ export interface ILedgerEntry { branchId?: number; projectId?: number; + taxRateId?: number; + taxRate?: number; + entryId?: number; createdAt?: Date; diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index 92385c046..f2a820e98 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -1,5 +1,5 @@ import { Knex } from 'knex'; -import { IItemEntry } from './ItemEntry'; +import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; export interface ISaleEstimate { @@ -29,7 +29,7 @@ export interface ISaleEstimateDTO { estimateDate?: Date; reference?: string; estimateNumber?: string; - entries: IItemEntry[]; + entries: IItemEntryDTO[]; note: string; termsConditions: string; sendToEmail: string; diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 4351f90f4..7ef8fdea2 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -1,11 +1,12 @@ import { Knex } from 'knex'; -import { ISystemUser, IAccount } from '@/interfaces'; +import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces'; import { IDynamicListFilter } from '@/interfaces/DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; export interface ISaleInvoice { id: number; - balance: number; + amount: number; + amountLocal?: number; paymentAmount: number; currencyCode: string; exchangeRate?: number; @@ -27,12 +28,21 @@ export interface ISaleInvoice { branchId?: number; projectId?: number; - localAmount?: number; - - localWrittenoffAmount?: number; + writtenoffAmount?: number; + writtenoffAmountLocal?: number; writtenoffExpenseAccountId?: number; - writtenoffExpenseAccount?: IAccount; + + taxAmountWithheld: number; + taxAmountWithheldLocal: number; + taxes: ITaxTransaction[]; + + total: number; + totalLocal: number; + + subtotal: number; + subtotalLocal: number; + subtotalExludingTax: number; } export interface ISaleInvoiceDTO { @@ -44,12 +54,15 @@ export interface ISaleInvoiceDTO { exchangeRate?: number; invoiceMessage: string; termsConditions: string; + isTaxExclusive: boolean; entries: IItemEntryDTO[]; delivered: boolean; warehouseId?: number | null; projectId?: number; branchId?: number | null; + + isInclusiveTax?: boolean; } export interface ISaleInvoiceCreateDTO extends ISaleInvoiceDTO { diff --git a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts new file mode 100644 index 000000000..8b04c9719 --- /dev/null +++ b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts @@ -0,0 +1,51 @@ +export interface SalesTaxLiabilitySummaryQuery { + fromDate: Date; + toDate: Date; + basis: 'cash' | 'accrual'; +} + +export interface SalesTaxLiabilitySummaryAmount { + amount: number; + formattedAmount: string; + currencyCode: string; +} + +export interface SalesTaxLiabilitySummaryTotal { + taxableAmount: SalesTaxLiabilitySummaryAmount; + taxAmount: SalesTaxLiabilitySummaryAmount; + collectedTaxAmount: SalesTaxLiabilitySummaryAmount; +} + +export interface SalesTaxLiabilitySummaryRate { + id: number; + taxName: string; + taxableAmount: SalesTaxLiabilitySummaryAmount; + taxAmount: SalesTaxLiabilitySummaryAmount; + taxPercentage: any; + collectedTaxAmount: SalesTaxLiabilitySummaryAmount; +} + +export enum SalesTaxLiabilitySummaryTableRowType { + TaxRate = 'TaxRate', + Total = 'Total', +} + +export interface SalesTaxLiabilitySummaryReportData { + taxRates: SalesTaxLiabilitySummaryRate[]; + total: SalesTaxLiabilitySummaryTotal; +} + +export type SalesTaxLiabilitySummaryPayableById = Record< + string, + { taxRateId: number; credit: number; debit: number } +>; + +export type SalesTaxLiabilitySummarySalesById = Record< + string, + { taxRateId: number; credit: number; debit: number } +>; + +export interface SalesTaxLiabilitySummaryMeta { + organizationName: string; + baseCurrency: string; +} diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts new file mode 100644 index 000000000..7bd912877 --- /dev/null +++ b/packages/server/src/interfaces/TaxRate.ts @@ -0,0 +1,88 @@ +import { Knex } from 'knex'; + +export interface ITaxRate { + id?: number; + name: string; + code: string; + rate: number; + description: string; + IsNonRecoverable: boolean; + IsCompound: boolean; + active: boolean; +} + +export interface ICommonTaxRateDTO { + name: string; + code: string; + rate: number; + description: string; + IsNonRecoverable: boolean; + IsCompound: boolean; + active: boolean; +} +export interface ICreateTaxRateDTO extends ICommonTaxRateDTO {} +export interface IEditTaxRateDTO extends ICommonTaxRateDTO {} + +export interface ITaxRateCreatingPayload { + createTaxRateDTO: ICreateTaxRateDTO; + tenantId: number; + trx: Knex.Transaction; +} +export interface ITaxRateCreatedPayload { + createTaxRateDTO: ICreateTaxRateDTO; + taxRate: ITaxRate; + tenantId: number; + trx: Knex.Transaction; +} + +export interface ITaxRateEditingPayload { + editTaxRateDTO: IEditTaxRateDTO; + tenantId: number; + trx: Knex.Transaction; +} +export interface ITaxRateEditedPayload { + editTaxRateDTO: IEditTaxRateDTO; + oldTaxRate: ITaxRate; + taxRate: ITaxRate; + tenantId: number; + trx: Knex.Transaction; +} + +export interface ITaxRateDeletingPayload { + oldTaxRate: ITaxRate; + tenantId: number; + trx: Knex.Transaction; +} + +export interface ITaxRateActivatingPayload { + taxRateId: number; + tenantId: number; + trx: Knex.Transaction; +} +export interface ITaxRateActivatedPayload { + taxRateId: number; + tenantId: number; + trx: Knex.Transaction; +} + +export interface ITaxRateDeletedPayload { + oldTaxRate: ITaxRate; + tenantId: number; + trx: Knex.Transaction; +} + +export interface ITaxTransaction { + id?: number; + taxRateId: number; + referenceType: string; + referenceId: number; + rate: number; + taxAccountId: number; +} + +export enum TaxRateAction { + CREATE = 'Create', + EDIT = 'Edit', + DELETE = 'Delete', + VIEW = 'View', +} diff --git a/packages/server/src/interfaces/index.ts b/packages/server/src/interfaces/index.ts index 7cd789457..1b23eedd3 100644 --- a/packages/server/src/interfaces/index.ts +++ b/packages/server/src/interfaces/index.ts @@ -73,6 +73,7 @@ export * from './Project'; export * from './Tasks'; export * from './Times'; export * from './ProjectProfitabilitySummary'; +export * from './TaxRate'; export interface I18nService { __: (input: string) => string; diff --git a/packages/server/src/lib/Transformer/Transformer.ts b/packages/server/src/lib/Transformer/Transformer.ts index cb9538a64..dfec3391f 100644 --- a/packages/server/src/lib/Transformer/Transformer.ts +++ b/packages/server/src/lib/Transformer/Transformer.ts @@ -1,8 +1,7 @@ import moment from 'moment'; import * as R from 'ramda'; import { includes, isFunction, isObject, isUndefined, omit } from 'lodash'; -import { formatNumber } from 'utils'; -import { isArrayLikeObject } from 'lodash/fp'; +import { formatNumber, sortObjectKeysAlphabetically } from 'utils'; export class Transformer { public context: any; @@ -82,6 +81,7 @@ export class Transformer { const normlizedItem = this.normalizeModelItem(item); return R.compose( + sortObjectKeysAlphabetically, this.transform, R.when(this.hasExcludeAttributes, this.excludeAttributesTransformed), this.includeAttributesTransformed diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 1facd5ecf..df5724e83 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -79,6 +79,8 @@ import { ProjectBillableTasksSubscriber } from '@/services/Projects/Projects/Pro import { ProjectBillableExpensesSubscriber } from '@/services/Projects/Projects/ProjectBillableExpenseSubscriber'; import { ProjectBillableBillSubscriber } from '@/services/Projects/Projects/ProjectBillableBillSubscriber'; import { SyncActualTimeTaskSubscriber } from '@/services/Projects/Times/SyncActualTimeTaskSubscriber'; +import { SaleInvoiceTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber'; +import { WriteInvoiceTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber'; export default () => { return new EventPublisher(); @@ -185,5 +187,9 @@ export const susbcribers = () => { ProjectBillableTasksSubscriber, ProjectBillableExpensesSubscriber, ProjectBillableBillSubscriber, + + // Tax Rates + SaleInvoiceTaxRateValidateSubscriber, + WriteInvoiceTaxTransactionsSubscriber, ]; }; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 5e07ff3dd..d76c7e618 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -58,6 +58,8 @@ import ItemWarehouseQuantity from 'models/ItemWarehouseQuantity'; import Project from 'models/Project'; import Time from 'models/Time'; import Task from 'models/Task'; +import TaxRate from 'models/TaxRate'; +import TaxRateTransaction from 'models/TaxRateTransaction'; export default (knex) => { const models = { @@ -119,6 +121,8 @@ export default (knex) => { Project, Time, Task, + TaxRate, + TaxRateTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/AccountTransaction.ts b/packages/server/src/models/AccountTransaction.ts index a8ad848f7..368d57afa 100644 --- a/packages/server/src/models/AccountTransaction.ts +++ b/packages/server/src/models/AccountTransaction.ts @@ -6,6 +6,10 @@ import { getTransactionTypeLabel } from '@/utils/transactions-types'; export default class AccountTransaction extends TenantModel { referenceType: string; + credit: number; + debit: number; + exchangeRate: number; + taxRate: number; /** * Table name @@ -25,7 +29,23 @@ export default class AccountTransaction extends TenantModel { * Virtual attributes. */ static get virtualAttributes() { - return ['referenceTypeFormatted']; + return ['referenceTypeFormatted', 'creditLocal', 'debitLocal']; + } + + /** + * Retrieves the credit amount in base currency. + * @return {number} + */ + get creditLocal() { + return this.credit * this.exchangeRate; + } + + /** + * Retrieves the debit amount in base currency. + * @return {number} + */ + get debitLocal() { + return this.debit * this.exchangeRate; } /** diff --git a/packages/server/src/models/ItemEntry.ts b/packages/server/src/models/ItemEntry.ts index cae1c9cf2..43b3f9376 100644 --- a/packages/server/src/models/ItemEntry.ts +++ b/packages/server/src/models/ItemEntry.ts @@ -1,9 +1,17 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; +import { getExlusiveTaxAmount, getInclusiveTaxAmount } from '@/utils/taxRate'; export default class ItemEntry extends TenantModel { + public taxRate: number; + public discount: number; + public quantity: number; + public rate: number; + public isInclusiveTax: number; + /** * Table name. + * @returns {string} */ static get tableName() { return 'items_entries'; @@ -11,26 +19,89 @@ export default class ItemEntry extends TenantModel { /** * Timestamps columns. + * @returns {string[]} */ get timestamps() { return ['created_at', 'updated_at']; } + /** + * Virtual attributes. + * @returns {string[]} + */ static get virtualAttributes() { - return ['amount']; + return [ + 'amount', + 'taxAmount', + 'amountExludingTax', + 'amountInclusingTax', + 'total', + ]; } + /** + * Item entry total. + * Amount of item entry includes tax and subtracted discount amount. + * @returns {number} + */ + get total() { + return this.amountInclusingTax; + } + + /** + * Item entry amount. + * Amount of item entry that may include or exclude tax. + * @returns {number} + */ get amount() { - return ItemEntry.calcAmount(this); + return this.quantity * this.rate; } - static calcAmount(itemEntry) { - const { discount, quantity, rate } = itemEntry; - const total = quantity * rate; - - return discount ? total - total * discount * 0.01 : total; + /** + * Item entry amount including tax. + * @returns {number} + */ + get amountInclusingTax() { + return this.isInclusiveTax ? this.amount : this.amount + this.taxAmount; } + /** + * Item entry amount excluding tax. + * @returns {number} + */ + get amountExludingTax() { + return this.isInclusiveTax ? this.amount - this.taxAmount : this.amount; + } + + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.amount * (this.discount / 100); + } + + /** + * Tag rate fraction. + * @returns {number} + */ + get tagRateFraction() { + return this.taxRate / 100; + } + + /** + * Tax amount withheld. + * @returns {number} + */ + get taxAmount() { + return this.isInclusiveTax + ? getInclusiveTaxAmount(this.amount, this.taxRate) + : getExlusiveTaxAmount(this.amount, this.taxRate); + } + + /** + * Item entry relations. + */ static get relationMappings() { const Item = require('models/Item'); const BillLandedCostEntry = require('models/BillLandedCostEntry'); @@ -40,6 +111,7 @@ export default class ItemEntry extends TenantModel { const SaleEstimate = require('models/SaleEstimate'); const ProjectTask = require('models/Task'); const Expense = require('models/Expense'); + const TaxRate = require('models/TaxRate'); return { item: { @@ -86,6 +158,9 @@ export default class ItemEntry extends TenantModel { }, }, + /** + * Sale receipt reference. + */ receipt: { relation: Model.BelongsToOneRelation, modelClass: SaleReceipt.default, @@ -96,7 +171,7 @@ export default class ItemEntry extends TenantModel { }, /** - * + * Project task reference. */ projectTaskRef: { relation: Model.HasManyRelation, @@ -108,7 +183,7 @@ export default class ItemEntry extends TenantModel { }, /** - * + * Project expense reference. */ projectExpenseRef: { relation: Model.HasManyRelation, @@ -120,7 +195,7 @@ export default class ItemEntry extends TenantModel { }, /** - * + * Project bill reference. */ projectBillRef: { relation: Model.HasManyRelation, @@ -130,6 +205,18 @@ export default class ItemEntry extends TenantModel { to: 'bills.id', }, }, + + /** + * Tax rate reference. + */ + tax: { + relation: Model.HasOneRelation, + modelClass: TaxRate.default, + join: { + from: 'items_entries.taxRateId', + to: 'tax_rates.id', + }, + }, }; } } diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 261110184..0e64fd7ee 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -1,5 +1,5 @@ import { mixin, Model, raw } from 'objection'; -import { castArray } from 'lodash'; +import { castArray, takeWhile } from 'lodash'; import moment from 'moment'; import TenantModel from 'models/TenantModel'; import ModelSetting from './ModelSetting'; @@ -13,6 +13,17 @@ export default class SaleInvoice extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + public taxAmountWithheld: number; + public balance: number; + public paymentAmount: number; + public exchangeRate: number; + public writtenoffAmount: number; + public creditedAmount: number; + public isInclusiveTax: boolean; + public writtenoffAt: Date; + public dueDate: Date; + public deliveredAt: Date; + /** * Table name */ @@ -27,6 +38,9 @@ export default class SaleInvoice extends mixin(TenantModel, [ return ['created_at', 'updated_at']; } + /** + * + */ get pluralName() { return 'asdfsdf'; } @@ -36,35 +50,97 @@ export default class SaleInvoice extends mixin(TenantModel, [ */ static get virtualAttributes() { return [ - 'localAmount', - 'dueAmount', - 'balanceAmount', 'isDelivered', 'isOverdue', 'isPartiallyPaid', 'isFullyPaid', - 'isPaid', 'isWrittenoff', + 'isPaid', + + 'dueAmount', + 'balanceAmount', 'remainingDays', 'overdueDays', - 'filterByBranches', + + 'subtotal', + 'subtotalLocal', + 'subtotalExludingTax', + + 'taxAmountWithheldLocal', + 'total', + 'totalLocal', + + 'writtenoffAmountLocal', ]; } /** - * Invoice amount in local currency. + * Invoice amount. + * @todo Sugger attribute to balance, we need to rename the balance to amount. * @returns {number} */ - get localAmount() { - return this.balance * this.exchangeRate; + get amount() { + return this.balance; } /** - * Invoice local written-off amount. + * Invoice amount in base currency. * @returns {number} */ - get localWrittenoffAmount() { - return this.writtenoffAmount * this.exchangeRate; + 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 subtotalExludingTax() { + 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; } /** @@ -97,7 +173,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ * @return {boolean} */ get dueAmount() { - return Math.max(this.balance - this.balanceAmount, 0); + return Math.max(this.total - this.balanceAmount, 0); } /** @@ -105,7 +181,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ * @return {boolean} */ get isPartiallyPaid() { - return this.dueAmount !== this.balance && this.dueAmount > 0; + return this.dueAmount !== this.total && this.dueAmount > 0; } /** @@ -333,6 +409,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ const PaymentReceiveEntry = require('models/PaymentReceiveEntry'); const Branch = require('models/Branch'); const Account = require('models/Account'); + const TaxRateTransaction = require('models/TaxRateTransaction'); return { /** @@ -382,7 +459,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, /** - * + * Invoice may has associated cost transactions. */ costTransactions: { relation: Model.HasManyRelation, @@ -397,7 +474,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, /** - * + * Invoice may has associated payment entries. */ paymentEntries: { relation: Model.HasManyRelation, @@ -420,6 +497,9 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, }, + /** + * Invoice may has associated written-off expense account. + */ writtenoffExpenseAccount: { relation: Model.BelongsToOneRelation, modelClass: Account.default, @@ -428,6 +508,21 @@ export default class SaleInvoice extends mixin(TenantModel, [ to: 'accounts.id', }, }, + + /** + * Invoice may has associated tax rate transactions. + */ + taxes: { + relation: Model.HasManyRelation, + modelClass: TaxRateTransaction.default, + join: { + from: 'sales_invoices.id', + to: 'tax_rate_transactions.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'SaleInvoice'); + }, + }, }; } diff --git a/packages/server/src/models/TaxRate.ts b/packages/server/src/models/TaxRate.ts new file mode 100644 index 000000000..e294b897a --- /dev/null +++ b/packages/server/src/models/TaxRate.ts @@ -0,0 +1,48 @@ +import { mixin, Model, raw } from 'objection'; +import TenantModel from 'models/TenantModel'; +import ModelSearchable from './ModelSearchable'; +import SoftDeleteQueryBuilder from '@/collection/SoftDeleteQueryBuilder'; + +export default class TaxRate extends mixin(TenantModel, [ModelSearchable]) { + /** + * Table name + */ + static get tableName() { + return 'tax_rates'; + } + + /** + * Soft delete query builder. + */ + static get QueryBuilder() { + return SoftDeleteQueryBuilder; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return {}; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/models/TaxRateTransaction.ts b/packages/server/src/models/TaxRateTransaction.ts new file mode 100644 index 000000000..3cbca88a0 --- /dev/null +++ b/packages/server/src/models/TaxRateTransaction.ts @@ -0,0 +1,56 @@ +import { mixin, Model, raw } from 'objection'; +import TenantModel from 'models/TenantModel'; +import ModelSearchable from './ModelSearchable'; + +export default class TaxRateTransaction extends mixin(TenantModel, [ + ModelSearchable, +]) { + /** + * Table name + */ + static get tableName() { + return 'tax_rate_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return {}; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const TaxRate = require('models/TaxRate'); + + return { + /** + * Belongs to the tax rate. + */ + taxRate: { + relation: Model.BelongsToOneRelation, + modelClass: TaxRate.default, + join: { + from: 'tax_rate_transactions.taxRateId', + to: 'tax_rates.id', + }, + }, + }; + } +} diff --git a/packages/server/src/repositories/AccountRepository.ts b/packages/server/src/repositories/AccountRepository.ts index decde91f5..8bc6bf7d1 100644 --- a/packages/server/src/repositories/AccountRepository.ts +++ b/packages/server/src/repositories/AccountRepository.ts @@ -2,6 +2,7 @@ import { Account } from 'models'; import TenantRepository from '@/repositories/TenantRepository'; import { IAccount } from '@/interfaces'; import { Knex } from 'knex'; +import { TaxPayableAccount } from '@/database/seeds/data/accounts'; export default class AccountRepository extends TenantRepository { /** @@ -116,7 +117,7 @@ export default class AccountRepository extends TenantRepository { if (!result) { result = await this.model.query(trx).insertAndFetch({ name: this.i18n.__('account.accounts_receivable.currency', { - currency: currencyCode + currency: currencyCode, }), accountType: 'accounts-receivable', currencyCode, @@ -127,6 +128,29 @@ export default class AccountRepository extends TenantRepository { return result; }; + /** + * Find or create tax payable account. + * @param {Record}extraAttrs + * @param {Knex.Transaction} trx + * @returns + */ + async findOrCreateTaxPayable( + extraAttrs: Record = {}, + trx?: Knex.Transaction + ) { + let result = await this.model + .query(trx) + .findOne({ slug: TaxPayableAccount.slug, ...extraAttrs }); + + if (!result) { + result = await this.model.query(trx).insertAndFetch({ + ...TaxPayableAccount, + ...extraAttrs, + }); + } + return result; + } + findOrCreateAccountsPayable = async ( currencyCode: string = '', extraAttrs = {}, diff --git a/packages/server/src/services/Accounting/JournalCommands.ts b/packages/server/src/services/Accounting/JournalCommands.ts index d1c585646..ed7ad043d 100644 --- a/packages/server/src/services/Accounting/JournalCommands.ts +++ b/packages/server/src/services/Accounting/JournalCommands.ts @@ -1,10 +1,6 @@ -import moment from 'moment'; -import { castArray, sumBy, toArray } from 'lodash'; -import { IBill, ISystemUser, IAccount } from '@/interfaces'; +import { castArray } from 'lodash'; import JournalPoster from './JournalPoster'; -import JournalEntry from './JournalEntry'; -import { IExpense, IExpenseCategory } from '@/interfaces'; -import { increment } from 'utils'; + export default class JournalCommands { journal: JournalPoster; models: any; @@ -16,7 +12,6 @@ export default class JournalCommands { */ constructor(journal: JournalPoster) { this.journal = journal; - this.repositories = this.journal.repositories; this.models = this.journal.models; } diff --git a/packages/server/src/services/Accounting/Ledger.ts b/packages/server/src/services/Accounting/Ledger.ts index ffd67a97a..7cb71bed8 100644 --- a/packages/server/src/services/Accounting/Ledger.ts +++ b/packages/server/src/services/Accounting/Ledger.ts @@ -234,6 +234,9 @@ export default class Ledger implements ILedger { entryId: entry.id, branchId: entry.branchId, projectId: entry.projectId, + + taxRateId: entry.taxRateId, + taxRate: entry.taxRate, }; } diff --git a/packages/server/src/services/Accounting/utils.ts b/packages/server/src/services/Accounting/utils.ts index 45a3de94e..ee675f09c 100644 --- a/packages/server/src/services/Accounting/utils.ts +++ b/packages/server/src/services/Accounting/utils.ts @@ -32,5 +32,8 @@ export const transformLedgerEntryToTransaction = ( projectId: entry.projectId, costable: entry.costable, + + taxRateId: entry.taxRateId, + taxRate: entry.taxRate, }; }; diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts new file mode 100644 index 000000000..7f9da7e7c --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts @@ -0,0 +1,131 @@ +import * as R from 'ramda'; +import { isEmpty, sumBy } from 'lodash'; +import { ITaxRate } from '@/interfaces'; +import { + SalesTaxLiabilitySummaryPayableById, + SalesTaxLiabilitySummaryQuery, + SalesTaxLiabilitySummaryRate, + SalesTaxLiabilitySummaryReportData, + SalesTaxLiabilitySummarySalesById, + SalesTaxLiabilitySummaryTotal, +} from '@/interfaces/SalesTaxLiabilitySummary'; +import FinancialSheet from '../FinancialSheet'; + +export class SalesTaxLiabilitySummary extends FinancialSheet { + private query: SalesTaxLiabilitySummaryQuery; + private taxRates: ITaxRate[]; + private payableTaxesById: SalesTaxLiabilitySummaryPayableById; + private salesTaxesById: SalesTaxLiabilitySummarySalesById; + + /** + * Sales tax liability summary constructor. + * @param {SalesTaxLiabilitySummaryQuery} query + * @param {ITaxRate[]} taxRates + * @param {SalesTaxLiabilitySummaryPayableById} payableTaxesById + * @param {SalesTaxLiabilitySummarySalesById} salesTaxesById + */ + constructor( + query: SalesTaxLiabilitySummaryQuery, + taxRates: ITaxRate[], + payableTaxesById: SalesTaxLiabilitySummaryPayableById, + salesTaxesById: SalesTaxLiabilitySummarySalesById + ) { + super(); + + this.query = query; + this.taxRates = taxRates; + this.payableTaxesById = payableTaxesById; + this.salesTaxesById = salesTaxesById; + } + + /** + * Retrieves the tax rate liability node. + * @param {ITaxRate} taxRate + * @returns {SalesTaxLiabilitySummaryRate} + */ + private taxRateLiability = ( + taxRate: ITaxRate + ): SalesTaxLiabilitySummaryRate => { + const payableTax = this.payableTaxesById[taxRate.id]; + const salesTax = this.salesTaxesById[taxRate.id]; + + const payableTaxAmount = payableTax + ? payableTax.credit - payableTax.debit + : 0; + const salesTaxAmount = salesTax ? salesTax.credit - salesTax.debit : 0; + + // Calculates the tax percentage. + const taxPercentage = R.compose( + R.unless(R.equals(0), R.divide(R.__, salesTaxAmount)) + )(payableTaxAmount); + + // Calculates the payable tax amount. + const collectedTaxAmount = payableTax ? payableTax.debit : 0; + + return { + id: taxRate.id, + taxName: `${taxRate.name} (${taxRate.rate}%)`, + taxableAmount: this.getAmountMeta(salesTaxAmount), + taxAmount: this.getAmountMeta(payableTaxAmount), + taxPercentage: this.getPercentageTotalAmountMeta(taxPercentage), + collectedTaxAmount: this.getAmountMeta(collectedTaxAmount), + }; + }; + + /** + * Filters the non-transactions tax rates. + * @param {SalesTaxLiabilitySummaryRate[]} nodes + * @returns {SalesTaxLiabilitySummaryRate[]} + */ + private filterNonTransactionsTaxRates = ( + nodes: SalesTaxLiabilitySummaryRate[] + ): SalesTaxLiabilitySummaryRate[] => { + return nodes.filter((node) => { + const salesTrxs = this.salesTaxesById[node.id]; + const payableTrxs = this.payableTaxesById[node.id]; + + return !isEmpty(salesTrxs) || !isEmpty(payableTrxs); + }); + }; + + /** + * Retrieves the tax rates liability nodes. + * @returns {SalesTaxLiabilitySummaryRate[]} + */ + private taxRatesLiability = (): SalesTaxLiabilitySummaryRate[] => { + return R.compose( + this.filterNonTransactionsTaxRates, + R.map(this.taxRateLiability) + )(this.taxRates); + }; + + /** + * Retrieves the tax rates total node. + * @param {SalesTaxLiabilitySummaryRate[]} nodes + * @returns {SalesTaxLiabilitySummaryTotal} + */ + private taxRatesTotal = ( + nodes: SalesTaxLiabilitySummaryRate[] + ): SalesTaxLiabilitySummaryTotal => { + const taxableAmount = sumBy(nodes, 'taxableAmount.amount'); + const taxAmount = sumBy(nodes, 'taxAmount.amount'); + const collectedTaxAmount = sumBy(nodes, 'collectedTaxAmount.amount'); + + return { + taxableAmount: this.getTotalAmountMeta(taxableAmount), + taxAmount: this.getTotalAmountMeta(taxAmount), + collectedTaxAmount: this.getTotalAmountMeta(collectedTaxAmount), + }; + }; + + /** + * Retrieves the report data. + * @returns {SalesTaxLiabilitySummaryReportData} + */ + public reportData = (): SalesTaxLiabilitySummaryReportData => { + const taxRates = this.taxRatesLiability(); + const total = this.taxRatesTotal(taxRates); + + return { taxRates, total }; + }; +} diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts new file mode 100644 index 000000000..45b955ab9 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts @@ -0,0 +1,79 @@ +import { ACCOUNT_TYPE } from '@/data/AccountTypes'; +import { + SalesTaxLiabilitySummaryPayableById, + SalesTaxLiabilitySummarySalesById, +} from '@/interfaces/SalesTaxLiabilitySummary'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { keyBy } from 'lodash'; +import { Inject, Service } from 'typedi'; + +@Service() +export class SalesTaxLiabilitySummaryRepository { + @Inject() + private tenancy: HasTenancyService; + + /** + * Retrieve tax rates. + * @param {number} tenantId + * @returns {Promise} + */ + public taxRates = (tenantId: number) => { + const { TaxRate } = this.tenancy.models(tenantId); + + return TaxRate.query().orderBy('name', 'desc'); + }; + + /** + * Retrieve taxes payable sum grouped by tax rate id. + * @param {number} tenantId + * @returns {Promise} + */ + public async taxesPayableSumGroupedByRateId( + tenantId: number + ): Promise { + const { AccountTransaction, Account } = this.tenancy.models(tenantId); + + // Retrieves tax payable accounts. + const taxPayableAccounts = await Account.query().whereIn('accountType', [ + ACCOUNT_TYPE.TAX_PAYABLE, + ]); + const payableAccountsIds = taxPayableAccounts.map((account) => account.id); + + const groupedTaxesById = await AccountTransaction.query() + .whereIn('account_id', payableAccountsIds) + .whereNot('tax_rate_id', null) + .groupBy('tax_rate_id') + .select(['tax_rate_id']) + .sum('credit as credit') + .sum('debit as debit'); + + return keyBy(groupedTaxesById, 'taxRateId'); + } + + /** + * Retrieve taxes sales sum grouped by tax rate id. + * @param {number} tenantId + * @returns {Promise} + */ + public taxesSalesSumGroupedByRateId = async ( + tenantId: number + ): Promise => { + const { AccountTransaction, Account } = this.tenancy.models(tenantId); + + const incomeAccounts = await Account.query().whereIn('accountType', [ + ACCOUNT_TYPE.INCOME, + ACCOUNT_TYPE.OTHER_INCOME, + ]); + const incomeAccountsIds = incomeAccounts.map((account) => account.id); + + const groupedTaxesById = await AccountTransaction.query() + .whereIn('account_id', incomeAccountsIds) + .whereNot('tax_rate_id', null) + .groupBy('tax_rate_id') + .select(['tax_rate_id']) + .sum('credit as credit') + .sum('debit as debit'); + + return keyBy(groupedTaxesById, 'taxRateId'); + }; +} diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts new file mode 100644 index 000000000..09fa9283b --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -0,0 +1,98 @@ +import { Inject, Service } from 'typedi'; +import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository'; +import { + SalesTaxLiabilitySummaryMeta, + SalesTaxLiabilitySummaryQuery, +} from '@/interfaces/SalesTaxLiabilitySummary'; +import { SalesTaxLiabilitySummary } from './SalesTaxLiabilitySummary'; +import { SalesTaxLiabilitySummaryTable } from './SalesTaxLiabilitySummaryTable'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; + +@Service() +export class SalesTaxLiabilitySummaryService { + @Inject() + private repostiory: SalesTaxLiabilitySummaryRepository; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Retrieve sales tax liability summary. + * @param {number} tenantId + * @param {SalesTaxLiabilitySummaryQuery} query + * @returns + */ + public async salesTaxLiability( + tenantId: number, + query: SalesTaxLiabilitySummaryQuery + ) { + const payableByRateId = + await this.repostiory.taxesPayableSumGroupedByRateId(tenantId); + + const salesByRateId = await this.repostiory.taxesSalesSumGroupedByRateId( + tenantId + ); + const taxRates = await this.repostiory.taxRates(tenantId); + + const taxLiabilitySummary = new SalesTaxLiabilitySummary( + query, + taxRates, + payableByRateId, + salesByRateId + ); + return { + data: taxLiabilitySummary.reportData(), + query, + meta: this.reportMetadata(tenantId), + }; + } + + /** + * Retrieve sales tax liability summary table. + * @param {number} tenantId + * @param {SalesTaxLiabilitySummaryQuery} query + * @returns + */ + public async salesTaxLiabilitySummaryTable( + tenantId: number, + query: SalesTaxLiabilitySummaryQuery + ) { + const report = await this.salesTaxLiability(tenantId, query); + + // Creates the sales tax liability summary table. + const table = new SalesTaxLiabilitySummaryTable(report.data, query); + + return { + table: { + rows: table.tableRows(), + columns: table.tableColumns(), + }, + data: report.data, + query: report.query, + meta: report.meta, + }; + } + + /** + * Retrieve the report meta. + * @param {number} tenantId - + * @returns {IBalanceSheetMeta} + */ + private reportMetadata(tenantId: number): SalesTaxLiabilitySummaryMeta { + const settings = this.tenancy.settings(tenantId); + + const organizationName = settings.get({ + group: 'organization', + key: 'name', + }); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); + + return { + organizationName, + baseCurrency, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts new file mode 100644 index 000000000..58fa2bc23 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts @@ -0,0 +1,161 @@ +import * as R from 'ramda'; +import { + SalesTaxLiabilitySummaryQuery, + SalesTaxLiabilitySummaryRate, + SalesTaxLiabilitySummaryReportData, + SalesTaxLiabilitySummaryTotal, +} from '@/interfaces/SalesTaxLiabilitySummary'; +import { tableRowMapper } from '@/utils'; +import { ITableColumn, ITableRow } from '@/interfaces'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import AgingReport from '../AgingSummary/AgingReport'; +import { IROW_TYPE } from './_constants'; + +export class SalesTaxLiabilitySummaryTable extends R.compose( + FinancialSheetStructure, + FinancialTable +)(AgingReport) { + private data: SalesTaxLiabilitySummaryReportData; + private query: SalesTaxLiabilitySummaryQuery; + + /** + * Sales tax liability summary table constructor. + * @param {SalesTaxLiabilitySummaryReportData} data + * @param {SalesTaxLiabilitySummaryQuery} query + */ + constructor( + data: SalesTaxLiabilitySummaryReportData, + query: SalesTaxLiabilitySummaryQuery + ) { + super(); + + this.data = data; + this.query = query; + } + + /** + * Retrieve the tax rate row accessors. + * @returns {ITableColumnAccessor[]} + */ + private get taxRateRowAccessor() { + return [ + { key: 'taxName', accessor: 'taxName' }, + { key: 'taxPercentage', accessor: 'taxPercentage.formattedAmount' }, + { key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' }, + { key: 'collectedTax', accessor: 'collectedTaxAmount.formattedAmount' }, + { key: 'taxAmount', accessor: 'taxAmount.formattedAmount' }, + ]; + } + + /** + * Retrieve the tax rate total row accessors. + * @returns {ITableColumnAccessor[]} + */ + private get taxRateTotalRowAccessors() { + return [ + { key: 'taxName', value: 'Total' }, + { key: 'taxPercentage', value: '' }, + { key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' }, + { key: 'collectedTax', accessor: 'collectedTaxAmount.formattedAmount' }, + { key: 'taxAmount', accessor: 'taxAmount.formattedAmount' }, + ]; + } + + /** + * Maps the tax rate node to table row. + * @param {SalesTaxLiabilitySummaryRate} node + * @returns {ITableRow} + */ + private taxRateTableRowMapper = ( + node: SalesTaxLiabilitySummaryRate + ): ITableRow => { + const columns = this.taxRateRowAccessor; + const meta = { + rowTypes: [IROW_TYPE.TaxRate], + id: node.id, + }; + return tableRowMapper(node, columns, meta); + }; + + /** + * Maps the tax rates nodes to table rows. + * @param {SalesTaxLiabilitySummaryRate[]} nodes + * @returns {ITableRow[]} + */ + private taxRatesTableRowsMapper = ( + nodes: SalesTaxLiabilitySummaryRate[] + ): ITableRow[] => { + return nodes.map(this.taxRateTableRowMapper); + }; + + /** + * Maps the tax rate total node to table row. + * @param {SalesTaxLiabilitySummaryTotal} node + * @returns {ITableRow} + */ + private taxRateTotalRowMapper = (node: SalesTaxLiabilitySummaryTotal) => { + const columns = this.taxRateTotalRowAccessors; + const meta = { + rowTypes: [IROW_TYPE.Total], + id: node.key, + }; + return tableRowMapper(node, columns, meta); + }; + + /** + * Retrieves the tax rate total row. + * @returns {ITableRow} + */ + private get taxRateTotalRow(): ITableRow { + return this.taxRateTotalRowMapper(this.data.total); + } + + /** + * Retrieves the tax rates rows. + * @returns {ITableRow[]} + */ + private get taxRatesRows(): ITableRow[] { + return this.taxRatesTableRowsMapper(this.data.taxRates); + } + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableRows(): ITableRow[] { + return R.compose( + R.unless(R.isEmpty, R.append(this.taxRateTotalRow)), + R.concat(this.taxRatesRows) + )([]); + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + return R.compose(this.tableColumnsCellIndexing)([ + { + label: 'Tax Name', + key: 'taxName', + }, + { + label: 'Tax Percentage', + key: 'taxPercentage', + }, + { + label: 'Taxable Amount', + key: 'taxableAmount', + }, + { + label: 'Collected Tax', + key: 'collectedTax', + }, + { + label: 'Tax Amount', + key: 'taxRate', + }, + ]); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/_constants.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/_constants.ts new file mode 100644 index 000000000..f030fd16c --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/_constants.ts @@ -0,0 +1,4 @@ +export enum IROW_TYPE { + TaxRate = 'TaxRate', + Total = 'Total', +} diff --git a/packages/server/src/services/Items/ItemsEntriesService.ts b/packages/server/src/services/Items/ItemsEntriesService.ts index b92b821ca..1b97f6fa0 100644 --- a/packages/server/src/services/Items/ItemsEntriesService.ts +++ b/packages/server/src/services/Items/ItemsEntriesService.ts @@ -264,4 +264,13 @@ export default class ItemsEntriesService { public getTotalItemsEntries(entries: ItemEntry[]): number { return sumBy(entries, (e) => ItemEntry.calcAmount(e)); } + + /** + * Retrieve the non-zero tax items entries. + * @param {IItemEntry[]} entries - + * @returns {IItemEntry[]} + */ + public getNonZeroEntries(entries: IItemEntry[]): IItemEntry[] { + return entries.filter((e) => e.taxRate > 0); + } } diff --git a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index c4743350e..e6f4c054e 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -13,16 +13,14 @@ import { import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform'; import ItemsEntriesService from '@/services/Items/ItemsEntriesService'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators'; import { SaleInvoiceIncrement } from './SaleInvoiceIncrement'; import { formatDateFields } from 'utils'; +import { ItemEntriesTaxTransactions } from '@/services/TaxRates/ItemEntriesTaxTransactions'; +import { ItemEntry } from '@/models'; @Service() export class CommandSaleInvoiceDTOTransformer { - @Inject() - private tenancy: HasTenancyService; - @Inject() private branchDTOTransform: BranchTransactionDTOTransform; @@ -38,6 +36,9 @@ export class CommandSaleInvoiceDTOTransformer { @Inject() private invoiceIncrement: SaleInvoiceIncrement; + @Inject() + private taxDTOTransformer: ItemEntriesTaxTransactions; + /** * Transformes the create DTO to invoice object model. * @param {ISaleInvoiceCreateDTO} saleInvoiceDTO - Sale invoice DTO. @@ -51,11 +52,9 @@ export class CommandSaleInvoiceDTOTransformer { authorizedUser: ITenantUser, oldSaleInvoice?: ISaleInvoice ): Promise { - const { ItemEntry } = this.tenancy.models(tenantId); + const entriesModels = this.transformDTOEntriesToModels(saleInvoiceDTO); + const amount = this.getDueBalanceItemEntries(entriesModels); - const balance = sumBy(saleInvoiceDTO.entries, (e) => - ItemEntry.calcAmount(e) - ); // Retreive the next invoice number. const autoNextNumber = this.invoiceIncrement.getNextInvoiceNumber(tenantId); @@ -68,20 +67,30 @@ export class CommandSaleInvoiceDTOTransformer { const initialEntries = saleInvoiceDTO.entries.map((entry) => ({ referenceType: 'SaleInvoice', + isInclusiveTax: saleInvoiceDTO.isInclusiveTax, ...entry, })); - 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 default cost and sell account to invoice items entries. this.itemsEntriesService.setItemsEntriesDefaultAccounts(tenantId) )(initialEntries); + const entries = R.compose( + // Remove tax code from entries. + R.map(R.omit(['taxCode'])) + )(asyncEntries); + const initialDTO = { ...formatDateFields( omit(saleInvoiceDTO, ['delivered', 'entries', 'fromEstimateId']), ['invoiceDate', 'dueDate'] ), // Avoid rewrite the deliver date in edit mode when already published. - balance, + balance: amount, currencyCode: customer.currencyCode, exchangeRate: saleInvoiceDTO.exchangeRate || 1, ...(saleInvoiceDTO.delivered && @@ -96,8 +105,34 @@ export class CommandSaleInvoiceDTOTransformer { } as ISaleInvoice; return R.compose( + this.taxDTOTransformer.assocTaxAmountWithheldFromEntries, this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) )(initialDTO); } + + /** + * Transforms the DTO entries to invoice entries models. + * @param {ISaleInvoiceCreateDTO | ISaleInvoiceEditDTO} entries + * @returns {IItemEntry[]} + */ + private transformDTOEntriesToModels = ( + saleInvoiceDTO: ISaleInvoiceCreateDTO | ISaleInvoiceEditDTO + ): ItemEntry[] => { + return saleInvoiceDTO.entries.map((entry) => { + return ItemEntry.fromJson({ + ...entry, + isInclusiveTax: saleInvoiceDTO.isInclusiveTax, + }); + }); + }; + + /** + * Gets the due balance from the invoice entries. + * @param {IItemEntry[]} entries + * @returns {number} + */ + private getDueBalanceItemEntries = (entries: ItemEntry[]) => { + return sumBy(entries, (e) => e.amount); + }; } diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index 7fb5a4407..f2245afef 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -32,8 +32,10 @@ export class GetSaleInvoice { const saleInvoice = await SaleInvoice.query() .findById(saleInvoiceId) .withGraphFetched('entries.item') + .withGraphFetched('entries.tax') .withGraphFetched('customer') - .withGraphFetched('branch'); + .withGraphFetched('branch') + .withGraphFetched('taxes.taxRate'); // Validates the given sale invoice existance. this.validators.validateInvoiceExistance(saleInvoice); diff --git a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts index c47291fe0..d816672c2 100644 --- a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts +++ b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts @@ -1,4 +1,5 @@ import * as R from 'ramda'; +import { Knex } from 'knex'; import { ISaleInvoice, IItemEntry, @@ -6,11 +7,11 @@ import { AccountNormal, ILedger, } from '@/interfaces'; -import { Knex } from 'knex'; import { Service, Inject } from 'typedi'; import Ledger from '@/services/Accounting/Ledger'; import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import ItemsEntriesService from '@/services/Items/ItemsEntriesService'; @Service() export class SaleInvoiceGLEntries { @@ -20,10 +21,13 @@ export class SaleInvoiceGLEntries { @Inject() private ledegrRepository: LedgerStorageService; + @Inject() + private itemsEntriesService: ItemsEntriesService; + /** * Writes a sale invoice GL entries. - * @param {number} tenantId - * @param {number} saleInvoiceId + * @param {number} tenantId - Tenant id. + * @param {number} saleInvoiceId - Sale invoice id. * @param {Knex.Transaction} trx */ public writeInvoiceGLEntries = async ( @@ -42,9 +46,17 @@ export class SaleInvoiceGLEntries { const ARAccount = await accountRepository.findOrCreateAccountReceivable( saleInvoice.currencyCode ); + // Find or create tax payable account. + const taxPayableAccount = await accountRepository.findOrCreateTaxPayable( + {}, + trx + ); // Retrieves the ledger of the invoice. - const ledger = this.getInvoiceGLedger(saleInvoice, ARAccount.id); - + const ledger = this.getInvoiceGLedger( + saleInvoice, + ARAccount.id, + taxPayableAccount.id + ); // Commits the ledger entries to the storage as UOW. await this.ledegrRepository.commit(tenantId, ledger, trx); }; @@ -94,10 +106,14 @@ export class SaleInvoiceGLEntries { */ public getInvoiceGLedger = ( saleInvoice: ISaleInvoice, - ARAccountId: number + ARAccountId: number, + taxPayableAccountId: number ): ILedger => { - const entries = this.getInvoiceGLEntries(saleInvoice, ARAccountId); - + const entries = this.getInvoiceGLEntries( + saleInvoice, + ARAccountId, + taxPayableAccountId + ); return new Ledger(entries); }; @@ -143,7 +159,7 @@ export class SaleInvoiceGLEntries { return { ...commonEntry, - debit: saleInvoice.localAmount, + debit: saleInvoice.totalLocal, accountId: ARAccountId, contactId: saleInvoice.customerId, accountNormal: AccountNormal.DEBIT, @@ -165,7 +181,7 @@ export class SaleInvoiceGLEntries { index: number ): ILedgerEntry => { const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); - const localAmount = entry.amount * saleInvoice.exchangeRate; + const localAmount = entry.amountExludingTax * saleInvoice.exchangeRate; return { ...commonEntry, @@ -176,11 +192,62 @@ export class SaleInvoiceGLEntries { itemId: entry.itemId, itemQuantity: entry.quantity, accountNormal: AccountNormal.CREDIT, - projectId: entry.projectId || saleInvoice.projectId + projectId: entry.projectId || saleInvoice.projectId, + taxRateId: entry.taxRateId, + taxRate: entry.taxRate, }; } ); + /** + * Retreives the GL entry of tax payable. + * @param {ISaleInvoice} saleInvoice - + * @param {number} taxPayableAccountId - + * @returns {ILedgerEntry} + */ + private getInvoiceTaxEntry = R.curry( + ( + saleInvoice: ISaleInvoice, + taxPayableAccountId: number, + entry: IItemEntry, + index: number + ): ILedgerEntry => { + const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); + + return { + ...commonEntry, + credit: entry.taxAmount, + accountId: taxPayableAccountId, + index: index + 3, + accountNormal: AccountNormal.CREDIT, + taxRateId: entry.taxRateId, + taxRate: entry.taxRate, + }; + } + ); + + /** + * Retrieves the invoice tax GL entries. + * @param {ISaleInvoice} saleInvoice + * @param {number} taxPayableAccountId + * @returns {ILedgerEntry[]} + */ + private getInvoiceTaxEntries = ( + saleInvoice: ISaleInvoice, + taxPayableAccountId: number + ): ILedgerEntry[] => { + // Retrieves the non-zero tax entries. + const nonZeroTaxEntries = this.itemsEntriesService.getNonZeroEntries( + saleInvoice.entries + ); + const transformTaxEntry = this.getInvoiceTaxEntry( + saleInvoice, + taxPayableAccountId + ); + // Transforms the non-zero tax entries to GL entries. + return nonZeroTaxEntries.map(transformTaxEntry); + }; + /** * Retrieves the invoice GL entries. * @param {ISaleInvoice} saleInvoice @@ -189,7 +256,8 @@ export class SaleInvoiceGLEntries { */ public getInvoiceGLEntries = ( saleInvoice: ISaleInvoice, - ARAccountId: number + ARAccountId: number, + taxPayableAccountId: number ): ILedgerEntry[] => { const receivableEntry = this.getInvoiceReceivableEntry( saleInvoice, @@ -198,6 +266,10 @@ export class SaleInvoiceGLEntries { const transformItemEntry = this.getInvoiceItemEntry(saleInvoice); const creditEntries = saleInvoice.entries.map(transformItemEntry); - return [receivableEntry, ...creditEntries]; + const taxEntries = this.getInvoiceTaxEntries( + saleInvoice, + taxPayableAccountId + ); + return [receivableEntry, ...creditEntries, ...taxEntries]; }; } diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts new file mode 100644 index 000000000..6f028423d --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts @@ -0,0 +1,78 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { formatNumber } from '@/utils'; +import { getExlusiveTaxAmount, getInclusiveTaxAmount } from '@/utils/taxRate'; +import { format } from 'mathjs'; + +export class SaleInvoiceTaxEntryTransformer extends Transformer { + /** + * Included attributes. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'name', + 'taxRateCode', + 'taxRate', + 'taxRateId', + 'taxRateAmount', + 'taxRateAmountFormatted', + ]; + }; + + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve tax rate code. + * @param taxEntry + * @returns {string} + */ + protected taxRateCode = (taxEntry) => { + return taxEntry.taxRate.code; + }; + + /** + * Retrieve tax rate id. + * @param taxEntry + * @returns {number} + */ + protected taxRate = (taxEntry) => { + return taxEntry.taxAmount || taxEntry.taxRate.rate; + }; + + /** + * Retrieve tax rate name. + * @param taxEntry + * @returns {string} + */ + protected name = (taxEntry) => { + return taxEntry.taxRate.name; + }; + + /** + * Retrieve tax rate amount. + * @param taxEntry + */ + protected taxRateAmount = (taxEntry) => { + const taxRate = this.taxRate(taxEntry); + + return this.options.isInclusiveTax + ? getInclusiveTaxAmount(this.options.amount, taxRate) + : getExlusiveTaxAmount(this.options.amount, taxRate); + }; + + /** + * Retrieve formatted tax rate amount. + * @returns {string} + */ + protected taxRateAmountFormatted = (taxEntry) => { + return formatNumber(this.taxRateAmount(taxEntry), { + currencyCode: this.options.currencyCode, + }); + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts index dfbd704fa..ffb2d8391 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts @@ -1,5 +1,6 @@ import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; +import { SaleInvoiceTaxEntryTransformer } from './SaleInvoiceTaxEntryTransformer'; export class SaleInvoiceTransformer extends Transformer { /** @@ -8,13 +9,20 @@ export class SaleInvoiceTransformer extends Transformer { */ public includeAttributes = (): string[] => { return [ - 'formattedInvoiceDate', - 'formattedDueDate', - 'formattedAmount', - 'formattedDueAmount', - 'formattedPaymentAmount', - 'formattedBalanceAmount', - 'formattedExchangeRate', + 'invoiceDateFormatted', + 'dueDateFormatted', + 'dueAmountFormatted', + 'paymentAmountFormatted', + 'balanceAmountFormatted', + 'exchangeRateFormatted', + 'subtotalFormatted', + 'subtotalLocalFormatted', + 'subtotalExludingTaxFormatted', + 'taxAmountWithheldFormatted', + 'taxAmountWithheldLocalFormatted', + 'totalFormatted', + 'totalLocalFormatted', + 'taxes', ]; }; @@ -23,7 +31,7 @@ export class SaleInvoiceTransformer extends Transformer { * @param {ISaleInvoice} invoice * @returns {String} */ - protected formattedInvoiceDate = (invoice): string => { + protected invoiceDateFormatted = (invoice): string => { return this.formatDate(invoice.invoiceDate); }; @@ -32,27 +40,16 @@ export class SaleInvoiceTransformer extends Transformer { * @param {ISaleInvoice} invoice * @returns {string} */ - protected formattedDueDate = (invoice): string => { + protected dueDateFormatted = (invoice): string => { return this.formatDate(invoice.dueDate); }; - /** - * Retrieve formatted invoice amount. - * @param {ISaleInvoice} invoice - * @returns {string} - */ - protected formattedAmount = (invoice): string => { - return formatNumber(invoice.balance, { - currencyCode: invoice.currencyCode, - }); - }; - /** * Retrieve formatted invoice due amount. * @param {ISaleInvoice} invoice * @returns {string} */ - protected formattedDueAmount = (invoice): string => { + protected dueAmountFormatted = (invoice): string => { return formatNumber(invoice.dueAmount, { currencyCode: invoice.currencyCode, }); @@ -63,7 +60,7 @@ export class SaleInvoiceTransformer extends Transformer { * @param {ISaleInvoice} invoice * @returns {string} */ - protected formattedPaymentAmount = (invoice): string => { + protected paymentAmountFormatted = (invoice): string => { return formatNumber(invoice.paymentAmount, { currencyCode: invoice.currencyCode, }); @@ -74,7 +71,7 @@ export class SaleInvoiceTransformer extends Transformer { * @param {ISaleInvoice} invoice * @returns {string} */ - protected formattedBalanceAmount = (invoice): string => { + protected balanceAmountFormatted = (invoice): string => { return formatNumber(invoice.balanceAmount, { currencyCode: invoice.currencyCode, }); @@ -85,7 +82,98 @@ export class SaleInvoiceTransformer extends Transformer { * @param {ISaleInvoice} invoice * @returns {string} */ - protected formattedExchangeRate = (invoice): string => { + protected exchangeRateFormatted = (invoice): string => { return formatNumber(invoice.exchangeRate, { money: false }); }; + + /** + * Retrieves formatted subtotal in base currency. + * (Tax inclusive if the tax inclusive is enabled) + * @param invoice + * @returns {string} + */ + protected subtotalFormatted = (invoice): string => { + return formatNumber(invoice.subtotal, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieves formatted subtotal in foreign currency. + * (Tax inclusive if the tax inclusive is enabled) + * @param invoice + * @returns {string} + */ + protected subtotalLocalFormatted = (invoice): string => { + return formatNumber(invoice.subtotalLocal, { + currencyCode: invoice.currencyCode, + }); + }; + + /** + * Retrieves formatted subtotal excluding tax in foreign currency. + * @param invoice + * @returns {string} + */ + protected subtotalExludingTaxFormatted = (invoice): string => { + return formatNumber(invoice.subtotalExludingTax, { + currencyCode: invoice.currencyCode, + }); + }; + + /** + * Retrieves formatted tax amount withheld in foreign currency. + * @param invoice + * @returns {string} + */ + protected taxAmountWithheldFormatted = (invoice): string => { + return formatNumber(invoice.taxAmountWithheld, { + currencyCode: invoice.currencyCode, + }); + }; + + /** + * Retrieves formatted tax amount withheld in base currency. + * @param invoice + * @returns {string} + */ + protected taxAmountWithheldLocalFormatted = (invoice): string => { + return formatNumber(invoice.taxAmountWithheldLocal, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieves formatted total in foreign currency. + * @param invoice + * @returns {string} + */ + protected totalFormatted = (invoice): string => { + return formatNumber(invoice.total, { + currencyCode: invoice.currencyCode, + }); + }; + + /** + * Retrieves formatted total in base currency. + * @param invoice + * @returns {string} + */ + protected totalLocalFormatted = (invoice): string => { + return formatNumber(invoice.totalLocal, { + currencyCode: this.context.organization.baseCurrency, + }); + }; + + /** + * Retrieve the taxes lines of sale invoice. + * @param {ISaleInvoice} invoice + */ + protected taxes = (invoice) => { + return this.item(invoice.taxes, new SaleInvoiceTaxEntryTransformer(), { + amount: invoice.amount, + isInclusiveTax: invoice.isInclusiveTax, + currencyCode: invoice.currencyCode, + }); + }; } diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts index 3b5057ed9..afeca6010 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts @@ -87,7 +87,7 @@ export class PaymentReceivesApplication { } /** - * deletes the given payment receive. + * Deletes the given payment receive. * @param {number} tenantId * @param {number} paymentReceiveId * @param {ISystemUser} authorizedUser @@ -126,7 +126,7 @@ export class PaymentReceivesApplication { } /** - * + * Retrieves the given payment receive. * @param {number} tenantId * @param {number} paymentReceiveId * @returns {Promise} diff --git a/packages/server/src/services/TaxRates/ActivateTaxRate.ts b/packages/server/src/services/TaxRates/ActivateTaxRate.ts new file mode 100644 index 000000000..2384b901d --- /dev/null +++ b/packages/server/src/services/TaxRates/ActivateTaxRate.ts @@ -0,0 +1,67 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { + ITaxRateActivatedPayload, + ITaxRateActivatingPayload, +} from '@/interfaces'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; +import events from '@/subscribers/events'; + +@Service() +export class ActivateTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * Activates the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @param {IEditTaxRateDTO} taxRateEditDTO + * @returns {Promise} + */ + public activateTaxRate(tenantId: number, taxRateId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const oldTaxRate = TaxRate.query().findById(taxRateId); + + // Validates the tax rate existance. + this.validators.validateTaxRateExistance(oldTaxRate); + + // Validates the tax rate inactive. + this.validators.validateTaxRateNotActive(oldTaxRate); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTaxRateActivating` event. + await this.eventPublisher.emitAsync(events.taxRates.onActivating, { + taxRateId, + tenantId, + trx, + } as ITaxRateActivatingPayload); + + const taxRate = await TaxRate.query(trx) + .findById(taxRateId) + .patch({ active: 1 }); + + // Triggers `onTaxRateCreated` event. + await this.eventPublisher.emitAsync(events.taxRates.onActivated, { + taxRateId, + tenantId, + trx, + } as ITaxRateActivatedPayload); + + return taxRate; + }); + } +} diff --git a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts new file mode 100644 index 000000000..9d7c2558e --- /dev/null +++ b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts @@ -0,0 +1,112 @@ +import { ServiceError } from '@/exceptions'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { IItemEntryDTO, ITaxRate } from '@/interfaces'; +import { ERRORS } from './constants'; +import { difference } from 'lodash'; + +@Service() +export class CommandTaxRatesValidators { + @Inject() + private tenancy: HasTenancyService; + + /** + * Validates the tax rate existance. + * @param {TaxRate | undefined | null} taxRate + */ + public validateTaxRateExistance(taxRate: ITaxRate | undefined | null) { + if (!taxRate) { + throw new ServiceError(ERRORS.TAX_RATE_NOT_FOUND); + } + } + + /** + * Validates the given tax rate active. + * @param {ITaxRate} taxRate + */ + public validateTaxRateNotActive(taxRate: ITaxRate) { + if (taxRate.active) { + throw new ServiceError(ERRORS.TAX_RATE_ALREADY_ACTIVE); + } + } + + /** + * Validates the given tax rate inactive. + * @param {ITaxRate} taxRate + */ + public validateTaxRateNotInactive(taxRate: ITaxRate) { + if (!taxRate.active) { + throw new ServiceError(ERRORS.TAX_RATE_ALREADY_INACTIVE); + } + } + + /** + * Validates the tax code uniquiness. + * @param {number} tenantId + * @param {string} taxCode + */ + public async validateTaxCodeUnique(tenantId: number, taxCode: string) { + const { TaxRate } = this.tenancy.models(tenantId); + + const foundTaxCode = await TaxRate.query().findOne({ code: taxCode }); + + if (foundTaxCode) { + throw new ServiceError(ERRORS.TAX_CODE_NOT_UNIQUE); + } + } + + /** + * Validates the tax codes of the given item entries DTO. + * @param {number} tenantId + * @param {IItemEntryDTO[]} itemEntriesDTO + * @throws {ServiceError} + */ + public async validateItemEntriesTaxCode( + tenantId: number, + itemEntriesDTO: IItemEntryDTO[] + ) { + const { TaxRate } = this.tenancy.models(tenantId); + + const filteredTaxEntries = itemEntriesDTO.filter((e) => e.taxCode); + const taxCodes = filteredTaxEntries.map((e) => e.taxCode); + + // Can't validate if there is no tax codes. + if (taxCodes.length === 0) return; + + const foundTaxCodes = await TaxRate.query().whereIn('code', taxCodes); + const foundCodes = foundTaxCodes.map((tax) => tax.code); + + const notFoundTaxCodes = difference(taxCodes, foundCodes); + + if (notFoundTaxCodes.length > 0) { + throw new ServiceError(ERRORS.ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND); + } + } + + /** + * Validates the tax rate id of the given item entries DTO. + * @param {number} tenantId + * @param {IItemEntryDTO[]} itemEntriesDTO + * @throws {ServiceError} + */ + public async validateItemEntriesTaxCodeId( + tenantId: number, + itemEntriesDTO: IItemEntryDTO[] + ) { + const filteredTaxEntries = itemEntriesDTO.filter((e) => e.taxRateId); + const taxRatesIds = filteredTaxEntries.map((e) => e.taxRateId); + + // Can't validate if there is no tax codes. + if (taxRatesIds.length === 0) return; + + const { TaxRate } = this.tenancy.models(tenantId); + const foundTaxCodes = await TaxRate.query().whereIn('id', taxRatesIds); + const foundTaxRatesIds = foundTaxCodes.map((tax) => tax.id); + + const notFoundTaxCodes = difference(taxRatesIds, foundTaxRatesIds); + + if (notFoundTaxCodes.length > 0) { + throw new ServiceError(ERRORS.ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND); + } + } +} diff --git a/packages/server/src/services/TaxRates/CreateTaxRate.ts b/packages/server/src/services/TaxRates/CreateTaxRate.ts new file mode 100644 index 000000000..a7795a3c7 --- /dev/null +++ b/packages/server/src/services/TaxRates/CreateTaxRate.ts @@ -0,0 +1,67 @@ +import { Knex } from 'knex'; +import { + ICreateTaxRateDTO, + ITaxRateCreatedPayload, + ITaxRateCreatingPayload, +} from '@/interfaces'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; + +@Service() +export class CreateTaxRate { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * Creates a new tax rate. + * @param {number} tenantId + * @param {ICreateTaxRateDTO} createTaxRateDTO + */ + public async createTaxRate( + tenantId: number, + createTaxRateDTO: ICreateTaxRateDTO + ) { + const { TaxRate } = this.tenancy.models(tenantId); + + // Validates the tax code uniquiness. + await this.validators.validateTaxCodeUnique( + tenantId, + createTaxRateDTO.code + ); + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTaxRateCreating` event. + await this.eventPublisher.emitAsync(events.taxRates.onCreating, { + createTaxRateDTO, + tenantId, + trx, + } as ITaxRateCreatingPayload); + + const taxRate = await TaxRate.query(trx).insertAndFetch({ + ...createTaxRateDTO, + }); + + // Triggers `onTaxRateCreated` event. + await this.eventPublisher.emitAsync(events.taxRates.onCreated, { + createTaxRateDTO, + taxRate, + tenantId, + trx, + } as ITaxRateCreatedPayload); + + return taxRate; + }); + } +} diff --git a/packages/server/src/services/TaxRates/DeleteTaxRate.ts b/packages/server/src/services/TaxRates/DeleteTaxRate.ts new file mode 100644 index 000000000..27c104de1 --- /dev/null +++ b/packages/server/src/services/TaxRates/DeleteTaxRate.ts @@ -0,0 +1,56 @@ +import { Inject, Service } from 'typedi'; +import { Knex } from 'knex'; +import { ITaxRateDeletedPayload, ITaxRateDeletingPayload } from '@/interfaces'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; +import events from '@/subscribers/events'; + +@Service() +export class DeleteTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * Deletes the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @returns {Promise} + */ + public deleteTaxRate(tenantId: number, taxRateId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const oldTaxRate = TaxRate.query().findById(taxRateId); + + // Validates the tax rate existance. + this.validators.validateTaxRateExistance(oldTaxRate); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTaxRateDeleting` event. + await this.eventPublisher.emitAsync(events.taxRates.onDeleting, { + oldTaxRate, + tenantId, + trx, + } as ITaxRateDeletingPayload); + + await TaxRate.query(trx).findById(taxRateId).delete(); + + // Triggers `onTaxRateDeleted` event. + await this.eventPublisher.emitAsync(events.taxRates.onDeleted, { + oldTaxRate, + tenantId, + trx, + } as ITaxRateDeletedPayload); + }); + } +} diff --git a/packages/server/src/services/TaxRates/EditTaxRate.ts b/packages/server/src/services/TaxRates/EditTaxRate.ts new file mode 100644 index 000000000..c2d0e5c1a --- /dev/null +++ b/packages/server/src/services/TaxRates/EditTaxRate.ts @@ -0,0 +1,126 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { omit } from 'lodash'; +import { + IEditTaxRateDTO, + ITaxRate, + ITaxRateEditedPayload, + ITaxRateEditingPayload, +} from '@/interfaces'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; +import events from '@/subscribers/events'; + +@Service() +export class EditTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * Detarmines whether the tax rate, name or code have been changed. + * @param {ITaxRate} taxRate + * @param {IEditTaxRateDTO} editTaxRateDTO + * @returns {boolean} + */ + private isTaxRateDTOChanged = ( + taxRate: ITaxRate, + editTaxRateDTO: IEditTaxRateDTO + ) => { + return ( + taxRate.rate !== editTaxRateDTO.rate || + taxRate.name !== editTaxRateDTO.name || + taxRate.code !== editTaxRateDTO.code + ); + }; + + /** + * Edits the given tax rate or creates a new if the rate or name have been changed. + * @param {number} tenantId + * @param {ITaxRate} oldTaxRate + * @param {IEditTaxRateDTO} editTaxRateDTO + * @param {Knex.Transaction} trx + * @returns {Promise} + */ + private async editTaxRateOrCreate( + tenantId: number, + oldTaxRate: ITaxRate, + editTaxRateDTO: IEditTaxRateDTO, + trx?: Knex.Transaction + ) { + const { TaxRate } = this.tenancy.models(tenantId); + const isTaxDTOChanged = this.isTaxRateDTOChanged( + oldTaxRate, + editTaxRateDTO + ); + if (isTaxDTOChanged) { + // Soft deleting the old tax rate. + await TaxRate.query(trx).findById(oldTaxRate.id).delete(); + + // Create a new tax rate with new edited data. + return TaxRate.query(trx).insertAndFetch({ + ...omit(oldTaxRate, ['id']), + ...editTaxRateDTO, + }); + } else { + return TaxRate.query(trx).patchAndFetchById(oldTaxRate.id, { + ...editTaxRateDTO, + }); + } + } + + /** + * Edits the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @param {IEditTaxRateDTO} taxRateEditDTO + * @returns {Promise} + */ + public async editTaxRate( + tenantId: number, + taxRateId: number, + editTaxRateDTO: IEditTaxRateDTO + ) { + const { TaxRate } = this.tenancy.models(tenantId); + + const oldTaxRate = await TaxRate.query().findById(taxRateId); + + // Validates the tax rate existance. + this.validators.validateTaxRateExistance(oldTaxRate); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTaxRateEditing` event. + await this.eventPublisher.emitAsync(events.taxRates.onEditing, { + editTaxRateDTO, + tenantId, + trx, + } as ITaxRateEditingPayload); + + const taxRate = await this.editTaxRateOrCreate( + tenantId, + oldTaxRate, + editTaxRateDTO, + trx + ); + // Triggers `onTaxRateEdited` event. + await this.eventPublisher.emitAsync(events.taxRates.onEdited, { + editTaxRateDTO, + taxRate, + tenantId, + trx, + } as ITaxRateEditedPayload); + + return taxRate; + }); + } +} diff --git a/packages/server/src/services/TaxRates/GetTaxRate.ts b/packages/server/src/services/TaxRates/GetTaxRate.ts new file mode 100644 index 000000000..5df27a87d --- /dev/null +++ b/packages/server/src/services/TaxRates/GetTaxRate.ts @@ -0,0 +1,39 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { TaxRateTransformer } from './TaxRateTransformer'; + +@Service() +export class GetTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private validators: CommandTaxRatesValidators; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @returns {Promise} + */ + public async getTaxRate(tenantId: number, taxRateId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const taxRate = await TaxRate.query().findById(taxRateId); + + // Validates the tax rate existance. + this.validators.validateTaxRateExistance(taxRate); + + // Transforms the tax rate. + return this.transformer.transform( + tenantId, + taxRate, + new TaxRateTransformer() + ); + } +} diff --git a/packages/server/src/services/TaxRates/GetTaxRates.ts b/packages/server/src/services/TaxRates/GetTaxRates.ts new file mode 100644 index 000000000..8fedb1c2d --- /dev/null +++ b/packages/server/src/services/TaxRates/GetTaxRates.ts @@ -0,0 +1,32 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { TaxRateTransformer } from './TaxRateTransformer'; + +@Service() +export class GetTaxRatesService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the tax rates list. + * @param {number} tenantId + * @returns {Promise} + */ + public async getTaxRates(tenantId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + // Retrieves the tax rates. + const taxRates = await TaxRate.query().orderBy('name', 'ASC'); + + // Transforms the tax rates. + return this.transformer.transform( + tenantId, + taxRates, + new TaxRateTransformer() + ); + } +} diff --git a/packages/server/src/services/TaxRates/InactivateTaxRate.ts b/packages/server/src/services/TaxRates/InactivateTaxRate.ts new file mode 100644 index 000000000..82ee30a89 --- /dev/null +++ b/packages/server/src/services/TaxRates/InactivateTaxRate.ts @@ -0,0 +1,67 @@ +import { + ITaxRateActivatedPayload, + ITaxRateActivatingPayload, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Knex } from 'knex'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; +import events from '@/subscribers/events'; + +@Service() +export class InactivateTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * Edits the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @param {IEditTaxRateDTO} taxRateEditDTO + * @returns {Promise} + */ + public async inactivateTaxRate(tenantId: number, taxRateId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const oldTaxRate = await TaxRate.query().findById(taxRateId); + + // Validates the tax rate existance. + this.validators.validateTaxRateExistance(oldTaxRate); + + // Validates the tax rate active. + this.validators.validateTaxRateNotInactive(oldTaxRate); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTaxRateActivating` event. + await this.eventPublisher.emitAsync(events.taxRates.onInactivating, { + taxRateId, + tenantId, + trx, + } as ITaxRateActivatingPayload); + + const taxRate = await TaxRate.query(trx) + .findById(taxRateId) + .patch({ active: 0 }); + + // Triggers `onTaxRateCreated` event. + await this.eventPublisher.emitAsync(events.taxRates.onInactivated, { + taxRateId, + tenantId, + trx, + } as ITaxRateActivatedPayload); + + return taxRate; + }); + } +} diff --git a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts new file mode 100644 index 000000000..5eaa7b980 --- /dev/null +++ b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts @@ -0,0 +1,72 @@ +import { Inject, Service } from 'typedi'; +import { keyBy, sumBy } from 'lodash'; +import { ItemEntry } from '@/models'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { IItem, IItemEntry, IItemEntryDTO } from '@/interfaces'; + +@Service() +export class ItemEntriesTaxTransactions { + @Inject() + private tenancy: HasTenancyService; + + /** + * Associates tax amount withheld to the model. + * @param model + * @returns + */ + public assocTaxAmountWithheldFromEntries(model: any) { + const entries = model.entries.map((entry) => ItemEntry.fromJson(entry)); + const taxAmountWithheld = sumBy(entries, 'taxAmount'); + + if (taxAmountWithheld) { + model.taxAmountWithheld = taxAmountWithheld; + } + return model; + } + + /** + * Associates tax rate id from tax code to entries. + * @param {number} tenantId + * @param {} model + */ + public assocTaxRateIdFromCodeToEntries = + (tenantId: number) => async (entries: any) => { + const entriesWithCode = entries.filter((entry) => entry.taxCode); + const taxCodes = entriesWithCode.map((entry) => entry.taxCode); + + const { TaxRate } = this.tenancy.models(tenantId); + const foundTaxCodes = await TaxRate.query().whereIn('code', taxCodes); + + const taxCodesMap = keyBy(foundTaxCodes, 'code'); + + return entries.map((entry) => { + if (entry.taxCode) { + entry.taxRateId = taxCodesMap[entry.taxCode]?.id; + } + return entry; + }); + }; + + /** + * Associates tax rate from tax id to entries. + * @param {number} tenantId + * @returns {Promise} + */ + public assocTaxRateFromTaxIdToEntries = + (tenantId: number) => async (entries: IItemEntry[]) => { + const entriesWithId = entries.filter((e) => e.taxRateId); + const taxRateIds = entriesWithId.map((e) => e.taxRateId); + + const { TaxRate } = this.tenancy.models(tenantId); + const foundTaxes = await TaxRate.query().whereIn('id', taxRateIds); + + const taxRatesMap = keyBy(foundTaxes, 'id'); + + return entries.map((entry) => { + if (entry.taxRateId) { + entry.taxRate = taxRatesMap[entry.taxRateId]?.rate; + } + return entry; + }); + }; +} diff --git a/packages/server/src/services/TaxRates/TaxRateTransformer.ts b/packages/server/src/services/TaxRates/TaxRateTransformer.ts new file mode 100644 index 000000000..8548245c0 --- /dev/null +++ b/packages/server/src/services/TaxRates/TaxRateTransformer.ts @@ -0,0 +1,29 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class TaxRateTransformer extends Transformer { + /** + * Include these attributes to tax rate object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['nameFormatted', 'rateFormatted']; + }; + + /** + * Retrieve the formatted rate. + * @param taxRate + * @returns {string} + */ + public rateFormatted = (taxRate): string => { + return `${taxRate.rate}%`; + }; + + /** + * Formats the tax rate name. + * @param taxRate + * @returns {string} + */ + protected nameFormatted = (taxRate): string => { + return `${taxRate.name} [${taxRate.rate}%]`; + }; +} diff --git a/packages/server/src/services/TaxRates/TaxRatesApplication.ts b/packages/server/src/services/TaxRates/TaxRatesApplication.ts new file mode 100644 index 000000000..c237c7251 --- /dev/null +++ b/packages/server/src/services/TaxRates/TaxRatesApplication.ts @@ -0,0 +1,109 @@ +import { Inject, Service } from 'typedi'; +import { ICreateTaxRateDTO, IEditTaxRateDTO } from '@/interfaces'; +import { CreateTaxRate } from './CreateTaxRate'; +import { DeleteTaxRateService } from './DeleteTaxRate'; +import { EditTaxRateService } from './EditTaxRate'; +import { GetTaxRateService } from './GetTaxRate'; +import { GetTaxRatesService } from './GetTaxRates'; +import { ActivateTaxRateService } from './ActivateTaxRate'; +import { InactivateTaxRateService } from './InactivateTaxRate'; + +@Service() +export class TaxRatesApplication { + @Inject() + private createTaxRateService: CreateTaxRate; + + @Inject() + private editTaxRateService: EditTaxRateService; + + @Inject() + private deleteTaxRateService: DeleteTaxRateService; + + @Inject() + private getTaxRateService: GetTaxRateService; + + @Inject() + private getTaxRatesService: GetTaxRatesService; + + @Inject() + private activateTaxRateService: ActivateTaxRateService; + + @Inject() + private inactivateTaxRateService: InactivateTaxRateService; + + /** + * Creates a new tax rate. + * @param {number} tenantId + * @param {ICreateTaxRateDTO} createTaxRateDTO + * @returns {Promise} + */ + public createTaxRate(tenantId: number, createTaxRateDTO: ICreateTaxRateDTO) { + return this.createTaxRateService.createTaxRate(tenantId, createTaxRateDTO); + } + + /** + * Edits the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @param {IEditTaxRateDTO} taxRateEditDTO + * @returns {Promise} + */ + public editTaxRate( + tenantId: number, + taxRateId: number, + editTaxRateDTO: IEditTaxRateDTO + ) { + return this.editTaxRateService.editTaxRate( + tenantId, + taxRateId, + editTaxRateDTO + ); + } + + /** + * Deletes the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @returns {Promise} + */ + public deleteTaxRate(tenantId: number, taxRateId: number) { + return this.deleteTaxRateService.deleteTaxRate(tenantId, taxRateId); + } + + /** + * Retrieves the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + * @returns {Promise} + */ + public getTaxRate(tenantId: number, taxRateId: number) { + return this.getTaxRateService.getTaxRate(tenantId, taxRateId); + } + + /** + * Retrieves the tax rates list. + * @param {number} tenantId + * @returns {Promise} + */ + public getTaxRates(tenantId: number) { + return this.getTaxRatesService.getTaxRates(tenantId); + } + + /** + * Activates the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + */ + public activateTaxRate(tenantId: number, taxRateId: number) { + return this.activateTaxRateService.activateTaxRate(tenantId, taxRateId); + } + + /** + * Inactivates the given tax rate. + * @param {number} tenantId + * @param {number} taxRateId + */ + public inactivateTaxRate(tenantId: number, taxRateId: number) { + return this.inactivateTaxRateService.inactivateTaxRate(tenantId, taxRateId); + } +} diff --git a/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts b/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts new file mode 100644 index 000000000..0e00471c8 --- /dev/null +++ b/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts @@ -0,0 +1,99 @@ +import { sumBy, chain, keyBy } from 'lodash'; +import { IItemEntry, ITaxTransaction } from '@/interfaces'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { Knex } from 'knex'; + +@Service() +export class WriteTaxTransactionsItemEntries { + @Inject() + private tenancy: HasTenancyService; + + /** + * Writes the tax transactions from the given item entries. + * @param {number} tenantId + * @param {IItemEntry[]} itemEntries + */ + public async writeTaxTransactionsFromItemEntries( + tenantId: number, + itemEntries: IItemEntry[], + trx?: Knex.Transaction + ) { + const { TaxRateTransaction, TaxRate } = this.tenancy.models(tenantId); + const aggregatedEntries = this.aggregateItemEntriesByTaxCode(itemEntries); + + const entriesTaxRateIds = aggregatedEntries.map((entry) => entry.taxRateId); + + const taxRates = await TaxRate.query(trx).whereIn('id', entriesTaxRateIds); + const taxRatesById = keyBy(taxRates, 'id'); + + const taxTransactions = aggregatedEntries.map((entry) => ({ + taxRateId: entry.taxRateId, + referenceType: entry.referenceType, + referenceId: entry.referenceId, + rate: entry.taxRate || taxRatesById[entry.taxRateId]?.rate, + })) as ITaxTransaction[]; + + await TaxRateTransaction.query(trx).upsertGraph(taxTransactions); + } + + /** + * Rewrites the tax rate transactions from the given item entries. + * @param {number} tenantId + * @param {IItemEntry[]} itemEntries + * @param {string} referenceType + * @param {number} referenceId + * @param {Knex.Transaction} trx + */ + public async rewriteTaxRateTransactionsFromItemEntries( + tenantId: number, + itemEntries: IItemEntry[], + referenceType: string, + referenceId: number, + trx?: Knex.Transaction + ) { + await Promise.all([ + this.removeTaxTransactionsFromItemEntries( + tenantId, + referenceId, + referenceType, + trx + ), + this.writeTaxTransactionsFromItemEntries(tenantId, itemEntries, trx), + ]); + } + + /** + * Aggregates by tax code id and sums the amount. + * @param {IItemEntry[]} itemEntries + * @returns {IItemEntry[]} + */ + private aggregateItemEntriesByTaxCode = ( + itemEntries: IItemEntry[] + ): IItemEntry[] => { + return chain(itemEntries.filter((item) => item.taxRateId)) + .groupBy((item) => item.taxRateId) + .values() + .map((group) => ({ ...group[0], amount: sumBy(group, 'amount') })) + .value(); + }; + + /** + * Removes the tax transactions from the given item entries. + * @param {number} tenantId - Tenant id. + * @param {string} referenceType - Reference type. + * @param {number} referenceId - Reference id. + */ + public async removeTaxTransactionsFromItemEntries( + tenantId: number, + referenceId: number, + referenceType: string, + trx?: Knex.Transaction + ) { + const { TaxRateTransaction } = this.tenancy.models(tenantId); + + await TaxRateTransaction.query(trx) + .where({ referenceType, referenceId }) + .delete(); + } +} diff --git a/packages/server/src/services/TaxRates/constants.ts b/packages/server/src/services/TaxRates/constants.ts new file mode 100644 index 000000000..e1553f4ee --- /dev/null +++ b/packages/server/src/services/TaxRates/constants.ts @@ -0,0 +1,8 @@ +export const ERRORS = { + TAX_RATE_NOT_FOUND: 'TAX_RATE_NOT_FOUND', + TAX_CODE_NOT_UNIQUE: 'TAX_CODE_NOT_UNIQUE', + ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND: 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND', + ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND: 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND', + TAX_RATE_ALREADY_ACTIVE: 'TAX_RATE_ALREADY_ACTIVE', + TAX_RATE_ALREADY_INACTIVE: 'TAX_RATE_ALREADY_INACTIVE' +}; diff --git a/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts new file mode 100644 index 000000000..9a120629c --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts @@ -0,0 +1,92 @@ +import { Inject, Service } from 'typedi'; +import { + ISaleInvoiceCreatingPaylaod, + ISaleInvoiceEditingPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { CommandTaxRatesValidators } from '../CommandTaxRatesValidators'; + +@Service() +export class SaleInvoiceTaxRateValidateSubscriber { + @Inject() + private taxRateDTOValidator: CommandTaxRatesValidators; + + /** + * Attaches events with handlers. + */ + public attach(bus) { + bus.subscribe( + events.saleInvoice.onCreating, + this.validateSaleInvoiceEntriesTaxCodeExistanceOnCreating + ); + bus.subscribe( + events.saleInvoice.onCreating, + this.validateSaleInvoiceEntriesTaxIdExistanceOnCreating + ); + bus.subscribe( + events.saleInvoice.onEditing, + this.validateSaleInvoiceEntriesTaxCodeExistanceOnEditing + ); + bus.subscribe( + events.saleInvoice.onEditing, + this.validateSaleInvoiceEntriesTaxIdExistanceOnEditing + ); + return bus; + } + + /** + * Validate invoice entries tax rate code existance when creating. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private validateSaleInvoiceEntriesTaxCodeExistanceOnCreating = async ({ + saleInvoiceDTO, + tenantId, + }: ISaleInvoiceCreatingPaylaod) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + saleInvoiceDTO.entries + ); + }; + + /** + * Validate the tax rate id existance when creating. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private validateSaleInvoiceEntriesTaxIdExistanceOnCreating = async ({ + saleInvoiceDTO, + tenantId, + }: ISaleInvoiceCreatingPaylaod) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCodeId( + tenantId, + saleInvoiceDTO.entries + ); + }; + + /** + * Validate invoice entries tax rate code existance when editing. + * @param {ISaleInvoiceEditingPayload} + */ + private validateSaleInvoiceEntriesTaxCodeExistanceOnEditing = async ({ + tenantId, + saleInvoiceDTO, + }: ISaleInvoiceEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + saleInvoiceDTO.entries + ); + }; + + /** + * Validates the invoice entries tax rate id existance when editing. + * @param {ISaleInvoiceEditingPayload} payload - + */ + private validateSaleInvoiceEntriesTaxIdExistanceOnEditing = async ({ + tenantId, + saleInvoiceDTO, + }: ISaleInvoiceEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCodeId( + tenantId, + saleInvoiceDTO.entries + ); + }; +} diff --git a/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts new file mode 100644 index 000000000..b65dd7904 --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts @@ -0,0 +1,84 @@ +import { Inject, Service } from 'typedi'; +import { + ISaleInvoiceCreatedPayload, + ISaleInvoiceDeletedPayload, + ISaleInvoiceEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { WriteTaxTransactionsItemEntries } from '../WriteTaxTransactionsItemEntries'; + +@Service() +export class WriteInvoiceTaxTransactionsSubscriber { + @Inject() + private writeTaxTransactions: WriteTaxTransactionsItemEntries; + + /** + * Attaches events with handlers. + */ + public attach(bus) { + bus.subscribe( + events.saleInvoice.onCreated, + this.writeInvoiceTaxTransactionsOnCreated + ); + bus.subscribe( + events.saleInvoice.onEdited, + this.rewriteInvoiceTaxTransactionsOnEdited + ); + bus.subscribe( + events.saleInvoice.onDelete, + this.removeInvoiceTaxTransactionsOnDeleted + ); + return bus; + } + + /** + * Writes the invoice tax transactions on invoice created. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private writeInvoiceTaxTransactionsOnCreated = async ({ + tenantId, + saleInvoice, + trx + }: ISaleInvoiceCreatedPayload) => { + await this.writeTaxTransactions.writeTaxTransactionsFromItemEntries( + tenantId, + saleInvoice.entries, + trx + ); + }; + + /** + * Rewrites the invoice tax transactions on invoice edited. + * @param {ISaleInvoiceEditedPayload} payload - + */ + private rewriteInvoiceTaxTransactionsOnEdited = async ({ + tenantId, + saleInvoice, + trx, + }: ISaleInvoiceEditedPayload) => { + await this.writeTaxTransactions.rewriteTaxRateTransactionsFromItemEntries( + tenantId, + saleInvoice.entries, + 'SaleInvoice', + saleInvoice.id, + trx + ); + }; + + /** + * Removes the invoice tax transactions on invoice deleted. + * @param {ISaleInvoiceEditingPayload} + */ + private removeInvoiceTaxTransactionsOnDeleted = async ({ + tenantId, + oldSaleInvoice, + trx + }: ISaleInvoiceDeletedPayload) => { + await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries( + tenantId, + oldSaleInvoice.id, + 'SaleInvoice', + trx + ); + }; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index f43366107..24ca0a0a3 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -13,7 +13,7 @@ export default { sendResetPassword: 'onSendResetPassword', resetPassword: 'onResetPassword', - resetingPassword: 'onResetingPassword' + resetingPassword: 'onResetingPassword', }, /** @@ -560,4 +560,21 @@ export default { onDeleting: 'onProjectTimeDeleting', onDeleted: 'onProjectTimeDeleted', }, + + taxRates: { + onCreating: 'onTaxRateCreating', + onCreated: 'onTaxRateCreated', + + onEditing: 'onTaxRateEditing', + onEdited: 'onTaxRateEdited', + + onDeleting: 'onTaxRateDeleting', + onDeleted: 'onTaxRateDeleted', + + onActivating: 'onTaxRateActivating', + onActivated: 'onTaxRateActivated', + + onInactivating: 'onTaxRateInactivating', + onInactivated: 'onTaxRateInactivated' + }, }; diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index fa2bc6772..2b09381d1 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -471,6 +471,15 @@ const castCommaListEnvVarToArray = (envVar: string): Array => { return envVar ? envVar?.split(',')?.map(_.trim) : []; }; +export const sortObjectKeysAlphabetically = (object) => { + return Object.keys(object) + .sort() + .reduce((objEntries, key) => { + objEntries[key] = object[key]; + return objEntries; + }, {}); +}; + export { templateRender, accumSum, @@ -503,5 +512,5 @@ export { mergeObjectsBykey, nestedArrayToFlatten, assocDepthLevelToObjectTree, - castCommaListEnvVarToArray + castCommaListEnvVarToArray, }; diff --git a/packages/server/src/utils/taxRate.ts b/packages/server/src/utils/taxRate.ts new file mode 100644 index 000000000..15d9e9d36 --- /dev/null +++ b/packages/server/src/utils/taxRate.ts @@ -0,0 +1,19 @@ +/** + * Get inclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getInclusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / (100 + taxRate); +}; + +/** + * Get exclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getExlusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / 100; +}; diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index 9e334af78..cad11bebb 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -47,6 +47,7 @@ import ProjectExpenseForm from '@/containers/Projects/containers/ProjectExpenseF import EstimatedExpenseFormDialog from '@/containers/Projects/containers/EstimatedExpenseFormDialog'; import ProjectInvoicingFormDialog from '@/containers/Projects/containers/ProjectInvoicingFormDialog'; import ProjectBillableEntriesFormDialog from '@/containers/Projects/containers/ProjectBillableEntriesFormDialog'; +import TaxRateFormDialog from '@/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog'; import { DialogsName } from '@/constants/dialogs'; /** @@ -134,7 +135,10 @@ export default function DialogsContainer() { - + + ); } diff --git a/packages/webapp/src/components/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index 2d1a372e8..ef96dc608 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -22,6 +22,7 @@ import VendorCreditDetailDrawer from '@/containers/Drawers/VendorCreditDetailDra import RefundCreditNoteDetailDrawer from '@/containers/Drawers/RefundCreditNoteDetailDrawer'; import RefundVendorCreditDetailDrawer from '@/containers/Drawers/RefundVendorCreditDetailDrawer'; import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransferDetailDrawer'; +import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer'; import { DRAWERS } from '@/constants/drawers'; @@ -43,16 +44,25 @@ export default function DrawersContainer() { - - + + - - + + + ); } diff --git a/packages/webapp/src/components/Forms/BlueprintFormik.tsx b/packages/webapp/src/components/Forms/BlueprintFormik.tsx index 09b14967d..93313abcf 100644 --- a/packages/webapp/src/components/Forms/BlueprintFormik.tsx +++ b/packages/webapp/src/components/Forms/BlueprintFormik.tsx @@ -10,7 +10,7 @@ import { EditableText, TextArea, } from '@blueprintjs-formik/core'; -import { MultiSelect } from '@blueprintjs-formik/select'; +import { MultiSelect, SuggestField } from '@blueprintjs-formik/select'; import { DateInput } from '@blueprintjs-formik/datetime'; import { FSelect } from './Select'; @@ -24,6 +24,7 @@ export { FSelect, MultiSelect as FMultiSelect, EditableText as FEditableText, + SuggestField as FSuggest, TextArea as FTextArea, DateInput as FDateInput, }; diff --git a/packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx b/packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx new file mode 100644 index 000000000..c3626b75f --- /dev/null +++ b/packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React, { useCallback } from 'react'; +import { Suggest } from '@blueprintjs-formik/select'; +import { FormGroup } from '@blueprintjs/core'; +import { CellType } from '@/constants'; + +export function TaxRatesSuggestInputCell({ + column: { id, suggestProps, formGroupProps }, + row: { index }, + cell: { value: cellValue }, + payload: { errors, updateData, taxRates }, +}) { + const error = errors?.[index]?.[id]; + + // Handle the item selected. + const handleItemSelected = useCallback( + (value, taxRate) => { + updateData(index, id, taxRate.id); + }, + [updateData, index, id], + ); + + return ( + + + selectedValue={cellValue} + items={taxRates} + valueAccessor={'id'} + labelAccessor={'code'} + textAccessor={'name_formatted'} + popoverProps={{ minimal: true, boundary: 'window' }} + inputProps={{ placeholder: '' }} + fill={true} + onItemChange={handleItemSelected} + {...suggestProps} + /> + + ); +} + +TaxRatesSuggestInputCell.cellType = CellType.Field; diff --git a/packages/webapp/src/constants/abilityOption.tsx b/packages/webapp/src/constants/abilityOption.tsx index fa460ff6b..fe0e788d4 100644 --- a/packages/webapp/src/constants/abilityOption.tsx +++ b/packages/webapp/src/constants/abilityOption.tsx @@ -20,7 +20,8 @@ export const AbilitySubject = { SubscriptionBilling: 'SubscriptionBilling', CreditNote: 'CreditNote', VendorCredit: 'VendorCredit', - Project:'Project' + Project:'Project', + TaxRate: 'TaxRate', }; export const ItemAction = { @@ -169,6 +170,7 @@ export const ReportsAction = { READ_INVENTORY_VALUATION_SUMMARY: 'read-inventory-valuation-summary', READ_INVENTORY_ITEM_DETAILS: 'read-inventory-item-details', READ_CASHFLOW_ACCOUNT_TRANSACTION: 'read-cashflow-account-transactions', + READ_SALES_TAX_LIABILITY_SUMMARY: 'read-sales-tax-liability-summary', }; export const PreferencesAbility = { @@ -185,3 +187,11 @@ export const SubscriptionBillingAbility = { View: 'view', Payment: 'payment', }; + + +export const TaxRateAction = { + View: 'View', + Create: 'Create', + Edit: 'Edit', + Delete: 'Delete', +}; diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index f8bf10668..115c25af2 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -46,5 +46,6 @@ export enum DialogsName { EstimateExpenseForm = 'estimate-expense-form', ProjectInvoicingForm = 'project-invoicing-form', ProjectBillableEntriesForm = 'project-billable-entries', - InvoiceNumberSettings = 'InvoiceNumberSettings' + InvoiceNumberSettings = 'InvoiceNumberSettings', + TaxRateForm = 'tax-rate-form', } diff --git a/packages/webapp/src/constants/drawers.ts b/packages/webapp/src/constants/drawers.ts index 6663990be..59237e4b4 100644 --- a/packages/webapp/src/constants/drawers.ts +++ b/packages/webapp/src/constants/drawers.ts @@ -22,4 +22,5 @@ export enum DRAWERS { REFUND_CREDIT_NOTE_DETAILS = 'refund-credit-detail-drawer', REFUND_VENDOR_CREDIT_DETAILS = 'refund-vendor-detail-drawer', WAREHOUSE_TRANSFER_DETAILS = 'warehouse-transfer-detail-drawer', + TAX_RATE_DETAILS = 'tax-rate-detail-drawer', } diff --git a/packages/webapp/src/constants/financialReportsMenu.tsx b/packages/webapp/src/constants/financialReportsMenu.tsx index e7b300bd2..c659462bd 100644 --- a/packages/webapp/src/constants/financialReportsMenu.tsx +++ b/packages/webapp/src/constants/financialReportsMenu.tsx @@ -87,9 +87,6 @@ export const financialReportMenus = [ }, ], }, -]; - -export const SalesAndPurchasesReportMenus = [ { sectionTitle: , reports: [ @@ -119,19 +116,6 @@ export const SalesAndPurchasesReportMenus = [ subject: AbilitySubject.Report, ability: ReportsAction.READ_SALES_BY_ITEMS, }, - { - title: , - desc: ( - - ), - link: '/financial-reports/inventory-valuation', - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY, - }, { title: , desc: ( @@ -189,4 +173,16 @@ export const SalesAndPurchasesReportMenus = [ }, ], }, + { + sectionTitle: 'Taxes', + reports: [ + { + title: 'Sales Tax Liability Summary', + desc: 'Reports the total amount of sales tax collected from customers', + link: '/financial-reports/sales-tax-liability-summary', + subject: AbilitySubject.Report, + ability: ReportsAction.READ_SALES_TAX_LIABILITY_SUMMARY, + }, + ], + }, ]; diff --git a/packages/webapp/src/constants/sidebarMenu.tsx b/packages/webapp/src/constants/sidebarMenu.tsx index 3f5c16cb1..9f67bd4c5 100644 --- a/packages/webapp/src/constants/sidebarMenu.tsx +++ b/packages/webapp/src/constants/sidebarMenu.tsx @@ -24,6 +24,7 @@ import { ExpenseAction, CashflowAction, PreferencesAbility, + TaxRateAction, } from '@/constants/abilityOption'; import { DialogsName } from './dialogs'; @@ -406,6 +407,15 @@ export const SidebarMenu = [ href: '/transactions-locking', type: ISidebarMenuItemType.Link, }, + { + text: 'Tax Rates', + href: '/tax-rates', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.TaxRate, + ability: TaxRateAction.View, + }, + }, ], }, { @@ -741,6 +751,21 @@ export const SidebarMenu = [ }, ], }, + { + text: 'Taxes', + type: ISidebarMenuItemType.Group, + children: [ + { + text: 'Sales Tax Liability Summary', + href: '/financial-reports/sales-tax-liability-summary', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_SALES_TAX_LIABILITY_SUMMARY, + }, + }, + ], + }, { text: , type: ISidebarMenuItemType.Group, diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index 89d12ac3d..417583f60 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -25,6 +25,7 @@ import WarehousesAlerts from '@/containers/Preferences/Warehouses/WarehousesAler import WarehousesTransfersAlerts from '@/containers/WarehouseTransfers/WarehousesTransfersAlerts'; import BranchesAlerts from '@/containers/Preferences/Branches/BranchesAlerts'; import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts'; +import TaxRatesAlerts from '@/containers/TaxRates/alerts'; export default [ ...AccountsAlerts, @@ -53,4 +54,5 @@ export default [ ...WarehousesTransfersAlerts, ...BranchesAlerts, ...ProjectAlerts, + ...TaxRatesAlerts ]; diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx index 1bc44fbce..a79b6b22c 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailHeader.tsx @@ -25,14 +25,12 @@ import { InvoiceDetailsStatus } from './utils'; export default function InvoiceDetailHeader() { const { invoice } = useInvoiceDetailDrawerContext(); - const handleCustomerLinkClick = () => {}; - return ( -

{invoice.formatted_amount}

+

{invoice.total_formatted}

@@ -75,11 +73,11 @@ export default function InvoiceDetailHeader() { textAlign={'right'} > - {invoice.formatted_due_amount} + {invoice.due_amount_formatted} - {invoice.formatted_payment_amount} + {invoice.payment_amount_formatted} } - value={} + value={} borderStyle={TotalLineBorderStyle.SingleDark} /> + {invoice.taxes.map((taxRate) => ( + + ))} } - value={invoice.formatted_amount} + value={invoice.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> } - value={invoice.formatted_payment_amount} + value={invoice.payment_amount_formatted} /> } - value={invoice.formatted_due_amount} + value={invoice.due_amount_formatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx b/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx new file mode 100644 index 000000000..9d9c88edd --- /dev/null +++ b/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx @@ -0,0 +1,20 @@ +// @ts-nocheck +import React, { createContext } from 'react'; + +const ItemEntriesTableContext = createContext(); + +function ItemEntriesTableProvider({ children, value }) { + const provider = { + ...value, + }; + return ( + + {children} + + ); +} + +const useItemEntriesTableContext = () => + React.useContext(ItemEntriesTableContext); + +export { ItemEntriesTableProvider, useItemEntriesTableContext }; diff --git a/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx b/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx index 6b51f8566..4a881cfec 100644 --- a/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx +++ b/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx @@ -1,103 +1,104 @@ // @ts-nocheck -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import classNames from 'classnames'; import { CLASSES } from '@/constants/classes'; import { DataTableEditable } from '@/components'; import { useEditableItemsEntriesColumns } from './components'; -import { - saveInvoke, - compose, - updateMinEntriesLines, - updateRemoveLineByIndex, -} from '@/utils'; import { useFetchItemRow, composeRowsOnNewRow, - composeRowsOnEditCell, + useComposeRowsOnEditTableCell, + useComposeRowsOnRemoveTableRow, } from './utils'; +import { + ItemEntriesTableProvider, + useItemEntriesTableContext, +} from './ItemEntriesTableProvider'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; /** * Items entries table. */ -function ItemsEntriesTable({ - // #ownProps - items, - entries, - initialEntries, - defaultEntry, - errors, - onUpdateData, - currencyCode, - itemType, // sellable or purchasable - landedCost = false, - minLinesNumber -}) { - const [rows, setRows] = React.useState(initialEntries); +function ItemsEntriesTable(props) { + const { value, initialValue, onChange } = props; - // Allows to observes `entries` to make table rows outside controlled. - useEffect(() => { - if (entries && entries !== rows) { - setRows(entries); - } - }, [entries, rows]); + const [localValue, handleChange] = useUncontrolled({ + value, + initialValue, + finalValue: [], + onChange, + }); + return ( + + + + ); +} + +/** + * Items entries table logic. + * @returns {JSX.Element} + */ +function ItemEntriesTableRoot() { + const { + localValue, + defaultEntry, + handleChange, + items, + errors, + currencyCode, + landedCost, + taxRates, + } = useItemEntriesTableContext(); // Editiable items entries columns. - const columns = useEditableItemsEntriesColumns({ landedCost }); + const columns = useEditableItemsEntriesColumns(); + + const composeRowsOnEditCell = useComposeRowsOnEditTableCell(); + const composeRowsOnDeleteRow = useComposeRowsOnRemoveTableRow(); // Handle the fetch item row details. const { setItemRow, cellsLoading, isItemFetching } = useFetchItemRow({ landedCost, - itemType, + itemType: null, notifyNewRow: (newRow, rowIndex) => { // Update the rate, description and quantity data of the row. - const newRows = composeRowsOnNewRow(rowIndex, newRow, rows); - - setRows(newRows); - onUpdateData(newRows); + const newRows = composeRowsOnNewRow(rowIndex, newRow, localValue); + handleChange(newRows); }, }); - // Handles the editor data update. const handleUpdateData = useCallback( (rowIndex, columnId, value) => { if (columnId === 'item_id') { setItemRow({ rowIndex, columnId, itemId: value }); } - const composeEditCell = composeRowsOnEditCell(rowIndex, columnId); - const newRows = composeEditCell(value, defaultEntry, rows); - - setRows(newRows); - onUpdateData(newRows); + const newRows = composeRowsOnEditCell(rowIndex, columnId, value); + handleChange(newRows); }, - [rows, defaultEntry, onUpdateData, setItemRow], + [localValue, defaultEntry, handleChange], ); // Handle table rows removing by index. const handleRemoveRow = (rowIndex) => { - const newRows = compose( - // Ensure minimum lines count. - updateMinEntriesLines(minLinesNumber, defaultEntry), - // Remove the line by the given index. - updateRemoveLineByIndex(rowIndex), - )(rows); - - setRows(newRows); - saveInvoke(onUpdateData, newRows); + const newRows = composeRowsOnDeleteRow(rowIndex); + handleChange(newRows); }; return ( { removeRow(index); }; - const exampleMenu = ( { /** * Retrieve editable items entries columns. */ -export function useEditableItemsEntriesColumns({ landedCost }) { +export function useEditableItemsEntriesColumns() { const { featureCan } = useFeatureCan(); + const { landedCost } = useItemEntriesTableContext(); + const isProjectsFeatureEnabled = featureCan(Features.Projects); return React.useMemo( () => [ { - Header: ItemHeaderCell, id: 'item_id', + Header: ItemHeaderCell, accessor: 'item_id', Cell: ItemsListCell, disableSortBy: true, @@ -129,6 +132,13 @@ export function useEditableItemsEntriesColumns({ landedCost }) { width: 70, align: Align.Right, }, + { + Header: 'Tax rate', + accessor: 'tax_rate_id', + Cell: TaxRatesSuggestInputCell, + disableSortBy: true, + width: 110, + }, { Header: intl.get('discount'), accessor: 'discount', diff --git a/packages/webapp/src/containers/Entries/utils.tsx b/packages/webapp/src/containers/Entries/utils.tsx index 6c8f8b01e..51ff04412 100644 --- a/packages/webapp/src/containers/Entries/utils.tsx +++ b/packages/webapp/src/containers/Entries/utils.tsx @@ -1,7 +1,7 @@ // @ts-nocheck -import React from 'react'; +import React, { useCallback } from 'react'; import * as R from 'ramda'; -import { sumBy, isEmpty, last } from 'lodash'; +import { sumBy, isEmpty, last, keyBy } from 'lodash'; import { useItem } from '@/hooks/query'; import { @@ -13,6 +13,12 @@ import { orderingLinesIndexes, updateTableRow, } from '@/utils'; +import { useItemEntriesTableContext } from './ItemEntriesTableProvider'; + +export const ITEM_TYPE = { + SELLABLE: 'SELLABLE', + PURCHASABLE: 'PURCHASABLE', +}; /** * Retrieve item entry total from the given rate, quantity and discount. @@ -39,11 +45,6 @@ export function updateItemsEntriesTotal(rows) { })); } -export const ITEM_TYPE = { - SELLABLE: 'SELLABLE', - PURCHASABLE: 'PURCHASABLE', -}; - /** * Retrieve total of the given items entries. */ @@ -150,12 +151,7 @@ export function useFetchItemRow({ landedCost, itemType, notifyNewRow }) { */ export const composeRowsOnEditCell = R.curry( (rowIndex, columnId, value, defaultEntry, rows) => { - return compose( - orderingLinesIndexes, - updateAutoAddNewLine(defaultEntry, ['item_id']), - updateItemsEntriesTotal, - updateTableCell(rowIndex, columnId, value), - )(rows); + return compose()(rows); }, ); @@ -171,10 +167,102 @@ export const composeRowsOnNewRow = R.curry((rowIndex, newRow, rows) => { }); /** - * - * @param {*} entries + * Associate tax rate to entries. + */ +export const assignEntriesTaxRate = R.curry((taxRates, entries) => { + const taxRatesById = keyBy(taxRates, 'id'); + + return entries.map((entry) => { + const taxRate = taxRatesById[entry.tax_rate_id]; + + return { + ...entry, + tax_rate: taxRate?.rate || 0, + }; + }); +}); + +/** + * Assign tax amount to entries. + * @param {boolean} isInclusiveTax + * @param entries * @returns */ -export const composeControlledEntries = (entries) => { - return R.compose(orderingLinesIndexes, updateItemsEntriesTotal)(entries); +export const assignEntriesTaxAmount = R.curry( + (isInclusiveTax: boolean, entries) => { + return entries.map((entry) => { + const taxAmount = isInclusiveTax + ? getInclusiveTaxAmount(entry.amount, entry.tax_rate) + : getExlusiveTaxAmount(entry.amount, entry.tax_rate); + + return { + ...entry, + tax_amount: taxAmount, + }; + }); + }, +); + +/** + * Get inclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getInclusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / (100 + taxRate); +}; + +/** + * Get exclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getExlusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / 100; +}; + +/** + * Compose rows when edit a table cell. + * @returns {Function} + */ +export const useComposeRowsOnEditTableCell = () => { + const { taxRates, isInclusiveTax, localValue, defaultEntry } = + useItemEntriesTableContext(); + + return useCallback( + (rowIndex, columnId, value) => { + return R.compose( + assignEntriesTaxAmount(isInclusiveTax), + assignEntriesTaxRate(taxRates), + orderingLinesIndexes, + updateAutoAddNewLine(defaultEntry, ['item_id']), + updateItemsEntriesTotal, + updateTableCell(rowIndex, columnId, value), + )(localValue); + }, + [taxRates, isInclusiveTax, localValue, defaultEntry], + ); +}; + +/** + * Compose rows when remove a table row. + * @returns {Function} + */ +export const useComposeRowsOnRemoveTableRow = () => { + const { minLinesNumber, defaultEntry, localValue } = + useItemEntriesTableContext(); + + return useCallback( + (rowIndex) => { + return compose( + // Ensure minimum lines count. + updateMinEntriesLines(minLinesNumber, defaultEntry), + // Remove the line by the given index. + updateRemoveLineByIndex(rowIndex), + )(localValue); + }, + [minLinesNumber, defaultEntry, localValue], + ); }; diff --git a/packages/webapp/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderDimensionsProvider.tsx b/packages/webapp/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderDimensionsProvider.tsx index 56872bcf9..eddfa70e4 100644 --- a/packages/webapp/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderDimensionsProvider.tsx +++ b/packages/webapp/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderDimensionsProvider.tsx @@ -9,7 +9,7 @@ const ARAgingSummaryHeaderDimensonsContext = React.createContext(); /** * ARAging summary header dismensions provider. - * @returns + * @returns {JSX.Element} */ function ARAgingSummaryHeaderDimensionsProvider({ query, ...props }) { // Features guard. diff --git a/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx b/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx index 984c86868..bde19bad6 100644 --- a/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx +++ b/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx @@ -3,11 +3,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { For, DashboardInsider } from '@/components'; import useFilterFinancialReports from './FilterFinancialReports'; - -import { - financialReportMenus, - SalesAndPurchasesReportMenus, -} from '@/constants/financialReportsMenu'; +import { financialReportMenus } from '@/constants/financialReportsMenu'; import '@/style/pages/FinancialStatements/FinancialSheets.scss'; @@ -39,18 +35,11 @@ function FinancialReportsSection({ sectionTitle, reports }) { */ export default function FinancialReports() { const financialReportMenu = useFilterFinancialReports(financialReportMenus); - const SalesAndPurchasesReportMenu = useFilterFinancialReports( - SalesAndPurchasesReportMenus, - ); return (
-
); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx new file mode 100644 index 000000000..5a187c1da --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx @@ -0,0 +1,72 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import moment from 'moment'; + +import { SalesTaxLiabilitySummaryLoadingBar } from './components'; +import { FinancialStatement, DashboardPageContent } from '@/components'; + +import SalesTaxLiabilitySummaryHeader from './SalesTaxLiabilitySummaryHeader'; +import SalesTaxLiabilitySummaryActionsBar from './SalesTaxLiabilitySummaryActionsBar'; +import { SalesTaxLiabilitySummaryBoot } from './SalesTaxLiabilitySummaryBoot'; +import { SalesTaxLiabilitySummaryBody } from './SalesTaxLiabilitySummaryBody'; +import { useSalesTaxLiabilitySummaryQuery } from './utils'; +import withSalesTaxLiabilitySummaryActions from './withSalesTaxLiabilitySummaryActions'; +import { compose } from '@/utils'; + +/** + * Sales tax liability summary. + * @returns {React.JSX} + */ +function SalesTaxLiabilitySummary({ + // #withSalesTaxLiabilitySummaryActions + toggleSalesTaxLiabilitySummaryFilterDrawer, +}) { + const [query, setQuery] = useSalesTaxLiabilitySummaryQuery(); + + const handleFilterSubmit = (filter) => { + const newFilter = { + ...filter, + fromDate: moment(filter.fromDate).format('YYYY-MM-DD'), + toDate: moment(filter.toDate).format('YYYY-MM-DD'), + }; + setQuery({ ...newFilter }); + }; + // Handle number format submit. + const handleNumberFormatSubmit = (values) => { + setQuery({ + ...query, + numberFormat: values, + }); + }; + // Hides the filter drawer once the page unmount. + useEffect( + () => () => { + toggleSalesTaxLiabilitySummaryFilterDrawer(false); + }, + [toggleSalesTaxLiabilitySummaryFilterDrawer], + ); + + return ( + + + + + + + + + + + + ); +} + +export default compose(withSalesTaxLiabilitySummaryActions)( + SalesTaxLiabilitySummary, +); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx new file mode 100644 index 000000000..9c551ff24 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx @@ -0,0 +1,131 @@ +// @ts-nocheck +import React from 'react'; +import { + NavbarGroup, + Button, + Classes, + NavbarDivider, + Popover, + PopoverInteractionKind, + Position, +} from '@blueprintjs/core'; +import classNames from 'classnames'; +import { DashboardActionsBar, FormattedMessage as T, Icon } from '@/components'; + +import NumberFormatDropdown from '@/components/NumberFormatDropdown'; + +import { compose, saveInvoke } from '@/utils'; +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; +import withSalesTaxLiabilitySummary from './withSalesTaxLiabilitySummary'; +import withSalesTaxLiabilitySummaryActions from './withSalesTaxLiabilitySummaryActions'; + +/** + * Sales tax liability summary - actions bar. + */ +function SalesTaxLiabilitySummaryActionsBar({ + // #withSalesTaxLiabilitySummary + salesTaxLiabilitySummaryFilter, + + // #withSalesTaxLiabilitySummaryActions + toggleSalesTaxLiabilitySummaryFilterDrawer: toggleFilterDrawer, + + // #ownProps + numberFormat, + onNumberFormatSubmit, +}) { + const { isLoading, refetchSalesTaxLiabilitySummary } = + useSalesTaxLiabilitySummaryContext(); + + // Handle filter toggle click. + const handleFilterToggleClick = () => { + toggleFilterDrawer(); + }; + // Handle re-calculate the report button. + const handleRecalcReport = () => { + refetchSalesTaxLiabilitySummary(); + }; + // Handle number format form submit. + const handleNumberFormatSubmit = (values) => { + saveInvoke(onNumberFormatSubmit, values); + }; + + return ( + + + + + + + + + ); +} + +export default compose( + withSalesTaxLiabilitySummary(({ salesTaxLiabilitySummaryFilter }) => ({ + salesTaxLiabilitySummaryFilter, + })), + withSalesTaxLiabilitySummaryActions, +)(SalesTaxLiabilitySummaryHeader); + +const SalesTaxSummaryFinancialHeader = styled(FinancialStatementHeader)` + .bp3-drawer { + max-height: 320px; + } +`; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx new file mode 100644 index 000000000..071372eec --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx @@ -0,0 +1,13 @@ +// @ts-nocheck +import React from 'react'; +import FinancialStatementDateRange from '../FinancialStatementDateRange'; +import RadiosAccountingBasis from '../RadiosAccountingBasis'; + +export function SalesTaxLiabilitySummaryHeaderGeneral() { + return ( +
+ + +
+ ); +} diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx new file mode 100644 index 000000000..a78ee4a8c --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx @@ -0,0 +1,103 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { compose } from 'ramda'; + +import { TableStyle } from '@/constants'; +import { ReportDataTable, FinancialSheet } from '@/components'; +import { defaultExpanderReducer, tableRowTypesToClassnames } from '@/utils'; +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; +import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; +import { useSalesTaxLiabilitySummaryColumns } from './utils'; + +/** + * Balance sheet table. + */ +function SalesTaxLiabilitySummaryTableRoot({ + // #ownProps + organizationName, +}) { + // Balance sheet context. + const { + salesTaxLiabilitySummary: { table, query }, + } = useSalesTaxLiabilitySummaryContext(); + + // Retrieve the database columns. + const columns = useSalesTaxLiabilitySummaryColumns(); + + // Retrieve default expanded rows of balance sheet. + const expandedRows = React.useMemo( + () => defaultExpanderReducer(table.rows, 3), + [table], + ); + + return ( + + + + ); +} + +const SalesTaxLiabilitySummaryDataTable = styled(ReportDataTable)` + .table { + .tbody .tr { + .td { + border-bottom: 0; + padding-top: 0.32rem; + padding-bottom: 0.32rem; + } + &:not(.no-results) { + .td { + border-bottom: 0; + padding-top: 0.4rem; + padding-bottom: 0.4rem; + } + &:not(:first-child) .td { + border-top: 1px solid transparent; + } + &.row_type--Total { + font-weight: 500; + + .td { + border-top: 1px solid #bbb; + border-bottom: 3px double #333; + } + } + &.row_type--TaxRate { + .td { + &.td-taxPercentage, + &.td-taxableAmount, + &.td-collectedTax, + &.td-taxRate { + color: #444; + } + } + } + } + } + } +`; + +export const SalesTaxLiabilitySummaryTable = compose( + withCurrentOrganization(({ organization }) => ({ + organizationName: organization.name, + })), +)(SalesTaxLiabilitySummaryTableRoot); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx new file mode 100644 index 000000000..6e4221b60 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx @@ -0,0 +1,17 @@ +// @ts-nocheck +import React from 'react'; + +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; +import FinancialLoadingBar from '../FinancialLoadingBar'; + +/** + * Sales tax liability summary loading bar. + */ +export function SalesTaxLiabilitySummaryLoadingBar() { + const { isFetching } = useSalesTaxLiabilitySummaryContext(); + + if (!isFetching) { + return null; + } + return ; +} diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts new file mode 100644 index 000000000..1b79dd941 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts @@ -0,0 +1,48 @@ +// @ts-nocheck +import React from 'react'; +import * as R from 'ramda'; +import { getColumnWidth } from '@/utils'; +import { Align } from '@/constants'; + +const getTableCellValueAccessor = (index) => `cells[${index}].value`; + +const taxNameAccessor = R.curry((data, column) => ({ + key: column.key, + Header: column.label, + accessor: getTableCellValueAccessor(column.cell_index), + sticky: 'left', + width: 300, + textOverview: true, + disableSortBy: true, +})); + +const taxableAmountAccessor = R.curry((data, column) => { + const accessor = getTableCellValueAccessor(column.cell_index); + + return { + Header: column.label, + id: column.key, + accessor: getTableCellValueAccessor(column.cell_index), + className: column.key, + width: getColumnWidth(data, accessor, { minWidth: 120 }), + align: Align.Right, + disableSortBy: true, + }; +}); + +const dynamicColumnMapper = R.curry((data, column) => { + const taxNameAccessorColumn = taxNameAccessor(data); + const taxableAmountColumn = taxableAmountAccessor(data); + + return R.compose( + R.when(R.pathEq(['key'], 'taxName'), taxNameAccessorColumn), + R.when(R.pathEq(['key'], 'taxableAmount'), taxableAmountColumn), + R.when(R.pathEq(['key'], 'taxRate'), taxableAmountColumn), + R.when(R.pathEq(['key'], 'taxPercentage'), taxableAmountColumn), + R.when(R.pathEq(['key'], 'collectedTax'), taxableAmountColumn), + )(column); +}); + +export const salesTaxLiabilitySummaryDynamicColumns = (columns, data) => { + return R.map(dynamicColumnMapper(data), columns); +}; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts new file mode 100644 index 000000000..951cdc6c5 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts @@ -0,0 +1,89 @@ +// @ts-nocheck +import React from 'react'; +import moment from 'moment'; +import * as Yup from 'yup'; +import { castArray } from 'lodash'; +import intl from 'react-intl-universal'; +import { transformToForm } from '@/utils'; +import { useAppQueryString } from '@/hooks'; +import { salesTaxLiabilitySummaryDynamicColumns } from './dynamicColumns'; +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; + +/** + * Retrieves the default sales tax liability summary query. + * @returns {} + */ +export const getDefaultSalesTaxLiablitySummaryQuery = () => ({ + fromDate: moment().startOf('month').format('YYYY-MM-DD'), + toDate: moment().format('YYYY-MM-DD'), + basis: 'cash', +}); + +/** + * Parses the sales tax liability summary query. + */ +const parseSalesTaxLiabilitySummaryQuery = (locationQuery) => { + const defaultQuery = getDefaultSalesTaxLiablitySummaryQuery(); + + const transformed = { + ...defaultQuery, + ...transformToForm(locationQuery, defaultQuery), + }; + return { + ...transformed, + + // Ensures the branches ids is always array. + branchesIds: castArray(transformed.branchesIds), + }; +}; + +/** + * Retrieves the sales tax liability summary query. + */ +export const useSalesTaxLiabilitySummaryQuery = () => { + // Retrieves location query. + const [locationQuery, setLocationQuery] = useAppQueryString(); + + // Merges the default filter query with location URL query. + const parsedQuery = React.useMemo( + () => parseSalesTaxLiabilitySummaryQuery(locationQuery), + [locationQuery], + ); + return [parsedQuery, setLocationQuery]; +}; + +/** + * Retrieves the sales tax liability summary default query. + */ +export const getSalesTaxLiabilitySummaryDefaultQuery = () => { + return { + basic: 'cash', + fromDate: moment().toDate(), + toDate: moment().toDate(), + }; +}; + +/** + * Retrieves the sales tax liability summary query validation. + */ +export const getSalesTaxLiabilitySummaryQueryValidation = () => + Yup.object().shape({ + dateRange: Yup.string().optional(), + fromDate: Yup.date().required().label(intl.get('fromDate')), + toDate: Yup.date() + .min(Yup.ref('fromDate')) + .required() + .label(intl.get('toDate')), + }); + +/** + * Retrieves the sales tax liability summary columns. + * @returns {ITableColumn[]} + */ +export const useSalesTaxLiabilitySummaryColumns = () => { + const { + salesTaxLiabilitySummary: { table }, + } = useSalesTaxLiabilitySummaryContext(); + + return salesTaxLiabilitySummaryDynamicColumns(table.columns, table.rows); +}; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts new file mode 100644 index 000000000..32a5d1552 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts @@ -0,0 +1,15 @@ +// @ts-nocheck +import { connect } from 'react-redux'; +import { getSalesTaxLiabilitySummaryFilterDrawer } from '@/store/financialStatement/financialStatements.selectors'; + +export default (mapState) => { + const mapStateToProps = (state, props) => { + const mapped = { + salesTaxLiabilitySummaryFilter: + getSalesTaxLiabilitySummaryFilterDrawer(state), + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + + return connect(mapStateToProps); +}; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts new file mode 100644 index 000000000..5453b2aa4 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +import { connect } from 'react-redux'; +import { toggleSalesTaxLiabilitySummaryFilterDrawer } from '@/store/financialStatement/financialStatements.actions'; + +const mapDispatchToProps = (dispatch) => ({ + toggleSalesTaxLiabilitySummaryFilterDrawer: (toggle) => + dispatch(toggleSalesTaxLiabilitySummaryFilterDrawer(toggle)), +}); + +export default connect(null, mapDispatchToProps); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx index c247b7b5a..66d35fa30 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx @@ -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(), diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index 983d1d001..a8463619b 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -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({
- + +
+ + +
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx new file mode 100644 index 000000000..e185455f9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx @@ -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 ( + + + + ); +} + +/** + * 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 ( + + ''} + valueAccessor={'key'} + popoverProps={{ minimal: true, usePortal: true, inline: false }} + buttonProps={{ small: true }} + onItemSelect={handleItemSelect} + filterable={false} + {...props} + /> + + ); +} + +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; +`; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx index 4a9c6cfe4..a82db600e 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx @@ -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, currency_code }, + } = useFormikContext(); + + const taxEntries = useInvoiceAggregatedTaxRates(); + return ( } + title={ + <> + {inclusive_exclusive_tax === '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} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx index 82f57a266..66d1ed48c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx @@ -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 ( - - {({ - form: { values, setFieldValue }, - field: { value }, - meta: { error, touched }, - }) => ( - { - setFieldValue('entries', entries); - }} - items={items} - errors={error} - linesNumber={4} - currencyCode={values.currency_code} - /> - )} - - + + {({ + form: { values, setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + { + setFieldValue('entries', entries); + }} + items={items} + taxRates={taxRates} + errors={error} + linesNumber={4} + currencyCode={values.currency_code} + isInclusiveTax={values.inclusive_exclusive_tax === TaxType.Inclusive} + /> + )} + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts new file mode 100644 index 000000000..4087e318b --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts @@ -0,0 +1,6 @@ +import { TaxType } from '@/interfaces/TaxRates'; + +export const InclusiveButtonOptions = [ + { key: TaxType.Inclusive, label: 'Inclusive of Tax' }, + { key: TaxType.Exclusive, label: 'Exclusive of Tax' }, +]; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index 4af9dabb9..d227dba44 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -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: TaxType.Inclusive, // Holds the invoice number that entered manually only. invoice_no_manually: '', reference_no: '', @@ -76,6 +80,9 @@ export function transformToEditForm(invoice) { return { ...transformToForm(invoice, defaultInvoice), + inclusive_exclusive_tax: invoice.is_inclusive_tax + ? TaxType.Inclusive + : TaxType.Exclusive, entries, }; } @@ -114,7 +121,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 +132,7 @@ export const customerNameFieldShouldUpdate = (newProps, oldProps) => { export const entriesFieldShouldUpdate = (newProps, oldProps) => { return ( newProps.items !== oldProps.items || + newProps.taxRates !== oldProps.taxRates || defaultFastFieldShouldUpdate(newProps, oldProps) ); }; @@ -154,13 +162,20 @@ 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, }), - entries: entries.map((entry) => ({ ...omit(entry, ['amount']) })), + is_inclusive_tax: values.inclusive_exclusive_tax === TaxType.Inclusive, + entries: entries.map((entry) => ({ + ...omit(entry, ['amount', 'tax_amount', 'tax_rate']), + })), delivered: false, }; } @@ -196,7 +211,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 +235,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 +292,9 @@ export const useInvoiceIsForeignCustomer = () => { return isForeignCustomer; }; +/** + * Resets the form state to initial values + */ export const resetFormState = ({ initialValues, values, resetForm }) => { resetForm({ values: { @@ -281,3 +305,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; +}; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx index efbb62239..c5ebc3aee 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx @@ -225,8 +225,8 @@ export function useInvoicesTableColumns() { }, { id: 'amount', - Header: intl.get('balance'), - accessor: 'formatted_amount', + Header: intl.get('amount'), + accessor: 'total_formatted', width: 120, align: 'right', clickable: true, diff --git a/packages/webapp/src/containers/TaxRates/alerts/TaxRateDeleteAlert.tsx b/packages/webapp/src/containers/TaxRates/alerts/TaxRateDeleteAlert.tsx new file mode 100644 index 000000000..32e6af28b --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/alerts/TaxRateDeleteAlert.tsx @@ -0,0 +1,92 @@ +// @ts-nocheck +import React from 'react'; +import { Intent, Alert } from '@blueprintjs/core'; +import { AppToaster, FormattedMessage as T } from '@/components'; + +import { useDeleteTaxRate } from '@/hooks/query/taxRates'; + +import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect'; +import withAlertActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { compose } from '@/utils'; +import { DRAWERS } from '@/constants/drawers'; + +/** + * Item delete alerts. + */ +function TaxRateDeleteAlert({ + name, + + // #withAlertStoreConnect + isOpen, + payload: { taxRateId }, + + // #withAlertActions + closeAlert, + + // #withDrawerActions + closeDrawer, +}) { + const { mutateAsync: deleteTaxRate, isLoading } = useDeleteTaxRate(); + + // Handle cancel delete item alert. + const handleCancelItemDelete = () => { + closeAlert(name); + }; + // Handle confirm delete item. + const handleConfirmDeleteItem = () => { + deleteTaxRate(taxRateId) + .then(() => { + AppToaster.show({ + message: 'The tax rate has been deleted successfully.', + intent: Intent.SUCCESS, + }); + closeDrawer(DRAWERS.TAX_RATE_DETAILS); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }, + ) + .finally(() => { + closeAlert(name); + }); + }; + + return ( + } + confirmButtonText={} + icon="trash" + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancelItemDelete} + onConfirm={handleConfirmDeleteItem} + loading={isLoading} + > +

+ Once you delete this tax rate, you won't be able to restore the item + later. +

+ +

+ Are you sure you want to delete ? If you're not sure, you can inactivate + it instead. +

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, + withDrawerActions, +)(TaxRateDeleteAlert); diff --git a/packages/webapp/src/containers/TaxRates/alerts/index.ts b/packages/webapp/src/containers/TaxRates/alerts/index.ts new file mode 100644 index 000000000..7c40cbfb0 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/alerts/index.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import React from 'react'; + +const TaxRateDeleteAlert = React.lazy(() => import('./TaxRateDeleteAlert')); + +/** + * Tax rates alerts. + */ +export default [{ name: 'tax-rate-delete', component: TaxRateDeleteAlert }]; diff --git a/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx new file mode 100644 index 000000000..9631ae97c --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck +import React from 'react'; +import { NavbarGroup, NavbarDivider, Button, Classes } from '@blueprintjs/core'; +import { + DashboardActionsBar, + FormattedMessage as T, + Can, + Icon, +} from '@/components'; +import { AbilitySubject, TaxRateAction } from '@/constants/abilityOption'; + +import withDialogActions from '@/containers/Dialog/withDialogActions'; + +import { DialogsName } from '@/constants/dialogs'; +import { compose } from '@/utils'; + +/** + * Tax rates actions bar. + */ +function TaxRatesActionsBar({ + // #withDialogActions + openDialog, +}) { + // Handle `new item` button click. + const onClickNewItem = () => { + openDialog(DialogsName.TaxRateForm); + }; + + return ( + + + + + + + + } + /> + ); +} + +export const TaxRatesLandingEmptyState = R.compose(withDialogActions)( + TaxRatesLandingEmptyStateRoot, +); diff --git a/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingProvider.tsx b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingProvider.tsx new file mode 100644 index 000000000..c85170c07 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingProvider.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React from 'react'; +import { isEmpty } from 'lodash'; +import { DashboardInsider } from '@/components/Dashboard'; +import { useTaxRates } from '@/hooks/query/taxRates'; + +const TaxRatesLandingContext = React.createContext(); + +/** + * Cash Flow data provider. + */ +function TaxRatesLandingProvider({ tableState, ...props }) { + // Fetch cash flow list . + const { + data: taxRates, + isFetching: isTaxRatesFetching, + isLoading: isTaxRatesLoading, + } = useTaxRates({}, { keepPreviousData: true }); + + // Detarmines whether the table should show empty state. + const isEmptyStatus = isEmpty(taxRates) && !isTaxRatesLoading; + + // Provider payload. + const provider = { + taxRates, + isTaxRatesFetching, + isTaxRatesLoading, + isEmptyStatus + }; + + return ( + + + + ); +} + +const useTaxRatesLandingContext = () => + React.useContext(TaxRatesLandingContext); + +export { TaxRatesLandingProvider, useTaxRatesLandingContext }; diff --git a/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingTable.tsx b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingTable.tsx new file mode 100644 index 000000000..6af00de39 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingTable.tsx @@ -0,0 +1,148 @@ +// @ts-nocheck +import React from 'react'; +import { Intent } from '@blueprintjs/core'; +import { + DataTable, + DashboardContentTable, + TableSkeletonHeader, + TableSkeletonRows, + AppToaster, +} from '@/components'; + +import withAlertsActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; +import withSettings from '@/containers/Settings/withSettings'; + +import { useTaxRatesTableColumns } from './_utils'; +import { useTaxRatesLandingContext } from './TaxRatesLandingProvider'; +import { TaxRatesLandingEmptyState } from './TaxRatesLandingEmptyState'; +import { TaxRatesTableActionsMenu } from './_components'; + +import { compose } from '@/utils'; +import { DRAWERS } from '@/constants/drawers'; +import { DialogsName } from '@/constants/dialogs'; +import { + useActivateTaxRate, + useInactivateTaxRate, +} from '@/hooks/query/taxRates'; + +/** + * Invoices datatable. + */ +function TaxRatesDataTable({ + // #withAlertsActions + openAlert, + + // #withDrawerActions + openDrawer, + + // #withDialogAction + openDialog, +}) { + // Invoices list context. + const { taxRates, isTaxRatesLoading, isEmptyStatus } = + useTaxRatesLandingContext(); + + // Invoices table columns. + const columns = useTaxRatesTableColumns(); + + const { mutateAsync: activateTaxRateMutate } = useActivateTaxRate(); + const { mutateAsync: inactivateTaxRateMutate } = useInactivateTaxRate(); + + // Handle delete tax rate. + const handleDeleteTaxRate = ({ id }) => { + openAlert('tax-rate-delete', { taxRateId: id }); + }; + // Handle edit tax rate. + const handleEditTaxRate = (taxRate) => { + openDialog(DialogsName.TaxRateForm, { id: taxRate.id }); + }; + // Handle view details tax rate. + const handleViewDetails = (taxRate) => { + openDrawer(DRAWERS.TAX_RATE_DETAILS, { taxRateId: taxRate.id }); + }; + // Handle table cell click. + const handleCellClick = (cell, event) => { + openDrawer(DRAWERS.TAX_RATE_DETAILS, { taxRateId: cell.row.original.id }); + }; + // Handles activating the given tax rate. + const handleActivateTaxRate = (taxRate) => { + activateTaxRateMutate(taxRate.id) + .then(() => { + AppToaster.show({ + message: 'The tax rate has been activated successfully.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + // Handles inactivating the given tax rate. + const handleInactivateTaxRate = (taxRate) => { + inactivateTaxRateMutate(taxRate.id) + .then(() => { + AppToaster.show({ + message: 'The tax rate has been inactivated successfully.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + // Display invoice empty status instead of the table. + if (isEmptyStatus) { + return ; + } + + return ( + + + + ); +} + +export default compose( + withDashboardActions, + withAlertsActions, + withDrawerActions, + withDialogActions, + withSettings(({ invoiceSettings }) => ({ + invoicesTableSize: invoiceSettings?.tableSize, + })), +)(TaxRatesDataTable); diff --git a/packages/webapp/src/containers/TaxRates/containers/_components.tsx b/packages/webapp/src/containers/TaxRates/containers/_components.tsx new file mode 100644 index 000000000..cd1ea677d --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/_components.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck +import React from 'react'; +import { Can, Icon } from '@/components'; +import { AbilitySubject, TaxRateAction } from '@/constants/abilityOption'; +import { safeCallback } from '@/utils'; +import { Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core'; + +/** + * Tax rates table actions menu. + * @returns {JSX.Element} + */ +export function TaxRatesTableActionsMenu({ + payload: { onEdit, onDelete, onViewDetails, onActivate, onInactivate }, + row: { original }, +}) { + return ( + + } + text={'View Details'} + onClick={safeCallback(onViewDetails, original)} + /> + + + } + text={'Edit Tax Rate'} + onClick={safeCallback(onEdit, original)} + /> + + + {!original.active && ( + } + text={'Activate Tax Rate'} + onClick={safeCallback(onActivate, original)} + /> + )} + {!!original.active && ( + } + text={'Inactivate Tax Rate'} + onClick={safeCallback(onInactivate, original)} + /> + )} + + + } + /> + + + ); +} diff --git a/packages/webapp/src/containers/TaxRates/containers/_utils.tsx b/packages/webapp/src/containers/TaxRates/containers/_utils.tsx new file mode 100644 index 000000000..29fe64649 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/_utils.tsx @@ -0,0 +1,82 @@ +// @ts-nocheck +import React from 'react'; +import { Intent, Tag } from '@blueprintjs/core'; +import { Align } from '@/constants'; +import styled from 'styled-components'; + +const codeAccessor = (taxRate) => { + return ( + + {taxRate.code} + + ); +}; + +const statusAccessor = (taxRate) => { + return taxRate.active ? ( + + Active + + ) : ( + + Inactive + + ); +}; + +const nameAccessor = (taxRate) => { + return ( + <> + {taxRate.name} + {!!taxRate.is_compound && (Compound tax)} + + ); +}; + +const DescriptionAccessor = (taxRate) => { + return {taxRate.description}; +}; + +/** + * Retrieves the tax rates table columns. + */ +export const useTaxRatesTableColumns = () => { + return [ + { + Header: 'Name', + accessor: nameAccessor, + width: 60, + }, + { + Header: 'Code', + accessor: codeAccessor, + width: 40, + }, + { + Header: 'Rate', + accessor: 'rate_formatted', + align: Align.Right, + width: 30, + }, + { + Header: 'Description', + accessor: DescriptionAccessor, + width: 100, + }, + { + Header: 'Status', + accessor: statusAccessor, + width: 30, + align: Align.Right, + }, + ]; +}; + +const CompoundText = styled('span')` + color: #738091; + margin-left: 5px; +`; + +const DescriptionText = styled('span')` + color: #5f6b7c; +`; diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateForm.schema.ts b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateForm.schema.ts new file mode 100644 index 000000000..5d182e7c8 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateForm.schema.ts @@ -0,0 +1,21 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const getSchema = () => + Yup.object().shape({ + name: Yup.string().required().label('Name'), + code: Yup.string().required().label('Code'), + active: Yup.boolean().optional().label('Active'), + describtion: Yup.string().optional().label('Description'), + rate: Yup.number() + .min(0, 'Enter a rate percentage of at least 0%') + .max(100, 'Enter a rate percentage of at most 100%') + .required() + .label('Rate'), + is_compound: Yup.boolean().optional().label('Is Compound'), + is_non_recoverable: Yup.boolean().optional().label('Is Non Recoverable'), + confirm_edit: Yup.boolean().optional(), + }); + +export const CreateTaxRateFormSchema = getSchema; +export const EditTaxRateFormSchema = getSchema; diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog.tsx new file mode 100644 index 000000000..263450edb --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog.tsx @@ -0,0 +1,42 @@ +// @ts-nocheck +import React, { lazy } from 'react'; +import styled from 'styled-components'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const TaxRateFormDialogContent = lazy( + () => import('./TaxRateFormDialogContent'), +); + +/** + * Tax rate form dialog. + */ +function TaxRateFormDialog({ + dialogName, + payload = { action: '', id: null }, + isOpen, +}) { + return ( + + + + + + ); +} + +const TaxRateDialog = styled(Dialog)` + max-width: 450px; +`; + +export default compose(withDialogRedux())(TaxRateFormDialog); diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogBoot.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogBoot.tsx new file mode 100644 index 000000000..6c3981016 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogBoot.tsx @@ -0,0 +1,60 @@ +// @ts-nocheck +import React from 'react'; +import { DialogContent } from '@/components'; +import { useTaxRate, useTaxRates } from '@/hooks/query/taxRates'; +import { DialogsName } from '@/constants/dialogs'; + +const TaxRateFormDialogContext = React.createContext(); + +interface TaxRateFormDialogBootProps { + taxRateId: number; + children?: JSX.Element; +} + +interface TaxRateFormDialogBootContext { + taxRateId: number; + taxRate: any; + isTaxRateLoading: boolean; + isTaxRateSuccess: boolean; + isNewMode: boolean; +} + +/** + * Money in dialog provider. + */ +function TaxRateFormDialogBoot({ + taxRateId, + ...props +}: TaxRateFormDialogBootProps) { + const { + data: taxRate, + isLoading: isTaxRateLoading, + isSuccess: isTaxRateSuccess, + } = useTaxRate(taxRateId, { + enabled: !!taxRateId, + }); + + const isNewMode = !taxRateId; + + // Provider data. + const provider = { + taxRateId, + taxRate, + isTaxRateLoading, + isTaxRateSuccess, + isNewMode, + dialogName: DialogsName.TaxRateForm, + }; + const isLoading = isTaxRateLoading; + + return ( + + + + ); +} + +const useTaxRateFormDialogContext = () => + React.useContext(TaxRateFormDialogContext); + +export { TaxRateFormDialogBoot, useTaxRateFormDialogContext }; diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogContent.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogContent.tsx new file mode 100644 index 000000000..b0c10b88f --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogContent.tsx @@ -0,0 +1,23 @@ +// @ts-nocheck +import React from 'react'; +import TaxRateFormDialogForm from './TaxRateFormDialogForm'; +import { TaxRateFormDialogBoot } from './TaxRateFormDialogBoot'; + +interface TaxRateFormDialogContentProps { + dialogName: string; + taxRateId: number; +} + +/** + * Tax rate form dialog content. + */ +export default function TaxRateFormDialogContent({ + dialogName, + taxRateId, +}: TaxRateFormDialogContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogForm.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogForm.tsx new file mode 100644 index 000000000..15a3a5ec0 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogForm.tsx @@ -0,0 +1,127 @@ +// @ts-nocheck +import React from 'react'; +import { Classes, Intent } from '@blueprintjs/core'; +import { Form, Formik } from 'formik'; +import { AppToaster } from '@/components'; + +import TaxRateFormDialogFormContent from './TaxRateFormDialogFormContent'; + +import { + CreateTaxRateFormSchema, + EditTaxRateFormSchema, +} from './TaxRateForm.schema'; +import { + isTaxRateChange, + transformApiErrors, + transformFormToReq, + transformTaxRateToForm, +} from './utils'; +import { useCreateTaxRate, useEditTaxRate } from '@/hooks/query/taxRates'; +import { useTaxRateFormDialogContext } from './TaxRateFormDialogBoot'; +import { TaxRateFormDialogFormFooter } from './TaxRateFormDialogFormFooter'; +import { TaxRateFormDialogFormErrors } from './TaxRateFormDialogFormErrors'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DRAWERS } from '@/constants/drawers'; +import { compose } from '@/utils'; + +/** + * Tax rate form dialog content. + */ +function TaxRateFormDialogForm({ + // #withDialogActions + closeDialog, + + // #withDrawerActions + closeDrawer, +}) { + // Account form context. + const { taxRate, taxRateId, isNewMode, dialogName } = + useTaxRateFormDialogContext(); + + // Form validation schema in create and edit mode. + const validationSchema = isNewMode + ? CreateTaxRateFormSchema + : EditTaxRateFormSchema; + + const { mutateAsync: createTaxRateMutate } = useCreateTaxRate(); + const { mutateAsync: editTaxRateMutate } = useEditTaxRate(); + + // Form initial values in create and edit mode. + const initialValues = transformTaxRateToForm(taxRate); + + // Callbacks handles form submit. + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const isTaxChanged = isTaxRateChange(initialValues, values); + + // Detarmines whether in edit mode and tax rate has been changed + // and confirm box is not checked. + if (!isNewMode && isTaxChanged && !values.confirm_edit) { + setErrors({ + confirm_edit: + 'Please review the terms and conditions below before proceeding', + }); + setSubmitting(false); + return; + } + const form = transformFormToReq(values); + + // Handle request success on edit. + const handleSuccessOnEdit = (response) => { + if (response?.data?.data?.id !== taxRateId) { + closeDrawer(DRAWERS.TAX_RATE_DETAILS); + } + }; + // Handle request success. + const handleSuccess = () => { + closeDialog(dialogName); + AppToaster.show({ + message: 'The tax rate has been created successfully.', + intent: Intent.SUCCESS, + }); + }; + // Handle request error. + const handleError = (error) => { + const { + response: { + data: { errors }, + }, + } = error; + + const errorsTransformed = transformApiErrors(errors); + setErrors({ ...errorsTransformed }); + setSubmitting(false); + }; + if (isNewMode) { + createTaxRateMutate({ ...form }) + .then(handleSuccess) + .catch(handleError); + } else { + editTaxRateMutate([taxRateId, { ...form }]) + .then(handleSuccessOnEdit) + .then(handleSuccess) + .catch(handleError); + } + }; + + return ( + + +
+ + +
+ + +
+ ); +} + +export default compose( + withDialogActions, + withDrawerActions, +)(TaxRateFormDialogForm); diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormContent.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormContent.tsx new file mode 100644 index 000000000..01f6056ef --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormContent.tsx @@ -0,0 +1,144 @@ +// @ts-nocheck +import React from 'react'; +import { useFormikContext } from 'formik'; +import { Tag, Text } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FCheckbox, FFormGroup, FInputGroup, Hint } from '@/components'; +import { transformTaxRateCodeValue, useIsTaxRateChanged } from './utils'; +import { useTaxRateFormDialogContext } from './TaxRateFormDialogBoot'; + +/** + * Tax rate form content. + * @returns {JSX.Element} + */ +export default function TaxRateFormDialogContent() { + return ( +
+ Required} + subLabel={ + 'The name as you would like it to appear in customers invoices.' + } + fastField={true} + > + + + + + Required} + fastField={true} + > + %} + fill={false} + fastField={true} + /> + + + + } + fastField={true} + > + + + + + + + + + + + + +
+ ); +} + +/** + * Tax rate code input group + * @returns {JSX.Element} + */ +function TaxRateCodeField() { + const { setFieldValue } = useFormikContext(); + + // Handle the field change. + const handleChange = (event) => { + const transformedValue = transformTaxRateCodeValue(event.target.value); + setFieldValue('code', transformedValue); + }; + + return ( + Required} + fastField={true} + > + + + ); +} + +function ConfirmEditingTaxRate() { + const isTaxRateChanged = useIsTaxRateChanged(); + const { isNewMode } = useTaxRateFormDialogContext(); + + // Can't continue if it is new mode or tax rate not changed. + if (!isTaxRateChanged || isNewMode) return null; + + return ( + + Please Note: + + + + + ); +} + +const RateFormGroup = styled(FInputGroup)` + max-width: 100px; +`; + +const CompoundFormGroup = styled(FFormGroup)` + margin-bottom: 0; +`; + +const EditWarningWrap = styled(`div`)` + background: #fcf8ec; + margin-left: -20px; + margin-right: -20px; + padding: 14px 20px; + font-size: 13px; + margin-top: 8px; + border-top: 1px solid #f2eddf; + border-bottom: 1px solid #f2eddf; +`; + +const ConfirmEditFormGroup = styled(FFormGroup)` + margin-bottom: 0; +`; diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormErrors.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormErrors.tsx new file mode 100644 index 000000000..45a715324 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormErrors.tsx @@ -0,0 +1,13 @@ +// @ts-nocheck +import React from 'react'; +import { Alert } from '@/components'; +import { Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; + +export function TaxRateFormDialogFormErrors() { + const { errors } = useFormikContext(); + + if (!errors.confirm_edit) return null; + + return {errors.confirm_edit}; +} diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormFooter.tsx b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormFooter.tsx new file mode 100644 index 000000000..3cf7f9ec3 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormFooter.tsx @@ -0,0 +1,42 @@ +// @ts-nocheck +import React from 'react'; +import * as R from 'ramda'; +import { useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import { DialogsName } from '@/constants/dialogs'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; + +function TaxRateFormDialogFormFooterRoot({ closeDialog }) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + closeDialog(DialogsName.TaxRateForm); + }; + + return ( +
+
+ + + +
+
+ ); +} + +export const TaxRateFormDialogFormFooter = R.compose(withDialogActions)( + TaxRateFormDialogFormFooterRoot, +); diff --git a/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/utils.ts b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/utils.ts new file mode 100644 index 000000000..3ea651a1b --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/utils.ts @@ -0,0 +1,86 @@ +// @ts-nocheck +import { useFormikContext } from 'formik'; +import * as R from 'ramda'; +import { omit } from 'lodash'; +import { transformToForm } from '@/utils'; + +// Default initial form values. +export const defaultInitialValues = { + name: '', + code: '', + rate: '', + description: '', + is_compound: false, + is_non_recoverable: false, + confirm_edit: false, +}; + +/** + * Transformers response errors to form errors. + * @returns {Record} + */ +export const transformApiErrors = (errors) => { + const fields = {}; + + if (errors.find((e) => e.type === 'TAX_CODE_NOT_UNIQUE')) { + fields.code = 'The tax rate is not unique.'; + } + return fields; +}; + +/** + * Tranformes form values to request values. + */ +export const transformFormToReq = (form) => { + return omit({ ...form }, ['confirm_edit']); +}; + +/** + * Detarmines whether the tax rate changed. + * @param initialValues + * @param formValues + * @returns {boolean} + */ +export const isTaxRateChange = (initialValues, formValues) => { + return initialValues.rate !== formValues.rate; +}; + +/** + * Detarmines whether the tax rate changed. + * @returns {boolean} + */ +export const useIsTaxRateChanged = () => { + const { initialValues, values } = useFormikContext(); + + return isTaxRateChange(initialValues, values); +}; + +const convertFormAttrsToBoolean = (form) => { + return { + ...form, + is_compound: !!form.is_compound, + is_non_recoverable: !!form.is_non_recoverable, + }; +}; + +export const transformTaxRateToForm = (taxRate) => { + return R.compose(convertFormAttrsToBoolean)({ + ...defaultInitialValues, + /** + * We only care about the fields in the form. Previously unfilled optional + * values such as `notes` come back from the API as null, so remove those + * as well. + */ + ...transformToForm(taxRate, defaultInitialValues), + }); +}; + +export const transformTaxRateCodeValue = (input: string) => { + // Remove non-alphanumeric characters and spaces using a regular expression + const cleanedString = input.replace(/\s+/g, ''); + + // Convert the cleaned string to uppercase + const uppercasedString = cleanedString.toUpperCase(); + + return uppercasedString; +}; diff --git a/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContent.tsx b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContent.tsx new file mode 100644 index 000000000..a831d3320 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContent.tsx @@ -0,0 +1,29 @@ +// @ts-nocheck +import React from 'react'; +import TaxRateDetailsContentActionsBar from './TaxRateDetailsContentActionsBar'; +import { TaxRateDetailsContentBoot } from './TaxRateDetailsContentBoot'; +import { DrawerBody, DrawerHeaderContent } from '@/components'; +import TaxRateDetailsContentDetails from './TaxRateDetailsContentDetails'; +import { DRAWERS } from '@/constants/drawers'; + +interface TaxRateDetailsContentProps { + taxRateid: number; +} + +export default function TaxRateDetailsContent({ + taxRateId, +}: TaxRateDetailsContentProps) { + return ( + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentActionsBar.tsx b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentActionsBar.tsx new file mode 100644 index 000000000..758372bc2 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentActionsBar.tsx @@ -0,0 +1,148 @@ +// @ts-nocheck +import React from 'react'; +import { + Button, + Classes, + Intent, + Menu, + MenuItem, + NavbarDivider, + NavbarGroup, + Popover, + PopoverInteractionKind, + Position, +} from '@blueprintjs/core'; +import * as R from 'ramda'; +import { AppToaster, Can, DashboardActionsBar, Icon } from '@/components'; +import { AbilitySubject, TaxRateAction } from '@/constants/abilityOption'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import withAlertsActions from '@/containers/Alert/withAlertActions'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { useTaxRateDetailsContext } from './TaxRateDetailsContentBoot'; +import { DialogsName } from '@/constants/dialogs'; +import { + useActivateTaxRate, + useInactivateTaxRate, +} from '@/hooks/query/taxRates'; + +/** + * Tax rate details content actions bar. + * @returns {JSX.Element} + */ +function TaxRateDetailsContentActionsBar({ + // #withDrawerActions + openDialog, + + // #withAlertsActions + openAlert, +}) { + const { taxRateId, taxRate } = useTaxRateDetailsContext(); + + const { mutateAsync: activateTaxRateMutate } = useActivateTaxRate(); + const { mutateAsync: inactivateTaxRateMutate } = useInactivateTaxRate(); + + // Handle edit tax rate. + const handleEditTaxRate = () => { + openDialog(DialogsName.TaxRateForm, { id: taxRateId }); + }; + // Handle delete tax rate. + const handleDeleteTaxRate = () => { + openAlert('tax-rate-delete', { taxRateId }); + }; + // Handle activate tax rate. + const handleActivateTaxRate = () => { + activateTaxRateMutate(taxRateId) + .then(() => { + AppToaster.show({ + message: 'The tax rate has been activated successfully.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + // Handle inactivate tax rate. + const handleInactivateTaxRate = () => { + inactivateTaxRateMutate(taxRateId) + .then(() => { + AppToaster.show({ + message: 'The tax rate has been inactivated successfully.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + + return ( + + + +
+ } + > +