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 = (
+ }
+ action={
+ <>
+
+
+
+
+ >
+ }
+ />
+ );
+}
+
+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 (
+
+ );
+}
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 (
+
+
+
+ }
+ text={'Edit Tax Rate'}
+ onClick={handleEditTaxRate}
+ />
+
+
+
+ }
+ intent={Intent.DANGER}
+ onClick={handleDeleteTaxRate}
+ />
+
+
+
+
+
+ {!taxRate.active && (
+
+ )}
+ {!!taxRate.active && (
+
+ )}
+
+ }
+ >
+ }
+ minimal={true}
+ />
+
+
+
+
+ );
+}
+
+export default R.compose(
+ withDrawerActions,
+ withDialogActions,
+ withAlertsActions,
+)(TaxRateDetailsContentActionsBar);
diff --git a/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentBoot.tsx b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentBoot.tsx
new file mode 100644
index 000000000..b1189dd49
--- /dev/null
+++ b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentBoot.tsx
@@ -0,0 +1,40 @@
+// @ts-nocheck
+import React, { createContext, useContext } from 'react';
+import { DrawerLoading } from '@/components';
+import { useTaxRate } from '@/hooks/query/taxRates';
+
+const TaxRateDetailsContext = createContext();
+
+interface TaxRateDetailsContentBootProps {
+ taxRateId: number;
+}
+
+/**
+ * Tax rate details content boot.
+ * @returns {JSX}
+ */
+export function TaxRateDetailsContentBoot({
+ taxRateId,
+ ...props
+}: TaxRateDetailsContentBootProps) {
+ const {
+ data: taxRate,
+ isFetching: isTaxRateFetching,
+ isLoading: isTaxRateLoading,
+ } = useTaxRate(taxRateId, { keepPreviousData: true });
+
+ const provider = {
+ isTaxRateLoading,
+ isTaxRateFetching,
+ taxRate,
+ taxRateId,
+ };
+
+ return (
+
+
+
+ );
+}
+
+export const useTaxRateDetailsContext = () => useContext(TaxRateDetailsContext);
diff --git a/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentDetails.tsx b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentDetails.tsx
new file mode 100644
index 000000000..e145ddaef
--- /dev/null
+++ b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentDetails.tsx
@@ -0,0 +1,86 @@
+// @ts-nocheck
+import React from 'react';
+import { Card, DetailItem, DetailsMenu } from '@/components';
+import { useTaxRateDetailsContext } from './TaxRateDetailsContentBoot';
+import { Intent, Tag } from '@blueprintjs/core';
+import styled from 'styled-components';
+
+export default function TaxRateDetailsContentDetails() {
+ const { taxRate } = useTaxRateDetailsContext();
+
+ return (
+
+
+
+ {taxRate.rate}%
+ {taxRate.active ? (
+
+ Active
+
+ ) : (
+
+ Inactive
+
+ )}
+
+
+
+
+
+
+ Enabled
+
+ ) : (
+
+ Disabled
+
+ )
+ }
+ />
+
+ Enabled
+
+ ) : (
+
+ Disabled
+
+ )
+ }
+ />
+
+
+
+ );
+}
+
+const TaxRateHeader = styled(`div`)`
+ margin-bottom: 1.25rem;
+ display: flex;
+ align-items: flex-start;
+ margin-top: 0.25rem;
+`;
+
+const TaxRateAmount = styled('div')`
+ line-height: 1;
+ font-size: 30px;
+ color: #565b71;
+ font-weight: 600;
+ display: inline-block;
+`;
+
+const TaxRateActiveTag = styled(Tag)`
+ margin-top: auto;
+ margin-bottom: auto;
+ margin-left: 1rem;
+`;
diff --git a/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer.tsx b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer.tsx
new file mode 100644
index 000000000..1c9257d49
--- /dev/null
+++ b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer.tsx
@@ -0,0 +1,35 @@
+// @ts-nocheck
+import React from 'react';
+import * as R from 'ramda';
+import { Drawer, DrawerHeaderContent, DrawerSuspense } from '@/components';
+import withDrawers from '@/containers/Drawer/withDrawers';
+import { DRAWERS } from '@/constants/drawers';
+
+const TaxRateDetailsDrawerContent = React.lazy(
+ () => import('./TaxRateDetailsContent'),
+);
+
+/**
+ * Tax rate details drawer.
+ */
+function TaxRateDetailsDrawer({
+ name,
+ // #withDrawer
+ isOpen,
+ payload: { taxRateId },
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+export default R.compose(withDrawers())(TaxRateDetailsDrawer);
diff --git a/packages/webapp/src/containers/TaxRates/pages/TaxRatesLanding.tsx b/packages/webapp/src/containers/TaxRates/pages/TaxRatesLanding.tsx
new file mode 100644
index 000000000..8990fe30b
--- /dev/null
+++ b/packages/webapp/src/containers/TaxRates/pages/TaxRatesLanding.tsx
@@ -0,0 +1,23 @@
+// @ts-nocheck
+import React, { useEffect } from 'react';
+
+import { DashboardPageContent } from '@/components';
+import { TaxRatesLandingProvider } from '../containers/TaxRatesLandingProvider';
+import TaxRatesLandingActionsBar from '../containers/TaxRatesLandingActionsBar';
+import TaxRatesDataTable from '../containers/TaxRatesLandingTable';
+
+/**
+ * Tax rates landing page.
+ * @returns {JSX.Element}
+ */
+export default function TaxRatesLanding() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/webapp/src/hooks/query/financialReports.tsx b/packages/webapp/src/hooks/query/financialReports.tsx
index 6941fb1d2..e0919584d 100644
--- a/packages/webapp/src/hooks/query/financialReports.tsx
+++ b/packages/webapp/src/hooks/query/financialReports.tsx
@@ -444,3 +444,24 @@ export function useTransactionsByReference(query, props) {
},
);
}
+
+/**
+ * Retrieves the sales tax liability summary report.
+ */
+export function useSalesTaxLiabilitySummary(query, props) {
+ return useRequestQuery(
+ [t.FINANCIAL_REPORT, t.SALES_TAX_LIABILITY_SUMMARY, query],
+ {
+ method: 'get',
+ url: '/financial_statements/sales-tax-liability-summary',
+ params: query,
+ headers: {
+ Accept: 'application/json+table',
+ },
+ },
+ {
+ select: (res) => res.data,
+ ...props,
+ },
+ );
+}
diff --git a/packages/webapp/src/hooks/query/taxRates.ts b/packages/webapp/src/hooks/query/taxRates.ts
new file mode 100644
index 000000000..6a731b4e0
--- /dev/null
+++ b/packages/webapp/src/hooks/query/taxRates.ts
@@ -0,0 +1,130 @@
+// @ts-nocheck
+import { useMutation, useQueryClient } from 'react-query';
+import { useRequestQuery } from '../useQueryRequest';
+import QUERY_TYPES from './types';
+import useApiRequest from '../useRequest';
+
+// Common invalidate queries.
+const commonInvalidateQueries = (queryClient) => {
+ queryClient.invalidateQueries(QUERY_TYPES.TAX_RATES);
+};
+
+/**
+ * Retrieves tax rates.
+ * @param {number} customerId - Customer id.
+ */
+export function useTaxRates(props) {
+ return useRequestQuery(
+ [QUERY_TYPES.TAX_RATES],
+ {
+ method: 'get',
+ url: `tax-rates`,
+ },
+ {
+ select: (res) => res.data.data,
+ defaultData: [],
+ ...props,
+ },
+ );
+}
+
+/**
+ * Retrieves tax rate.
+ * @param {number} taxRateId - Tax rate id.
+ */
+export function useTaxRate(taxRateId: string, props) {
+ return useRequestQuery(
+ [QUERY_TYPES.TAX_RATES, taxRateId],
+ {
+ method: 'get',
+ url: `tax-rates/${taxRateId}}`,
+ },
+ {
+ select: (res) => res.data.data,
+ ...props,
+ },
+ );
+}
+
+/**
+ * Edit the given tax rate.
+ */
+export function useEditTaxRate(props) {
+ const queryClient = useQueryClient();
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ ([id, values]) => apiRequest.post(`tax-rates/${id}`, values),
+ {
+ onSuccess: (res, id) => {
+ commonInvalidateQueries(queryClient);
+ queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);
+ },
+ ...props,
+ },
+ );
+}
+
+/**
+ * Creates a new tax rate.
+ */
+export function useCreateTaxRate(props) {
+ const queryClient = useQueryClient();
+ const apiRequest = useApiRequest();
+
+ return useMutation((values) => apiRequest.post('tax-rates', values), {
+ onSuccess: (res, id) => {
+ commonInvalidateQueries(queryClient);
+ queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);
+ },
+ ...props,
+ });
+}
+
+/**
+ * Delete the given tax rate.
+ */
+export function useDeleteTaxRate(props) {
+ const queryClient = useQueryClient();
+ const apiRequest = useApiRequest();
+
+ return useMutation((id) => apiRequest.delete(`tax-rates/${id}`), {
+ onSuccess: (res, id) => {
+ commonInvalidateQueries(queryClient);
+ queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);
+ },
+ ...props,
+ });
+}
+
+/**
+ * Activate the given tax rate.
+ */
+export function useActivateTaxRate(props) {
+ const queryClient = useQueryClient();
+ const apiRequest = useApiRequest();
+
+ return useMutation((id) => apiRequest.post(`tax-rates/${id}/active`), {
+ onSuccess: (res, id) => {
+ commonInvalidateQueries(queryClient);
+ queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);
+ },
+ ...props,
+ });
+}
+
+/**
+ * Inactivate the given tax rate.
+ */
+export function useInactivateTaxRate(props) {
+ const queryClient = useQueryClient();
+ const apiRequest = useApiRequest();
+
+ return useMutation((id) => apiRequest.post(`tax-rates/${id}/inactive`), {
+ onSuccess: (res, id) => {
+ commonInvalidateQueries(queryClient);
+ queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);
+ },
+ ...props,
+ });
+}
diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx
index c557228db..c0173cab7 100644
--- a/packages/webapp/src/hooks/query/types.tsx
+++ b/packages/webapp/src/hooks/query/types.tsx
@@ -32,6 +32,7 @@ const FINANCIAL_REPORTS = {
REALIZED_GAIN_OR_LOSS: 'REALIZED_GAIN_OR_LOSS',
UNREALIZED_GAIN_OR_LOSS: 'UNREALIZED_GAIN_OR_LOSS',
PROJECT_PROFITABILITY_SUMMARY: 'PROJECT_PROFITABILITY_SUMMARY',
+ SALES_TAX_LIABILITY_SUMMARY: 'SALES_TAX_LIABILITY_SUMMARY'
};
const BILLS = {
@@ -224,6 +225,10 @@ const ORGANIZATION = {
ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES: 'ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES',
};
+export const TAX_RATES = {
+ TAX_RATES: 'TAX_RATES',
+}
+
export default {
...Authentication,
...ACCOUNTS,
@@ -257,4 +262,5 @@ export default {
...BRANCHES,
...DASHBOARD,
...ORGANIZATION,
+ ...TAX_RATES
};
diff --git a/packages/webapp/src/hooks/useUncontrolled.ts b/packages/webapp/src/hooks/useUncontrolled.ts
new file mode 100644
index 000000000..6d441fb8b
--- /dev/null
+++ b/packages/webapp/src/hooks/useUncontrolled.ts
@@ -0,0 +1,36 @@
+import React, { useState } from 'react';
+
+interface UseUncontrolledInput {
+ /** Value for controlled state */
+ value?: T;
+
+ /** Initial value for uncontrolled state */
+ initialValue?: T;
+
+ /** Final value for uncontrolled state when value and initialValue are not provided */
+ finalValue?: T;
+
+ /** Controlled state onChange handler */
+ onChange?(value: T): void;
+}
+
+export function useUncontrolled({
+ value,
+ initialValue,
+ finalValue,
+ onChange = () => {},
+}: UseUncontrolledInput) {
+ const [uncontrolledValue, setUncontrolledValue] = useState(
+ initialValue !== undefined ? initialValue : finalValue,
+ );
+
+ const handleUncontrolledChange = (val: T) => {
+ setUncontrolledValue(val);
+ onChange?.(val);
+ };
+
+ if (value !== undefined) {
+ return [value as T, onChange, true];
+ }
+ return [uncontrolledValue as T, handleUncontrolledChange, false];
+}
diff --git a/packages/webapp/src/interfaces/ItemEntries.ts b/packages/webapp/src/interfaces/ItemEntries.ts
new file mode 100644
index 000000000..ef52dcbfc
--- /dev/null
+++ b/packages/webapp/src/interfaces/ItemEntries.ts
@@ -0,0 +1,11 @@
+export interface ItemEntry {
+ index: number;
+ item_id: number;
+ description: string;
+ quantity: number;
+ rate: number;
+ discount: number;
+ tax_rate_id: number;
+ tax_rate: number;
+ tax_amount: number;
+}
diff --git a/packages/webapp/src/interfaces/TaxRates.ts b/packages/webapp/src/interfaces/TaxRates.ts
new file mode 100644
index 000000000..6c55b7b49
--- /dev/null
+++ b/packages/webapp/src/interfaces/TaxRates.ts
@@ -0,0 +1,4 @@
+export enum TaxType {
+ Inclusive = 'inclusive',
+ Exclusive = 'exclusive',
+}
diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx
index ed66b4373..5193c63f8 100644
--- a/packages/webapp/src/routes/dashboard.tsx
+++ b/packages/webapp/src/routes/dashboard.tsx
@@ -440,7 +440,9 @@ export const getDashboardRoutes = () => [
path: `/financial-reports/project-profitability-summary`,
component: lazy(
() =>
- import('@/containers/FinancialStatements/ProjectProfitabilitySummary/ProjectProfitabilitySummary'),
+ import(
+ '@/containers/FinancialStatements/ProjectProfitabilitySummary/ProjectProfitabilitySummary'
+ ),
),
breadcrumb: intl.get('project_profitability_summary'),
pageTitle: intl.get('project_profitability_summary'),
@@ -448,6 +450,20 @@ export const getDashboardRoutes = () => [
sidebarExpand: false,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
+ {
+ path: '/financial-reports/sales-tax-liability-summary',
+ component: lazy(
+ () =>
+ import(
+ '@/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary'
+ ),
+ ),
+ breadcrumb: 'Sales Tax Liability Summary',
+ pageTitle: 'Sales Tax Liability Summary',
+ backLink: true,
+ sidebarExpand: false,
+ subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
+ },
{
path: '/financial-reports',
component: lazy(
@@ -1053,6 +1069,15 @@ export const getDashboardRoutes = () => [
),
pageTitle: intl.get('sidebar.projects'),
},
+ {
+ path: '/tax-rates',
+ component: lazy(
+ () =>
+ import('@/containers/TaxRates/pages/TaxRatesLanding'),
+ ),
+ pageTitle: 'Tax Rates',
+ subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
+ },
// Homepage
{
path: `/`,
diff --git a/packages/webapp/src/store/financialStatement/financialStatements.actions.tsx b/packages/webapp/src/store/financialStatement/financialStatements.actions.tsx
index c2dc6003e..091608213 100644
--- a/packages/webapp/src/store/financialStatement/financialStatements.actions.tsx
+++ b/packages/webapp/src/store/financialStatement/financialStatements.actions.tsx
@@ -244,3 +244,16 @@ export function toggleProjectProfitabilitySummaryFilterDrawer(toggle) {
},
};
}
+
+/**
+ * Toggles display of the sales tax liablilty summary filter drawer.
+ * @param {boolean} toggle
+ */
+export function toggleSalesTaxLiabilitySummaryFilterDrawer(toggle) {
+ return {
+ type: `${t.SALES_TAX_LIABILITY_SUMMARY}/${t.DISPLAY_FILTER_DRAWER_TOGGLE}`,
+ payload: {
+ toggle,
+ },
+ };
+}
diff --git a/packages/webapp/src/store/financialStatement/financialStatements.reducer.tsx b/packages/webapp/src/store/financialStatement/financialStatements.reducer.tsx
index 2a5bc6c9d..0c1589074 100644
--- a/packages/webapp/src/store/financialStatement/financialStatements.reducer.tsx
+++ b/packages/webapp/src/store/financialStatement/financialStatements.reducer.tsx
@@ -61,6 +61,9 @@ const initialState = {
projectProfitabilitySummary: {
dispalyFilterDrawer: false,
},
+ salesTaxLiabilitySummary: {
+ displayFilterDrawer: false,
+ }
};
/**
@@ -124,4 +127,8 @@ export default createReducer(initialState, {
t.PROJECT_PROFITABILITY_SUMMARY,
'projectProfitabilitySummary',
),
+ ...financialStatementFilterToggle(
+ t.SALES_TAX_LIABILITY_SUMMARY,
+ 'salesTaxLiabilitySummary',
+ )
});
diff --git a/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx b/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx
index b5f0c8d10..1c44babfd 100644
--- a/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx
+++ b/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx
@@ -86,6 +86,10 @@ export const projectProfitabilitySummaryFilterDrawerSelector = (state) => {
return filterDrawerByTypeSelector('projectProfitabilitySummary')(state);
};
+export const salesTaxLiabilitySummaryFilterDrawerSelector = (state) => {
+ return filterDrawerByTypeSelector('salesTaxLiabilitySummary')(state);
+};
+
/**
* Retrieve balance sheet filter drawer.
*/
@@ -278,3 +282,11 @@ export const getProjectProfitabilitySummaryFilterDrawer = createSelector(
return isOpen;
},
);
+
+/**
+ * Retrieve sales tax liability summary filter drawer.
+ */
+export const getSalesTaxLiabilitySummaryFilterDrawer = createSelector(
+ salesTaxLiabilitySummaryFilterDrawerSelector,
+ (isOpen) => isOpen,
+);
diff --git a/packages/webapp/src/store/financialStatement/financialStatements.types.tsx b/packages/webapp/src/store/financialStatement/financialStatements.types.tsx
index 88da7d11d..eeaeb01f1 100644
--- a/packages/webapp/src/store/financialStatement/financialStatements.types.tsx
+++ b/packages/webapp/src/store/financialStatement/financialStatements.types.tsx
@@ -20,4 +20,5 @@ export default {
PROJECT_PROFITABILITY_SUMMARY: 'PROJECT PROFITABILITY SUMMARY',
REALIZED_GAIN_OR_LOSS: 'REALIZED GAIN OR LOSS',
UNREALIZED_GAIN_OR_LOSS: 'UNREALIZED GAIN OR LOSS',
+ SALES_TAX_LIABILITY_SUMMARY: 'SALES TAX LIABILITY SUMMARY',
};